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

99 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。
偏差说明:<如有写在这里,无则写"无">
```