new-api/docs/agent-specs/2026-05-19-channel-subscription-quota-review.md
zizi f245e9bdf5 feat: add channel quota reset controls
Add per-channel quota limits with reset scheduling and runtime checks so channel capacity can be capped and restored on configured periods.
2026-05-20 12:57:19 +08:00

7.9 KiB
Raw Permalink Blame History

渠道订阅类型额度设计文档

版本: 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 中已实现完整的用户订阅额度重置机制:

// 订阅套餐可配置重置周期
QuotaResetPeriod    // daily/weekly/monthly/custom/never

// 用户订阅记录
AmountTotal         // 总额度
AmountUsed          // 已消耗
LastResetTime       // 上次重置
NextResetTime       // 下次重置

渠道额度直接复用此设计模式,但消费模式不同(见 3.1)。


2. 目标与非目标

2.1 目标

  • 渠道可配置额度上限(QuotaLimit
  • 渠道可配置重置周期(QuotaResetPerioddaily / 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 重置时机:定时任务 + 懒检查兜底

与订阅一致:

// 定时任务(每分钟)
ResetDueChannels()

// 请求前懒检查(定时任务未及时触发时补救)
maybeResetChannelQuotaTx()

关键:懒检查必须在额度检查之前执行,否则重置周期刚到时旧 UsedQuota 仍会拒绝合法请求。

3.3 并发安全FOR UPDATE 行锁 + Updates 精确更新

tx.Set("gorm:query_option", "FOR UPDATE").
    Where("id = ? AND next_reset_time <= ?", channelId, now).
    First(&locked)

重置时只更新相关字段,不用 Save 避免覆盖并发更新:

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 数据库字段变更

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. 数据流

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
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_limitquota_reset_period
  • QuotaLimit > 0UsedQuota >= 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-307NormalizeResetPeriod
  • model/subscription.go:309-348calcNextResetTime
  • model/subscription.go:933-967maybeResetUserSubscriptionWithPlanTx
  • model/subscription.go:1100-1140ResetDueSubscriptions
  • service/subscription_reset_task.go — 定时任务