docs(routing): capture phase1 foundation plan
This commit is contained in:
@@ -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 或模型名大小写问题
|
||||
|
||||
525
docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md
Normal file
525
docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md
Normal 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,也依然只会在运行时把不同路线混成一个账号池,结构上仍然是不成立的。
|
||||
661
docs/PLUGIN_CLOSED_LOOP_IMPLEMENTATION_PLAN_2026-05-28.md
Normal file
661
docs/PLUGIN_CLOSED_LOOP_IMPLEMENTATION_PLAN_2026-05-28.md
Normal 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 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}
|
||||
```
|
||||
|
||||
#### Redis key:route failure
|
||||
|
||||
```text
|
||||
routefail:{route_id}
|
||||
```
|
||||
|
||||
#### Redis key:route 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. 最后把普通用户前端切到逻辑分组视角
|
||||
339
docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md
Normal file
339
docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md
Normal 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 管理层
|
||||
- 前置智能路由层
|
||||
- 面向普通用户的聚合前端层
|
||||
|
||||
当前第一层基本成立,第二层已完成设计但未落地,第三层和第四层是后续真正的产品化主线。
|
||||
597
docs/PLUGIN_ROUTE_STICKY_DESIGN.md
Normal file
597
docs/PLUGIN_ROUTE_STICKY_DESIGN.md
Normal 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、强粘性、高缓存命中”的目标效果。
|
||||
875
docs/plans/2026-05-28-phase1-logical-routing-foundation-plan.md
Normal file
875
docs/plans/2026-05-28-phase1-logical-routing-foundation-plan.md
Normal 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 再验证**,不允许只凭本地测试宣布完成。
|
||||
Reference in New Issue
Block a user