14 KiB
宿主侧最小改造方案:让“同 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.sqlchannel_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.gocheckGroupConflicts()Create()Update()
它调用 repo.GetGroupsInOtherChannels(),只要发现 group 已经挂在别的 channel 上,就返回:
GROUP_ALREADY_IN_CHANNELone or more groups already belong to another channel
所以当前不是只有数据库唯一索引在拦,服务层也在拦。
3. 渠道缓存与热路径读取把 group -> single channel 写死了
宿主当前缓存结构是:
sub2api-official-fresh/backend/internal/service/channel_service.gochannelCache.channelByGroupID map[int64]*Channel
构建缓存时:
populateChannelCache()里直接cache.channelByGroupID[gid] = ch
读取时:
GetChannelForGroup()lookupGroupChannel()ResolveChannelMapping()IsModelRestricted()ResolveChannelMappingAndRestrict()
这些都默认一个 group 只会命中一个 channel。
所以即使你删掉唯一索引,当前热路径也只会“最后一个覆盖前一个”,并不会真正支持多 channel。
4. 账号调度根本没有 channel 维度
这点比唯一索引更关键。
宿主当前调度账号的维度是:
group_idplatform- mixed scheduling mode
而不是:
group_idchannel_idplatform
证据:
sub2api-official-fresh/backend/internal/service/gateway_service.golistSchedulableAccounts()- 调用的都是:
ListSchedulableByGroupIDAndPlatform()ListSchedulableByGroupIDAndPlatforms()
sub2api-official-fresh/backend/internal/service/scheduler_snapshot_service.gobucketFor()只有GroupID / Platform / ModeloadAccountsFromDB()也是按groupID + platform
sub2api-official-fresh/backend/internal/repository/account_repo.goqueryAccountsByGroup()- 只查
account_groups.group_id
也就是说:
- 账号只知道“自己属于哪个 group”
- 不知道“自己在这个 group 内属于哪个 channel”
如果只放开 group -> multiple channels,调度依然会把同组里的所有账号混在一起挑选,根本无法稳定区分官方线和中转线。
5. sticky session 也只按 group 记忆,不按 channel 分桶
宿主当前粘性会话缓存接口全部只带:
groupIDsessionHash
证据:
sub2api-official-fresh/backend/internal/service/gateway_service.goGetSessionAccountID(ctx, groupID, sessionHash)SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl)RefreshSessionTTL(...)DeleteSessionAccountID(...)
openai_sticky_compat.gogemini_messages_compat_service.goopenai_ws_state_store.go
这意味着:
- 同一个 group 下如果未来存在多个 channel
- 且用户用同一个 session 先后请求不同 alias
当前 sticky key 会把它们混成一个会话桶,发生串线。
6. 账号统计定价也通过 group -> single channel 反推
证据:
sub2api-official-fresh/backend/internal/service/account_stats_pricing.goresolveAccountStatsCost()里直接调用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_idgroup_idprioritycreated_at
需要新增:
channel_id BIGINT NULL REFERENCES channels(id) ON DELETE CASCADE
对应文件:
sub2api-official-fresh/backend/ent/schema/account_group.go- 新 migration 文件,而不是改老 migration
推荐约束:
- 先允许
channel_id为NULL,兼容历史数据 - 增加组合索引:
(group_id, channel_id, priority)(account_id, channel_id)
- 把主键从
(account_id, group_id)升级为:- surrogate
id,或者 - 组合主键
(account_id, group_id, channel_id)
- surrogate
这里建议直接改成 独立自增 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”
最小正确规则:
- 取出该
group下所有 active channels - 对每个 channel 计算:
- 这个请求模型是否命中其 mapping / supported models / restrict rule
- 如果没有命中:
- 返回“无 channel 命中”,走 legacy fallback
- 如果只命中 1 个:
- 选它
- 如果命中多个:
- 返回显式冲突错误,例如
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.gosub2api-official-fresh/backend/internal/service/admin_service.gosub2api-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.gosub2api-official-fresh/backend/internal/service/gateway_service.gosub2api-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.goopenai_sticky_compat.gogemini_messages_compat_service.goopenai_ws_state_store.go
当前 key 维度只有:
group_idsession_hash
最小正确改法:
- 所有 sticky cache key 升级为:
group_idchannel_idsession_hash
兼容策略:
- 先读新 key
- 未命中时按旧 key 回退一次
- 一旦旧 key 命中,迁移写入新 key
这样可以平滑上线,不会把线上已有 session 全打断。
E. 使用记录与账号统计定价
E1. usage log 继续记录 channel_id
这部分宿主已经有基础:
usage_logs已有channel_idChannelMappingResult也已有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_idchannel_id
这样 route-lab 的 asxs 与 codex2api 才会真正形成两个独立账号池。
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_groups加channel_id- 调度 / sticky / usage 走
channel_id ResolveChannelForModel()要求“最多唯一命中”
可验证场景:
gpt-5.4-asxsgpt-5.4-codex2api
都挂到同一个 group,但分别命中不同 channel 和不同账号池。
Phase 2:再支持“同一个公开模型名双线路”
例如:
gpt-5.4- route A = asxs
- route B = codex2api
这一阶段仅靠 Phase 1 不够,还要补:
channel.priority或route_priorityfailover_policy- 何时允许 fallback
- upstream 429
- upstream 5xx
- quota exhausted
- model unsupported
否则多个 channel 同时命中同一模型名时,只会进入 CHANNEL_ROUTE_AMBIGUOUS。
推荐的最小验收标准
宿主按 Phase 1 改完后,至少通过这组真实验收:
gpt-asxs-route-lab导入成功gpt-codex2api-route-lab可复用同一个group_id- 两者创建出不同
channel_id - 两条线路各自只调度自己的账号
- 同一用户 key 访问:
gpt-5.4-asxs命中 asxs 账号池gpt-5.4-codex2api命中 codex2api 账号池
- sticky session 不会在两条 alias 之间串线
- usage log 的
channel_id与真实命中路线一致
一句话结论
让“同 group 多 channel”真正成立的最小正确改造不是“删掉一个唯一索引”,而是:
- 放开
channel_groups的 group 唯一约束 - 给
account_groups增加channel_id - 让
gateway / scheduler / sticky session / account stats pricing全部以channel_id为 route 维度
少了第 2、3 条,宿主即使允许一个 group 绑定多个 channel,也依然只会在运行时把不同路线混成一个账号池,结构上仍然是不成立的。