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:
@@ -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"])
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user