Files
meida/docs/开发任务包/T2-任务奖励领取.md
T
xsl e511b6a66f 收尾两批并行开发的文档回写与任务包
- 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 中相应引用改为留痕表述
2026-08-14 21:07:20 +08:00

7.6 KiB
Raw Blame History

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_id PK、delta、tx_type、status、idempotency_key UNIQUE、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、测试文件。

  1. 纯域函数(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-like
    
    语义:bonus + amount > task_bonus_max → 不允许(封顶);否则返回 replace(state, bonus=state.bonus + amount)。task_bonus_max 来自 bl_quota_rule。
  2. 服务方法 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 的记账口径一致)。
  3. 路由(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)。

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(新文件,允许跑):
    1. dev-token 登录 → claim sign_in → 200,GET /quota?quota_key=agent_chat 的 bonus_granted = 1;
    2. 再 claim 同一 task → 40910;
    3. 把 Redis 无关、纯 DB 断言:bl_ledger 出现 system_grant 行且 balance_after = base+bonus;
    4. 封顶路径可用 TASK_BONUS_RULES_JSON 覆盖 amount 为 11(> task_bonus_max=10)→ claim → 40310,且无新流水。 (settings 覆盖用 monkeypatch.setenv + get_settings.cache_clear(),结束恢复,参考 T1 文档的写法。)

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 原文)

  1. 「签到、完善资料、反馈、邀请、分享完成即到账」——五类 task_key 全部走通 claim;
  2. 发放幂等:同一任务同一周期重复 claim 返回 40910,账本无重复行;
  3. 封顶:bonus_granted 永不超过 bl_quota_rule.task_bonus_max;
  4. 全部检查绿(mypy 范围:apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage + packages/domain strict)。

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)。

偏差说明:<如有写在这里,无则写"无">