docs(routing): capture phase1 foundation plan

This commit is contained in:
phamnazage-jpg
2026-05-28 15:37:13 +08:00
parent 7f75d8a670
commit 361a93270f
6 changed files with 3038 additions and 0 deletions

View File

@@ -100,6 +100,47 @@
- `gpt-codex2api-route-lab` 在尝试复用同一 group 时被宿主直接拒绝artifact`artifacts/real-host-acceptance/20260528_142320_remote43_gpt-codex2api-route-lab_key_import/03-import.body.json`
- 宿主返回 `409 GROUP_ALREADY_IN_CHANNEL`,错误为:`one or more groups already belong to another channel`
- 因此当前真实结论不是“同组多 channel 可继续验证路由策略”,而是 **stock / patched sub2api 当前结构上不允许同一个 group 绑定到第二个 channel**
- 2026-05-28 已进一步把宿主侧最小改造方案固化到 `docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md`
- 真实最小改造不只是移除 `channel_groups(group_id)` 唯一索引
- 还必须给宿主 `account_groups` 引入 `channel_id`,并让 `gateway / scheduler / sticky session / account stats pricing` 全部从 `group -> single channel` 升级到 `group + channel` 维度
- 否则就算数据库允许同一 group 绑定多个 channel运行时账号池仍会被按 group 混跑,结构上仍不成立
- 2026-05-28 已明确 fallback 方案:不修改宿主源码,改由 relay-manager 插件层维护 `logical_group -> route -> shadow_group` 三层抽象,详见 `docs/PLUGIN_ROUTE_STICKY_DESIGN.md`
- 前端只看到一个逻辑分组
- 插件层先做 route 级 sticky再把请求稳定转发到某个宿主 shadow group
- 宿主继续只做单线路 group 内的 account sticky / 调度
- 2026-05-28 已新增插件整体需求盘点 `docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md`
- 已把“增加模型、维护逻辑分组、智能路由、供应商帐号导入与停启用、普通用户前端”五大功能域统一收口
- 并明确区分 `已完成 / 待优化 / 待完成 / 未来规划`
- 2026-05-28 已继续细化闭环实施规划 `docs/PLUGIN_CLOSED_LOOP_IMPLEMENTATION_PLAN_2026-05-28.md`
- 明确当前插件数据库仍为 SQLite`SUB2API_CRM_SQLITE_DSN`
- 明确后续继续以 SQLite 作为主状态库Redis 作为智能路由运行态缓存
- 明确智能路由日志必须结构化落入插件 SQLite而不是只放 Redis 或 stdout
- 2026-05-28 已新增 Phase 1 可开工任务单 `docs/plans/2026-05-28-phase1-logical-routing-foundation-plan.md`
- 已把 `SQLite migration / logical_group-route repo+API / 路由日志写入器 / Redis sticky 抽象` 拆成可执行任务
- 已继续细化到任务级 `入场条件 / 产出清单 / 远端验证步骤 / 证据要求 / 回滚原则`
- 并明确要求:每个闭环功能完成后,都必须提交、推送、部署到 `remote43` 再验证,不能只停留在本地测试
- 当前 Phase 1 的统一真相是:
- 主状态库继续使用 SQLite
- 路由运行态使用 Redis 或 memory backend 抽象
- 智能路由日志必须最终结构化写回插件 SQLite
- 2026-05-28 已完成 Phase 1 / `P1-T1 SQLite schema foundation`
- 提交:`7f75d8a6 feat(routing): add logical group schema foundation`
- 新 migration`internal/store/migrations/0010_logical_groups_and_routes.sql`
- 本地门禁已通过:
- `gofmt -l .`
- `go vet ./...`
- `go test -cover ./internal/...`
- `go test ./tests/integration/... -count=1`
- remote43 已原位升级到 `repo HEAD = 7f75d8a`
- `http://127.0.0.1:18173/healthz` 返回 `ok`
- remote43 实例 SQLite `/home/ubuntu/sub2api-kimi-patched-auto2-20260525_18169/sub2api-cn-relay-manager.db` 已确认包含:
- `logical_groups`
- `logical_group_models`
- `logical_group_routes`
- `logical_group_route_models`
- 这轮远端验证还顺手暴露并修正了一个部署细节:
- 若只在 `/home/ubuntu` 下直接拉起 CRM新进程会回退到默认相对 SQLite 路径 `/home/ubuntu/sub2api-cn-relay-manager.db`
- 当前已改为显式 `cd` 到实例目录并 `source .env.crm` 后再启动,确保 migration 生效在实例库而不是错误的默认库
- 2026-05-26 已把“最终用户 -> 公网域名 -> OpenClaw”这一跳补进正式验证口径
- 公网根地址当前统一为 `https://sub.tksea.top`
- OpenClaw 本地 `MiniMax` 运行时故障已定位为 `pi-ai/openai-node` 未继承系统 `HTTP(S)_PROXY`,不是 allowlist 或模型名大小写问题

View File

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

View File

@@ -0,0 +1,661 @@
# 插件闭环实施规划2026-05-28
日期2026-05-28
## 目标
把插件后续演进从“概念设计”推进到“可执行实施”,明确:
- 当前数据库是什么
- 后续继续沿用什么存储结构
- 每个功能域怎样形成闭环
- 智能路由的日志必须落在哪里
- 哪些运行态适合放 Redis哪些必须写插件数据库
这份文档是对以下文档的进一步细化:
- [PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md](/home/long/project/sub2api-cn-relay-manager/docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md:1)
- [PLUGIN_ROUTE_STICKY_DESIGN.md](/home/long/project/sub2api-cn-relay-manager/docs/PLUGIN_ROUTE_STICKY_DESIGN.md:1)
## 一、当前数据库结论
### 当前插件用的是什么数据库
当前插件主数据库是 **SQLite**
直接证据:
- 配置项:
- [internal/config/config.go](/home/long/project/sub2api-cn-relay-manager/internal/config/config.go:12)
- 环境变量 `SUB2API_CRM_SQLITE_DSN`
- 默认配置:
- [.env.example](/home/long/project/sub2api-cn-relay-manager/.env.example:2)
- `SUB2API_CRM_SQLITE_DSN=file:/data/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000`
- 打开数据库与迁移入口:
- [internal/store/sqlite/db.go](/home/long/project/sub2api-cn-relay-manager/internal/store/sqlite/db.go:35)
### 当前 SQLite 里已经在存什么
当前 SQLite 已经承载:
- host 注册信息
- pack/provider 元数据
- provider drafts
- import batches / import items
- managed resources
- probe results
- access closures
- reconcile runs
相关表来自:
- [0001_init.sql](/home/long/project/sub2api-cn-relay-manager/internal/store/migrations/0001_init.sql:1)
- [0002_operational_runtime.sql](/home/long/project/sub2api-cn-relay-manager/internal/store/migrations/0002_operational_runtime.sql:1)
- 后续 migrations `0003``0009*`
### 当前数据库特性边界
SQLite 目前是合理选择,原因是:
- 项目当前是单实例控制面为主
- 状态库规模仍然偏中小
- 现有 repo / migration / integration test 全围绕 SQLite 建设
但要明确一个实现事实:
- [internal/store/sqlite/db.go](/home/long/project/sub2api-cn-relay-manager/internal/store/sqlite/db.go:42)
已经把连接池限制为单 writer 友好模式:
- `SetMaxOpenConns(1)`
- `SetMaxIdleConns(1)`
这意味着:
- SQLite 很适合当前控制面状态持久化
- 但智能路由日志不能无限制高频同步写库,否则会放大写锁争用
所以后续方案应当是:
- **SQLite 继续作为插件主状态库**
- **Redis 作为智能路由运行态缓存**
- **智能路由日志按结构化事件写回 SQLite**
---
## 二、后续存储策略
## 总体原则
### 1. SQLite保存“真相状态”
放 SQLite 的必须是:
- 配置真相
- 可审计真相
- 需要查询/回放/后台管理的结构化记录
### 2. Redis保存“短期运行态”
放 Redis 的应该是:
- sticky route
- route cooldown
- 短期失败计数
- 热路由选择缓存
### 3. 智能路由日志:必须最终落 SQLite
用户已经明确要求:
- **智能路由日志要存在插件中**
所以要求不能只停留在 Redis也不能只打 stdout。
正确做法是:
- 请求热路径上先写结构化内存事件/异步队列
- 由后台 writer 批量落 SQLite
- 关键失败场景允许同步兜底落一条简版事件
---
## 三、目标架构
建议后续插件内部形成三层存储:
```text
SQLite
- 配置真相
- 业务真相
- 路由日志
Redis
- sticky route
- cooldown
- short-lived routing cache
In-memory
- 单请求上下文
- 异步日志缓冲队列
```
## SQLite 负责
- `logical_groups`
- `logical_group_routes`
- `logical_group_models`
- `route_shadow_groups`
- `route_decision_logs`
- `route_failover_events`
- `route_sticky_audit`
- `provider account inventory` 扩展表
## Redis 负责
- 当前 route sticky
- route 失败计数
- route cooldown
- 可选的 user-model route cache
---
## 四、功能闭环规划
下面按功能域写成闭环,不再只列功能点。
## 4.1 增加模型闭环
### 目标闭环
运营在管理页完成一次“新增模型”后,应形成:
1. provider manifest 被创建或更新
2. 模型被纳入某个 `logical_group`
3. 至少一条 route 被配置好
4. route 对应的 shadow group 被定义好
5. 后续可直接导入供应商帐号
### 当前已有能力
- provider drafts
- publish to pack repo
- 同模型冲突校验
### 待补技术细节
新增模型不应只产生 provider 文件,还应额外落地以下数据:
#### 新表:`logical_groups`
```sql
CREATE TABLE logical_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logical_group_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
route_policy TEXT NOT NULL DEFAULT 'priority',
sticky_mode TEXT NOT NULL DEFAULT 'conversation_preferred',
conversation_ttl_seconds INTEGER NOT NULL DEFAULT 7200,
user_model_ttl_seconds INTEGER NOT NULL DEFAULT 1800,
failover_threshold INTEGER NOT NULL DEFAULT 2,
cooldown_seconds INTEGER NOT NULL DEFAULT 600,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
#### 新表:`logical_group_models`
```sql
CREATE TABLE logical_group_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (logical_group_id, public_model)
);
```
### 闭环接口
建议新增:
- `POST /api/logical-groups`
- `POST /api/logical-groups/{group_id}/models`
- `GET /api/logical-groups`
- `GET /api/logical-groups/{group_id}`
### 验收标准
- 新增模型后,不只是 pack 文件变了
- 插件库里也能看到:
- 该模型属于哪个 logical group
- 哪些 route 支持它
---
## 4.2 维护逻辑分组闭环
### 目标闭环
运营维护一个逻辑分组时,应能完成:
1. 新建逻辑分组
2. 绑定公开模型集合
3. 绑定多条 route
4. 每条 route 绑定 shadow group
5. 管理页可查看该逻辑分组当前真实承载结构
### 新表:`logical_group_routes`
```sql
CREATE TABLE logical_group_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT NOT NULL UNIQUE,
logical_group_id TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 100,
shadow_group_id TEXT NOT NULL,
shadow_host_id TEXT NOT NULL,
upstream_base_url_hint TEXT NOT NULL DEFAULT '',
cooldown_until TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### 新表:`logical_group_route_models`
```sql
CREATE TABLE logical_group_route_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT NOT NULL,
public_model TEXT NOT NULL,
shadow_model TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (route_id, public_model)
);
```
### 闭环接口
- `POST /api/logical-groups/{group_id}/routes`
- `PUT /api/logical-groups/{group_id}/routes/{route_id}`
- `GET /api/logical-groups/{group_id}/routes`
- `POST /api/logical-groups/{group_id}/routes/{route_id}/models`
### 验收标准
- 打开某个 logical group 时,可以完整看到:
- 公开模型
- route 列表
- 每条 route 对应的 shadow group
- route 当前状态
---
## 4.3 智能路由闭环
这是新增需求后的核心实现。
### 目标闭环
一次用户请求进入插件后,应形成完整闭环:
1. 识别 logical group
2. 识别 public model
3. 计算 sticky key
4. 选择 route
5. 记录 route decision log
6. 转发到对应 shadow group
7. 收到结果后更新 sticky / fail count / cooldown
8. 记录最终路由结果日志
### 运行态存储
#### Redis keysticky
```text
lg:{logical_group_id}:m:{public_model}:conv:{conversation_id}
lg:{logical_group_id}:m:{public_model}:sess:{session_id}
lg:{logical_group_id}:m:{public_model}:user:{user_id}
```
#### Redis keyroute failure
```text
routefail:{route_id}
```
#### Redis keyroute cooldown
```text
routecool:{route_id}
```
### 路由日志必须写入 SQLite
#### 新表:`route_decision_logs`
```sql
CREATE TABLE route_decision_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
user_key TEXT NOT NULL DEFAULT '',
conversation_key TEXT NOT NULL DEFAULT '',
sticky_key TEXT NOT NULL DEFAULT '',
sticky_key_type TEXT NOT NULL DEFAULT '',
sticky_hit INTEGER NOT NULL DEFAULT 0,
selected_route_id TEXT NOT NULL,
selected_shadow_group_id TEXT NOT NULL,
fallback_used INTEGER NOT NULL DEFAULT 0,
error_class TEXT NOT NULL DEFAULT '',
upstream_status INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
#### 新表:`route_failover_events`
```sql
CREATE TABLE route_failover_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
from_route_id TEXT NOT NULL,
to_route_id TEXT NOT NULL,
reason TEXT NOT NULL,
failure_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
#### 新表:`route_sticky_audit`
```sql
CREATE TABLE route_sticky_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sticky_key TEXT NOT NULL,
sticky_key_type TEXT NOT NULL,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
route_id TEXT NOT NULL,
action TEXT NOT NULL,
expires_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### 为什么日志必须进 SQLite
因为后续必须支持:
- 查询“这次为什么走了 codex2api 而不是 asxs”
- 查询某个 logical group 最近 24h 的 route 命中情况
- 查询 fallback 是否频繁发生
- 查询 sticky 命中率
这些都必须靠结构化插件日志完成,宿主侧日志无法替代。
### 日志写入策略
SQLite 是单 writer不能每个请求把大量日志同步直写。
建议:
1. 路由热路径先产出 `RouteDecisionEvent`
2. 写入内存 channel/buffer
3. 后台 writer 每 100ms 或每 100 条批量写 SQLite
4. 如果 writer 满了:
- 保底同步写一条精简版失败记录
- 或至少 stderr 告警
### 路由服务接口建议
建议新增:
- `RouteResolver`
- `StickyStore`
- `RouteDecisionLogger`
```go
type RouteResolver interface {
Resolve(ctx context.Context, req RouteRequest) (RouteDecision, error)
}
type StickyStore interface {
Get(ctx context.Context, key string) (StickyBinding, bool, error)
Set(ctx context.Context, key string, binding StickyBinding, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}
type RouteDecisionLogger interface {
Append(ctx context.Context, event RouteDecisionEvent) error
}
```
### 验收标准
- 同一会话优先命中同一路 route
- retryable 失败时能 fallback
- fallback 有日志
- sticky 命中有日志
- 所有路由决策都能在插件库里查询到
---
## 4.4 供应商帐号导入与停启用闭环
### 目标闭环
管理员对供应商帐号做一次操作后,应形成:
1. 预检
2. 导入或复用
3. 绑定到 provider / route / shadow group
4. 记录帐号库存状态
5. 后续可以启用/停用/下线
### 当前问题
当前更像“导入任务系统”,还不是“帐号资产系统”。
### 建议新增表:`provider_accounts`
```sql
CREATE TABLE provider_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
route_id TEXT NOT NULL DEFAULT '',
shadow_group_id TEXT NOT NULL DEFAULT '',
host_account_id TEXT NOT NULL,
key_fingerprint TEXT NOT NULL,
account_name TEXT NOT NULL DEFAULT '',
account_status TEXT NOT NULL,
last_probe_status TEXT NOT NULL DEFAULT '',
last_probe_at TEXT NOT NULL DEFAULT '',
disabled_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (host_id, host_account_id)
);
```
### 闭环接口
- `GET /api/provider-accounts`
- `POST /api/provider-accounts/{id}/disable`
- `POST /api/provider-accounts/{id}/enable`
- `POST /api/provider-accounts/{id}/retire`
### 验收标准
- 能看到帐号库存
- 能看到帐号属于哪个 route / shadow group
- 能手动停启用
- 状态变化有日志可追
---
## 4.5 普通用户前端闭环
### 目标闭环
普通用户看到的是逻辑分组,而不是宿主真实 group。
一次用户进入 portal 后,应形成:
1. 看到逻辑分组列表
2. 看到该分组可用模型
3. 申请/使用的 key 对应逻辑分组
4. 后端实际路由到某个 shadow group
5. 用户无需感知宿主真实 group 名称
### 当前现实
当前 `/portal/` 已存在,但仍偏宿主分组视角。
### 待做接口
建议新增面向 portal 的聚合 API
- `GET /api/portal/logical-groups`
- `GET /api/portal/logical-groups/{group_id}`
- `GET /api/portal/logical-groups/{group_id}/models`
### 验收标准
- 用户前端不再直接暴露 `gpt-shared__asxs` 这种 shadow group
- 只看到 `GPT Shared`
- 用户使用体验上是一个产品,不是多个宿主组装件
---
## 五、实施顺序
为了保证每步都闭环,建议按下面顺序推进。
## Phase 1数据模型闭环
目标:
- 先让插件库知道什么是 `logical_group / route / shadow_group`
范围:
- SQLite migrations
- sqlite repos
- 基础 CRUD API
输出:
- 逻辑分组配置可持久化
- route 配置可持久化
- 每个闭环功能完成后,必须提交、推送、部署到 `remote43`,并完成服务器验证后再更新执行板
## Phase 2智能路由最小闭环
目标:
- 让请求真正能按 logical group -> route -> shadow group 跑起来
范围:
- Redis sticky
- route resolver
- route forwarding
- SQLite route logs
输出:
- `asxs + codex2api` 单 logical group 跑通
## Phase 3帐号资产闭环
目标:
- 把“导入任务”升级成“帐号资产管理”
范围:
- provider_accounts 视图
- enable/disable/retire
- route / shadow group 归属展示
输出:
- 能运维帐号池
## Phase 4普通用户产品闭环
目标:
- 用户前端彻底切换到逻辑分组视角
范围:
- portal 聚合 API
- portal 前端改造
输出:
- 用户只看到逻辑分组
---
## 六、技术决策总结
### 当前数据库
- **SQLite**
### 后续主状态库
- **继续使用 SQLite**
### 智能路由运行态
- **Redis**
### 智能路由日志
- **必须最终落 SQLite**
### 为什么不是一开始就换 PostgreSQL
当前不建议立刻切 PostgreSQL原因是
- 现有 repo、migration、integration test 都围绕 SQLite
- 当前主要瓶颈不是查询能力,而是产品结构未闭环
- 先把闭环跑通比先换库更重要
但要保留一个现实判断:
- 如果后续 route 日志量显著升高
- 或多实例控制面出现
- 或需要复杂聚合分析
那时再评估:
- SQLite -> PostgreSQL
- Redis 保持不变
## 一句话结论
当前插件数据库是 **SQLite**;后续仍建议以 **SQLite 作为主状态库**,并以 **Redis 承载智能路由运行态缓存**
同时,**智能路由日志必须结构化写回插件 SQLite**,不能只停留在 Redis 或 stdout。
后续真正的闭环实施顺序应当是:
1. 先补 `logical_group / route / shadow_group` 数据模型
2. 再做前置智能路由最小闭环
3. 再做供应商帐号资产管理
4. 最后把普通用户前端切到逻辑分组视角

View File

@@ -0,0 +1,339 @@
# 插件整体需求梳理2026-05-28
日期2026-05-28
## 背景
`sub2api-cn-relay-manager` 的最初定位,是一个**不修改宿主源码**的外部控制面:
- 通过宿主 HTTP 管理 API 导入 provider / account / group / plan
- 维护状态库
- 做访问闭环验证
- 提供最小管理页
随着新需求增加,当前插件目标已经从“导入控制面”扩展为一个更完整的**模型供应管理 + 逻辑分组 + 前置路由 + 用户前端聚合层**。
因此这里需要重新把插件职责收口,并明确区分:
- **已完成**:已经有真实代码与真实部署/验收支撑
- **待优化**:能力已存在,但还不够产品化或还不够顺手
- **待完成**:已经明确要做,但当前还没有真正落地
- **未来规划**:方向已确认,但不应假装已经进入当前交付范围
## 当前插件职责总图
按现在的产品边界,插件最终应覆盖 5 大功能域:
1. 增加模型
2. 维护逻辑分组
3. 智能路由
4. 供应商帐号导入与停启用
5. 普通用户前端
这 5 个功能域里,当前真正成熟的是:
- 模型与 provider 管理控制面
- provider 草稿发布到 pack 仓库
- 供应商帐号导入与 access closure
- 最小管理员前端
而“逻辑分组 + 智能路由 + 面向普通用户的统一产品层”是本轮新增后的主线。
---
## 一、增加模型
这里的“增加模型”,当前真实语义不是直接往宿主里加一个模型开关,而是:
- 在 pack/provider 体系里新增或更新 provider manifest
- 定义其 `provider_id / display_name / base_url / supported_models / smoke_test_model`
- 再由插件导入供应商 key把这条 provider 变成真实可访问线路
### 已完成
- 已有 `providers.html` 管理页,可浏览 pack/provider 目录
- 已有 provider manifest 草稿能力:
- `POST /api/provider-drafts`
- `GET /api/provider-drafts`
- `GET /api/provider-drafts/{draft_id}`
- `PUT /api/provider-drafts/{draft_id}`
- `DELETE /api/provider-drafts/{draft_id}`
- 已有 provider 草稿发布能力:
- `POST /api/provider-drafts/{draft_id}/publish`
- 发布时已可自动:
-`packs/<pack>/providers/*.json`
- bump `pack.json` patch version
- 更新 `checksums.txt`
- 重跑 pack 校验
- `git add` + `git commit`
- `Provider Manifest 草稿` 已做过一轮可用性优化:
- 最近成功模板回填
- `Provider ID` 自动生成与避重
- 前端同模型冲突提示
- 服务端 `save draft / publish` 模型冲突校验
### 待优化
- 草稿表单虽然能用但仍偏“manifest 视角”,不是“运营录入视角”
- `providers.html` 还缺更强的“已有模板复用 / 最近一次导入参考 / 同类线路推荐”
- “新增模型”与“导入供应商帐号”虽然已经在同页,但产品语义仍然容易混淆
- route-lab / 逻辑分组引入后,新增模型页需要补一层:
- 这是新增 provider
- 还是把模型纳入某个 `logical_group`
### 待完成
- 增加模型时直接选择或创建 `logical_group`
- 增加模型时直接配置 route 归属,而不只是落一个 provider 文件
- 新增模型后自动生成 shadow group 规划建议
### 未来规划
- 模型家族级模板库
- 官方 / 中转 / 专线 的线路类型模板
- 按历史成功接入记录生成默认字段与 smoke 策略
---
## 二、维护逻辑分组
这是本轮新增后最核心的新能力。
逻辑分组的目标是:
- 前端只显示一个业务分组
- 后面可以挂多个真实 route
- 每个 route 再映射到宿主里的一个 shadow group
### 已完成
- 已经完成概念澄清:当前宿主不支持“同一个真实 group 多 channel”
- 已完成两份关键设计文档:
- [HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md](/home/long/project/sub2api-cn-relay-manager/docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md:1)
- [PLUGIN_ROUTE_STICKY_DESIGN.md](/home/long/project/sub2api-cn-relay-manager/docs/PLUGIN_ROUTE_STICKY_DESIGN.md:1)
- 已完成真实实验验证:
- route-lab `asxs`
- route-lab `codex2api`
- 证明当前宿主真实约束是 `GROUP_ALREADY_IN_CHANNEL`
- 已明确 fallback 方向:
- 不改宿主源码
- 由插件层维护 `logical_group -> route -> shadow_group`
### 待优化
- 当前还没有一个统一文档把“逻辑分组”放回整体产品需求里,这份文档正是补这个缺口
- 当前 portal 与 admin 页面里还没有逻辑分组这个产品层
- 逻辑分组和 pack/provider 的关系还没有被操作界面正式表达出来
### 待完成
- `logical_groups` 持久化模型
- `logical_group_routes` 持久化模型
- 逻辑分组 CRUD API
- route 绑定与 shadow group 绑定 API
- 管理页上的逻辑分组维护入口
### 未来规划
- 逻辑分组级别的运营标签、描述、默认模型集合
- 逻辑分组级别的 SLA/成本/优先级策略
- 逻辑分组级别的用户权限与套餐映射
---
## 三、智能路由
这里的智能路由,不是宿主内部调度,而是插件层的**前置路由**
- 用户请求进入插件
- 插件先决定 route
- 再把请求稳定转发到对应 shadow group
- 宿主继续在 shadow group 内做账号调度
### 已完成
- 已完成路线方向选择:
- 放弃“宿主内多 channel”
- 采用“插件前置路由 + 宿主 shadow group”路线
- 已完成第一版设计:
- route sticky
- route failover
- Redis key 设计
- conversation/session/user-model sticky 设计
- cooldown / retryable / non-retryable 分类
### 待优化
- 设计已成型,但还没有拆成实现任务单
- 当前普通用户 portal 还没有接入这层前置路由
- 对 route 健康状态、错误分类、熔断观测还没有产品级可视化
### 待完成
- 前置路由器服务实现
- Redis sticky 实现
- route cooldown / failover 实现
- logical group -> route -> shadow group 实时解析
- 用户请求转发到 shadow group 的数据面链路
### 未来规划
- 同公开模型名双线路主备
- route priority + cost-aware routing
- latency-aware routing
- A/B route policy
- 自动摘除劣化线路与自动恢复
---
## 四、供应商帐号导入与停启用
这是当前插件最接近成熟生产能力的一块,但需求已经不止“导入”。
### 当前要覆盖的完整语义
- 导入供应商帐号 / key
- 预检导入
- 批量导入
- 复用已有帐号
- 重新启用 disabled / deprecated 帐号
- 停用帐号
- 查看帐号状态与导入结果
### 已完成
- 已有 `preview-import` / `import` 主链路
- 已有最小与结构化 batch-import API
- 已有 batch-import 管理页与运行结果查询
- 已支持 key 去重、复用、导入结果投影
- 已在文档与实现中支持:
- 命中 disabled / deprecated 账号时走 `reactivated`
- 已有 access closure 与 provider status 聚合
- 已完成多条真实 provider 验收
### 待优化
- 当前“供应商帐号导入”仍偏导入任务视角,不是帐号资产视角
- 没有真正的“帐号库存页 / 帐号状态列表 / route 归属页”
- “reactivated” 虽然已支持,但前端没有被清晰表达成一个显式运维动作
- 缺少一眼可见的:
- active
- warning
- disabled
- deprecated
- broken
状态面板
### 待完成
- 供应商帐号清单页
- 帐号启用 / 停用 / 下线 API
- 把帐号与 route / shadow group / logical group 的关系展示出来
- 明确“导入新 key”和“启用已有 key”的不同操作入口
### 未来规划
- 批量失效检测
- 自动停用持续失败帐号
- 多 key 池健康分层
- 帐号级配额/余额/延迟监控
---
## 五、普通用户前端
这是当前最容易被误判“已经有了”的部分。
严格说,当前已经有用户 Portal但它还是**宿主分组视角**,不是插件逻辑分组视角。
### 已完成
- 已有公网用户 portal
- `/portal/`
- 已有登录态、用户信息、订阅、key 列表等最小页面
- 已有管理员 portal
- `/portal/admin/`
- `/portal/admin/providers.html`
- `/portal/admin/batch-import.html`
### 待优化
- 当前普通用户最终看到的仍是宿主真实 group / subscription 结构
- 这和未来“逻辑分组 + 多 route”产品层不一致
- 用户仍可能看到过于底层的分组信息,而不是统一产品视图
### 待完成
- 逻辑分组视角的普通用户前端
- 把多个 shadow group 聚合成一个用户可见产品分组
- 用户侧模型展示改成逻辑分组模型集合,而不是宿主原始 group 集合
- 用户创建 key / 查看权限时走逻辑分组视图
### 未来规划
- 用户侧逻辑分组购买 / 订阅 / 升级
- 逻辑分组可见性与分层套餐
- 用户侧展示当前线路状态、可用模型集合、使用建议
---
## 六、当前真实状态总表
### 已完成
- 外部控制面主链路成立host 注册、探测、导入、access closure、reconcile、rollback
- 管理员前端已上线provider 管理、batch-import、管理员 session 登录
- provider 草稿与发布主链路已打通
- provider 模型冲突校验已落到服务端
- 供应商帐号导入主链路已具备真实宿主验收基础
- 宿主不支持同 group 多 channel 的事实已经被真实验证
- 插件前置路由 + logical group 方案已经完成设计收口
### 待优化
- 管理页仍偏工程/manifest 视角,不够产品化
- provider、新增模型、导入帐号三者的产品语义需要重新整理
- batch-import 已有结构化入口,但普通运营视角还不够强
- 当前 portal 仍是宿主分组视角,不是逻辑分组视角
### 待完成
- `logical_group` 数据模型、API、管理页
- 前置智能路由器实现
- Redis sticky / failover / cooldown
- route 与 shadow group 管理
- 供应商帐号启用/停用与库存视图
- 普通用户逻辑分组前端
### 未来规划
- 同公开模型名双线路主备
- 成本/延迟/成功率驱动的智能路由
- 用户侧订阅、购买、升级与逻辑分组统一产品化
- 运营指标、审计面板、健康监控
---
## 七、当前推荐主线
如果要按价值排序,当前插件后续实现主线建议是:
1. 先做 `logical_group` 数据模型与管理 API
2. 再做前置路由器最小闭环:
- priority
- sticky
- failover
- shadow group 转发
3. 再补供应商帐号的启用/停用与 route 归属视图
4. 最后重做普通用户 portal把宿主真实分组隐藏到逻辑分组之后
## 一句话结论
插件已经从“导入控制面”演进为一个四层产品:
- 模型与 provider 管理层
- 逻辑分组与 route 管理层
- 前置智能路由层
- 面向普通用户的聚合前端层
当前第一层基本成立,第二层已完成设计但未落地,第三层和第四层是后续真正的产品化主线。

View File

@@ -0,0 +1,597 @@
# 插件层逻辑分组与粘性路由设计
日期2026-05-28
## 目标
在**不修改宿主源码**的前提下,由 relay-manager 插件层提供一层“逻辑分组 + 多 route 调度”能力,实现:
- 前端只看到一个逻辑分组
- 插件自动把请求路由到背后的多个真实线路
- 同一会话尽量保持 route 粘性,提高宿主与上游缓存命中
- 宿主继续只承载单线路 group不承担多 route 聚合
这个设计对应的真实部署形态是:
```text
User
-> relay-manager plugin router
-> sub2api host (shadow group A / B / C)
-> upstream route A / B / C
```
## 核心概念
### 1. 逻辑分组 `logical_group`
逻辑分组是插件层对用户暴露的“产品分组”,例如:
- `gpt-shared`
- `deepseek-shared`
它不是宿主里的真实 `group`,而是插件自己的聚合对象。
职责:
- 面向前端展示
- 绑定一组公开模型
- 绑定一组 route
- 承载 route policy、sticky policy、fallback policy
### 2. 路由线路 `route`
route 是逻辑分组下的一条具体出站线路,例如:
- `asxs`
- `codex2api`
- `official`
职责:
- 指向一个真实宿主 shadow group
- 声明支持哪些公开模型
- 提供优先级、权重、健康状态、熔断状态
### 3. 宿主影子分组 `shadow_group`
shadow group 是宿主里的真实 group例如
- `gpt-shared__asxs`
- `gpt-shared__codex2api`
职责:
- 承载单条 route 对应的账号池
- 继续使用宿主既有的 group 内 sticky/account scheduling
- 不再对用户直接暴露
## 数据结构
### A. 逻辑分组定义
建议插件层持久化表:`logical_groups`
```json
{
"logical_group_id": "gpt-shared",
"display_name": "GPT Shared",
"status": "active",
"description": "GPT 多线路逻辑分组",
"public_models": [
"gpt-5.4",
"gpt-5.4-mini"
],
"default_route_policy": "priority",
"sticky_policy": {
"mode": "conversation_preferred",
"conversation_ttl_seconds": 7200,
"user_model_ttl_seconds": 1800
},
"failover_policy": {
"consecutive_retryable_failures": 2,
"cooldown_seconds": 600
},
"created_at": "2026-05-28T00:00:00Z",
"updated_at": "2026-05-28T00:00:00Z"
}
```
字段说明:
- `public_models`: 对用户暴露的公开模型
- `default_route_policy`: 第一版建议只支持 `priority`
- `sticky_policy`: route 级粘性配置
- `failover_policy`: 熔断与切换阈值
### B. route 定义
建议插件层持久化表:`logical_group_routes`
```json
{
"route_id": "gpt-shared.asxs",
"logical_group_id": "gpt-shared",
"name": "asxs",
"status": "active",
"priority": 10,
"weight": 100,
"shadow_group_id": "gpt-shared__asxs",
"shadow_group_host_id": "remote43-route-lab-18169",
"supported_public_models": [
"gpt-5.4",
"gpt-5.4-mini"
],
"upstream_base_url_hint": "https://api.asxs.top/v1",
"retryable_error_classes": [
"upstream_5xx",
"upstream_429",
"gateway_timeout"
],
"non_retryable_error_classes": [
"model_unsupported",
"invalid_api_key",
"group_disabled"
],
"cooldown_until": null,
"metadata": {}
}
```
字段说明:
- `priority`: 数值越小优先级越高
- `shadow_group_id`: 该 route 对应宿主真实 group
- `shadow_group_host_id`: 对应宿主实例
- `cooldown_until`: 熔断冷却截止时间
### C. 模型覆盖定义
第一版可以不单独拆表,直接使用 `route.supported_public_models`
如果后续要支持 route 级别模型差异,可以扩展成:
- `logical_group_route_models`
例如:
```json
{
"route_id": "gpt-shared.codex2api",
"public_model": "gpt-5.4",
"shadow_model": "gpt-5.4",
"enabled": true
}
```
第一版建议不预先复杂化,先假设:
- `public_model == shadow upstream model`
- 或 shadow model 已由宿主 provider/account mapping 解决
## 运行态上下文
插件层处理一次请求时,建议构造统一上下文:
```json
{
"logical_group_id": "gpt-shared",
"public_model": "gpt-5.4",
"user_id": "u_123",
"api_key_id": "k_456",
"conversation_id": "conv_789",
"session_id": "sess_789",
"request_id": "req_abc",
"sticky_key": "computed-later",
"selected_route_id": "gpt-shared.asxs",
"selected_shadow_group_id": "gpt-shared__asxs"
}
```
## 路由流程
### Phase 1优先支持“同逻辑分组多 route不同公开模型名或相同模型名但明确 priority”
插件层一次请求的最小流程应为:
```text
1. 解析请求
2. 确定 logical_group_id
3. 确定 public_model
4. 生成 sticky key
5. 先查 sticky route
6. sticky route 可用则继续使用
7. 否则按 route policy 选择候选 route
8. 写入 sticky
9. 转发到对应 shadow group
10. 记录结果,必要时更新失败计数 / 冷却状态
```
### 详细步骤
#### 1. 解析用户请求
从请求中提取:
- 逻辑分组
- 公开模型名
- 用户标识
- conversation/session 标识
推荐优先级:
- `conversation_id`
- `session_id`
- 请求体 metadata 里的稳定用户标识
- API key / user id
#### 2. 加载逻辑分组配置
读取:
- `logical_groups`
- `logical_group_routes`
过滤:
- `logical_group.status == active`
- `route.status == active`
- 当前时间未落入 `cooldown_until`
- `public_model``supported_public_models`
#### 3. 生成 sticky key
按“强粘性优先”原则:
1. conversation 级
2. session 级
3. user+model 级
4. 稳定哈希兜底
### 推荐 key 生成规则
#### 3.1 conversation 级 sticky
`conversation_id` 存在时:
```text
lg:{logical_group_id}:m:{public_model}:conv:{conversation_id}
```
#### 3.2 session 级 sticky
`session_id` 存在时:
```text
lg:{logical_group_id}:m:{public_model}:sess:{session_id}
```
#### 3.3 user-model 级 sticky
当只有用户标识时:
```text
lg:{logical_group_id}:m:{public_model}:user:{user_or_api_key_id}
```
#### 3.4 hash bucket 兜底
完全无会话标识时:
```text
lg:{logical_group_id}:m:{public_model}:bucket:{stable_hash(user_or_ip_or_api_key)%128}
```
这不是强 session 粘性,只是为了避免每次随机抖动。
## Redis key 设计
### A. sticky route key
value 建议:
```json
{
"route_id": "gpt-shared.asxs",
"shadow_group_id": "gpt-shared__asxs",
"public_model": "gpt-5.4",
"bound_at": "2026-05-28T12:00:00Z",
"expires_at": "2026-05-28T14:00:00Z",
"last_ok_at": "2026-05-28T12:34:00Z",
"fail_count": 0
}
```
TTL
- conversation/session 级:默认 `7200s`
- user-model 级:默认 `1800s`
- bucket 级:默认 `600s`
### B. route failure counter
key
```text
routefail:{route_id}
```
value
```json
{
"consecutive_retryable_failures": 1,
"last_failure_at": "2026-05-28T12:35:00Z",
"last_error_class": "upstream_5xx"
}
```
作用:
- 辅助 route 熔断
- 与 sticky key 解耦
### C. route cooldown key
key
```text
routecool:{route_id}
```
value
```json
{
"cooldown_until": "2026-05-28T12:45:00Z",
"reason": "consecutive_retryable_failures"
}
```
作用:
- route 进入冷却期后,不参与新 sticky 选择
- 已命中该 route 的旧 sticky 也要在下次请求时重新评估
### D. 可选:逻辑分组模型首选 route cache
key
```text
routepref:{logical_group_id}:{public_model}
```
value
```json
{
"route_id": "gpt-shared.asxs",
"updated_at": "2026-05-28T12:00:00Z"
}
```
第一版可直接读 DB不一定要上 Redis。
只有当路由配置频繁热更新、且请求量明显上来后,才值得加这层缓存。
## 选路算法
### 第一版推荐:`priority + sticky + failover`
不要一开始做复杂权重、实时负载、成本优化。
第一版最稳的算法:
1. 先查 sticky route
2. 如果 sticky route 当前健康且支持该模型,则继续使用
3. 否则按 `priority` 从小到大选第一个健康 route
4. 成功后写新的 sticky
5. 失败时按错误类别决定是否切换
### 伪代码
```text
resolveRoute(ctx):
candidates = active routes for logical_group + public_model
sticky = redis.get(sticky_key)
if sticky exists:
route = find route by sticky.route_id
if route is healthy and not cooling down:
return route
sort candidates by priority asc
for route in candidates:
if route healthy and not cooling down:
redis.set(sticky_key, route)
return route
return no_route_available
```
## 错误分类与 failover
### A. 可重试失败 `retryable`
建议包括:
- 宿主 `502/503/504`
- upstream `5xx`
- upstream timeout
- upstream `429`
- route host temporarily unavailable
处理策略:
- 单次失败先累加 `fail_count`
- 达到阈值后触发 route fallback
- 对旧 route 进入 `cooldown`
### B. 不可重试失败 `non-retryable`
建议包括:
- invalid api key
- model unsupported
- group disabled
- account disabled
- provider misconfigured
处理策略:
- 当前 route 立即标记不可用于该模型
- 直接尝试下一个 route
- 如果没有其他 route则返回明确错误
### C. fallback 规则
默认建议:
- `consecutive_retryable_failures >= 2` 时切 route
- `cooldown = 600s`
这组值保守、简单,足够第一版使用。
## 粘性策略
### 1. 为什么插件层必须自己做 sticky
因为宿主当前 sticky 是按真实 `group_id` 做的,而不是按逻辑分组。
如果插件层不先把会话稳定送到同一个 shadow group
- 宿主会在不同 shadow group 之间重新选账号
- 上游缓存命中会下降
- 会话体验会抖动
所以正确顺序是:
1. 插件层先做 route sticky
2. 宿主层再做 shadow group 内 account sticky
### 2. TTL 建议
默认建议:
- conversation sticky`2h`
- session sticky`2h`
- user-model sticky`30m`
- bucket sticky`10m`
原则:
- 长会话优先稳定
- 无状态请求尽量降低长期粘死风险
### 3. 何时刷新 sticky TTL
只在这些情况下刷新:
- 本次请求成功
- 本次 route 未进入 cooldown
不要在失败请求上无脑刷新 TTL否则坏 route 会被粘住。
## 转发行为
插件在选定 route 后,应把请求转发到:
- 指定宿主实例
- 指定 shadow group 对应的用户入口/API key
第一版建议保持简单:
- 一个 route 对应一个宿主 shadow group
- 一个 shadow group 对应一组独立宿主 API key / group token / user key
插件需要做的是:
- 保持原始请求体
- 保留 conversation/session 标识
- 可附加少量内部调试头,例如:
- `X-Relay-Logical-Group`
- `X-Relay-Route-ID`
- `X-Relay-Shadow-Group`
这些头只用于内部审计,不暴露给终端用户。
## 最小观测字段
插件层至少要记录:
- `request_id`
- `logical_group_id`
- `public_model`
- `selected_route_id`
- `selected_shadow_group_id`
- `sticky_key_type`
- `sticky_hit`
- `fallback_used`
- `error_class`
- `upstream_status`
- `latency_ms`
这样以后排查“为什么这次走了 codex2api 而不是 asxs”时才有证据。
## 第一版推荐实现边界
第一版只做这些,避免过度设计:
- 逻辑分组
- route 列表
- priority 选路
- Redis sticky
- Redis cooldown
- retryable / non-retryable 分类
- 转发到 shadow group
第一版不要做:
- 动态权重学习
- 实时成本最优
- 自动 A/B
- 跨 route token 级缓存共享
- 复杂多臂老虎机
## 以 asxs + codex2api 为例
### 逻辑分组
```json
{
"logical_group_id": "gpt-shared",
"display_name": "GPT Shared",
"public_models": ["gpt-5.4", "gpt-5.4-mini"]
}
```
### routes
```json
[
{
"route_id": "gpt-shared.asxs",
"priority": 10,
"shadow_group_id": "gpt-shared__asxs",
"supported_public_models": ["gpt-5.4", "gpt-5.4-mini"]
},
{
"route_id": "gpt-shared.codex2api",
"priority": 20,
"shadow_group_id": "gpt-shared__codex2api",
"supported_public_models": ["gpt-5.4", "gpt-5.4-mini"]
}
]
```
效果:
- 默认优先 `asxs`
- `asxs` 故障时切 `codex2api`
- 同一会话尽量持续命中第一次成功选中的 route
## 一句话结论
这套设计的本质是:
- 对外暴露一个 `logical_group`
- 对内维护多个 `route -> shadow_group`
- 由插件层先做 **route sticky**
- 再由宿主在 shadow group 内做 **account sticky**
这样才能在不修改宿主源码的前提下,尽量接近“一个分组、多 URL、强粘性、高缓存命中”的目标效果。

View File

@@ -0,0 +1,875 @@
# Phase 1 实施计划:逻辑分组与路由基础设施
日期2026-05-28
## 目标
把插件新增方向收敛成第一阶段可开工任务,只做“基础设施闭环”,范围限定为:
1. SQLite migration 设计
2. `logical_group / route` repo 与 API
3. 路由日志 repo 与写入器
4. Redis sticky 接口封装
本阶段**不做**
- 完整前置路由数据面转发
- 普通用户 Portal 改造
- 供应商帐号库存页
- route health dashboard
但本阶段完成后,必须满足:
- 插件数据库里已经能表达 `logical_group -> route -> shadow_group`
- 插件数据库里已经能接收结构化路由日志
- 插件代码里已经有独立的 sticky store 抽象
- 每个闭环功能完成后,都必须部署到 `remote43` 验证,不只停留在本地测试
## 总体约束
### 技术约束
- 主状态库继续使用 **SQLite**
- 路由运行态缓存使用 **Redis**
- 智能路由日志必须**最终落入插件 SQLite**
- 不修改宿主源码
- 不直写宿主数据库
- 仅通过宿主 HTTP API 与宿主交互
### 质量门禁
每个任务完成后必须通过:
```bash
gofmt -l .
go vet ./...
go test -cover ./internal/...
go test ./tests/integration/... -count=1
```
如有脚本变更,还必须通过:
```bash
bash ./scripts/test/test_real_host_scripts.sh
bash ./scripts/test/test_tksea_portal_assets.sh
```
### 远端验证门禁
每个“闭环功能”完成后,不允许只在本地宣布完成,必须执行:
1. 提交代码
2. 推送远端仓库
3. 上传到 `remote43`
4. 重启/部署对应服务
5.`remote43` 或公网入口完成验证
6. 生成或补充验证证据
7. 更新 `docs/EXECUTION_BOARD.md`
这里的验证对象默认是:
- CRM`remote43` 上的控制面
- 公网admin 入口 `https://sub.tksea.top/portal/admin/`
- 若涉及真实导入或路由闭环,则补 `artifacts/real-host-acceptance/...`
## 0. 当前环境基线
### 数据库
- 当前插件数据库SQLite
- 配置项:`SUB2API_CRM_SQLITE_DSN`
- 默认值:`file:/data/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000`
### 服务器基线
- 当前真实部署宿主:`remote43`
- 远端部署脚本:
- [setup_remote43_patched_stack.sh](/home/long/project/sub2api-cn-relay-manager/scripts/deploy/setup_remote43_patched_stack.sh:1)
- 公网 portal 部署脚本:
- [deploy_tksea_portal.sh](/home/long/project/sub2api-cn-relay-manager/scripts/deploy/deploy_tksea_portal.sh:1)
- 真实验收脚本:
- [import_remote43_provider.sh](/home/long/project/sub2api-cn-relay-manager/scripts/acceptance/import_remote43_provider.sh:1)
## 0.1 当前代码缺口
Phase 1 不是从零开始,但也不是在已有实现上只补一两个字段。当前代码基线里,和本阶段直接相关的缺口有:
- `internal/store/migrations/` 里还没有 `logical_group / route / route logging` 相关表
- `internal/store/sqlite/db.go` 里还没有这些 repo 的挂载入口
- `internal/app/http_api.go` 里还没有 `logical_group / route` 的管理 API
- `internal/config/config.go` 里还没有 Redis 运行态配置
- `internal/routing/` 目录当前还不存在,`StickyStore` 与路由日志写入器都还未成形
- 当前 remote43 验证脚本主要覆盖 provider import / portal不覆盖 logical routing foundation
因此本阶段不是“补 UI”而是先建立
1. 可迁移的 SQLite 结构
2. 可管理的控制面 API
3. 可审计的路由日志基础设施
4. 可替换的 Redis sticky 适配层
## 1. 实施顺序
```text
P1-T1 SQLite schema foundation
P1-T2 logical_group / route repo + admin API
P1-T3 route logging repo + async writer
P1-T4 Redis sticky store abstraction
```
说明:
- `P1-T1` 先建结构
- `P1-T2` 让结构可读写
- `P1-T3` 让智能路由日志可落库
- `P1-T4` 先把 sticky 抽象封装好,为 Phase 2 路由器实现铺路
## 1.1 任务依赖矩阵
| 任务 | 依赖 | 可并行度 | 完成后解锁 |
| --- | --- | --- | --- |
| `P1-T1` | 无 | 不可跳过 | `P1-T2``P1-T3` |
| `P1-T2` | `P1-T1` | 可与 `P1-T3` 部分交叉,但建议先后执行 | Phase 2 管理页接线 |
| `P1-T3` | `P1-T1` | 可与 `P1-T2` 交叉 | Phase 2 路由器写日志 |
| `P1-T4` | 无强依赖,但建议在 `P1-T2` 之后落配置 | 可独立推进 | Phase 2 `RouteResolver` |
工程上建议仍然按 `T1 -> T2 -> T3 -> T4` 串行推进,原因是每个任务结束后都要提交、推送、部署 remote43、做服务器验证串行更容易隔离回归和定位问题。
---
## 2. P1-T1 SQLite Schema Foundation
## 目标闭环
让插件 SQLite 正式支持以下核心对象:
- `logical_groups`
- `logical_group_models`
- `logical_group_routes`
- `logical_group_route_models`
完成后,插件数据库应能完整表达:
```text
logical_group
-> public models
-> routes
-> each route -> shadow group
```
## 需要新增的 migration
建议新增:
- `internal/store/migrations/0010_logical_groups_and_routes.sql`
建议建表:
### `logical_groups`
```sql
CREATE TABLE logical_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logical_group_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
route_policy TEXT NOT NULL DEFAULT 'priority',
sticky_mode TEXT NOT NULL DEFAULT 'conversation_preferred',
conversation_ttl_seconds INTEGER NOT NULL DEFAULT 7200,
user_model_ttl_seconds INTEGER NOT NULL DEFAULT 1800,
failover_threshold INTEGER NOT NULL DEFAULT 2,
cooldown_seconds INTEGER NOT NULL DEFAULT 600,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### `logical_group_models`
```sql
CREATE TABLE logical_group_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (logical_group_id) REFERENCES logical_groups(logical_group_id) ON DELETE CASCADE,
UNIQUE (logical_group_id, public_model)
);
```
### `logical_group_routes`
```sql
CREATE TABLE logical_group_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT NOT NULL UNIQUE,
logical_group_id TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL,
weight INTEGER NOT NULL DEFAULT 100,
shadow_group_id TEXT NOT NULL,
shadow_host_id TEXT NOT NULL,
upstream_base_url_hint TEXT NOT NULL DEFAULT '',
cooldown_until TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (logical_group_id) REFERENCES logical_groups(logical_group_id) ON DELETE CASCADE
);
```
### `logical_group_route_models`
```sql
CREATE TABLE logical_group_route_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT NOT NULL,
public_model TEXT NOT NULL,
shadow_model TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (route_id) REFERENCES logical_group_routes(route_id) ON DELETE CASCADE,
UNIQUE (route_id, public_model)
);
```
## 文件范围
- Add: `internal/store/migrations/0010_logical_groups_and_routes.sql`
- Add tests:
- `internal/store/sqlite/db_test.go`
- 如有必要新增 repo test skeleton
## 入场条件
- 重新核对当前 migration 序列,确认下一号位确实应为 `0010`
- 明确 `logical_group_id / route_id / public_model` 的唯一性契约
- 明确 remote43 当前数据库文件位置与 CRM 启动方式,避免上线后只能看到 `/healthz` 失败却无法解释
## 产出清单
- 一份新 migration 文件
- 至少一组 migration smoke tests
- `db.Open()` 后对新表存在性的自动验证
- `docs/EXECUTION_BOARD.md` 中新增一条“0010 已上线验证”的真实记录
## 本地验证
至少覆盖:
- migration 可顺序执行
- 外键与唯一约束正确
- 重复运行 migration 不报错
- `SQLite Open()` 后新表可查询
## 上传与服务器验证 Gate
### 上传动作
1. 提交并推送当前任务代码
2. 把 CRM 新二进制部署到 `remote43`
### 服务器验证
验证目标:
- CRM 进程能在 `remote43` 上正常启动
- 新 migration 不会导致 SQLite 启动失败
- 旧数据不会因 migration 失败导致 CRM 无法启动
最低验证方式:
1. 更新 remote43 CRM 二进制
2. 重启 CRM
3. 验证:
- `/healthz` 返回 `ok`
4. 如能 SSH 到远端,再执行一轮 SQLite 表存在性检查
建议远端只读验证内容:
- `logical_groups`
- `logical_group_models`
- `logical_group_routes`
- `logical_group_route_models`
### 证据要求
-`docs/EXECUTION_BOARD.md` 记录:
- migration 名称
- remote43 启动验证结果
- 是否做了 SQLite 表存在性确认
- 如做远端 SQL 验证,保留命令摘要或表名检查摘要
---
## 3. P1-T2 logical_group / route Repo + Admin API
## 目标闭环
管理员已经能通过插件 API 完整维护:
- 逻辑分组
- 逻辑分组模型
- route
- route 的公开模型覆盖
完成后,管理面必须满足:
1. 能创建 logical group
2. 能列出 logical group
3. 能查看单个 logical group
4. 能为 logical group 增加 route
5. 能为 route 增加公开模型
## 需要新增的 SQLite repo
建议新增:
- `internal/store/sqlite/logical_groups_repo.go`
- `internal/store/sqlite/logical_group_models_repo.go`
- `internal/store/sqlite/logical_group_routes_repo.go`
- `internal/store/sqlite/logical_group_route_models_repo.go`
建议 repo 方法最小集合:
### `LogicalGroupsRepo`
- `Create`
- `GetByLogicalGroupID`
- `List`
- `Update`
- `Delete`
### `LogicalGroupModelsRepo`
- `Create`
- `ListByLogicalGroupID`
- `DeleteByLogicalGroupIDAndModel`
### `LogicalGroupRoutesRepo`
- `Create`
- `GetByRouteID`
- `ListByLogicalGroupID`
- `Update`
- `DeleteByRouteID`
### `LogicalGroupRouteModelsRepo`
- `Create`
- `ListByRouteID`
- `DeleteByRouteIDAndModel`
## 需要新增的 Admin API
建议新增接口:
- `POST /api/logical-groups`
- `GET /api/logical-groups`
- `GET /api/logical-groups/{group_id}`
- `PUT /api/logical-groups/{group_id}`
- `DELETE /api/logical-groups/{group_id}`
- `POST /api/logical-groups/{group_id}/models`
- `GET /api/logical-groups/{group_id}/models`
- `DELETE /api/logical-groups/{group_id}/models/{model}`
- `POST /api/logical-groups/{group_id}/routes`
- `GET /api/logical-groups/{group_id}/routes`
- `PUT /api/logical-groups/{group_id}/routes/{route_id}`
- `DELETE /api/logical-groups/{group_id}/routes/{route_id}`
- `POST /api/logical-groups/{group_id}/routes/{route_id}/models`
- `GET /api/logical-groups/{group_id}/routes/{route_id}/models`
## 文件范围
- Add sqlite repos and repo tests
- Modify:
- `internal/store/sqlite/db.go`
- `internal/app/http_api.go`
- Add HTTP tests
## 入场条件
- `P1-T1` 已上线并在 remote43 验证通过
- 结构字段和状态枚举已稳定,不在 API 层临时发明第二套命名
- 管理员 session 登录链路在 remote43 仍然可用,避免 API 做完却无真实入口可验
## 产出清单
- 4 组 SQLite repo
- 1 组 logical-group 管理 API
- 1 组 route 管理 API
- 完整的 handler tests / repo tests / integration tests
- 一份真实 API 验证记录
## 本地验证
至少覆盖:
- repo CRUD test
- HTTP handler test
- 鉴权 test
- 非法输入校验 test
- 一条 logical group + route + route model 的完整 create/list/get 闭环 test
## 上传与服务器验证 Gate
### 上传动作
1. 提交并推送
2. 部署新 CRM 到 `remote43`
### 服务器验证
最低验证:
- 通过公网 admin 同域 API 或直接 CRM API
- 创建一个测试 `logical_group`
- 给它创建一条测试 route
- 再查询回来
建议验证路径:
- `https://sub.tksea.top/portal-admin-api/...`
如果还没做前端页面,也至少用 API 验证:
1. `POST /api/logical-groups`
2. `POST /api/logical-groups/{group}/routes`
3. `GET /api/logical-groups/{group}`
4. `GET /api/logical-groups/{group}/routes`
建议额外验证:
5. `POST /api/logical-groups/{group}/models`
6. `POST /api/logical-groups/{group}/routes/{route}/models`
7. 再次 `GET /api/logical-groups/{group}`,确认聚合视图已包含 models 与 routes
### 证据要求
- 至少保留一组 request/response 摘要
- 在执行板记录:
- API 已能真实创建/读取 logical group
- remote43 已通过验证
- 是否完成了 route model 真实写入与回读
---
## 4. P1-T3 Route Logging Repo + Async Writer
## 目标闭环
插件已经能把智能路由相关结构化日志写进自己的数据库,即使真正的数据面路由还没上线,也要先把日志基础设施建好。
完成后必须满足:
1. 插件能写 route decision log
2. 插件能写 route failover event
3. 插件能写 sticky audit
4. 写入方式是可复用的异步 writer而不是散落的裸 SQL
## 需要新增的 migration
建议新增:
- `internal/store/migrations/0011_route_logging.sql`
建议建表:
### `route_decision_logs`
```sql
CREATE TABLE route_decision_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
user_key TEXT NOT NULL DEFAULT '',
conversation_key TEXT NOT NULL DEFAULT '',
sticky_key TEXT NOT NULL DEFAULT '',
sticky_key_type TEXT NOT NULL DEFAULT '',
sticky_hit INTEGER NOT NULL DEFAULT 0,
selected_route_id TEXT NOT NULL,
selected_shadow_group_id TEXT NOT NULL,
fallback_used INTEGER NOT NULL DEFAULT 0,
error_class TEXT NOT NULL DEFAULT '',
upstream_status INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### `route_failover_events`
```sql
CREATE TABLE route_failover_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
from_route_id TEXT NOT NULL,
to_route_id TEXT NOT NULL,
reason TEXT NOT NULL,
failure_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### `route_sticky_audit`
```sql
CREATE TABLE route_sticky_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sticky_key TEXT NOT NULL,
sticky_key_type TEXT NOT NULL,
logical_group_id TEXT NOT NULL,
public_model TEXT NOT NULL,
route_id TEXT NOT NULL,
action TEXT NOT NULL,
expires_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
## 需要新增的 repo / service
建议新增:
- `internal/store/sqlite/route_decision_logs_repo.go`
- `internal/store/sqlite/route_failover_events_repo.go`
- `internal/store/sqlite/route_sticky_audit_repo.go`
- `internal/routing/logwriter.go`
### `RouteDecisionLogger`
建议做成接口:
```go
type RouteDecisionLogger interface {
AppendDecision(ctx context.Context, event RouteDecisionEvent) error
AppendFailover(ctx context.Context, event RouteFailoverEvent) error
AppendStickyAudit(ctx context.Context, event RouteStickyAuditEvent) error
}
```
### Writer 策略
第一版建议:
- 内存 channel 缓冲
- 定时批量 flush
- flush 失败打日志
- 关键事件支持同步兜底
## 入场条件
- `P1-T1` 已完成并在 remote43 验证通过
- 已明确日志写入是“插件真相日志”,不是宿主 access log 透传
- 明确 hot path 不允许因 SQLite 写锁把请求链路拖死
## 产出清单
- 1 份新 migration`0011_route_logging.sql`
- 3 组日志 repo
- 1 个异步 writer
- 1 套 event types
- 最小可复现的“写一条日志再查回来”验证路径
## 本地验证
至少覆盖:
- repo create/list smoke tests
- writer 能写入 SQLite
- writer 批量 flush 正常
- flush 失败不致进程崩溃
- event 字段完整保留
## 上传与服务器验证 Gate
### 上传动作
1. 提交并推送
2. 部署 CRM 到 `remote43`
### 服务器验证
最低验证:
- 通过一个内部测试接口或最小临时 wiring写入一条 route decision log
- 再查询 SQLite 中确实存在
建议优先顺序:
1. 若已接入内部 admin test endpoint则走 API 写入/回读
2. 若暂时没有查询 API则允许用 remote43 只读 SQLite 查询验证
3. 不允许只看 stdout 推断“应该写进库了”
如果当前还没有公开查询 API允许通过 SSH 在 `remote43` 本机做一次只读 SQLite 查询验证。
但要求验证动作必须可复现,不能只口头说“我看到了”。
### 证据要求
- 执行板记录:
- 路由日志表 migration 成功
- 写入器已在 remote43 验证可落库
- 说明验证方式是“API 回读”还是“remote43 本机 SQLite 只读查询”
---
## 5. P1-T4 Redis Sticky Store Abstraction
## 目标闭环
在还没做真正路由器前,先把 sticky store 抽象稳定下来。
完成后必须满足:
1. 代码里有统一 `StickyStore` 接口
2.`RedisStickyStore` 实现
3.`InMemoryStickyStore` 测试替身或 fallback
4. route sticky / route fail count / route cooldown 的 key 规则已固化
## 注意
本任务是“接口封装 + 适配层”,不是要在这一任务里把完整 route resolver 做完。
## 需要新增内容
建议新增:
- `internal/routing/sticky.go`
- `internal/routing/sticky_redis.go`
- `internal/routing/sticky_memory.go`
建议接口:
```go
type StickyStore interface {
Get(ctx context.Context, key string) (StickyBinding, bool, error)
Set(ctx context.Context, key string, binding StickyBinding, ttl time.Duration) error
Delete(ctx context.Context, key string) error
GetRouteFailure(ctx context.Context, routeID string) (RouteFailureState, bool, error)
SetRouteFailure(ctx context.Context, routeID string, state RouteFailureState, ttl time.Duration) error
ClearRouteFailure(ctx context.Context, routeID string) error
GetCooldown(ctx context.Context, routeID string) (RouteCooldownState, bool, error)
SetCooldown(ctx context.Context, routeID string, state RouteCooldownState, ttl time.Duration) error
ClearCooldown(ctx context.Context, routeID string) error
}
```
## Redis key 规范
### sticky
```text
lg:{logical_group_id}:m:{public_model}:conv:{conversation_id}
lg:{logical_group_id}:m:{public_model}:sess:{session_id}
lg:{logical_group_id}:m:{public_model}:user:{user_id}
```
### route failure
```text
routefail:{route_id}
```
### cooldown
```text
routecool:{route_id}
```
## 配置建议
本任务建议同时补上新的配置结构,但可以先不强制 remote43 真正启用:
- `SUB2API_CRM_REDIS_ADDR`
- `SUB2API_CRM_REDIS_PASSWORD`
- `SUB2API_CRM_REDIS_DB`
- `SUB2API_CRM_ROUTE_RUNTIME_BACKEND=memory|redis`
第一版建议默认:
- 没配 Redis 时使用 `memory`
- 配了 Redis 且连接成功时启用 `redis`
## 入场条件
- 已确认当前项目没有现成 Redis runtime abstraction可直接新建 `internal/routing/`
- 明确 remote43 栈里 Redis 是否已存在、连接参数从哪里注入
- 明确本任务只做“抽象 + backend 适配”,不偷偷把 `RouteResolver` 一起塞进来
## 产出清单
- `StickyStore` 接口
- `MemoryStickyStore`
- `RedisStickyStore`
- 新配置项与启动配置解析
- 最小 set/get/failure/cooldown 的测试覆盖
## 本地验证
至少覆盖:
- in-memory store test
- redis adapter test如果能做假客户端或集成测试
- key 生成稳定性 test
- TTL 行为 test
## 上传与服务器验证 Gate
### 上传动作
1. 提交并推送
2. 部署 CRM 到 `remote43`
### 服务器验证
最低验证:
- CRM 在无 Redis 配置时仍能启动
- 若 remote43 栈已有 Redis则打开 Redis backend 配置后启动成功
- 做一次最小 sticky set/get 验证
如果 Phase 1 还没有开放测试 API可通过
- 临时内部 health probe
- 或 SSH + 本地辅助脚本
完成远端验证。
建议最小远端验证拆成两段:
1. `memory` 模式:
- 不配 Redis
- CRM 能启动
- sticky test path 能 set/get 成功
2. `redis` 模式:
- 配 Redis
- CRM 能启动
- sticky test path 能 set/get 成功
- cooldown / route failure 能读写成功
### 证据要求
- 执行板记录:
- sticky store 抽象已落地
- remote43 在 `memory` 模式启动通过
- 如启用 Redis也记录 Redis 模式验证结果
---
## 5.1 Phase 1 统一配置增量
为了避免每个任务各自偷加一套配置名Phase 1 里建议一次性统一收口以下新增环境变量:
- `SUB2API_CRM_REDIS_ADDR`
- `SUB2API_CRM_REDIS_PASSWORD`
- `SUB2API_CRM_REDIS_DB`
- `SUB2API_CRM_ROUTE_RUNTIME_BACKEND`
其中:
- `P1-T1` 不需要使用这些变量
- `P1-T2` 不应该依赖这些变量
- `P1-T3` 可先不依赖 Redis
- `P1-T4` 负责正式接入并验证
---
## 6. 每个闭环功能的统一发布流程
以下步骤是 Phase 1 每个任务完成后的统一动作,不可跳过。
### Step A本地质量门禁
```bash
gofmt -l .
go vet ./...
go test -cover ./internal/...
go test ./tests/integration/... -count=1
```
### Step B提交与推送
- 提交本任务代码
- 推送到 3 个远端
### Step C部署到 `remote43`
按任务内容选择:
- CRM / stack使用 `scripts/deploy/setup_remote43_patched_stack.sh` 的既有流程更新 remote43 CRM
- portal 资产:若有前端改动,再用 `scripts/deploy/deploy_tksea_portal.sh`
### Step D服务器验证
至少验证:
- `/healthz`
- 对应新增 API 或功能
- 若涉及真实导入/真实运行,补 acceptance 证据
### Step E证据沉淀
- 更新 `docs/EXECUTION_BOARD.md`
- 如产生真实运行验证,补:
- `artifacts/real-host-acceptance/...`
## 6.1 统一证据模板
为了让后续每个任务的“已完成”可审计,建议每次至少记录以下 8 项:
1. 提交 SHA
2. 推送的远端列表
3. remote43 部署时间
4. remote43 CRM 版本或 `HEAD`
5. `/healthz` 结果
6. 任务对应的功能验证结果
7. 失败时的回滚动作
8. 证据位置
如需要单独存 Phase 1 证据,建议目录模板:
```text
artifacts/phase1-verification/YYYYMMDD_remote43_p1-tX_<short-name>/
```
如果实际没有新 artifact也必须在执行板里写清楚验证方式不允许空口确认。
## 6.2 回滚原则
每个任务都要带回滚预案,否则不算闭环。
- `P1-T1` / `P1-T3` 涉及 migration
- 不能假设 SQLite 方便回退 schema
- 真实回滚策略应是“回滚二进制 + 保证新表对旧代码无害”
- `P1-T2` 涉及新 API
- 出问题先回滚二进制,保留表结构
- `P1-T4` 涉及 runtime backend
- Redis backend 异常时必须可降级回 `memory`
---
## 7. Phase 1 完成定义
只有同时满足下面 7 条,才能宣称 Phase 1 完成:
1. SQLite 已有 logical group / route 结构表
2. Admin API 已能 CRUD logical group / route
3. 路由日志表已存在
4. 路由日志写入器已存在且可落库
5. Sticky store 抽象已存在
6. 所有任务都已至少在 `remote43` 部署验证一次
7. 执行板已更新真实状态,不留“本地已完成、远端未验证”的假完成
## 一句话结论
Phase 1 不追求把智能路由全部做完,而是先把 **SQLite 结构、管理 API、路由日志、Redis sticky 抽象** 这四个地基打好。
并且从这一阶段开始,**每个闭环功能完成后都必须上传到 remote43 再验证**,不允许只凭本地测试宣布完成。