diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 91e8da82..d2af99d8 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -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 或模型名大小写问题 diff --git a/docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md b/docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md new file mode 100644 index 00000000..818ccf7b --- /dev/null +++ b/docs/HOST_MULTI_CHANNEL_MINIMAL_RETROFIT.md @@ -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,也依然只会在运行时把不同路线混成一个账号池,结构上仍然是不成立的。 diff --git a/docs/PLUGIN_CLOSED_LOOP_IMPLEMENTATION_PLAN_2026-05-28.md b/docs/PLUGIN_CLOSED_LOOP_IMPLEMENTATION_PLAN_2026-05-28.md new file mode 100644 index 00000000..e85fde38 --- /dev/null +++ b/docs/PLUGIN_CLOSED_LOOP_IMPLEMENTATION_PLAN_2026-05-28.md @@ -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. 最后把普通用户前端切到逻辑分组视角 diff --git a/docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md b/docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md new file mode 100644 index 00000000..200fa7bd --- /dev/null +++ b/docs/PLUGIN_REQUIREMENTS_OVERVIEW_2026-05-28.md @@ -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//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 管理层 +- 前置智能路由层 +- 面向普通用户的聚合前端层 + +当前第一层基本成立,第二层已完成设计但未落地,第三层和第四层是后续真正的产品化主线。 diff --git a/docs/PLUGIN_ROUTE_STICKY_DESIGN.md b/docs/PLUGIN_ROUTE_STICKY_DESIGN.md new file mode 100644 index 00000000..35e6f535 --- /dev/null +++ b/docs/PLUGIN_ROUTE_STICKY_DESIGN.md @@ -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、强粘性、高缓存命中”的目标效果。 diff --git a/docs/plans/2026-05-28-phase1-logical-routing-foundation-plan.md b/docs/plans/2026-05-28-phase1-logical-routing-foundation-plan.md new file mode 100644 index 00000000..6e7230ca --- /dev/null +++ b/docs/plans/2026-05-28-phase1-logical-routing-foundation-plan.md @@ -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_/ +``` + +如果实际没有新 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 再验证**,不允许只凭本地测试宣布完成。