From 76a0502ea33a92cd2135ca2ac834c5c47a0e4d79 Mon Sep 17 00:00:00 2001 From: xsl Date: Sat, 12 Sep 2026 00:23:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E4=BB=BB=E5=8A=A1=E6=8D=A2?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E5=AE=9A=E7=A8=BF=E9=97=AD=E7=8E=AF=E2=80=94?= =?UTF-8?q?=E2=80=94claim=20=E5=87=AD=E6=8D=AE=E6=A0=A1=E9=AA=8C=EF=BC=884?= =?UTF-8?q?0311=EF=BC=89+=20=E5=AE=9A=E7=A8=BF=204=20=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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「占位值」说法顺手更正。 --- .env.example | 6 +- apps/api/routers/quota.py | 4 +- packages/billing/repo.py | 41 +++- packages/billing/service.py | 48 +++- packages/shared/config.py | 20 +- packages/shared/errors.py | 2 + tests/integration/test_task_bonus.py | 58 +++-- .../test_task_bonus_eligibility.py | 213 ++++++++++++++++++ tests/unit/test_task_bonus_rules.py | 17 +- 9 files changed, 365 insertions(+), 44 deletions(-) create mode 100644 tests/integration/test_task_bonus_eligibility.py diff --git a/.env.example b/.env.example index a852da5..aaacb9d 100755 --- a/.env.example +++ b/.env.example @@ -91,9 +91,11 @@ LLM_BUDGET_TIERS_JSON=[{"threshold":0.6,"name":"warn","blocked":[]},{"threshold" REFRESH_TOKEN_TTL_SECONDS=2592000 # ── 任务奖励(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 决定幂等粒度(同一任务同周期只能领一次)。 -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)───────────────────────────────────────────────── EVENTS_MAX_BATCH=50 diff --git a/apps/api/routers/quota.py b/apps/api/routers/quota.py index 5309b7b..542f9a9 100755 --- a/apps/api/routers/quota.py +++ b/apps/api/routers/quota.py @@ -71,8 +71,8 @@ async def claim_task_bonus( ) -> dict[str, object]: """同一任务同一周期只能领一次(重复领返回 40910),受规则封顶(40310). - 奖励数值由 ``TASK_BONUS_RULES_JSON`` 配置驱动(FR-BILL-04),当前是占位值, - 产品定稿后改 env 即生效。 + 奖励数值由 ``TASK_BONUS_RULES_JSON`` 配置驱动(FR-BILL-04),2026-09-11 起默认值 + 即产品定稿(产品方案 §3.2);report_feedback/first_report 有服务端凭据校验(40311)。 """ claim = await billing.claim_task_bonus( user_id=user_id, diff --git a/packages/billing/repo.py b/packages/billing/repo.py index ef92013..4f350d3 100755 --- a/packages/billing/repo.py +++ b/packages/billing/repo.py @@ -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 diff --git a/packages/billing/service.py b/packages/billing/service.py index 5c4f8ec..0888884 100755 --- a/packages/billing/service.py +++ b/packages/billing/service.py @@ -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: diff --git a/packages/shared/config.py b/packages/shared/config.py index 56219fe..8fbda97 100644 --- a/packages/shared/config.py +++ b/packages/shared/config.py @@ -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: { diff --git a/packages/shared/errors.py b/packages/shared/errors.py index abf3930..a1abda9 100644 --- a/packages/shared/errors.py +++ b/packages/shared/errors.py @@ -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, "报告不存在或已删除"), diff --git a/tests/integration/test_task_bonus.py b/tests/integration/test_task_bonus.py index 56886e7..89815ea 100644 --- a/tests/integration/test_task_bonus.py +++ b/tests/integration/test_task_bonus.py @@ -86,15 +86,15 @@ async def test_claim_grants_immediately_and_repeats_are_rejected(client: Any) -> assert body["data"] == { "task_key": "sign_in", "quota_key": "agent_chat", - "amount": 1, # 占位值;正式数值由产品定稿后改 TASK_BONUS_RULES_JSON - "bonus_granted": 1, - "remaining": 11, # base_limit 10 + bonus 1 - used 0 + "amount": 10, # §3.2 定稿值(T2b);改 TASK_BONUS_RULES_JSON 即生效 + "bonus_granted": 10, + "remaining": 20, # base_limit 10 + bonus 10 - used 0 } # 发放即到账:额度视图立刻可见。 view = await _quota_view(client, headers, "agent_chat") - assert view["bonus_granted"] == 1 - assert view["remaining"] == 11 + assert view["bonus_granted"] == 10 + assert view["remaining"] == 20 # 同一任务同一周期重复 claim → 40910(FR-BILL-03 发放幂等)。 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].reason == "sign_in" 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:") # 幂等拒绝不改变账面。 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) + # 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 = ( "sign_in", - "profile_complete", "report_feedback", "share_report", - "invite_friend", + "first_report", ) for task_key in all_tasks: resp = await client.post(f"/api/v1/quota/tasks/{task_key}/claim", headers=headers) assert resp.status_code == 200, 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") - assert chat["bonus_granted"] == 2 - assert chat["remaining"] == 12 + assert chat["bonus_granted"] == 10 + 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") - assert tryon["bonus_granted"] == 2 - assert tryon["remaining"] == 5 # base 3 + bonus 2(task_bonus_max=3 未触顶) + assert tryon["bonus_granted"] == 3 + assert tryon["remaining"] == 6 # base 3 + bonus 3(task_bonus_max=3 未触顶) regen = await _quota_view(client, headers, "report_regen") assert regen["bonus_granted"] == 1 assert regen["remaining"] == 2 rows = await _system_grant_rows(user_id) - assert len(rows) == 5 - assert len({row.idempotency_key for row in rows}) == 5 + assert len(rows) == 4 + assert len({row.idempotency_key for row in rows}) == 4 async def test_unknown_task_returns_40410(client: Any) -> None: diff --git a/tests/integration/test_task_bonus_eligibility.py b/tests/integration/test_task_bonus_eligibility.py new file mode 100644 index 0000000..b9fec60 --- /dev/null +++ b/tests/integration/test_task_bonus_eligibility.py @@ -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) == [] diff --git a/tests/unit/test_task_bonus_rules.py b/tests/unit/test_task_bonus_rules.py index 72c1651..a38d961 100644 --- a/tests/unit/test_task_bonus_rules.py +++ b/tests/unit/test_task_bonus_rules.py @@ -14,13 +14,13 @@ import pytest from packages.domain.billing.quota import PERIOD_TYPES from packages.shared.config import Settings -#: FR-BILL-03 的五类任务:签到、完善资料、反馈、分享、邀请。 -_FR_BILL_03_TASKS = ( +#: 产品方案 §3.2 定稿目录(2026-09-11):签到、反馈、分享、首次速测; +#: invite_friend/profile_complete 无可信完成信号,已下架(不在默认值里)。 +_FINAL_CATALOG_TASKS = ( "sign_in", - "profile_complete", "report_feedback", "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) -def test_default_rules_cover_all_five_frbill03_tasks() -> None: +def test_default_rules_cover_the_final_catalog() -> None: 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 + # 下架任务不得出现在默认值里(不下发即下架,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: @@ -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 amount = conf.get("amount") 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