feat(billing): task bonus claim endpoint with idempotency and cap

POST /quota/tasks/{task_key}/claim:配置驱动任务注册表、
grant_bonus 纯域函数、ledger 幂等键防重、task_bonus_max 封顶(FR-BILL-03/04)。

实现:packages/domain/billing/quota.py 新增 BonusDecision/grant_bonus(mypy strict,
恰好到顶允许、超一格整体拒绝)、grant_idempotency_key、PERIOD_TYPES;
packages/billing/service.py 新增 claim_task_bonus(幂等快捷路径 → 行锁 → 封顶判定
→ 同事务更新 bonus_granted 并写 system_grant 流水,delta=0 且 balance_after 按
remaining 口径;IntegrityError 并发兜底回滚为 40910);响应模型 TaskBonusClaimResponse
放新建的 packages/billing/schemas.py;路由挂在 apps/api/routers/quota.py,
rules 由 settings.task_bonus_rules 注入(amount 为占位值,产品定稿后改 env)。
测试:新增单测 11 个(封顶边界、amount<=0、不动 used、hard_cap 独立生效、
默认规则形状与 env 覆盖通路);新增集成 4 个(领取+重复 40910+纯 DB 流水断言、
五类任务全部到账、未知任务 40410、amount=11>max=10 → 40310 且无新流水)。
门禁全绿:ruff check / ruff format --check / mypy 两段 / lint-imports;
单测 180 passed,集成 7 passed。

偏差说明:1) 任务文档提到的 BillingService._load_rule 实际名为 _require_rule,
   直接复用现有助手;2) packages/billing/repo.py 无需新增函数(现有
   get_ledger_by_idempotency_key/lock_or_create_quota/add_ledger 已覆盖本任务);
   3) 文档设想的「TASK_BONUS_RULES_JSON 空值容错」经 env 源实测不成立——
   pydantic-settings 对 dict 字段先做 JSON 解码,空白字符串在 Settings 构造时即
   报错(共享校验器/config.py 属禁改,未动);单测改为覆盖受支持的空配置形态 "{}"。
This commit is contained in:
xsl
2026-08-14 16:23:14 +08:00
parent 9e7a886863
commit ecdfbb64c3
7 changed files with 587 additions and 5 deletions
+190
View File
@@ -0,0 +1,190 @@
"""任务奖励领取端到端(FR-BILL-03):发放、幂等、封顶、流水.
只依赖 PostgreSQL(额度与账本都在库里),不经过 Redis,也不触发任何 LLM
调用 —— 与 test_vertical_slice.py 的 FakeProvider 思路一致:集成用例验证
配额链路,不该花钱也不该受网络影响。
"""
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",
),
]
@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"bonus-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 _system_grant_rows(user_id: int) -> list[Any]:
"""纯 DB 断言用:直接读 bl_ledger,不经过 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 _quota_view(client: Any, headers: dict[str, str], quota_key: str) -> dict[str, Any]:
resp = await client.get("/api/v1/quota", headers=headers, params={"quota_key": quota_key})
assert resp.json()["code"] == 0
data: dict[str, Any] = resp.json()["data"]
return data
async def test_claim_grants_immediately_and_repeats_are_rejected(client: Any) -> None:
headers, user_id = await _login(client)
claim = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert claim.status_code == 200
body = claim.json()
assert body["code"] == 0
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
}
# 发放即到账:额度视图立刻可见。
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 1
assert view["remaining"] == 11
# 同一任务同一周期重复 claim → 40910(FR-BILL-03 发放幂等)。
again = await client.post("/api/v1/quota/tasks/sign_in/claim", headers=headers)
assert again.status_code == 409
assert again.json()["code"] == 40910
# 纯 DB 断言:恰好一条 system_grant 流水,balance_after = base + bonus。
rows = await _system_grant_rows(user_id)
assert len(rows) == 1
assert rows[0].delta == 0
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].idempotency_key.startswith(f"grant:{user_id}:sign_in:")
# 幂等拒绝不改变账面。
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 1
async def test_all_five_frbill03_tasks_land_on_their_quotas(client: Any) -> None:
headers, user_id = await _login(client)
all_tasks = (
"sign_in",
"profile_complete",
"report_feedback",
"share_report",
"invite_friend",
)
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 一个。
chat = await _quota_view(client, headers, "agent_chat")
assert chat["bonus_granted"] == 2
assert chat["remaining"] == 12
tryon = await _quota_view(client, headers, "tryon")
assert tryon["bonus_granted"] == 2
assert tryon["remaining"] == 5 # base 3 + bonus 2(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
async def test_unknown_task_returns_40410(client: Any) -> None:
headers, _ = await _login(client)
resp = await client.post("/api/v1/quota/tasks/not_a_real_task/claim", headers=headers)
assert resp.status_code == 404
assert resp.json()["code"] == 40410
@pytest.fixture
def capped_rules(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""把 sign_in 的 amount 覆盖成 11(> agent_chat 的 task_bonus_max=10).
monkeypatch.setenv + get_settings.cache_clear()(结束恢复),与 T1 的
settings 覆盖写法一致;依赖该 fixture 的用例会把 client 建在覆盖之后。
"""
from packages.shared.config import get_settings
monkeypatch.setenv(
"TASK_BONUS_RULES_JSON",
json.dumps({"sign_in": {"quota_key": "agent_chat", "amount": 11, "claim_period": "day"}}),
)
get_settings.cache_clear()
try:
yield
finally:
monkeypatch.undo()
get_settings.cache_clear()
async def test_claim_over_the_cap_is_rejected_without_any_ledger(
capped_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 == 403
assert resp.json()["code"] == 40310
# 拒绝路径不写任何流水,bonus 也不动(FR-BILL-03 验收 3)。
assert await _system_grant_rows(user_id) == []
view = await _quota_view(client, headers, "agent_chat")
assert view["bonus_granted"] == 0