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