Files
meida_backend/packages/billing/repo.py
T
xsl 76a0502ea3 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「占位值」说法顺手更正。
2026-09-12 00:23:43 +08:00

119 lines
4.0 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Data access for the billing domain. **Only this module may touch bl_* tables.**
架构文档 §3.2 硬性规则 1: 每个域只能通过对方暴露的 service 接口调用,禁止直接
查对方的表。import-linter enforces it (contract "no cross-domain repository access").
"""
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, RpFeedback, RpReport
async def get_rule(session: AsyncSession, quota_key: str) -> QuotaRule | None:
return await session.get(QuotaRule, quota_key)
async def lock_or_create_quota(
session: AsyncSession,
*,
user_id: int,
quota_key: str,
period_key: str,
limit_value: int,
) -> Quota:
"""Get the period row with a row lock, creating it on first use.
``ON CONFLICT DO NOTHING`` + ``SELECT ... FOR UPDATE`` makes concurrent first
requests for the same period safe without an advisory lock.
"""
await session.execute(
pg_insert(Quota)
.values(
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
used=0,
limit_value=limit_value,
bonus_granted=0,
)
.on_conflict_do_nothing(index_elements=["user_id", "quota_key", "period_key"])
)
result = await session.execute(
select(Quota)
.where(
Quota.user_id == user_id,
Quota.quota_key == quota_key,
Quota.period_key == period_key,
)
.with_for_update()
)
row = result.scalar_one()
return row
async def get_ledger_by_idempotency_key(session: AsyncSession, key: str) -> Ledger | None:
result = await session.execute(select(Ledger).where(Ledger.idempotency_key == key))
return result.scalar_one_or_none()
async def get_ledger(session: AsyncSession, tx_id: int) -> Ledger | None:
return await session.get(Ledger, tx_id)
async def list_ledger(session: AsyncSession, user_id: int, limit: int = 50) -> list[Ledger]:
result = await session.execute(
select(Ledger)
.where(Ledger.user_id == user_id)
.order_by(Ledger.created_at.desc(), Ledger.tx_id.desc())
.limit(limit)
)
return list(result.scalars().all())
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