diff --git a/.env.example b/.env.example index 1bd98fb..829d0d5 100755 --- a/.env.example +++ b/.env.example @@ -69,7 +69,9 @@ LLM_PEAK_PRICING_ENABLED=true # ── 全局日成本熔断(NFR-COST-02)───────────────────────────────────────── # 按用户配额封不住总成本(总成本 = 用户数 × 人均,用户数无上限), -# 真正封顶的只有这个全局开关。1000_00 分 = ¥1000/天 +# 真正封顶的只有这个全局开关。 +# 100000 分 = ¥1000/天,是按 8-17 之前旧单价推的占位,不是验收值。 +# 峰谷生效后须用实测 token 分布重算(T-P1-25b),否则熔断会在错误位置触发。 LLM_DAILY_BUDGET_ENABLED=true LLM_DAILY_BUDGET_CENTS=100000 LLM_BUDGET_TIMEZONE_OFFSET_HOURS=8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc730c..87bf3f1 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: run: uv run lint-imports --verbose # --- 防线 4: 单元测试 + 覆盖率 --- - - name: Pytest + - name: Pytest (unit) run: uv run pytest --cov-report=xml # --- 防线 5: 数据库迁移必须可正向执行并可回滚 (T-P1-06) --- @@ -94,6 +94,12 @@ jobs: uv run alembic downgrade base uv run alembic upgrade head + # --- 防线 5b: 集成切片。容器已经为迁移往返拉起来了,不跑等于白起 --- + - name: Pytest (integration) + env: + MEIDA_RUN_INTEGRATION: "1" + run: uv run pytest -m integration --no-cov + # --- 防线 6: 敏感信息扫描 --- - name: Secrets scan (detect-secrets) run: | diff --git a/apps/api/routers/quota.py b/apps/api/routers/quota.py index 47503ef..1d54658 100755 --- a/apps/api/routers/quota.py +++ b/apps/api/routers/quota.py @@ -29,7 +29,7 @@ async def get_quota( period_key=snapshot.period_key, used=snapshot.used, limit_value=snapshot.limit_value, - bonus_value=snapshot.bonus_value, + bonus_granted=snapshot.bonus_granted, remaining=snapshot.remaining, reset_at=snapshot.reset_at, ).model_dump() diff --git a/apps/api/schemas.py b/apps/api/schemas.py index cc5b910..8003cac 100755 --- a/apps/api/schemas.py +++ b/apps/api/schemas.py @@ -61,7 +61,7 @@ class QuotaResponse(BaseModel): period_key: str used: int limit_value: int - bonus_value: int + bonus_granted: int remaining: int reset_at: datetime | None diff --git a/migrations/versions/0003_align_quota_with_product_ssot.py b/migrations/versions/0003_align_quota_with_product_ssot.py new file mode 100644 index 0000000..c00d613 --- /dev/null +++ b/migrations/versions/0003_align_quota_with_product_ssot.py @@ -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") diff --git a/packages/billing/repo.py b/packages/billing/repo.py index 5c97b4b..ef92013 100755 --- a/packages/billing/repo.py +++ b/packages/billing/repo.py @@ -38,7 +38,7 @@ async def lock_or_create_quota( period_key=period_key, used=0, limit_value=limit_value, - bonus_value=0, + bonus_granted=0, ) .on_conflict_do_nothing(index_elements=["user_id", "quota_key", "period_key"]) ) diff --git a/packages/billing/service.py b/packages/billing/service.py index c0df09c..87a2370 100755 --- a/packages/billing/service.py +++ b/packages/billing/service.py @@ -44,7 +44,7 @@ class QuotaSnapshot: period_key: str used: int limit_value: int - bonus_value: int + bonus_granted: int remaining: int reset_at: datetime | None @@ -69,14 +69,17 @@ class BillingService: limit_value=rule.base_limit, ) 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( quota_key=quota_key, period_key=period_key, used=row.used, limit_value=row.limit_value, - bonus_value=row.bonus_value, + bonus_granted=row.bonus_granted, remaining=state.remaining, reset_at=quota_rules.period_reset_at(now, period_type, self._tz_offset), ) @@ -126,7 +129,10 @@ class BillingService: limit_value=rule.base_limit, ) 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: @@ -222,7 +228,10 @@ class BillingService: limit_value=rule.base_limit, ) 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) row.used = new_state.used diff --git a/packages/domain/billing/quota.py b/packages/domain/billing/quota.py index 6086803..80bd926 100755 --- a/packages/domain/billing/quota.py +++ b/packages/domain/billing/quota.py @@ -74,10 +74,14 @@ class QuotaState: used: int base_limit: int bonus: int = 0 + hard_cap: int | None = None @property 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 def remaining(self) -> int: diff --git a/packages/shared/models.py b/packages/shared/models.py index 46a6a02..9b7adfe 100755 --- a/packages/shared/models.py +++ b/packages/shared/models.py @@ -127,8 +127,9 @@ class Quota(Base): quota_key: Mapped[str] = mapped_column(String(64), nullable=False) period_key: Mapped[str] = mapped_column(String(32), nullable=False) used: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 本周期创建时从规则拷下的 base_limit。规则中途被改,本周期仍按快照判定。 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( 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_message: Mapped[str | None] = mapped_column(String(512)) 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) submitted_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() diff --git a/packages/task/service.py b/packages/task/service.py index 41756f4..64c96c1 100755 --- a/packages/task/service.py +++ b/packages/task/service.py @@ -42,11 +42,11 @@ class TaskService: task_type: TaskType, idempotency_key: str, payload: dict[str, Any] | None = None, - points_reserved: int = 0, + quota_reserved: int = 0, reserve_tx_id: int | None = None, eta_seconds: int | None = None, ) -> Task: - """Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣点。""" + """Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣减配额。""" existing = await repo.find_by_idempotency_key(self._session, idempotency_key) if existing is not None: return existing @@ -61,7 +61,7 @@ class TaskService: progress=progress_for(TaskStatus.PENDING), idempotency_key=idempotency_key, payload=payload or {}, - points_reserved=points_reserved, + quota_reserved=quota_reserved, reserve_tx_id=reserve_tx_id, eta_seconds=eta_seconds, ), diff --git a/tests/unit/test_quota_domain.py b/tests/unit/test_quota_domain.py index 03a735b..298c5b2 100755 --- a/tests/unit/test_quota_domain.py +++ b/tests/unit/test_quota_domain.py @@ -109,3 +109,12 @@ def test_ledger_can_recompute_the_balance() -> None: state = quota.refund(state, 1) deltas.append(1) 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