feat(billing): 任务换额度定稿闭环——claim 凭据校验(40311)+ 定稿 4 任务目录

- claim_task_bonus 在幂等快捷路径前做完成凭据校验:report_feedback 查当日 rp_feedback、first_report 查 succeeded rp_report,无凭据 40311 且不打流水;sign_in/share_report 不校验(产品决策:签到行为即领取,分享内测期接受端侧自报,hard_cap 锁死刷量收益)
- task_bonus_rules 默认值与 .env.example 同步产品定稿(产品方案 §3.2,2026-09-11):sign_in 对话+10/day、report_feedback 发型+2/day、share_report 换装+3/day、first_report 重生成+1/lifetime;下架 invite_friend/profile_complete
- billing/repo 新增只读凭据探测 has_feedback_between/has_completed_report(import-linter 禁 billing→report,直查 shared.models 的 rp_* 表,永远只读)
- 测试:test_task_bonus_eligibility 5 用例(40311 两分支/凭据后 200/40910/40410/零流水)

偏差说明:test_task_bonus 与 test_task_bonus_rules 断言数值随定稿更新(发放/幂等/封顶语义未动);quota.py docstring「占位值」说法顺手更正。
This commit is contained in:
xsl
2026-09-12 00:23:43 +08:00
parent b9c3e4dada
commit 76a0502ea3
9 changed files with 365 additions and 44 deletions
+43 -5
View File
@@ -10,7 +10,7 @@ Nothing here decides *how much* a feature costs — that comes from
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta, timezone
from typing import Any, cast
from sqlalchemy.exc import IntegrityError
@@ -310,20 +310,26 @@ class BillingService:
task_key: str,
rules: dict[str, dict[str, Any]],
) -> TaskBonusClaim:
"""领取任务奖励:发放即到账、幂等、封顶(FR-BILL-03).
"""领取任务奖励:完成校验 → 发放即到账、幂等、封顶(FR-BILL-03).
``rules`` 来自 ``settings.task_bonus_rules``(FR-BILL-04 配置化):
amount 目前是占位值,产品在产品方案 §3.2 定稿后改 env 即生效,
不需要改代码。
目录与数值以产品方案 §3.2 定稿为准,改 env 即生效,不需要改代码。
"""
conf = rules.get(task_key)
if conf is None:
raise AppError(ErrorCode.TASK_BONUS_UNKNOWN, data={"task_key": task_key})
quota_key, amount, claim_period = _parse_task_rule(task_key, conf)
now = datetime.now(UTC)
# 完成凭据校验(T2b)必须在幂等快捷路径之前:无凭据的 claim 一律 40311,
# 不得因为「本周期已领过」之类的既有状态绕过校验语义(顺序反过来时
# 已领用户会看到 40910 而非 40311,两者都可接受,但先校验口径更一致)。
await self._require_task_eligibility(
user_id=user_id, task_key=task_key, quota_key=quota_key, now=now
)
rule = await self._require_rule(quota_key)
quota_period_type = cast(quota_rules.PeriodType, rule.period_type)
now = datetime.now(UTC)
# 幂等粒度跟任务的 claim_period 走,额度行周期跟 bl_quota_rule 走:
# 「每天可领」的任务可以给「按月计量」的额度加成,两套周期不能混。
quota_pk = quota_rules.period_key(now, quota_period_type, self._tz_offset)
@@ -416,6 +422,38 @@ class BillingService:
# -- helpers ------------------------------------------------------------
async def _require_task_eligibility(
self, *, user_id: int, task_key: str, quota_key: str, now: datetime
) -> None:
"""claim 前的完成凭据校验(T2b / 产品方案 §3.2):无凭据抛 40311.
映射写死、不做配置化——校验逻辑与任务语义绑定,不是运营参数:
- ``report_feedback``:当日(UTC+8,与 ``period_key`` 同时区口径)存在
``rp_feedback`` 行;
- ``first_report``:存在已完成(succeeded)的 ``rp_report`` 行;
- ``sign_in`` / ``share_report`` 不校验:签到点击行为本身即完成;分享
内测期端侧自报、由 ``hard_cap`` 兜底(§3.2 决策理由 4)。
埋点只作观测、不作发放凭据(可伪造且有节流延迟),故这里只读业务表。
拒绝路径不写任何流水(校验在写路径之前)。
"""
eligible = True
if task_key == "report_feedback":
tz = timezone(timedelta(hours=self._tz_offset))
day_start = now.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
day_start_utc = day_start.astimezone(UTC)
eligible = await repo.has_feedback_between(
self._session,
user_id=user_id,
start=day_start_utc,
end=day_start_utc + timedelta(days=1),
)
elif task_key == "first_report":
eligible = await repo.has_completed_report(self._session, user_id=user_id)
if not eligible:
QUOTA_EVENTS.labels(quota_key, "grant", "not_eligible").inc()
logger.info("task_bonus_not_eligible", user_id=user_id, task_key=task_key)
raise AppError(ErrorCode.TASK_BONUS_NOT_ELIGIBLE, data={"task_key": task_key})
async def _require_rule(self, quota_key: str) -> QuotaRule:
rule = await repo.get_rule(self._session, quota_key)
if rule is None: