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
+41 -17
View File
@@ -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:
@@ -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.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