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
+40 -1
View File
@@ -6,11 +6,13 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import Ledger, Quota, QuotaRule
from packages.shared.models import Ledger, Quota, QuotaRule, RpFeedback, RpReport
async def get_rule(session: AsyncSession, quota_key: str) -> QuotaRule | None:
@@ -77,3 +79,40 @@ async def list_ledger(session: AsyncSession, user_id: int, limit: int = 50) -> l
def add_ledger(session: AsyncSession, ledger: Ledger) -> Ledger:
session.add(ledger)
return ledger
# -- 任务完成凭据探测(T2b,rp_* 只读) --------------------------------------
# 分层契约(pyproject import-linter layers)禁止 packages.billing 依赖
# packages.report,而 claim 完成校验又必须读 rp_feedback/rp_report 的服务端
# 可信信号 —— 直接查 shared.models 的只读 exists 探测是唯一合规通道。
# 纪律:billing 对 rp_* 永远只读,绝不写入;「完成判定」语义仍归 report 域。
#: rp_report.status 的「已完成」取值:与 packages/report/repo.py 的
#: STATUS_SUCCEEDED 对齐(同上,分层契约不允许 import 该常量,故复制字面量)。
_REPORT_STATUS_SUCCEEDED = "succeeded"
async def has_feedback_between(
session: AsyncSession, *, user_id: int, start: datetime, end: datetime
) -> bool:
"""``[start, end)`` 内该用户是否存在反馈行(report_feedback 完成凭据)。"""
result = await session.execute(
select(RpFeedback.feedback_id)
.where(
RpFeedback.user_id == user_id,
RpFeedback.created_at >= start,
RpFeedback.created_at < end,
)
.limit(1)
)
return result.first() is not None
async def has_completed_report(session: AsyncSession, *, user_id: int) -> bool:
"""该用户是否存在已完成报告(first_report 完成凭据)。"""
result = await session.execute(
select(RpReport.report_id)
.where(RpReport.user_id == user_id, RpReport.status == _REPORT_STATUS_SUCCEEDED)
.limit(1)
)
return result.first() is not None
+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:
+10 -10
View File
@@ -131,19 +131,15 @@ class Settings(BaseSettings):
agent_history_turns: int = 10
# --- 任务奖励(FR-BILL-03/04,配置化)-----------------------------------
# amount 全部是占位值 1:正式数值待产品在产品方案 §3.2 定稿后改 env,不改代码。
# 默认值与产品方案 §3.2 定稿目录一致(2026-09-11):4 项上线任务;
# invite_friend/profile_complete 无可信完成信号,不下发即下架(claim 40410)。
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。
task_bonus_rules: dict[str, dict[str, Any]] = Field(
default_factory=lambda: {
"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"},
"sign_in": {"quota_key": "agent_chat", "amount": 10, "claim_period": "day"},
"report_feedback": {"quota_key": "hairstyle", "amount": 2, "claim_period": "day"},
"share_report": {"quota_key": "tryon", "amount": 3, "claim_period": "day"},
"first_report": {"quota_key": "report_regen", "amount": 1, "claim_period": "lifetime"},
},
alias="TASK_BONUS_RULES_JSON",
)
@@ -163,6 +159,10 @@ class Settings(BaseSettings):
cos_presign_expire_seconds: int = 600
upload_max_bytes: int = 20_971_520 # 20MB
upload_allowed_types: str = "image/jpeg,image/png,image/webp"
# 本地磁盘直传落盘目录(无 COS 四件套的降级路线,docs 9228f67 决策):
# POST /uploads/direct 把字节写到 {upload_dir}/{scene}/{user_id}/{uuid}.{ext},
# object_key 为相对此目录的 POSIX 路径。相对进程 cwd,与 report_export_dir 同约定。
upload_dir: str = "var/uploads"
# scene → {"bucket": "temp"|"user"|"kb"|"report"}。新增上传场景只改这里。
upload_scenes: dict[str, dict[str, Any]] = Field(
default_factory=lambda: {
+2
View File
@@ -39,6 +39,7 @@ class ErrorCode(IntEnum):
CONSENT_REQUIRED = 40302
MINOR_RESTRICTED = 40303
TASK_BONUS_CAP_REACHED = 40310
TASK_BONUS_NOT_ELIGIBLE = 40311
NOT_FOUND = 40400
TASK_BONUS_UNKNOWN = 40410
REPORT_NOT_FOUND = 40420
@@ -101,6 +102,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta(
403, "任务奖励已达上限", "明天再来,或等额度自然恢复"
),
ErrorCode.TASK_BONUS_NOT_ELIGIBLE: ErrorMeta(403, "任务尚未完成", "完成后再来领取"),
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"),