526 lines
14 KiB
Markdown
526 lines
14 KiB
Markdown
# 宿主侧最小改造方案:让“同 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,也依然只会在运行时把不同路线混成一个账号池,结构上仍然是不成立的。
|