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:
xsl
2026-08-14 11:28:35 +08:00
parent c97f8ae3e1
commit 2009aa938c
11 changed files with 179 additions and 16 deletions
@@ -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")