# 插件闭环实施规划（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. 最后把普通用户前端切到逻辑分组视角
