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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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, "报告不存在或已删除"),
|
||||
|
||||
Reference in New Issue
Block a user