Files
sub2api-cn-relay-manager/docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md
2026-05-28 15:37:13 +08:00

14 KiB
Raw Permalink Blame History

宿主侧最小改造方案:让“同 group 多 channel”真正成立

日期2026-05-28

目标

不推翻现有 group / account / channel 主体语义 的前提下,让宿主 sub2api 支持:

  • 同一个 group 绑定多个 channel
  • 每个 channel 持有自己的模型 alias / mapping / restrict / pricing
  • 同一个 group 下的不同 channel 使用各自独立的账号池
  • gateway / sticky session / usage log / account stats pricing 都能准确感知本次请求命中的 channel

这份方案只回答“怎样以最小改造让结构真正成立”,不假装当前 stock / patched host 已经支持。

已验证的真实阻塞

1. 数据库层显式禁止一个 group 属于多个 channel

宿主 migration 里已经把这个约束写死:

  • sub2api-official-fresh/backend/migrations/081_create_channels.sql
    • channel_groups 表定义后立即创建了唯一索引:
      • CREATE UNIQUE INDEX ... idx_channel_groups_group_id ON channel_groups (group_id);
    • 注释也明确写了:每个分组最多属于一个渠道

这就是 remote43 实验里 GROUP_ALREADY_IN_CHANNEL 的第一层来源。

2. 服务层 Create / Update 也主动拦截 group 复用

宿主 ChannelService 在写入前会主动检查冲突:

  • sub2api-official-fresh/backend/internal/service/channel_service.go
    • checkGroupConflicts()
    • Create()
    • Update()

它调用 repo.GetGroupsInOtherChannels(),只要发现 group 已经挂在别的 channel 上,就返回:

  • GROUP_ALREADY_IN_CHANNEL
  • one or more groups already belong to another channel

所以当前不是只有数据库唯一索引在拦,服务层也在拦。

3. 渠道缓存与热路径读取把 group -> single channel 写死了

宿主当前缓存结构是:

  • sub2api-official-fresh/backend/internal/service/channel_service.go
    • channelCache.channelByGroupID map[int64]*Channel

构建缓存时:

  • populateChannelCache() 里直接 cache.channelByGroupID[gid] = ch

读取时:

  • GetChannelForGroup()
  • lookupGroupChannel()
  • ResolveChannelMapping()
  • IsModelRestricted()
  • ResolveChannelMappingAndRestrict()

这些都默认一个 group 只会命中一个 channel

所以即使你删掉唯一索引,当前热路径也只会“最后一个覆盖前一个”,并不会真正支持多 channel。

4. 账号调度根本没有 channel 维度

这点比唯一索引更关键。

宿主当前调度账号的维度是:

  • group_id
  • platform
  • mixed scheduling mode

而不是:

  • group_id
  • channel_id
  • platform

证据:

  • sub2api-official-fresh/backend/internal/service/gateway_service.go
    • listSchedulableAccounts()
    • 调用的都是:
      • ListSchedulableByGroupIDAndPlatform()
      • ListSchedulableByGroupIDAndPlatforms()
  • sub2api-official-fresh/backend/internal/service/scheduler_snapshot_service.go
    • bucketFor() 只有 GroupID / Platform / Mode
    • loadAccountsFromDB() 也是按 groupID + platform
  • sub2api-official-fresh/backend/internal/repository/account_repo.go
    • queryAccountsByGroup()
    • 只查 account_groups.group_id

也就是说:

  • 账号只知道“自己属于哪个 group”
  • 不知道“自己在这个 group 内属于哪个 channel”

如果只放开 group -> multiple channels,调度依然会把同组里的所有账号混在一起挑选,根本无法稳定区分官方线和中转线。

5. sticky session 也只按 group 记忆,不按 channel 分桶

宿主当前粘性会话缓存接口全部只带:

  • groupID
  • sessionHash

证据:

  • sub2api-official-fresh/backend/internal/service/gateway_service.go
    • GetSessionAccountID(ctx, groupID, sessionHash)
    • SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl)
    • RefreshSessionTTL(...)
    • DeleteSessionAccountID(...)
  • openai_sticky_compat.go
  • gemini_messages_compat_service.go
  • openai_ws_state_store.go

这意味着:

  • 同一个 group 下如果未来存在多个 channel
  • 且用户用同一个 session 先后请求不同 alias

当前 sticky key 会把它们混成一个会话桶,发生串线。

6. 账号统计定价也通过 group -> single channel 反推

证据:

  • sub2api-official-fresh/backend/internal/service/account_stats_pricing.go
    • resolveAccountStatsCost() 里直接调用 channelService.GetChannelForGroup(ctx, groupID)

这在单 channel 时代成立,但多 channel 后就不成立了。

好消息是:

  • usage_logs 已经有 channel_id 字段
  • channel_id 已经在落库列里保留

所以这条可以顺着已有字段修正,而不需要重新设计 usage log。

最小正确目标模型

如果要让“同 group 多 channel”真正成立而不是表面能写进去宿主内部语义至少要变成

group
  = 用户权限 / 套餐 / 计费边界

channel
  = 路线定义
  = alias / model mapping / restrict / pricing / features

account_group
  = 某个账号在某个 group 内,属于哪个 channel

也就是:

group 1 --- N channel
group 1 --- N account_group rows
account_group row carries channel_id

这个粒度是最小且正确的,因为:

  • group 仍然是用户和计费边界,不用重做订阅系统
  • channel 仍然是路线与模型映射定义,不用推翻现有 channel 设计
  • 只需把“账号属于 group”升级成“账号在 group 内属于哪个 channel”

最小改造范围

A. 数据库与 Ent schema

A1. 放开 channel_groups 的 group 唯一约束

修改宿主 migration

  • sub2api-official-fresh/backend/migrations/081_create_channels.sql

目标:

  • 删除 idx_channel_groups_group_id ON channel_groups (group_id) 唯一索引
  • 改成组合唯一:
    • UNIQUE(channel_id, group_id)

这样才能允许:

  • 同一个 group 绑定多个 channel
  • 同一个 channel 不能重复绑定同一个 group

A2. 给 account_groups 增加 channel_id

当前 account_groups 只有:

  • account_id
  • group_id
  • priority
  • created_at

需要新增:

  • channel_id BIGINT NULL REFERENCES channels(id) ON DELETE CASCADE

对应文件:

  • sub2api-official-fresh/backend/ent/schema/account_group.go
  • 新 migration 文件,而不是改老 migration

推荐约束:

  • 先允许 channel_idNULL,兼容历史数据
  • 增加组合索引:
    • (group_id, channel_id, priority)
    • (account_id, channel_id)
  • 把主键从 (account_id, group_id) 升级为:
    • surrogate id,或者
    • 组合主键 (account_id, group_id, channel_id)

这里建议直接改成 独立自增 id,避免 Ent edge schema 的复合主键后续再扩展时继续放大维护成本。

A3. 给 account_groups(channel_id, group_id) 补一致性约束

为避免账号绑定到一个“并不覆盖该 group 的 channel”推荐增加逻辑约束

  • account_groups.channel_id IS NOT NULL
  • (channel_id, group_id) 必须存在于 channel_groups

Postgres 里可以通过:

  • channel_groups(channel_id, group_id) 组合唯一
  • account_groups(channel_id, group_id) 组合外键

来保证。

这条约束很值,因为它能把“账号组装错 route”的问题提前挡在写入层。

B. ChannelService 从“单 channel”升级为“候选 channel 集”

需要修改:

  • sub2api-official-fresh/backend/internal/service/channel_service.go

B1. 缓存结构改成 group -> channels

当前:

channelByGroupID map[int64]*Channel

应改成:

channelsByGroupID map[int64][]*Channel

同时把这些按 group -> single channel 命名的方法重做:

  • GetChannelForGroup() -> 保留兼容包装,但不再作为核心接口
  • 新增:
    • GetChannelsForGroup(ctx, groupID int64) ([]*Channel, error)
    • ResolveChannelForModel(ctx, groupID int64, requestedModel string) (*Channel, ChannelMappingResult, error)

B2. 路由解析从“查唯一 channel”改为“在候选集合里选 1 个 channel”

最小正确规则:

  1. 取出该 group 下所有 active channels
  2. 对每个 channel 计算:
    • 这个请求模型是否命中其 mapping / supported models / restrict rule
  3. 如果没有命中:
    • 返回“无 channel 命中”,走 legacy fallback
  4. 如果只命中 1 个:
    • 选它
  5. 如果命中多个:
    • 返回显式冲突错误,例如 CHANNEL_ROUTE_AMBIGUOUS

这样能立刻支持第一阶段:

  • 同 group
  • 多 channel
  • 不同 alias

并且不会默默串线。

B3. Create / Update 不再禁止 group 复用

当前:

  • checkGroupConflicts() 会拦住任何 group 复用

需要改成:

  • 只校验当前请求里的 group_ids 去重是否合法
  • 不再禁止“同一个 group 被另一个 channel 使用”

C. 账号绑定与调度必须带上 channel 维度

这是让结构“真正成立”的核心改造。

C1. 账号绑定 API 要能表达“账号在 group 内属于哪个 channel”

需要改造:

  • sub2api-official-fresh/backend/internal/service/account_service.go
  • sub2api-official-fresh/backend/internal/service/admin_service.go
  • sub2api-official-fresh/backend/internal/repository/account_repo.go

最小做法:

  • 现有 BindGroups(accountID, groupIDs) 保留做 legacy
  • 新增:
    • BindGroupChannels(accountID, []AccountGroupBinding)

其中:

type AccountGroupBinding struct {
    GroupID   int64
    ChannelID *int64
    Priority  int
}

relay-manager 的 provider 导入链路后续只用新的 channel-aware 绑定。

C2. 调度查询改成 group + channel + platform

需要改造:

  • sub2api-official-fresh/backend/internal/repository/account_repo.go
  • sub2api-official-fresh/backend/internal/service/gateway_service.go
  • sub2api-official-fresh/backend/internal/service/scheduler_snapshot_service.go

新增查询:

  • ListSchedulableByGroupIDChannelIDAndPlatform(...)
  • ListSchedulableByGroupIDChannelIDAndPlatforms(...)

行为:

  • channel_id 已解析出来,则只查这个 channel 下绑定到该 group 的账号
  • channel_id 为空,则走 legacy group-only 查询

C3. Scheduler bucket 必须加入 channel_id

当前 bucket

GroupID + Platform + Mode

需要改成:

GroupID + ChannelID + Platform + Mode

否则缓存仍然会把不同 channel 的账号池混在一起。

D. Sticky session 必须加入 channel_id

需要改造:

  • sub2api-official-fresh/backend/internal/service/gateway_service.go
  • openai_sticky_compat.go
  • gemini_messages_compat_service.go
  • openai_ws_state_store.go

当前 key 维度只有:

  • group_id
  • session_hash

最小正确改法:

  • 所有 sticky cache key 升级为:
    • group_id
    • channel_id
    • session_hash

兼容策略:

  • 先读新 key
  • 未命中时按旧 key 回退一次
  • 一旦旧 key 命中,迁移写入新 key

这样可以平滑上线,不会把线上已有 session 全打断。

E. 使用记录与账号统计定价

E1. usage log 继续记录 channel_id

这部分宿主已经有基础:

  • usage_logs 已有 channel_id
  • ChannelMappingResult 也已有 ChannelID

所以这里不需要重新设计字段,只需要保证:

  • 解析 route 时得到的 channel_id
  • 在真实选路成功后稳定写入 usage log

E2. account stats pricing 不再通过 group -> single channel 反推

需要改造:

  • sub2api-official-fresh/backend/internal/service/account_stats_pricing.go

当前:

  • resolveAccountStatsCost() 直接调用 GetChannelForGroup(groupID)

多 channel 后应改成:

  • 优先使用 usage log / request 上下文里的 channel_id
  • 再按 channel_id 读取对应 channel 定价
  • 不再从 group_id 反推出“唯一 channel”

F. relay-manager 对接面需要的最小配合

这部分不是宿主内部必须先改的第一步,但要提前定口径。

F1. provider 导入时把账号绑定到 channel

当前 relay-manager 已经是:

  • 先 ensure group
  • 再 ensure channel
  • 再创建 account 并绑定到 group

宿主改完后relay-manager 需要跟着改成:

  • 先 ensure group
  • 再 ensure channel
  • 再创建 account
  • 再把 account 绑定到:
    • group_id
    • channel_id

这样 route-lab 的 asxscodex2api 才会真正形成两个独立账号池。

F2. Phase 1 不要求 provider schema 变更

第一阶段只要验证“同 group 多 channel + 不同 alias”

  • 现有 provider manifest 基本够用
  • 不必先改 pack/provider schema

也就是说:

  • 宿主先支持 channel-aware account binding
  • relay-manager 只要把导入后的账号正确绑到 channel

就能做第一轮真实验证。

分阶段建议

Phase 1先支持“同 group 多 channel + 不同 alias”

这是最小可交付版本。

范围:

  • 放开 channel_groups 唯一约束
  • account_groupschannel_id
  • 调度 / sticky / usage 走 channel_id
  • ResolveChannelForModel() 要求“最多唯一命中”

可验证场景:

  • gpt-5.4-asxs
  • gpt-5.4-codex2api

都挂到同一个 group但分别命中不同 channel 和不同账号池。

Phase 2再支持“同一个公开模型名双线路”

例如:

  • gpt-5.4
    • route A = asxs
    • route B = codex2api

这一阶段仅靠 Phase 1 不够,还要补:

  • channel.priorityroute_priority
  • failover_policy
  • 何时允许 fallback
    • upstream 429
    • upstream 5xx
    • quota exhausted
    • model unsupported

否则多个 channel 同时命中同一模型名时,只会进入 CHANNEL_ROUTE_AMBIGUOUS

推荐的最小验收标准

宿主按 Phase 1 改完后,至少通过这组真实验收:

  1. gpt-asxs-route-lab 导入成功
  2. gpt-codex2api-route-lab 可复用同一个 group_id
  3. 两者创建出不同 channel_id
  4. 两条线路各自只调度自己的账号
  5. 同一用户 key 访问:
    • gpt-5.4-asxs 命中 asxs 账号池
    • gpt-5.4-codex2api 命中 codex2api 账号池
  6. sticky session 不会在两条 alias 之间串线
  7. usage log 的 channel_id 与真实命中路线一致

一句话结论

让“同 group 多 channel”真正成立的最小正确改造不是“删掉一个唯一索引”,而是:

  1. 放开 channel_groups 的 group 唯一约束
  2. account_groups 增加 channel_id
  3. gateway / scheduler / sticky session / account stats pricing 全部以 channel_id 为 route 维度

少了第 2、3 条,宿主即使允许一个 group 绑定多个 channel也依然只会在运行时把不同路线混成一个账号池结构上仍然是不成立的。