# 宿主侧最小改造方案:让“同 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”真正成立,而不是表面能写进去,宿主内部语义至少要变成: ```text group = 用户权限 / 套餐 / 计费边界 channel = 路线定义 = alias / model mapping / restrict / pricing / features account_group = 某个账号在某个 group 内,属于哪个 channel ``` 也就是: ```text 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_id` 为 `NULL`,兼容历史数据 - 增加组合索引: - `(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` 当前: ```go channelByGroupID map[int64]*Channel ``` 应改成: ```go 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)` 其中: ```go 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: ```text GroupID + Platform + Mode ``` 需要改成: ```text 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 的 `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-asxs` - `gpt-5.4-codex2api` 都挂到同一个 group,但分别命中不同 channel 和不同账号池。 ### Phase 2:再支持“同一个公开模型名双线路” 例如: - `gpt-5.4` - route A = asxs - route B = codex2api 这一阶段仅靠 Phase 1 不够,还要补: - `channel.priority` 或 `route_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,也依然只会在运行时把不同路线混成一个账号池,结构上仍然是不成立的。