fix(billing): make quota seed and column names match the product SSOT
The product plan is the only source for quota numbers, but the 0001 seed used a second set (agent_chat 20/50, tryon hard 10) and omitted hairstyle, hd_export and report regen. Migration 0003 replaces the seed and renames points_reserved → quota_reserved and bonus_value → bonus_granted so the docs and the schema stop contradicting each other. Effective limit now clips at hard_cap. CI runs the integration slice against the Postgres/Redis services it already starts.
This commit is contained in:
+3
-1
@@ -69,7 +69,9 @@ LLM_PEAK_PRICING_ENABLED=true
|
|||||||
|
|
||||||
# ── 全局日成本熔断(NFR-COST-02)─────────────────────────────────────────
|
# ── 全局日成本熔断(NFR-COST-02)─────────────────────────────────────────
|
||||||
# 按用户配额封不住总成本(总成本 = 用户数 × 人均,用户数无上限),
|
# 按用户配额封不住总成本(总成本 = 用户数 × 人均,用户数无上限),
|
||||||
# 真正封顶的只有这个全局开关。1000_00 分 = ¥1000/天
|
# 真正封顶的只有这个全局开关。
|
||||||
|
# 100000 分 = ¥1000/天,是按 8-17 之前旧单价推的占位,不是验收值。
|
||||||
|
# 峰谷生效后须用实测 token 分布重算(T-P1-25b),否则熔断会在错误位置触发。
|
||||||
LLM_DAILY_BUDGET_ENABLED=true
|
LLM_DAILY_BUDGET_ENABLED=true
|
||||||
LLM_DAILY_BUDGET_CENTS=100000
|
LLM_DAILY_BUDGET_CENTS=100000
|
||||||
LLM_BUDGET_TIMEZONE_OFFSET_HOURS=8
|
LLM_BUDGET_TIMEZONE_OFFSET_HOURS=8
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ jobs:
|
|||||||
run: uv run lint-imports --verbose
|
run: uv run lint-imports --verbose
|
||||||
|
|
||||||
# --- 防线 4: 单元测试 + 覆盖率 ---
|
# --- 防线 4: 单元测试 + 覆盖率 ---
|
||||||
- name: Pytest
|
- name: Pytest (unit)
|
||||||
run: uv run pytest --cov-report=xml
|
run: uv run pytest --cov-report=xml
|
||||||
|
|
||||||
# --- 防线 5: 数据库迁移必须可正向执行并可回滚 (T-P1-06) ---
|
# --- 防线 5: 数据库迁移必须可正向执行并可回滚 (T-P1-06) ---
|
||||||
@@ -94,6 +94,12 @@ jobs:
|
|||||||
uv run alembic downgrade base
|
uv run alembic downgrade base
|
||||||
uv run alembic upgrade head
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
# --- 防线 5b: 集成切片。容器已经为迁移往返拉起来了,不跑等于白起 ---
|
||||||
|
- name: Pytest (integration)
|
||||||
|
env:
|
||||||
|
MEIDA_RUN_INTEGRATION: "1"
|
||||||
|
run: uv run pytest -m integration --no-cov
|
||||||
|
|
||||||
# --- 防线 6: 敏感信息扫描 ---
|
# --- 防线 6: 敏感信息扫描 ---
|
||||||
- name: Secrets scan (detect-secrets)
|
- name: Secrets scan (detect-secrets)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async def get_quota(
|
|||||||
period_key=snapshot.period_key,
|
period_key=snapshot.period_key,
|
||||||
used=snapshot.used,
|
used=snapshot.used,
|
||||||
limit_value=snapshot.limit_value,
|
limit_value=snapshot.limit_value,
|
||||||
bonus_value=snapshot.bonus_value,
|
bonus_granted=snapshot.bonus_granted,
|
||||||
remaining=snapshot.remaining,
|
remaining=snapshot.remaining,
|
||||||
reset_at=snapshot.reset_at,
|
reset_at=snapshot.reset_at,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ class QuotaResponse(BaseModel):
|
|||||||
period_key: str
|
period_key: str
|
||||||
used: int
|
used: int
|
||||||
limit_value: int
|
limit_value: int
|
||||||
bonus_value: int
|
bonus_granted: int
|
||||||
remaining: int
|
remaining: int
|
||||||
reset_at: datetime | None
|
reset_at: datetime | None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Align quota seed and column names with the product SSOT.
|
||||||
|
|
||||||
|
三件事必须一起做,否则文档声称与实现继续各说各话:
|
||||||
|
|
||||||
|
1. ``tk_task.points_reserved`` → ``quota_reserved``
|
||||||
|
架构 v1.2 已改名,0001 没跟上。V1 扣的是配额不是点数。
|
||||||
|
2. ``bl_quota.bonus_value`` → ``bonus_granted``
|
||||||
|
与架构 §7.4 判定公式用词对齐;``limit_value`` 保留(本周期 base_limit 快照)。
|
||||||
|
3. ``bl_quota_rule`` 种子改按产品方案 §3.2,并补 ``hairstyle`` / ``hd_export`` /
|
||||||
|
``report_regen``。原种子 ``agent_chat`` 20/50、``tryon`` hard 10 是第二套口径,
|
||||||
|
趁表里没有生产数据一次改掉。
|
||||||
|
|
||||||
|
游客额度依赖 ``id_guest``,本迁移不写 ``guest_*`` key。
|
||||||
|
记忆条数 200 是存储上限,不是消耗型配额,不进本表。
|
||||||
|
|
||||||
|
Reversible: 是。降级把列名改回,规则恢复为 0001 的三行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0003"
|
||||||
|
down_revision: str | None = "0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_RULES = sa.table(
|
||||||
|
"bl_quota_rule",
|
||||||
|
sa.column("quota_key", sa.String),
|
||||||
|
sa.column("period_type", sa.String),
|
||||||
|
sa.column("base_limit", sa.Integer),
|
||||||
|
sa.column("task_bonus_max", sa.Integer),
|
||||||
|
sa.column("hard_cap", sa.Integer),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 产品方案 §3.2。改数字只改那里,再回来改这一份。
|
||||||
|
_SSOT = [
|
||||||
|
{
|
||||||
|
"quota_key": "agent_chat",
|
||||||
|
"period_type": "day",
|
||||||
|
"base_limit": 10,
|
||||||
|
"task_bonus_max": 10,
|
||||||
|
"hard_cap": 40,
|
||||||
|
"description": "AI 对话(产品方案 §3.2)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "tryon",
|
||||||
|
"period_type": "day",
|
||||||
|
"base_limit": 3,
|
||||||
|
"task_bonus_max": 3,
|
||||||
|
"hard_cap": 8,
|
||||||
|
"description": "换装试穿(产品方案 §3.2)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "hairstyle",
|
||||||
|
"period_type": "day",
|
||||||
|
"base_limit": 3,
|
||||||
|
"task_bonus_max": 2,
|
||||||
|
"hard_cap": 6,
|
||||||
|
"description": "发型 / 发色试戴(产品方案 §3.2)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "hd_export",
|
||||||
|
"period_type": "day",
|
||||||
|
"base_limit": 1,
|
||||||
|
"task_bonus_max": 0,
|
||||||
|
"hard_cap": 3,
|
||||||
|
"description": "高清导出(产品方案 §3.2)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "report_basic",
|
||||||
|
"period_type": "lifetime",
|
||||||
|
"base_limit": 1,
|
||||||
|
"task_bonus_max": 0,
|
||||||
|
"hard_cap": 1,
|
||||||
|
"description": "初级美学报告首次(终身,产品方案 §3.2)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "report_regen",
|
||||||
|
"period_type": "month",
|
||||||
|
"base_limit": 1,
|
||||||
|
"task_bonus_max": 1,
|
||||||
|
"hard_cap": 2,
|
||||||
|
"description": "报告重新生成(每 30 天;月硬上限 2 即「每日 ≤ 2」)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_LEGACY = [
|
||||||
|
{
|
||||||
|
"quota_key": "agent_chat",
|
||||||
|
"period_type": "day",
|
||||||
|
"base_limit": 20,
|
||||||
|
"task_bonus_max": 10,
|
||||||
|
"hard_cap": 50,
|
||||||
|
"description": "穿搭 Agent 每日对话轮次",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "report_basic",
|
||||||
|
"period_type": "month",
|
||||||
|
"base_limit": 1,
|
||||||
|
"task_bonus_max": 1,
|
||||||
|
"hard_cap": 3,
|
||||||
|
"description": "初级美学报告生成次数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quota_key": "tryon",
|
||||||
|
"period_type": "day",
|
||||||
|
"base_limit": 3,
|
||||||
|
"task_bonus_max": 3,
|
||||||
|
"hard_cap": 10,
|
||||||
|
"description": "智能试穿出图次数",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column("tk_task", "points_reserved", new_column_name="quota_reserved")
|
||||||
|
op.alter_column("bl_quota", "bonus_value", new_column_name="bonus_granted")
|
||||||
|
op.execute(sa.text("DELETE FROM bl_quota_rule"))
|
||||||
|
op.bulk_insert(_RULES, _SSOT)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(sa.text("DELETE FROM bl_quota_rule"))
|
||||||
|
op.bulk_insert(_RULES, _LEGACY)
|
||||||
|
op.alter_column("bl_quota", "bonus_granted", new_column_name="bonus_value")
|
||||||
|
op.alter_column("tk_task", "quota_reserved", new_column_name="points_reserved")
|
||||||
@@ -38,7 +38,7 @@ async def lock_or_create_quota(
|
|||||||
period_key=period_key,
|
period_key=period_key,
|
||||||
used=0,
|
used=0,
|
||||||
limit_value=limit_value,
|
limit_value=limit_value,
|
||||||
bonus_value=0,
|
bonus_granted=0,
|
||||||
)
|
)
|
||||||
.on_conflict_do_nothing(index_elements=["user_id", "quota_key", "period_key"])
|
.on_conflict_do_nothing(index_elements=["user_id", "quota_key", "period_key"])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class QuotaSnapshot:
|
|||||||
period_key: str
|
period_key: str
|
||||||
used: int
|
used: int
|
||||||
limit_value: int
|
limit_value: int
|
||||||
bonus_value: int
|
bonus_granted: int
|
||||||
remaining: int
|
remaining: int
|
||||||
reset_at: datetime | None
|
reset_at: datetime | None
|
||||||
|
|
||||||
@@ -69,14 +69,17 @@ class BillingService:
|
|||||||
limit_value=rule.base_limit,
|
limit_value=rule.base_limit,
|
||||||
)
|
)
|
||||||
state = quota_rules.QuotaState(
|
state = quota_rules.QuotaState(
|
||||||
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
|
used=row.used,
|
||||||
|
base_limit=row.limit_value,
|
||||||
|
bonus=row.bonus_granted,
|
||||||
|
hard_cap=rule.hard_cap,
|
||||||
)
|
)
|
||||||
return QuotaSnapshot(
|
return QuotaSnapshot(
|
||||||
quota_key=quota_key,
|
quota_key=quota_key,
|
||||||
period_key=period_key,
|
period_key=period_key,
|
||||||
used=row.used,
|
used=row.used,
|
||||||
limit_value=row.limit_value,
|
limit_value=row.limit_value,
|
||||||
bonus_value=row.bonus_value,
|
bonus_granted=row.bonus_granted,
|
||||||
remaining=state.remaining,
|
remaining=state.remaining,
|
||||||
reset_at=quota_rules.period_reset_at(now, period_type, self._tz_offset),
|
reset_at=quota_rules.period_reset_at(now, period_type, self._tz_offset),
|
||||||
)
|
)
|
||||||
@@ -126,7 +129,10 @@ class BillingService:
|
|||||||
limit_value=rule.base_limit,
|
limit_value=rule.base_limit,
|
||||||
)
|
)
|
||||||
state = quota_rules.QuotaState(
|
state = quota_rules.QuotaState(
|
||||||
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
|
used=row.used,
|
||||||
|
base_limit=row.limit_value,
|
||||||
|
bonus=row.bonus_granted,
|
||||||
|
hard_cap=rule.hard_cap,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -222,7 +228,10 @@ class BillingService:
|
|||||||
limit_value=rule.base_limit,
|
limit_value=rule.base_limit,
|
||||||
)
|
)
|
||||||
state = quota_rules.QuotaState(
|
state = quota_rules.QuotaState(
|
||||||
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
|
used=row.used,
|
||||||
|
base_limit=row.limit_value,
|
||||||
|
bonus=row.bonus_granted,
|
||||||
|
hard_cap=rule.hard_cap,
|
||||||
)
|
)
|
||||||
new_state = quota_rules.refund(state, reservation.amount)
|
new_state = quota_rules.refund(state, reservation.amount)
|
||||||
row.used = new_state.used
|
row.used = new_state.used
|
||||||
|
|||||||
@@ -74,10 +74,14 @@ class QuotaState:
|
|||||||
used: int
|
used: int
|
||||||
base_limit: int
|
base_limit: int
|
||||||
bonus: int = 0
|
bonus: int = 0
|
||||||
|
hard_cap: int | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_limit(self) -> int:
|
def effective_limit(self) -> int:
|
||||||
return self.base_limit + self.bonus
|
raw = self.base_limit + self.bonus
|
||||||
|
if self.hard_cap is None:
|
||||||
|
return raw
|
||||||
|
return min(raw, self.hard_cap)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def remaining(self) -> int:
|
def remaining(self) -> int:
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ class Quota(Base):
|
|||||||
quota_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
quota_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
period_key: Mapped[str] = mapped_column(String(32), nullable=False)
|
period_key: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# 本周期创建时从规则拷下的 base_limit。规则中途被改,本周期仍按快照判定。
|
||||||
limit_value: Mapped[int] = mapped_column(Integer, nullable=False)
|
limit_value: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
bonus_value: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
bonus_granted: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
@@ -190,7 +191,7 @@ class Task(Base):
|
|||||||
error_code: Mapped[int | None] = mapped_column(Integer)
|
error_code: Mapped[int | None] = mapped_column(Integer)
|
||||||
error_message: Mapped[str | None] = mapped_column(String(512))
|
error_message: Mapped[str | None] = mapped_column(String(512))
|
||||||
retry_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
|
retry_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
|
||||||
points_reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
quota_reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
reserve_tx_id: Mapped[int | None] = mapped_column(BigInteger)
|
reserve_tx_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
submitted_at: Mapped[datetime] = mapped_column(
|
submitted_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ class TaskService:
|
|||||||
task_type: TaskType,
|
task_type: TaskType,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
points_reserved: int = 0,
|
quota_reserved: int = 0,
|
||||||
reserve_tx_id: int | None = None,
|
reserve_tx_id: int | None = None,
|
||||||
eta_seconds: int | None = None,
|
eta_seconds: int | None = None,
|
||||||
) -> Task:
|
) -> Task:
|
||||||
"""Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣点。"""
|
"""Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣减配额。"""
|
||||||
existing = await repo.find_by_idempotency_key(self._session, idempotency_key)
|
existing = await repo.find_by_idempotency_key(self._session, idempotency_key)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
@@ -61,7 +61,7 @@ class TaskService:
|
|||||||
progress=progress_for(TaskStatus.PENDING),
|
progress=progress_for(TaskStatus.PENDING),
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
points_reserved=points_reserved,
|
quota_reserved=quota_reserved,
|
||||||
reserve_tx_id=reserve_tx_id,
|
reserve_tx_id=reserve_tx_id,
|
||||||
eta_seconds=eta_seconds,
|
eta_seconds=eta_seconds,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -109,3 +109,12 @@ def test_ledger_can_recompute_the_balance() -> None:
|
|||||||
state = quota.refund(state, 1)
|
state = quota.refund(state, 1)
|
||||||
deltas.append(1)
|
deltas.append(1)
|
||||||
assert state.used == -sum(deltas)
|
assert state.used == -sum(deltas)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hard_cap_clips_base_plus_bonus() -> None:
|
||||||
|
# 产品方案 §3.2:有效额度 = min(base + bonus, hard_cap)
|
||||||
|
state = quota.QuotaState(used=0, base_limit=10, bonus=10, hard_cap=40)
|
||||||
|
assert state.effective_limit == 20
|
||||||
|
clipped = quota.QuotaState(used=0, base_limit=10, bonus=40, hard_cap=40)
|
||||||
|
assert clipped.effective_limit == 40
|
||||||
|
assert not quota.evaluate(clipped, 41).allowed
|
||||||
|
|||||||
Reference in New Issue
Block a user