- Sprint 执行计划 v1.1:§1.1 补第一批交付实据;T9 SPIKE 结论回写 Sprint 1 表 (T-P0-16 通过 2.8ms;T-P0-12 有条件;T-P0-10 原型 6/6 + 真实照片重验前置); Sprint 2 表标注 T-P2-01~06 交付;新增 §9 两批交付记录 - 开发任务拆解 v1.5:交付回写 + 新风险登记(RTMPose 域差异、BiRefNet 异步化) - 新增 开发任务包/:三批多 agent 并行的协作规程与 14 份任务契约 - 新增 前置开通清单-云资源与资质.md - 清理已用毕的评审报告(文档评审 2026-08-13、项目整体评审 2026-08-14), README 中相应引用改为留痕表述
7.6 KiB
T2 · 任务奖励额度领取(claim)
对应任务:
T-P1-26收尾件 | 需求:FR-BILL-03、架构 §9.3 | 分支:task/t2-task-bonus| worktree:/home/xsl/meida_backend-t2先读《README.md(协作规程)》,遵守全部纪律。
1. 目标
实现产品方案 §3.2 的「做任务换额度」闭环的服务端:POST /quota/tasks/{task_key}/claim。
发放即到账、发放幂等(同一任务同一周期不重复发放,FR-BILL-03 验收原文)、受 bl_quota_rule.task_bonus_max 封顶。
2. 已就绪的契约(协调提交已提供)
配置(config.py 已加字段,.env.example 已示例)
task_bonus_rules: dict[str, dict[str, Any]] # env: TASK_BONUS_RULES_JSON
默认值(协调提交写死在 config.py,含义:task_key → {quota_key, amount, claim_period}):
{
"sign_in": {"quota_key": "agent_chat", "amount": 1, "claim_period": "day"},
"profile_complete":{"quota_key": "agent_chat", "amount": 1, "claim_period": "lifetime"},
"report_feedback": {"quota_key": "report_regen", "amount": 1, "claim_period": "day"},
"share_report": {"quota_key": "tryon", "amount": 1, "claim_period": "day"},
"invite_friend": {"quota_key": "tryon", "amount": 1, "claim_period": "day"}
}
⚠️ amount 全部是占位值 1——具体数值待产品在产品方案 §3.2 补充后改 env,不改代码(FR-BILL-04 配置化)。代码注释里必须写明这一点。
错误码(errors.py 已加)
TASK_BONUS_UNKNOWN = 40410:404「任务不存在或暂未开放」;TASK_BONUS_ALREADY_CLAIMED = 40910:409「这个周期的任务奖励已领取」,hint「下个周期再来」;TASK_BONUS_CAP_REACHED = 40310:403「任务奖励已达上限」,hint「明天再来,或等额度自然恢复」。 (注意:这些文案已按 FR-BILL-07 写好,不得出现价格/购买/会员/升级字样,你不需要也不要改 meta。)
现有代码事实(先读再写)
packages/domain/billing/quota.py:QuotaState(used, base_limit, bonus, hard_cap)、period_key(now, period_type, tz_offset_hours)、period_reset_at(...)、reserve_idempotency_key/refund_idempotency_key;PeriodType = Literal["day","month","lifetime"]。packages/billing/service.py:BillingService(session, tz_offset_hours);QuotaSnapshot;reserve/commit/refund/get_quota/list_ledger;内部_load_rule(quota_key)、lock_or_create_quota(...)(返回已行锁的Quota行,limit_value是 base_limit 快照,bonus_granted是本周期已获奖励)。packages/billing/repo.py:add_ledger/find_ledger_by_idempotency_key等(照现有模式补你需要的)。packages/shared/models.py:Ledger(bl_ledger:tx_idPK、delta、tx_type、status、idempotency_keyUNIQUE、balance_after、quota_key、period_key、reason、ref_type、ref_id)。apps/api/routers/quota.py:现有GET /quota、GET /quota/ledger,响应模式照抄。
3. 实现要点
允许改动:packages/billing/service.py、packages/billing/repo.py、packages/billing/schemas.py(新建)、packages/domain/billing/quota.py、apps/api/routers/quota.py、测试文件。
- 纯域函数(
packages/domain/billing/quota.py,mypy strict):语义:@dataclass(frozen=True, slots=True) class BonusDecision: ... def grant_bonus(state: QuotaState, amount: int, task_bonus_max: int) -> QuotaDecision-likebonus + amount > task_bonus_max→ 不允许(封顶);否则返回replace(state, bonus=state.bonus + amount)。task_bonus_max来自bl_quota_rule。 - 服务方法
BillingService.claim_task_bonus(user_id, task_key, rules):- task_key 不在 rules →
AppError(TASK_BONUS_UNKNOWN); - 解析
{quota_key, amount, claim_period};rule = await self._load_rule(quota_key); pk = period_key(now, claim_period, tz);幂等键f"grant:{user_id}:{task_key}:{pk}";- 先查
bl_ledger.idempotency_key已存在 →TASK_BONUS_ALREADY_CLAIMED(幂等快捷路径); lock_or_create_quota(同周期)→grant_bonus(...)封顶判定 → 不允许则TASK_BONUS_CAP_REACHED(不写任何流水);- 同一事务内:更新
bl_quota.bonus_granted,插入Ledger(tx_type="system_grant", delta=0, status="committed", balance_after=state.remaining, reason=task_key, ref_type="task_bonus", ref_id=task_key),提交; - 并发兜底:
IntegrityError(唯一键冲突)→ 回滚 →TASK_BONUS_ALREADY_CLAIMED。delta=0的理由写进注释:system_grant 不改变used,只提高有效额度,balance_after仍按 remaining 语义记录(流水可重算的是 used,不是余额本身——与现有 reserve/commit 的记账口径一致)。
- task_key 不在 rules →
- 路由(
apps/api/routers/quota.py追加):POST /tasks/{task_key}/claim(注意 router prefix 是/quota,最终路径即架构 §9.3 的POST /quota/tasks/{task_key}/claim)。请求无需 body;响应ok(TaskBonusClaimResponse(...)),字段:task_key/quota_key/amount/bonus_granted/remaining。Pydantic 模型放packages/billing/schemas.py(新建;apps/api/schemas.py 禁改)。- rules 从 settings 读:路由函数签名加
settings: SettingsDep,传dict(settings.task_bonus_rules)。
- rules 从 settings 读:路由函数签名加
4. 测试要求
- 单测
tests/unit/test_bonus_domain.py:grant_bonus的封顶边界(bonus+amount == max 允许、== max+1 拒绝)、amount≤0 抛 ValueError、不改变 used。 - 单测
tests/unit/test_task_bonus_rules.py:settings 解析TASK_BONUS_RULES_JSON(字符串 JSON / dict / 空值容错)——参考 config.py 的_parse_json_object模式,如果协调提交已把task_bonus_rules加进该 validator 的字段列表就不用测解析,直接测默认值形状(每个 entry 必有 quota_key/amount/claim_period,claim_period ∈ PeriodType)。 - 集成测试
tests/integration/test_task_bonus.py(新文件,允许跑):- dev-token 登录 → claim
sign_in→ 200,GET /quota?quota_key=agent_chat的bonus_granted= 1; - 再 claim 同一 task → 40910;
- 把 Redis 无关、纯 DB 断言:
bl_ledger出现system_grant行且balance_after= base+bonus; - 封顶路径可用
TASK_BONUS_RULES_JSON覆盖 amount 为 11(> task_bonus_max=10)→ claim → 40310,且无新流水。 (settings 覆盖用monkeypatch.setenv + get_settings.cache_clear(),结束恢复,参考 T1 文档的写法。)
- dev-token 登录 → claim
5. 禁改清单(通用禁改之外)
packages/llm/**、packages/agent/**、packages/identity/**、packages/analytics/**、packages/storage/**、apps/api/routers/{agent,auth,tasks,events,uploads}.py、apps/api/deps.py、tests/integration/test_vertical_slice.py。
6. 验收标准(FR-BILL-03 原文)
- 「签到、完善资料、反馈、邀请、分享完成即到账」——五类 task_key 全部走通 claim;
- 发放幂等:同一任务同一周期重复 claim 返回 40910,账本无重复行;
- 封顶:
bonus_granted永不超过bl_quota_rule.task_bonus_max; - 全部检查绿(mypy 范围:
apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage+packages/domainstrict)。
7. 提交信息
feat(billing): task bonus claim endpoint with idempotency and cap
POST /quota/tasks/{task_key}/claim:配置驱动任务注册表、
grant_bonus 纯域函数、ledger 幂等键防重、task_bonus_max 封顶(FR-BILL-03/04)。
偏差说明:<如有写在这里,无则写"无">