# 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` 已示例) ```python task_bonus_rules: dict[str, dict[str, Any]] # env: TASK_BONUS_RULES_JSON ``` 默认值(协调提交写死在 config.py,含义:task_key → {quota_key, amount, claim_period}): ```json { "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): ```python @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)。 偏差说明:<如有写在这里,无则写"无"> ```