Files
dsh_shenxian/dsh-server-docs/03-数据库/DB-03-插件数据面规范.md
T
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

279 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 插件数据面规范(插件如何接入数据库)
> 状态:✅ **已落文档库,本文件即插件数据面的唯一正式来源** —— `dsh-server-docs/03-数据库/DB-03-插件数据面规范.md`(2026-09-20 落点,专项区 `03-数据库/`)。
> ⚠️ **不再"待落点"**:`04-调整方案/143` 是**另一件事**(「MCN 工作台入口失效与语言切换显示修复」),与本文无关;⛔ 不要再把本文往 04-143 归位。
> 立稿:2026-09-20 | 起草会话 `IM插件数据面-专门文档`
> 来源:档案 142 §3.6(三条平台保护)· `05-交接单/IM群组-01-房间内核与DB.md §九`(内核侧 DB 裁决)· `05-交接单/IM群组-04-插件SDK与扩展点契约.md §九`(插件侧契约)。
> 本文是**专门文档**:把散在两处的条款收拢成一份可直接照着实现的规范;冲突时**以本文为准**,并回改上述两处。
> ✅ **执行状态(2026-09-23 · 插件投放与分库线第 4 棒)**:数据面已**开工并落地**,本规范除标注处外**可照着实现**。
> - ✅ **已落地**:共享只读**包**库 `bundled-plugins`(D2,"包库"与本文件的"插件库"是两回事)+ admin 投放面(`POST /api/plugins/business/share`)。逐条读数见 `05-交接单/插件投放与分库线-01共享只读包库与插件数据面.md §十二`。
> - ✅ **建库权限已解决(2026-09-23 · 用户拍板 B 档)**:`ALTER ROLE dshs CREATEDB;`(47 已落地 + 11 条验收)⇒ **§三"谁建库"不再受阻**。
> - ✅ **建库口已接线**:`src/db/plugin-data/datastore.ts`(`CREATE DATABASE` 单语句非事务 / 台账 + 审计 / 台账 × `pg_database` 对账)+ 控制面 v15 迁移(两张台账表)。落地件 ⇒ `交付物/建库口接线-B档落地-20260923.md`。
> - 🔴 **§六"跨节点:worker 经覆盖网络调 Manager 侧数据面"仍不通**:所需"实例→控制面"身份通道**今天为 0**(147 §三 G3 / 序47 §②.1)。
> - ⚠️ **B 档的真实缺口(已由代码兜住)**:PG **无**"只允许 `dshs_pl_*` 前缀"的原生机制 ⇒ 白名单只能靠平台代码三点(库名点生成 + 正则复校 + 每次建库落台账审计)。详见 §六-1。
> - ✅ 上表 `§十 差异清单` 的 **D1–D5 / D7 回改已落文**(本轮)。
## 〇、定位
回答一个问题:**业务插件要存数据,怎么接?**
一句话:**一个插件一个库**(`dshs_pl_<pluginId>`,建在同一 PostgreSQL 13.23 实例上)—— 插件**不建表、不写 SQL、不持有连接**,用**声明式 schema** 由内核翻译建表,数据读写一律**走内核数据 API**、由内核按插件身份路由到该插件自己的库。
- 适用:所有走「候选池 → 实例功能管理启用」通道的业务插件(自研 / 第三方)。
- 不适用:平台自身内核表(`rooms` / `room_members` / `messages` / `files` …);那些归控制面库与 A 单。
- 🟢 范围:只讲技术实现。| **架构依据(冲突时以它为准)**:`02-架构设计/数据-分库与权威存储架构.md`。
## 一、第一步:先判落点(再谈建表)
**第一判据**:插件数据的落点**只有一处** —— **该插件自己的库** `dshs_pl_<pluginId>`。库在**同一个 PostgreSQL 13.23 实例**上,由内核按"调用方插件身份"路由;插件**看不到库名、也拿不到别的插件的库**。
库内再按**归属**分两类,两者**同库共存**,靠归属列区分:
| 归属 | 谁的数据 | 归属列(内核自动补) | 读写权限 |
|---|---|---|---|
| `scope: user` | 某个用户自己的(插件里的个人数据 / 索引 / 产物) | `user_id` | 只能读写**自己**的行 |
| `scope: room` | 跨用户共享的房间维度数据(跑团角色卡 / MUD 地图 / 办公任务卡) | `room_id` | 复用房间成员判定(`canSee`) |
🔴 **归属列强制**(分库的**前提**,不是可选项):每张业务表**必须**带 `user_id` 或 `room_id`,由内核建表时自动补齐;**插件不许自建表、不许省略归属列**。
理由:库的维度是**插件**,用户维度的备份/迁移**只能靠归属列切分** —— 缺了归属列,按用户导出/迁移**直接不可能**。
⛔ **同一份数据不双写**(双写 = 脑裂)。
🔴 **不得以实例本地库作为数据权威**(2026-09-22 新增):插件可以在实例本地放**缓存**(丢了可重建),但**权威数据必须在插件库**。旧形态「插件在实例里自建 SQLite 存业务数据」此后**不允许**;存量(如 MCN)按架构定稿的 S2 步骤导入插件库。
⚠️ `pluginId` = 包名去掉 scope(如 `@dsh-local/trpg-kit` ⇒ `trpg_kit`):**小写、连字符转下划线**;库名 = `dshs_pl_<pluginId>`。
## 二、红线(违反即不予启用)
1. ⛔ 插件**不得直连数据库**:不持有连接串 / 不 import 数据库驱动 / 不自建连接池。
2. ⛔ 插件**不得写原生 SQL**:无 `CREATE TABLE` / `ALTER` / `DROP` / `SELECT`;无触发器、存储过程、厂商专有类型。
3. ⛔ 插件**不得访问其他插件的库**:看不到也查不了别人的 `dshs_pl_*`、控制面库与共享数据。
4. ⛔ 插件**不得自组跨库事务**(PG 跨 database 无原生事务)。
5. ⛔ 插件**不得绕过归属判定**:`scope: user` 只能读写自己的行;`scope: room` 由内核强制注入成员校验。
6. ⛔ 插件**不得以实例本地库 / 本地文件作为数据权威**(可作为缓存,权威必须在插件库)。
> 判据(可机械验证):插件包内 `grep -rE "CREATE TABLE|ALTER TABLE|DROP TABLE|better-sqlite3|from 'pg'|require\('pg'\)"` **零命中**。
## 三、建表:声明式 schema
插件在 `im.data` 上声明表;**字段类型仅限中性集合**,由内核翻译成 **sqlite + pg 两套 DDL**:
| 中性类型 | sqlite | pg | 说明 |
|---|---|---|---|
| `text` | TEXT | TEXT | 短文本 |
| `bigtext` | TEXT | TEXT | 长文本(有长度上限) |
| `integer` | INTEGER | INTEGER | 32 位整数 |
| `bigint` | INTEGER | BIGINT | 计数 / 时间戳毫秒 |
| `real` | REAL | DOUBLE PRECISION | 浮点 |
| `boolean` | INTEGER | BOOLEAN | 布尔 |
| `json` | TEXT | JSONB | 结构化载荷 |
| `timestamp` | TEXT(ISO) | TIMESTAMPTZ | 时间 |
| `uuid` | TEXT | UUID | 标识 |
内核在建表时**自动补齐**:**库名** `dshs_pl_<pluginId>`、表名前缀 `p_<pluginId>_`、**归属列**(`user_id` 或 `room_id`,按 `scope` 定)、审计列(`created_at` / `updated_at`)。
### 三·补 —— 谁建库、什么时候建(2026-09-23 落文,原缺失项)
**谁建库**:**平台侧**(⛔ 不是插件、⛔ 不是实例)。插件只声明、不建库。建库由 admin 在门户上触发:
```
上传 ─检测(安全 + 兼容 + 数据面声明校验)→ 通过 ⇒ 状态 pending
⇒ admin 点 [建库]
⇒ POST /api/plugins/business/:id/datastore
⇒ CREATE DATABASE dshs_pl_<pluginId> + 落台账
─建库成功→ [发布] → 用户才能启用
```
- **权限前提**:`ALTER ROLE dshs CREATEDB;`(2026-09-23 拍板 **B 档**;47 已落地 · 11 条验收)。⛔ 不得给 `CREATEROLE` / `SUPERUSER`。重建步骤 ⇒ `DEPLOY-本部署.md §6.4`。
- **语句姿势**:`CREATE DATABASE dshs_pl_<pluginId> OWNER dshs ENCODING 'UTF8' TEMPLATE template0;` —— **单语句、⛔ 不走事务**(PG 语句级内核限制:`CREATE DATABASE cannot be executed from a function` / 不能进事务块)。
- **库名两重防线**(B 档缺口只能靠代码兜):① `pluginDbNameOf` 从 `.dsh` 声明换算点生成,⛔ 不收请求体里的库名;② `assertPluginDbName` 在拼 SQL **之前**复校 `^dshs_pl_[a-z][a-z0-9_]{0,40}$` + `quoteIdent` 标准引用。非法 ⇒ **400 `invalid_plugin_db_name`**,⛔ 不下发 PG。
- **幂等**:已存在 ⇒ 返 `exists`(捕获 PG `42P04 duplicate_database`),⛔ 不报错、⛔ 不重建。
- **错误码**:`PG_CREATEDB_MISSING`(503 · 未授 `CREATEDB` 或已回滚)|`invalid_plugin_db_name`(400)。⚠️ D 档的 `PLUGIN_DB_HELPER_MISSING` **已作废**。
- **对账**:`GET /api/plugins/business/datastores/reconcile` ⇒ 台账 × `pg_database` 双向差集(orphan = 绕过平台建的库;missing = 台账有而 PG 无)。
示例(跑团插件):
```yaml
pluginId: trpg_kit
schemaVersion: 3
tables:
- name: character_sheet # 实际表名 => p_trpg_kit_character_sheet
scope: room # room | user(room = 注入房间 ACL;user = 只能读写自己的行)
columns:
- { name: pc_name, type: text, notNull: true, maxBytes: 128 }
- { name: sheet, type: json, notNull: true, maxBytes: 16384 }
- { name: sheet_rev,type: integer, default: 1 }
indexes:
- { columns: [room_id, pc_name], unique: true }
```
约束:每表**至多 20 列**、`json` 列 `maxBytes` 必填、表与列名 `^[a-z][a-z0-9_]{0,40}$`、每插件**至多 20 张表**。
## 四、迁移:只增不减
- 插件声明 `schemaVersion`(整数,单调递增);内核按版本推进。
- **只允许**:加表 · 加列(**必须带默认值**)· 加索引。
- ⛔ **不允许**:删列、改列类型、重命名、加无默认值的非空列(会导致上线即失败)。
- 执行时机 = 🔄 **2026-09-23 修订:分两步,且都是插件级一次性动作**(原写法"启用插件时"会退化成 per-user 动作 —— 同插件每个用户开通都跑一遍 DDL):
- **建库**:admin 点 `[建库]` 时(`POST …/:id/datastore`)⇒ 建库 + 建表 + 归属列 + 索引一次做完,落台账 `plugin_datastores`。
- **迁移**(`schemaVersion` 推进):**插件级一次性**,全局只推进一次;**用户开通只读台账版本、⛔ 不触发 DDL**(`POST /api/plugins/mine/apply` 走门禁但不改结构)。
- 任一用户启用前,平台先校验台账 `status = ready` 且版本满足 ⇒ 不满足 ⇒ 拒绝启用(`datastore_not_ready`)。
- 任一步失败 ⇒ **拒绝启用**(⛔ 不做"半迁移"),旧结构保持可用、可回滚。
- ⚠️ **表级 DDL 在同一事务内**(失败整体回退);但 **建库项不可回滚** ⇒ 建库失败**不 DROP**,留空库 + 台账 `status='error'` + `last_error`,等 `[重试]` 覆盖。
- 迁移语句由内核生成并记入审计(`plugin_data_audit`)。
- **TOCTOU 防护**:预演(`/datastore/plan`)产出 `planHash` ⇒ 执行时必须回传同一指纹才对;⛔ 建库 / 迁移路径一律**不传** `planHash`(omit ⇒ 保留旧值,`COALESCE` 语义),⚠️ 传 `null` 会清掉指纹 = 拆掉防护。
### 四·补 —— 兼容矩阵:包版本 × `schemaVersion` 怎么判(2026-09-23 落文,P2「要有定义」的兑现)
> **两个版本号是两件事,⛔ 不混为一谈**:`version` = 插件**包**版本(`package.json` 的 `version`,可能因改文案/修 bug 而不动结构);`schemaVersion` = **数据面结构**版本(整数、单调递增)。**判"库该不该动"只看 `schemaVersion`**;包版本只用来判"该装哪个包"与 B 档回滚素材的指向。
**判据(按优先级从上到下取首个命中项)**:
| 场景 | 包版本 | `schemaVersion` vs 库现值 | 允许的动作 | 依据 |
|---|---|---|---|---|
| 首次装 | 任意 | 库不存在 | **建库** → 迁移 → 可启用 | §三·补 |
| 升级 | 新 > 旧 | 新 **≥** 现值 | **升级**(只增:加表/加列带默认值/加索引) | §四 |
| 等价换包 | 新 > 旧 | 新 **=** 现值 | **只换代码,不碰库**(`drift` 不触发) | §四 |
| 降级 | 新 **<** 旧 | 新 **<** 现值 | ⛔ **拒绝启用** + 明确错误码;**不回滚数据** | §四 + 本表下注 |
| 同名同版本重传 | 相同 | 相同 | **幂等**(`tgz sha256` 相等 ⇒ 不动文件) | §六-2 / S2 |
| 同名同版本但内容不同 | 相同 | 相同 | **冲突** ⇒ 两段式确认后才替换(⚠️ 会波及所有已指向的用户) | S2 |
**三条硬规则**:
1. **降级不自动发生**:`schemaVersion` 回退 ⇒ 状态 `drift`,`executable=false` ⇒ 前端「确认执行」置灰、后端 `POST /datastore/migrate` 也拒。给用户的错误必须**可读**(原话:不写"未达预期"了事),且**库一个字节都不改** —— 旧版代码若认不出新列,忽略即可(与 §八 一致)。
2. **⛔ `UPDATE ≠ DROP`**:更新路径**永不** `DROP DATABASE`;卸载才走 §六-4(清单 + admin 确认 + 默认保留 30 天)。
3. **矩阵的机器判据 = `diffDecl()`**:上表是**给人看的口径**,机器侧唯一实现是 `src/db/plugin-data/diff.ts` 的 `diffDecl(decl, current, …)` + `pluginDbNameOf` 推导的 `plan.schemaVersion`。判据来源是**库的现实**(真读 `information_schema`),⛔ 不是台账里记的那个数 —— 台账只是**对账参照**(§六-2 的 `reconcile` 抓的正是二者不等)。
**B 档(只留最新一版)给本矩阵加的两条前置**(§五 S5-a-3,2026-09-23 既有裁决):
- 每次上传**先落一份 tgz** 到 `/opt/dsh/backups/plugins/<pkg>/<ver>.tgz` —— 否则"降级到哪一版"没有素材;
- 台账 `plugin_datastores` 必须记**当前包版本** —— 否则判不出该回滚到哪个包。
⚠️ **无灰度是 B 档的已知代价**(已拍板接受):共享层同名替换后,**所有**已指向它的用户下一次生效即为新版本 ⇒ 执行回报里必须写清"影响 N 个用户、动作耗时"。
**验证(兼容矩阵的可机械复现判据)**:
```
① 装 v1(schemaVersion=1) → 写 3 行 → 升 v2(schemaVersion=2,加一列)
期望:3 行仍在、新列有默认值;`plan.summary.columns >= 1`;退出码 0
② 强行装回 v1
期望:状态 `drift`、`executable=false`、错误码可读、`select count(*)` 仍 = 3(库未被改)
③ 同名同版本、内容相同重传
期望:`idempotent:true`,共享层 mtime / sha256 不变
④ 同名同版本、内容不同
期望:`conflict:true` + 双方 sha256;确认前**不动任何文件**
```
## 五、访问 API(唯一入口)
```ts
im.data.table<T>('character_sheet')
.insert(row) // 单行;返回 id
.update(id, patch) // 按主键
.delete(id) // 软删标记(内核保留 30 天,见 §六-4)
.find({ where, order, limit }) // 游标分页:返回 { rows, cursor }
.count({ where })
```
语义要点:
1. **库路由由内核决定**:插件只报表名,内核按**调用方插件身份**路由到 `dshs_pl_<pluginId>`;插件看不到库名,也拿不到别人的库。
2. **归属维度自动注入判定**:`scope: user` 的表每次读写附加「调用者 == `user_id`」校验;`scope: room` 附加「调用者是该房成员」(复用内核 `canSee`)⇒ 越权得到 **0 行 / 明确拒绝**,不是"查得到但看不到"。
3. **同库内要原子 ⇒ 走内核事务口**:`im.data.tx([...])` 仅支持**同一个插件库内**的多表;跨库(如同时改房间属性 + 自己的数据)**没有事务** ⇒ 必须拆成幂等步骤 + 补偿,⛔ 不许靠双写。
4. **读写路由**:写、以及"刚写就读"**一律走主库**;⛔ 不从只读副本读自己的新行(副本延迟会破坏读己所写)。
5. **限流**:插件写操作吃**与 agent 同族的预算**,并受房间级速率上限约束(142 §3.6 保护约束 3)。
6. **分页**:一律**游标**,不用 `OFFSET`(大偏移在 pg 上会退化)。
## 六、管理面
1. **配额**(防单插件拖垮库):每插件 **总行数** + **单行字节** + **每房行数** 三重上限(库维度天然对齐"一插件一库");超限返回**明确错误码**(⛔ 不静默丢、⛔ 不静默截断),并在插件卡片上可见。
2. **审计**:DDL、迁移、超阈值批量写记录进内核表 `plugin_data_audit`(谁 / 何时 / 哪个插件 / 影响行数);空间占用进平台看板。
- 🔴 **两张台账表 = 控制面 v15 迁移新增**(2026-09-23 落地):
| 表 | 键 | 作用 |
|---|---|---|
| `plugin_datastores` | `plugin_id` → `database_name` | **库台账**:当前 `schema_version` + 插件包 `version` + `status`(`created`/`ready`/`error`) + `plan_hash` + `last_error` + 时间戳。**B 档白名单的第三点兜底** —— 使"平台到底建过哪些库"可对账 |
| `plugin_data_audit` | 自增 id | **审计流水**:建库 / 迁移 / DDL 逐条留痕(谁 / 何时 / 哪个插件 / 动作 / 影响) |
- ⚠️ 两表**由平台启动时 `runPgMigrations` 自动落**(47 当前停在 v13,部署 v15 代码后随 `restart dshs` 自动补齐);⛔ 不需手工建表。
- **对账**:`GET /api/plugins/business/datastores/reconcile` ⇒ 台账 × `pg_database` 双向差集(抓 orphan / missing)。
3. **可观测**:每插件暴露「行数 / 体积 / 最近写入」三项,进"功能管理"页。
4. **卸载**:停用插件 ⇒ **数据默认保留 30 天**(可配置);窗口内可恢复。
彻底卸载 = `DROP DATABASE dshs_pl_<pluginId>`(**一次带走该插件全部表**,比逐表 DROP 干净)⇒ 属**不可逆** ⇒ 必须 **admin 显式确认 + 先出受影响清单**(库名 / 表 / 行数 / 体积),⛔ 不做"卸载即删"。
5. **备份 / 迁移**(2026-09-22 新增):单插件备份 = `pg_dump dshs_pl_<pluginId>`;**按用户导出/迁移** = 统一迁移器按**归属列**遍历(控制面库 + 各插件库 + 桶清单)⇒ 一次遍历、可重入、可对账。⛔ 用户注销**不** DROP 插件库(库属插件、不属用户),只删该 `user_id` 的行 + 回收桶内对象。
6. **禁用后的行为**:插件无关;房间照常(数据面异常不得影响消息流,见 §七)。
### 六·补 —— 插件文件落点与凭据投递(2026-09-23 落文 · 对 carbon 线两问的裁定)
> **一句话**:插件的**非数据库文件**一律落 `<userRoot>/home/.dsh/plugins/<pluginId>/`(D4);该目录**由实例侧插件自己建**,平台侧不碰。
| 项 | 口径 |
|---|---|
| **落点** | `<userRoot>/home/.dsh/plugins/<pluginId>/`(= `/var/lib/dshs/users/<uid>/home/.dsh/plugins/<pluginId>/`,**实例内是同一绝对路径**,bwrap `--bind <userRoot> <userRoot>` 不重映射) |
| **`<pluginId>` 定义** | 去 scope、小写、非 `[a-z0-9_]` 折 `_`(`src/db/plugin-data/schema.ts:126` `pluginIdOf`)⇒ 与库名 `dshs_pl_<pluginId>` **同一个 token**。例:`@dsh-local/dsh-plugin-carbon` ⇒ `dsh_plugin_carbon`(⛔ 不是 `carbon`) |
| **谁建目录** | **实例侧插件首次用时 `mkdir -p`**(幂等);投递类文件由投递方 `install -d -m 700 -o <uid> -g <uid>` 先建。⛔ **平台侧不碰** —— `src/fs/user-fs.ts:126` 的 home 白名单只有 3 个**裸文件名**,不含任何目录 |
| **目录内取数姿势** | `join(process.env.DSH_HOME, '.dsh', 'plugins', pluginId)`。`DSH_HOME` 由平台注入(`src/supervisor/orchestrator.ts:813`),实例内**必存在**,缺失 ⇒ **fail-fast 报错**,⛔ 不许回退 |
| **⛔ 禁 `homedir()`** | 实例内 `HOME` = `<userRoot>/ws`(`orchestrator.ts:811`),而 `ws` 会**被平台按产物清理**(`:857`)⇒ `homedir()` 回退不是「降级可用」,是**把数据写到会被清掉的位置** |
| **⛔ 不存在 `im.paths.pluginData()`** | 实测三处零命中(`dsh_shenxian/src` / 宿主仓 `deepseek-harness` / `node_modules`)。**本规范不再引用该 API**;实例内取目录按上行的 `DSH_HOME` 拼 |
| **凭据类文件** | 与插件数据**同目录**(同一清理单元):文件 `0600`、**属主 = 该用户 uid**(⛔ root 写的 0600 实例读不了,同 `user-fs.ts:110-111` 的教训);插件侧**只读**,⛔ 不覆盖投递来的文件名 |
| **⚠️ 跨机** | `<userRoot>` 是**那台机上的**路径。用户实例在 worker 上时,在管理机直写 `<userRoot>/home/…` = **静默空操作**(`src/fs/user-fs.ts:122-124`,档案 138 §五)⇒ 投递必须发生在 `hostIdFor(userId)` 指到的那台机 |
| **卸载清理单元** | 整个 `<pluginId>/` 目录(与库 `dshs_pl_<pluginId>` 同粒度:库走 §六-4,文件走本行) |
⚠️ **现状缺口(⛔ 不许当已知项推断)**:平台侧**没有**「把插件凭据投给用户」的通路 ⇒ 凭据仍是插件线**带外投递**。要做成「用户点一下开通 ⇒ 凭据自动就位」,须在平台立项;建议**并进 worker 侧 `/plugins/apply` 同一条腿**(与装配搬到实例所在机同族,零新增入站口)。
## 七、故障与降级
| 情形 | 行为 |
|---|---|
| 插件回调超时 | 熔断该插件数据面调用;**降级为普通聊天**,房间消息流不受影响 |
| 超配额 | 返回明确错误码;插件功能不可用但状态可见(⛔ 不静默吞掉用户数据) |
| 迁移失败 | **拒绝启用**,保留旧结构 |
| 某插件库不可用 | 只影响该插件;其他插件与内核照常(一插件一库的天然收益) |
| DB 主库不可用 | 房间读取按内核既有降级;插件写入直接失败并提示(⛔ 不落本地再补 = 会造成脑裂) |
## 八、与其他能力的衔接
- **房间 ACL**:房间维度表的权限**复用**内核判定,⛔ 不新起一套文件/数据权限模型(与 `files` 面同源)。
- **对象存储**:大对象(图片 / 视频 / Excel)**不进 DB**,走 `files` 面(`sha256` + `bucket_key`)⇒ 插件只拿 `file_id`;插件表里**只存引用**。
- **缓存 / 队列**:内核提供 `im.cache`(get/set/del + TTL)与 `im.queue`(enqueue/lease/ack)**端口**;本期实现 = 内存 Map / PG 表,⛔ 插件不得自行引入 Redis / MQ 进程(宿主内存紧:实例硬顶 1024 MiB)。
- **版本兼容**:`schemaVersion` 与插件包版本一并记录;降级安装(装旧版插件)⇒ 若旧版不认新结构,**拒绝启用**并提示,⛔ 不回滚数据(完整矩阵见 §四·补)。
### 八·补 —— 端侧边界声明(2026-09-23 落文 · S6-3)
> **一句话**:端侧设备访问的是**同一个平台 API**,不另起一套数据面通路。
| 项 | 口径 |
|---|---|
| **看见** | ✅ 端侧(桌面壳 / 浏览器)调 `GET /api/plugins/mine` 与 `GET /api/plugins/shared/catalog`,与网页端**同一份数据、同一套鉴权** |
| **开通** | ✅ 端侧调 `POST /api/plugins/mine/apply`,与网页端同一条路径、同一套后端门禁(数据面未就绪一律拒) |
| **本地装包** | ⛔ **不要求**。端侧**不下载、不安装**插件包实体 —— 包在服务端共享只读库(D2),端侧只发请求 |
| **凭据下发** | ⛔ **不下发平台级凭据到客户端**。端侧只持有**该用户自己的会话**;插件库连接由**内核代持**(§五-1),客户端拿不到库名、更拿不到连接串 |
| **数据落点** | 插件数据**一律落服务端库**(`dshs_pl_<pluginId>`);端侧本地**不落插件数据**(§六-5 的"跟用户走"指的是 `home/.dsh/plugins/<id>/`,那在**服务端该用户的主目录**里,不是端侧磁盘) |
| **离线** | ⚠️ 端侧离线时插件**不可用**(数据面在服务端)—— 这是"数据入库"口径的必然推论,⛔ 不是缺陷 |
⚠️ **现状读数(⛔ 不许当已知项推断)**:端侧**插件投放通路 = 无**(`交接单 §9.4` 实测),且桌面线客户端载体本身尚未产出
(序46 §⑬ 遗留 1 停在 `client-artifact-missing`)。**本声明只定义"端侧该看到什么、不该拿到什么"**;
⛔ 造端侧载体、做端侧本地包分发**不在本规范范围**(后者属"跨节点内容分发",需单独立项)。
## 九、验收清单(可机械复现)
| # | 断言 | 期望 |
|---|---|---|
| 1 | 零裸 SQL / 零 DB 驱动 | 插件包内 `CREATE TABLE`、`better-sqlite3`、`pg` **零命中** |
| 2 | 双后端可建 | 同一声明在 sqlite 与 pg 上**都建库建成** |
| 3 | 库与前缀 | 实际库名 `dshs_pl_<pluginId>`、表名全部 `p_<pluginId>_*`;不存在无前缀的插件表 |
| 4 | 越权 | 读别的插件库 / 控制面库 ⇒ **拒绝** |
| 5 | 归属判定 | `scope: user` 读他人行 ⇒ **0 行**;`scope: room` 非成员读 ⇒ **0 行** |
| 6 | 迁移 | 加列成功;声明删列或改类型 ⇒ **拒绝启用**(预期失败) |
| 7 | 配额 | 超限返回明确错误码,且**无静默丢行**(前后计数可对账) |
| 8 | 卸载 | 停用后数据仍在且可查;`DROP` 走确认流程 |
| 9 | 降级 | 插件数据面挂起 ⇒ 房间消息流照常、无 5xx |
| 10 | **库归属** | 新启用一个插件 ⇒ 只新增 `dshs_pl_<pluginId>` 一个库;插件触达不到其他库 |
| 11 | **归属列** | 每张业务表都有 `user_id` 或 `room_id`;缺列 ⇒ **拒绝启用** |
| 12 | **按用户可切分** | 用迁移器按某 `user_id` 导出 ⇒ 行数与"该用户在各库的行数之和"一致 |
## 十、待定
- 配额具体数值(总行数 / 单行字节 / 每房行数)⇒ **待压测标定**,与档案 142 §六-2(房间上限与发言预算)同一批。
- `im.data` 是否提供**聚合查询**(`groupBy` / `sum`)还是只给 `count` ⇒ 先只给 `count`(够用且不易被滥用),确有需要再评估。
- 插件数据的**跨房共享表**(`scope: plugin`)默认关闭,**需 admin 审核后生效**(与档案 142 §六-4 同一条口径)。
- **每插件库的连接池参数**(池大小 / 空闲回收 / 全平台上限)⇒ 随架构定稿 S1 定稿;⚠️ 默认档用独立 database(库数 = 插件数),若插件数量增长到连接吃紧,降为同 database 内 schema 档(同一套代码,只改路由映射)。
- **用户注销时的归属行清理器**(跨控制面库 + 各插件库 + 桶)⇒ 与统一迁移器同一批实现(S5)。