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 属禁改,未动);单测改为覆盖受支持的空配置形态 "{}"。
63 lines
2.6 KiB
Python
63 lines
2.6 KiB
Python
"""settings.task_bonus_rules 的形状与 env 通路(FR-BILL-03/04).
|
||
|
||
协调提交已把 ``task_bonus_rules`` 纳入 ``_parse_json_object`` 共享校验器,
|
||
解析逻辑不再单独测;这里测默认值形状(每条配置必须能被 claim 路径安全消费)
|
||
以及集成测试要依赖的 ``TASK_BONUS_RULES_JSON`` 覆盖通路。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from packages.domain.billing.quota import PERIOD_TYPES
|
||
from packages.shared.config import Settings
|
||
|
||
#: FR-BILL-03 的五类任务:签到、完善资料、反馈、分享、邀请。
|
||
_FR_BILL_03_TASKS = (
|
||
"sign_in",
|
||
"profile_complete",
|
||
"report_feedback",
|
||
"share_report",
|
||
"invite_friend",
|
||
)
|
||
|
||
|
||
def _rules_without_env() -> dict[str, dict[str, object]]:
|
||
# _env_file=None:不读 .env,只看进程环境,保证断言的是代码内默认值。
|
||
return dict(Settings(_env_file=None).task_bonus_rules)
|
||
|
||
|
||
def test_default_rules_cover_all_five_frbill03_tasks() -> None:
|
||
rules = _rules_without_env()
|
||
for task_key in _FR_BILL_03_TASKS:
|
||
assert task_key in rules, task_key
|
||
|
||
|
||
def test_every_default_rule_entry_has_a_valid_shape() -> None:
|
||
for task_key, conf in _rules_without_env().items():
|
||
assert isinstance(conf, dict), task_key
|
||
quota_key = conf.get("quota_key")
|
||
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 conf.get("claim_period") in PERIOD_TYPES, task_key
|
||
|
||
|
||
def test_rules_json_env_reaches_settings_via_alias(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
# 集成测试用同一通路把 amount 覆盖成超限值验证封顶,这里先证明通路本身可用。
|
||
raw = json.dumps({"sign_in": {"quota_key": "agent_chat", "amount": 11, "claim_period": "day"}})
|
||
monkeypatch.setenv("TASK_BONUS_RULES_JSON", raw)
|
||
settings = Settings(_env_file=None)
|
||
assert settings.task_bonus_rules["sign_in"]["amount"] == 11
|
||
|
||
|
||
def test_empty_rules_object_env_means_no_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
# 关闭任务奖励的受支持形态是 "{}":解析为空注册表,所有 claim 返回 40410,
|
||
# 服务照常启动。(空白字符串不是合法 JSON,pydantic-settings 在 Settings
|
||
# 构造时就地失败——那是共享校验器的行为,不归本任务改。)
|
||
monkeypatch.setenv("TASK_BONUS_RULES_JSON", "{}")
|
||
assert Settings(_env_file=None).task_bonus_rules == {}
|