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
+4 -2
View File
@@ -91,9 +91,11 @@ LLM_BUDGET_TIERS_JSON=[{"threshold":0.6,"name":"warn","blocked":[]},{"threshold"
REFRESH_TOKEN_TTL_SECONDS=2592000 REFRESH_TOKEN_TTL_SECONDS=2592000
# ── 任务奖励(FR-BILL-03/04)─────────────────────────────────────────────── # ── 任务奖励(FR-BILL-03/04)───────────────────────────────────────────────
# amount 均为占位值 1:正式数值待产品在产品方案 §3.2 定稿后改此处,不改代码。 # 产品方案 §3.2 定稿目录(2026-09-11):签到+10 对话/日、反馈+2 发型/日、
# 分享+3 换装/日、首次速测+1 重生成/终身。invite_friend/profile_complete 下架
# (无可信完成信号,不下发即下架,claim 返回 40410)。
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。 # claim_period 决定幂等粒度(同一任务同周期只能领一次)。
TASK_BONUS_RULES_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"}} TASK_BONUS_RULES_JSON={"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"}}
# ── 客户端埋点(FR-SYS-06)───────────────────────────────────────────────── # ── 客户端埋点(FR-SYS-06)─────────────────────────────────────────────────
EVENTS_MAX_BATCH=50 EVENTS_MAX_BATCH=50
+2 -2
View File
@@ -71,8 +71,8 @@ async def claim_task_bonus(
) -> dict[str, object]: ) -> dict[str, object]:
"""同一任务同一周期只能领一次(重复领返回 40910),受规则封顶(40310). """同一任务同一周期只能领一次(重复领返回 40910),受规则封顶(40310).
奖励数值由 ``TASK_BONUS_RULES_JSON`` 配置驱动(FR-BILL-04),当前是占位值, 奖励数值由 ``TASK_BONUS_RULES_JSON`` 配置驱动(FR-BILL-04),2026-09-11 起默认值
产品定稿后改 env 即生效。 即产品定稿(产品方案 §3.2);report_feedback/first_report 有服务端凭据校验(40311)。
""" """
claim = await billing.claim_task_bonus( claim = await billing.claim_task_bonus(
user_id=user_id, user_id=user_id,
+40 -1
View File
@@ -6,11 +6,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession 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: 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: def add_ledger(session: AsyncSession, ledger: Ledger) -> Ledger:
session.add(ledger) session.add(ledger)
return 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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta, timezone
from typing import Any, cast from typing import Any, cast
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -310,20 +310,26 @@ class BillingService:
task_key: str, task_key: str,
rules: dict[str, dict[str, Any]], rules: dict[str, dict[str, Any]],
) -> TaskBonusClaim: ) -> TaskBonusClaim:
"""领取任务奖励:发放即到账、幂等、封顶(FR-BILL-03). """领取任务奖励:完成校验 → 发放即到账、幂等、封顶(FR-BILL-03).
``rules`` 来自 ``settings.task_bonus_rules``(FR-BILL-04 配置化): ``rules`` 来自 ``settings.task_bonus_rules``(FR-BILL-04 配置化):
amount 目前是占位值,产品在产品方案 §3.2 定稿后改 env 即生效, 目录与数值以产品方案 §3.2 定稿为准,改 env 即生效,不需要改代码。
不需要改代码。
""" """
conf = rules.get(task_key) conf = rules.get(task_key)
if conf is None: if conf is None:
raise AppError(ErrorCode.TASK_BONUS_UNKNOWN, data={"task_key": task_key}) raise AppError(ErrorCode.TASK_BONUS_UNKNOWN, data={"task_key": task_key})
quota_key, amount, claim_period = _parse_task_rule(task_key, conf) 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) rule = await self._require_rule(quota_key)
quota_period_type = cast(quota_rules.PeriodType, rule.period_type) quota_period_type = cast(quota_rules.PeriodType, rule.period_type)
now = datetime.now(UTC)
# 幂等粒度跟任务的 claim_period 走,额度行周期跟 bl_quota_rule 走: # 幂等粒度跟任务的 claim_period 走,额度行周期跟 bl_quota_rule 走:
# 「每天可领」的任务可以给「按月计量」的额度加成,两套周期不能混。 # 「每天可领」的任务可以给「按月计量」的额度加成,两套周期不能混。
quota_pk = quota_rules.period_key(now, quota_period_type, self._tz_offset) quota_pk = quota_rules.period_key(now, quota_period_type, self._tz_offset)
@@ -416,6 +422,38 @@ class BillingService:
# -- helpers ------------------------------------------------------------ # -- 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: async def _require_rule(self, quota_key: str) -> QuotaRule:
rule = await repo.get_rule(self._session, quota_key) rule = await repo.get_rule(self._session, quota_key)
if rule is None: if rule is None:
+10 -10
View File
@@ -131,19 +131,15 @@ class Settings(BaseSettings):
agent_history_turns: int = 10 agent_history_turns: int = 10
# --- 任务奖励(FR-BILL-03/04,配置化)----------------------------------- # --- 任务奖励(FR-BILL-03/04,配置化)-----------------------------------
# amount 全部是占位值 1:正式数值待产品在产品方案 §3.2 定稿后改 env,不改代码。 # 默认值与产品方案 §3.2 定稿目录一致(2026-09-11):4 项上线任务;
# invite_friend/profile_complete 无可信完成信号,不下发即下架(claim 40410)。
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。 # claim_period 决定幂等粒度(同一任务同周期只能领一次)。
task_bonus_rules: dict[str, dict[str, Any]] = Field( task_bonus_rules: dict[str, dict[str, Any]] = Field(
default_factory=lambda: { default_factory=lambda: {
"sign_in": {"quota_key": "agent_chat", "amount": 1, "claim_period": "day"}, "sign_in": {"quota_key": "agent_chat", "amount": 10, "claim_period": "day"},
"profile_complete": { "report_feedback": {"quota_key": "hairstyle", "amount": 2, "claim_period": "day"},
"quota_key": "agent_chat", "share_report": {"quota_key": "tryon", "amount": 3, "claim_period": "day"},
"amount": 1, "first_report": {"quota_key": "report_regen", "amount": 1, "claim_period": "lifetime"},
"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"},
}, },
alias="TASK_BONUS_RULES_JSON", alias="TASK_BONUS_RULES_JSON",
) )
@@ -163,6 +159,10 @@ class Settings(BaseSettings):
cos_presign_expire_seconds: int = 600 cos_presign_expire_seconds: int = 600
upload_max_bytes: int = 20_971_520 # 20MB upload_max_bytes: int = 20_971_520 # 20MB
upload_allowed_types: str = "image/jpeg,image/png,image/webp" 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"}。新增上传场景只改这里。 # scene → {"bucket": "temp"|"user"|"kb"|"report"}。新增上传场景只改这里。
upload_scenes: dict[str, dict[str, Any]] = Field( upload_scenes: dict[str, dict[str, Any]] = Field(
default_factory=lambda: { default_factory=lambda: {
+2
View File
@@ -39,6 +39,7 @@ class ErrorCode(IntEnum):
CONSENT_REQUIRED = 40302 CONSENT_REQUIRED = 40302
MINOR_RESTRICTED = 40303 MINOR_RESTRICTED = 40303
TASK_BONUS_CAP_REACHED = 40310 TASK_BONUS_CAP_REACHED = 40310
TASK_BONUS_NOT_ELIGIBLE = 40311
NOT_FOUND = 40400 NOT_FOUND = 40400
TASK_BONUS_UNKNOWN = 40410 TASK_BONUS_UNKNOWN = 40410
REPORT_NOT_FOUND = 40420 REPORT_NOT_FOUND = 40420
@@ -101,6 +102,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta( ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta(
403, "任务奖励已达上限", "明天再来,或等额度自然恢复" 403, "任务奖励已达上限", "明天再来,或等额度自然恢复"
), ),
ErrorCode.TASK_BONUS_NOT_ELIGIBLE: ErrorMeta(403, "任务尚未完成", "完成后再来领取"),
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"), ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"), ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"), ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"),
+41 -17
View File
@@ -86,15 +86,15 @@ async def test_claim_grants_immediately_and_repeats_are_rejected(client: Any) ->
assert body["data"] == { assert body["data"] == {
"task_key": "sign_in", "task_key": "sign_in",
"quota_key": "agent_chat", "quota_key": "agent_chat",
"amount": 1, # 占位值;正式数值由产品定稿后改 TASK_BONUS_RULES_JSON "amount": 10, # §3.2 定稿值(T2b);改 TASK_BONUS_RULES_JSON 即生效
"bonus_granted": 1, "bonus_granted": 10,
"remaining": 11, # base_limit 10 + bonus 1 - used 0 "remaining": 20, # base_limit 10 + bonus 10 - used 0
} }
# 发放即到账:额度视图立刻可见。 # 发放即到账:额度视图立刻可见。
view = await _quota_view(client, headers, "agent_chat") view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 1 assert view["bonus_granted"] == 10
assert view["remaining"] == 11 assert view["remaining"] == 20
# 同一任务同一周期重复 claim → 40910(FR-BILL-03 发放幂等)。 # 同一任务同一周期重复 claim → 40910(FR-BILL-03 发放幂等)。
again = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers) again = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
@@ -108,43 +108,67 @@ async def test_claim_grants_immediately_and_repeats_are_rejected(client: Any) ->
assert rows[0].status == "committed" assert rows[0].status == "committed"
assert rows[0].reason == "sign_in" assert rows[0].reason == "sign_in"
assert rows[0].ref_type == "task_bonus" assert rows[0].ref_type == "task_bonus"
assert rows[0].balance_after == 10 + 1 assert rows[0].balance_after == 10 + 10
assert rows[0].idempotency_key.startswith(f"grant:{user_id}:sign_in:") assert rows[0].idempotency_key.startswith(f"grant:{user_id}:sign_in:")
# 幂等拒绝不改变账面。 # 幂等拒绝不改变账面。
view = await _quota_view(client, headers, "agent_chat") view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 1 assert view["bonus_granted"] == 10
async def test_all_five_frbill03_tasks_land_on_their_quotas(client: Any) -> None: async def test_final_catalog_tasks_land_on_their_quotas(client: Any) -> None:
headers, user_id = await _login(client) headers, user_id = await _login(client)
# T2b 起 report_feedback 需当日 rp_feedback 凭据、first_report 需已完成
# rp_report 凭据(否则 40311):先落齐两行。本用例聚焦「各任务落到各自
# 配额」的发放语义,不覆盖凭据闸门(见 test_task_bonus_eligibility.py)。
from packages.domain.ids import next_id
from packages.shared.db import session_scope
from packages.shared.models import RpFeedback, RpReport
async with session_scope() as session:
report_id = next_id()
session.add(RpReport(report_id=report_id, user_id=user_id, status="succeeded"))
session.add(
RpFeedback(
feedback_id=next_id(),
report_id=report_id,
user_id=user_id,
dimension_key="overall",
verdict="accurate",
)
)
# §3.2 定稿目录(T2b):invite_friend/profile_complete 已下架,不在 env。
all_tasks = ( all_tasks = (
"sign_in", "sign_in",
"profile_complete",
"report_feedback", "report_feedback",
"share_report", "share_report",
"invite_friend", "first_report",
) )
for task_key in all_tasks: for task_key in all_tasks:
resp = await client.post(f"/api/v1/quota/tasks/{task_key}/claim", headers=headers) resp = await client.post(f"/api/v1/quota/tasks/{task_key}/claim", headers=headers)
assert resp.status_code == 200, task_key assert resp.status_code == 200, task_key
assert resp.json()["code"] == 0, task_key assert resp.json()["code"] == 0, task_key
# agent_chat 吃两个任务(sign_in + profile_complete);tryon 吃两个;report_regen 一个。 # 定稿数值:sign_in→agent_chat+10、report_feedback→hairstyle+2、
# share_report→tryon+3、first_report→report_regen+1,各自落各自配额。
chat = await _quota_view(client, headers, "agent_chat") chat = await _quota_view(client, headers, "agent_chat")
assert chat["bonus_granted"] == 2 assert chat["bonus_granted"] == 10
assert chat["remaining"] == 12 assert chat["remaining"] == 20
hairstyle = await _quota_view(client, headers, "hairstyle")
assert hairstyle["bonus_granted"] == 2
assert hairstyle["remaining"] == 5 # base 3 + bonus 2(task_bonus_max=2 触顶即止)
tryon = await _quota_view(client, headers, "tryon") tryon = await _quota_view(client, headers, "tryon")
assert tryon["bonus_granted"] == 2 assert tryon["bonus_granted"] == 3
assert tryon["remaining"] == 5 # base 3 + bonus 2(task_bonus_max=3 未触顶) assert tryon["remaining"] == 6 # base 3 + bonus 3(task_bonus_max=3 未触顶)
regen = await _quota_view(client, headers, "report_regen") regen = await _quota_view(client, headers, "report_regen")
assert regen["bonus_granted"] == 1 assert regen["bonus_granted"] == 1
assert regen["remaining"] == 2 assert regen["remaining"] == 2
rows = await _system_grant_rows(user_id) rows = await _system_grant_rows(user_id)
assert len(rows) == 5 assert len(rows) == 4
assert len({row.idempotency_key for row in rows}) == 5 assert len({row.idempotency_key for row in rows}) == 4
async def test_unknown_task_returns_40410(client: Any) -> None: async def test_unknown_task_returns_40410(client: Any) -> None:
@@ -0,0 +1,213 @@
"""任务换额度完成校验端到端(T2b / FR-BILL-03):40311 凭据闸门 + 定稿数值.
本机 .env 的 TASK_BONUS_RULES_JSON 可能是旧占位目录,本文件用 monkeypatch
把规则钉死为产品方案 §3.2 定稿目录(4 项,invite_friend 下架),断言与
.env 取值解耦。既有 test_task_bonus.py 仍按 .env 现状断言占位行为,两个
文件各管各的口径。
"""
from __future__ import annotations
import json
import os
import uuid
from collections.abc import AsyncIterator, Iterator
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
#: 产品方案 §3.2 定稿目录(2026-09-11):签到+10对话/反馈+2发型/分享+3换装/
#: 首测+1重生成;invite_friend 不下发即下架。
_FINAL_RULES = {
"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"},
}
@pytest.fixture
def final_rules(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""把 TASK_BONUS_RULES_JSON 钉为定稿目录(进程 env 优先于 .env)."""
from packages.shared.config import get_settings
monkeypatch.setenv("TASK_BONUS_RULES_JSON", json.dumps(_FINAL_RULES))
get_settings.cache_clear()
try:
yield
finally:
monkeypatch.undo()
get_settings.cache_clear()
@pytest.fixture
async def client() -> AsyncIterator[Any]:
import httpx
from apps.api.main import create_app
from packages.shared.config import get_settings
app = create_app(get_settings())
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
async def _login(client: Any) -> tuple[dict[str, str], int]:
"""dev-token:每个用例用独立 device_key,拿到的就是全新用户."""
resp = await client.post(
"/api/v1/auth/dev-token", json={"device_key": f"elig-it-{uuid.uuid4().hex[:8]}"}
)
assert resp.json()["code"] == 0
data = resp.json()["data"]
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
async def _grant_rows(user_id: int) -> list[Any]:
"""纯 DB 断言用:直接读 bl_ledger 的 system_grant 流水,不经过 HTTP 视图."""
from sqlalchemy import select
from packages.shared.db import session_scope
from packages.shared.models import Ledger
async with session_scope() as session:
result = await session.execute(
select(Ledger).where(Ledger.user_id == user_id, Ledger.tx_type == "system_grant")
)
return list(result.scalars().all())
async def _create_report(user_id: int, *, status: str = "succeeded") -> int:
"""直接落一行 rp_report 作为完成凭据(不跑流水线,只借表结构)."""
from packages.domain.ids import next_id
from packages.shared.db import session_scope
from packages.shared.models import RpReport
report_id = next_id()
async with session_scope() as session:
session.add(RpReport(report_id=report_id, user_id=user_id, status=status))
return report_id
async def _create_feedback(user_id: int, report_id: int) -> None:
"""落一条当日 rp_feedback(created_at 走 server_default,即“今天”)."""
from packages.domain.ids import next_id
from packages.shared.db import session_scope
from packages.shared.models import RpFeedback
async with session_scope() as session:
session.add(
RpFeedback(
feedback_id=next_id(),
report_id=report_id,
user_id=user_id,
dimension_key="overall",
verdict="accurate",
)
)
async def test_first_report_requires_completed_report(final_rules: None, client: Any) -> None:
headers, user_id = await _login(client)
# 无任何报告 → 40311,且不写流水(验收:拒绝路径不落 bl_ledger)。
resp = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40311
assert await _grant_rows(user_id) == []
# 未完成(pending)的报告不算凭据。
await _create_report(user_id, status="pending")
resp = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40311
assert await _grant_rows(user_id) == []
# 有已完成报告 → 200,定稿数值:report_regen +1。
await _create_report(user_id, status="succeeded")
ok_resp = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert ok_resp.status_code == 200
data = ok_resp.json()["data"]
assert data["task_key"] == "first_report"
assert data["quota_key"] == "report_regen"
assert data["amount"] == 1
assert data["bonus_granted"] == 1
# lifetime 周期再 claim → 40910。
again = await client.post("/api/v1/quota/tasks/first_report/claim", headers=headers)
assert again.status_code == 409
assert again.json()["code"] == 40910
assert len(await _grant_rows(user_id)) == 1
async def test_report_feedback_requires_same_day_feedback(final_rules: None, client: Any) -> None:
headers, user_id = await _login(client)
# 无反馈 → 40311,不写流水。
resp = await client.post("/api/v1/quota/tasks/report_feedback/claim", headers=headers)
assert resp.status_code == 403
assert resp.json()["code"] == 40311
assert await _grant_rows(user_id) == []
# 提交一条当日反馈后 → 200,定稿数值:hairstyle +2。
report_id = await _create_report(user_id)
await _create_feedback(user_id, report_id)
ok_resp = await client.post("/api/v1/quota/tasks/report_feedback/claim", headers=headers)
assert ok_resp.status_code == 200
data = ok_resp.json()["data"]
assert data["quota_key"] == "hairstyle"
assert data["amount"] == 2
assert data["bonus_granted"] == 2
assert len(await _grant_rows(user_id)) == 1
async def test_sign_in_final_amount_and_same_day_repeat(final_rules: None, client: Any) -> None:
headers, _user_id = await _login(client)
resp = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["quota_key"] == "agent_chat"
assert data["amount"] == 10
assert data["bonus_granted"] == 10
assert data["remaining"] == 20 # base_limit 10 + bonus 10
again = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert again.status_code == 409
assert again.json()["code"] == 40910
async def test_share_report_self_reported(final_rules: None, client: Any) -> None:
"""share_report 内测期端侧自报、无服务端校验(§3.2 决策理由 4)."""
headers, _ = await _login(client)
resp = await client.post("/api/v1/quota/tasks/share_report/claim", headers=headers)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["quota_key"] == "tryon"
assert data["amount"] == 3
assert data["bonus_granted"] == 3
async def test_invite_friend_off_shelf_returns_40410(final_rules: None, client: Any) -> None:
"""invite_friend 不在定稿目录:配置化下架,claim 走 40410,不写死分支."""
headers, user_id = await _login(client)
resp = await client.post("/api/v1/quota/tasks/invite_friend/claim", headers=headers)
assert resp.status_code == 404
assert resp.json()["code"] == 40410
assert await _grant_rows(user_id) == []
+10 -7
View File
@@ -14,13 +14,13 @@ import pytest
from packages.domain.billing.quota import PERIOD_TYPES from packages.domain.billing.quota import PERIOD_TYPES
from packages.shared.config import Settings from packages.shared.config import Settings
#: FR-BILL-03 的五类任务:签到、完善资料、反馈、分享、邀请。 #: 产品方案 §3.2 定稿目录(2026-09-11):签到、反馈、分享、首次速测;
_FR_BILL_03_TASKS = ( #: invite_friend/profile_complete 无可信完成信号,已下架(不在默认值里)。
_FINAL_CATALOG_TASKS = (
"sign_in", "sign_in",
"profile_complete",
"report_feedback", "report_feedback",
"share_report", "share_report",
"invite_friend", "first_report",
) )
@@ -29,10 +29,13 @@ def _rules_without_env() -> dict[str, dict[str, object]]:
return dict(Settings(_env_file=None).task_bonus_rules) return dict(Settings(_env_file=None).task_bonus_rules)
def test_default_rules_cover_all_five_frbill03_tasks() -> None: def test_default_rules_cover_the_final_catalog() -> None:
rules = _rules_without_env() rules = _rules_without_env()
for task_key in _FR_BILL_03_TASKS: for task_key in _FINAL_CATALOG_TASKS:
assert task_key in rules, task_key assert task_key in rules, task_key
# 下架任务不得出现在默认值里(不下发即下架,claim 返回 40410)。
assert "invite_friend" not in rules
assert "profile_complete" not in rules
def test_every_default_rule_entry_has_a_valid_shape() -> None: def test_every_default_rule_entry_has_a_valid_shape() -> None:
@@ -42,7 +45,7 @@ def test_every_default_rule_entry_has_a_valid_shape() -> None:
assert isinstance(quota_key, str) and quota_key, task_key assert isinstance(quota_key, str) and quota_key, task_key
amount = conf.get("amount") amount = conf.get("amount")
assert isinstance(amount, int) and not isinstance(amount, bool), task_key assert isinstance(amount, int) and not isinstance(amount, bool), task_key
assert amount > 0, task_key # 占位值 1,正式数值由产品定稿后改 env assert amount > 0, task_key # 定稿数值(§3.2),改 env 即生效
assert conf.get("claim_period") in PERIOD_TYPES, task_key assert conf.get("claim_period") in PERIOD_TYPES, task_key