# 渠道订阅类型额度设计文档 **版本:** v2.0 **日期:** 2026-05-19 **状态:** 审阅版 --- ## 1. 背景与问题 ### 1.1 现状 Channel 模型仅有两种额度相关字段: | 字段 | 语义 | 来源 | |------|------|------| | `Balance` | 上游 API 实际余额(USD) | 上游 API 查询 | | `UsedQuota` | 累计消耗量 | 本地扣减,永不重置 | 两者都无法满足"固定额度上限 + 周期重置"的运营需求。 ### 1.2 需求场景 运营者希望对渠道配额管控: - 渠道 A:每天最多 1 万 quota,超限拒绝请求 - 渠道 B:每月最多 50 万 quota,超限拒绝请求 - 渠道 C:不设上限,仅记录消耗(与现有 `UsedQuota` 等价) ### 1.3 已有参考实现 `model/subscription.go` 中已实现完整的用户订阅额度重置机制: ```go // 订阅套餐可配置重置周期 QuotaResetPeriod // daily/weekly/monthly/custom/never // 用户订阅记录 AmountTotal // 总额度 AmountUsed // 已消耗 LastResetTime // 上次重置 NextResetTime // 下次重置 ``` 渠道额度直接复用此设计模式,但消费模式不同(见 3.1)。 --- ## 2. 目标与非目标 ### 2.1 目标 - 渠道可配置额度上限(`QuotaLimit`) - 渠道可配置重置周期(`QuotaResetPeriod`:daily / weekly / monthly / custom / never) - 周期到达时自动重置 `UsedQuota` 为 0 - 额度耗尽时拒绝请求(返回 429) ### 2.2 非目标 - 不支持预扣 + 结算模式(与订阅的 `PreConsume` 不同) - 不支持按渠道内单个 key 分别管控 - 不支持按模型分组分别设置额度 - 不支持额度耗尽自动降级到备用渠道(仅拒绝) - 不支持额度使用率告警通知 --- ## 3. 核心决策 ### 3.1 消费模式:实时扣减(非预扣 + 结算) 订阅采用"预扣 + 结算"双阶段,因为订阅钱包需要精确余额。 渠道采用**实时扣减**,更简单: 1. 请求前检查:`UsedQuota >= QuotaLimit` → 拒绝 2. 请求完成:`UsedQuota += actualQuota` 不做预估算额。`UsedQuota >= QuotaLimit` 时拒绝,意味着当前周期内已消耗量达到上限。由于请求完成才扣减,最后一个被放行的请求可能实际消耗后超过 `QuotaLimit`,但这是可接受的——与 `UpdateChannelUsedQuota` 的原子递增语义一致。 ### 3.2 重置时机:定时任务 + 懒检查兜底 与订阅一致: ```go // 定时任务(每分钟) ResetDueChannels() // 请求前懒检查(定时任务未及时触发时补救) maybeResetChannelQuotaTx() ``` **关键**:懒检查必须在额度检查之前执行,否则重置周期刚到时旧 `UsedQuota` 仍会拒绝合法请求。 ### 3.3 并发安全:FOR UPDATE 行锁 + Updates 精确更新 ```go tx.Set("gorm:query_option", "FOR UPDATE"). Where("id = ? AND next_reset_time <= ?", channelId, now). First(&locked) ``` 重置时只更新相关字段,不用 `Save` 避免覆盖并发更新: ```go tx.Model(&Channel{}).Where("id = ?", channel.Id).Updates(map[string]interface{}{ "used_quota": 0, "last_reset_time": base.Unix(), "next_reset_time": next, }) ``` ### 3.4 额度耗尽行为:返回 429 额度耗尽时返回 HTTP 429,不自动降级。降级涉及渠道选择逻辑改动,复杂度高,列为非目标。 ### 3.5 Balance 与 QuotaLimit 的关系 两者独立共存: | 字段 | 语义 | 来源 | |------|------|------| | `Balance` | 上游 API 实际余额 | 上游 API 查询 | | `UsedQuota` | 本地累计消耗 | 本地扣减 | | `QuotaLimit` | 运营者设定的额度上限 | 配置 | `Balance` 是上游真实余额,`QuotaLimit` 是本地配额管控,两者互不影响。运营者可同时使用或只选其一。 ### 3.6 多 Key 模式 `UsedQuota` 是整个渠道的累计,不区分单个 key。多 key 模式下仅全局管控,与现有行为一致。 --- ## 4. 影响范围 ### 4.1 新增文件 | 文件 | 说明 | |------|------| | `model/channel_quota.go` | 渠道额度核心逻辑(重置时间计算、重置事务、ResetDueChannels、CheckChannelQuota) | | `service/channel_quota_reset_task.go` | 渠道额度重置定时任务 | ### 4.2 修改文件 | 文件 | 修改内容 | |------|----------| | `model/channel.go` | 新增 5 个字段 | | `service/text_quota.go` | 请求前懒重置 + 额度检查 | | `controller/channel.go` | 校验新字段 + 创建/更新时初始化 NextResetTime | | `main.go` | 启动定时任务 | ### 4.3 数据库字段变更 ```sql ALTER TABLE channels ADD COLUMN quota_limit BIGINT DEFAULT 0; ALTER TABLE channels ADD COLUMN quota_reset_period VARCHAR(16) DEFAULT 'never'; ALTER TABLE channels ADD COLUMN quota_reset_custom_seconds INT DEFAULT 0; ALTER TABLE channels ADD COLUMN last_reset_time BIGINT DEFAULT 0; ALTER TABLE channels ADD COLUMN next_reset_time BIGINT DEFAULT 0; ``` `used_quota` 已存在,沿用。现有渠道 `quota_reset_period = 'never'` 即为原有行为。 --- ## 5. 数据流 ```mermaid sequenceDiagram participant Req as 请求 participant Relay as Relay 层 (text_quota.go) participant Model as channel_quota.go participant DB as Database Req->>Relay: 请求完成,准备扣减 Relay->>Model: 懒重置检查 (channelId) Model->>DB: SELECT ... FOR UPDATE alt NextResetTime <= now Model->>DB: UPDATE used_quota=0, last/next_reset_time end Relay->>Model: 额度检查 (channelId) alt UsedQuota >= QuotaLimit (且 QuotaLimit > 0) Relay-->>Req: 429 Channel quota exceeded else Relay->>DB: UPDATE used_quota += actual end ``` ```mermaid sequenceDiagram participant Cron as 定时任务 (每分钟) participant Model as channel_quota.go participant DB as Database Cron->>Model: ResetDueChannels(300) Model->>DB: SELECT next_reset_time <= now AND quota_limit > 0 loop 每条渠道 Model->>DB: FOR UPDATE 锁行 Model->>DB: UPDATE used_quota=0, last/next_reset_time end ``` --- ## 6. 风险与兼容性 | 风险 | 影响 | 缓解措施 | |------|------|----------| | `quota_reset_period = 'never'` 时 `UsedQuota` 行为不变 | 无 | 默认值即为此 | | 历史渠道 `next_reset_time = 0` | 无 | 定时任务跳过,懒检查也跳过 | | 重置时刻有进行中请求 | 低 | `FOR UPDATE` 锁保证串行 | | 最后一个放行请求可能超用 | 低 | 可接受,与现有行为一致 | | SQLite 下 `FOR UPDATE` 无效 | 低 | SQLite 自带数据库级锁,事务串行化 | | 缓存中 `UsedQuota` 可能过期 | 低 | 额度检查直接查 DB,不依赖缓存 | --- ## 7. 验收标准 ### 7.1 功能验收 - [ ] 新增/编辑渠道时可配置 `quota_limit` 和 `quota_reset_period` - [ ] `QuotaLimit > 0` 且 `UsedQuota >= QuotaLimit` 时请求返回 429 - [ ] `QuotaLimit = 0` 时不做额度检查(原有行为) - [ ] 每日重置:次日 00:00 `UsedQuota` 清零 - [ ] 每月重置:每月 1 日 00:00 清零 - [ ] 自定义秒数重置:按配置精确重置 - [ ] 永不重置:`UsedQuota` 持续累计 - [ ] 创建/更新渠道时正确初始化 `NextResetTime` ### 7.2 兼容性验收 - [ ] 未设置 `QuotaLimit` 的历史渠道行为不变 - [ ] GORM AutoMigrate 正确添加新字段 - [ ] SQLite / MySQL / PostgreSQL 均兼容 --- ## 8. 回滚与降级 ### 8.1 回滚 部署后如需回滚:新字段由 GORM AutoMigrate 添加,旧代码忽略未知字段,向前兼容。回滚后定时任务不运行,`UsedQuota` 恢复持续累计行为。 ### 8.2 降级 关闭单个渠道的额度管控:设 `QuotaLimit = 0` + `QuotaResetPeriod = 'never'`,等价于原有行为。 --- ## 9. 参考实现 - `model/subscription.go:300-307` — `NormalizeResetPeriod` - `model/subscription.go:309-348` — `calcNextResetTime` - `model/subscription.go:933-967` — `maybeResetUserSubscriptionWithPlanTx` - `model/subscription.go:1100-1140` — `ResetDueSubscriptions` - `service/subscription_reset_task.go` — 定时任务