Files
sub2api-cn-relay-manager/docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md

526 lines
14 KiB
Markdown
Raw Normal View 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”真正成立而不是表面能写进去宿主内部语义至少要变成
```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也依然只会在运行时把不同路线混成一个账号池结构上仍然是不成立的。