Files
dsh_shenxian/dsh-server-docs/04-调整方案/138-平台共享模型-管理员逐用户授权.md
T
admin c2b7c5ef71 平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效
三条线合并入库(同一次部署批次,源码与生产此前已一致):

一、档案 138 · 平台共享模型:管理员逐用户授权(默认关闭)
  用户口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
  用户才能在会话中使用(以及在设置的模型设置页面展示)」。
  · DB 迁移 v11:新增 users.shared_model_granted(DEFAULT 0 = 默认关闭)。
    ⚠️ 刻意**不**复用 v6 的 shared_model_enabled —— 那是用户侧偏好(用户能自己关,默认 1),
    而本需求要的是**管理员门禁**;共用一列则用户点一下就给自己授权,门禁形同不存在。
    生效 = granted ∧ enabled(server.ts#sharedLandingRows)。
  · 新路由 POST /api/admin/users/:id/models/shared(requireAdmin)+ 审计 shared_model_grant
    + **尽力而为**重启该用户实例(租约被占/实例未运行都不算失败)。
  · admin 用户列表新增「共享模型」列;用户侧 /api/me/keys 增 shared.granted / sharedModelGranted;
    插件 0.3.24:未授权时「平台共享模型」整块不渲染。

二、顺带修掉一个既有真缺陷:平台侧写用户 home 必须走 UserFs(用户卷在 worker 上)
  landModels 原先 join(owner.home_dir, …) + 本机 fs ⇒ 对「实例不在控制面本机」的用户
  读到空串(**不报错**)⇒ 模型落地一直是**静默空操作**(托管清单还被清空)
  ——即档案 87 的模型设置页对 guest 这类用户**从未生效**。
  · UserFs 新增 readHomeFile/writeHomeFile + 文件名白名单(settings.yaml / .credentials.yaml)
  · worker agent 新增 /fs/home-read /fs/home-write(只认白名单裸文件名)
  · landModels 改走 userFs(与"文件面"同一份按归属路由 ⇒ 落地与实例必然同机)
  · 同轮把 /api/me/locale(档案 102 语言偏好)也改成同一套(原先同样失效)
  · home-files.ts 抽出 backupHomeFile(备份留平台侧,命名规则逐字不变)

三、档案 139 · 品牌中文名:能力枢纽 → 能力网络(其他语言仍 CapabilityNet)
  落点四处:i18n 中文词条 / admin.html 顶栏 / favicon.svg 的 title+aria-label / design.css 注释;
  test/i18n-brand.test.mjs 期望值同步。档案 137 顶部加"后续"指针,不改历史。

验证(全部真机实测):
  · 红腿:未授权 → 106 上 guest 的 .credentials.yaml refs 变空(共享 key 被撤)
  · 绿腿:授权 → key 回来 + 托管清单恢复 ["DEEPSEEK_API_KEY"]
  · admin 列表带出 sharedModelGranted;用户侧 granted 随授权翻面(true/shared ↔ false/none)
  · 开关两次均 200(不再假失败);插件实装 0.3.24 且含 gating 字符串
  · 语言偏好:106 上 settings.yaml 出现 locale.preference=en(属主=实例属主,既有段逐字保留)
  · 本机 npm test 226 tests / 225 pass / 0 fail / 1 skipped;四个 verify 脚本全绿

部署:47 推 51 个 lib 产物、106 推 12 个(lib/ 是 gitignore ⇒ 回滚点物化在
/opt/dsh/backups/seq138b-20260919-125345/,逐文件对账 0 不一致;先 106 后 47);
插件 business-plugins 0.3.24(两机 artifacts 与本机 pack md5 一致)。
2026-09-19 13:51:01 +08:00

155 lines
15 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.
# 138 · 平台共享模型 —— 管理员**逐用户**授权(默认关闭)
- 日期:2026-09-19
- 状态:✅ **已上线并端到端验证**(控制面 + 前端 + 插件 **0.3.24**;两腿实测:未授权撤销、授权恢复)
- 触发(用户口径,原话):「**调整设计 admin设置的共享模型,需要admin在用户列表中开启(新增选项,默认关闭),用户才能在会话中使用(以及在设置的模型设置页面展示)**」
- 落点:`src/db/{schema,types,repo,pg,sqlite,adapter}.ts`、`src/web/routes/{admin,auth}.ts`、`src/web/server.ts`、`web/admin.html`、`poc/business-plugins/lib/client.js`(**0.3.24**)+ **顺带修掉一个既有真缺陷**:`src/fs/{user-fs,local-user-fs,remote-user-fs}.ts`、`src/worker/agent.ts`、`src/web/home-files.ts`(见 §五)
> **TL;DR**|① 共享模型从「人人默认可用(用户可自关)」改成「**管理员逐用户授权**」——新增列
> `users.shared_model_granted`(**默认 0 = 关闭**),**生效 = granted ∧ enabled**(前者 admin 在**用户列表**里开,
> 后者是用户自己的偏好,保留不动);② 未授权时**设置页连"平台共享模型"那一块都不渲染**(用户原话"开启才展示");
> ③ 🔴 **顺带发现并修掉一个既有真缺陷**:落地层原先用**本机 fs** 写用户 home,而用户卷在 worker 上
> ⇒ 对"实例不在本机"的用户,**模型落地一直是静默空操作**(托管清单被清空、目标文件一个字节没动)
> —— 也就是说档案 87 那套「模型设置页」对 **guest 这类用户从来就没生效过**;本档把它改成走
> `UserFs`(与"文件面"同一份按归属路由),并给 worker agent 补了两个**白名单**端点。
---
## 一、为什么是"新开一列"而不是改旧列的默认值(本轮最关键的设计判断)
| 事实 | 依据 |
|---|---|
| 旧列 `users.shared_model_enabled`(v6)语义 = **用户侧偏好**(用户自己能在设置页关掉,默认 1) | `schema.ts` 的 `SQLITE_V6` 注释 + `dashboard`(档案 87 口径②) |
| 用户要的是**管理员门禁** —— "只有 admin 开了,用户才能用" | 本轮触发原话 |
| 若两者共用一列:**用户在自己设置页点一下就把自己"授权"了** ⇒ 门禁形同不存在 | 直接推论 |
⇒ 故**两列两人**:`shared_model_granted`(admin,默认 **0**)+ `shared_model_enabled`(用户,默认 1,**不变**),
**生效 = `granted ∧ enabled`**(`server.ts#sharedLandingRows`)。缺一不给。
> ⚠️ `DEFAULT 0` 让**存量行也一并变 0**(两个方言加列都用默认值回填)⇒ 迁移后所有既有用户都"未授权"。
> 这正是"默认关闭"的字面语义;代价是 **guest 需要 admin 开一次**才会重新拿到共享模型(见 §四-③)。
## 二、改动清单
| 层 | 文件 | 改动 |
|---|---|---|
| DB 迁移 | `src/db/schema.ts` | **v11**:`ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;`(两方言同款;注释写清"为什么新开列") |
| 类型 | `src/db/types.ts` | `User.shared_model_granted` + `PublicUser.sharedModelGranted`;`toUser` 用 **`Number(row.x ?? 0) !== 0`** |
| 仓储 | `src/db/repo.ts` | `USER_COLS` 加列、`findSessionWithUser` 补列、新增 `get/setSharedModelGranted`(缺失行按 **false** = 失败关闭) |
| PG | `src/db/pg.ts` | 🔴 **本文件另有一份 `USER_COLS`** —— 加列未同步它 ⇒ `listPublicUsers` 读不到该列、admin 列表恒显示"未开启"(**实测踩到**,见 §六-2) |
| SQLite | `src/db/sqlite.ts` / `adapter.ts` | 转发 + 接口声明 |
| 生效判据 | `src/web/server.ts` | `sharedLandingRows` 三条件(**granted ∧ enabled ∧ 不是那个 admin 本人**);`resolveApiKey` 的回退注入**也判门禁**(失败关闭) |
| 管理面路由 | `src/web/routes/admin.ts` | 新增 **`POST /api/admin/users/:id/models/shared`**(`requireAdmin`,body `{enabled}`)→ 落库 + 审计 `shared_model_grant` + **尽力而为**重启该用户实例 |
| 用户面接口 | `src/web/routes/auth.ts` | `sharedKeyInfo` 加 `granted`;`keySourceOf` 加门禁(未授权 ⇒ `none`);`GET /api/me/keys` 增 `sharedModelGranted` |
| 管理页 | `web/admin.html` | 用户表**新增「共享模型」列**(已开启/已关闭按钮;admin 与 pending 行显示「—」)+ 一行说明 |
| 插件 | `poc/business-plugins/lib/client.js`(0.3.24) | 「平台共享模型」区块改为 **`shared.granted` 为真才渲染**(未授权整块不出现) |
| 测试 | `test/db.test.mjs`、`test/local-user-fs.test.mjs`、`scripts/verify-platform-admin-section.mjs` | 新增 4 条:granted 默认关 ∧ 可开关 ∧ 与偏好互不影响、列表带出该列、**未授权 ⇒ 区块不渲染(正反两腿)** |
## 三、🔴 三条判据(都是踩出来的)
1. **门禁类判据一律"失败关闭"**:`getSharedModelGranted` 对"查不到这个人"返回 **false**(而 `getSharedModelEnabled`
对同样情形返回 true —— 两条**故意相反**,已在单测里钉死)。
2. **门禁判在"使用点",不判在"归属判据"里**(本轮红腿实测):`sharedDeepseekKey()` 同时被
① 「一次性交接」用来**认领**老实现写下的那行 key(这是**归属**问题)② 回退 env 注入(这是**授权**问题)。
第一版把门禁加在函数内部 ⇒ 被撤销授权的用户**认不出自己写过的行** ⇒ 那行永远删不掉
⇒ "关掉即生效"不成立。**修法**:函数保持不判,门禁挪到 `resolveApiKey` 的调用点。
3. **授权路由里的"重启实例"必须尽力而为**:授权已落库才是真结果;实例没在跑、或归属租约被别的持有者占着,
都**不算开启失败**(第一版直接 `await restartMain` ⇒ 租约被占时回 500,admin 界面显示"操作失败",
而库里其实已经改对了)。
## 四、验证(全部真机实测)
| # | 项 | 证据 |
|---|---|---|
| ① | **迁移 v11 生效** | `SELECT version FROM schema_migrations` ⇒ `11`;`\d users` 出现 `shared_model_granted integer not null default 0`;四个用户该列全 0(默认关闭) |
| ② | **红腿:未授权 ⇒ 共享 key 被撤** | guest(实例在 **w-106**)设为 `false` → 重启实例 → 106 上 `.credentials.yaml` 的 `refs:` **变空**(`DEEPSEEK_API_KEY` 消失)、托管清单 `{"refs":[],"routes":[]}` |
| ③ | **绿腿:授权 ⇒ 恢复** | 设为 `true` → 重启 → `refs: DEEPSEEK_API_KEY: 'sk-…'` **回来**、托管清单 `{"refs":["DEEPSEEK_API_KEY"]}` |
| ④ | **admin 列表带出状态** | `GET /api/admin/users` ⇒ guest `sharedModelGranted=True`(其余 false) |
| ⑤ | **用户侧展示判据翻面** | guest 会话 `GET /api/me/keys`:授权时 `shared.granted=true effective=shared`;撤销后立刻 `granted=false effective=none` |
| ⑥ | **开关不再报假失败** | 撤销/开启两次调用均 **200** `{ok:true, sharedModelGranted:…, restarted:false}`(实例未运行) |
| ⑦ | **插件实装** | 106 上 guest 的 `profiles/web/node_modules/@dsh-local/business-plugins` = **0.3.24**,`lib/client.js` 含 `shared.granted` ×2;`.dsh-stage/` 有 0.3.24.tgz(本机 pack 与 `/opt/dsh/artifacts/` md5 一致 `75f342eb…`) |
| ⑧ | **零回归(本机)** | `npm test` **226 tests / 225 pass / 0 fail / 1 skipped**;`verify-static` / `verify-inject` / `verify-platform-admin-section`(含新 2 条)/ `verify-models-render` 全绿 |
| ⑨ | **语言偏好跨机修复生效** | guest(实例在 **w-106**)`POST /api/me/locale {locale:'en'}` ⇒ 200 `changed:true`;106 上 `settings.yaml` 出现 `locale: preference: en`(属主 = 实例属主,既有段逐字保留);平台侧备份 `settings-<id>-<ts>.yaml` 同步落地 |
⚠️ **未做**:无浏览器的 UI 目视(admin 表格多了一列、「共享模型」区块的隐藏效果)—— 留给你看一眼。
⛔ 未重启任何**正在服务**的用户实例去做验证(验证用的 guest 实例由我自己停/起,收口时回到 stopped)。
## 五、🔴 顺带修掉的既有真缺陷:**跨机用户的"模型落地"一直是空操作**
**怎么发现的**:按上面 §四-② 第一次跑红腿时,guest 的凭据文件**一个字节没动**、而托管清单却被清空了。
根因链(文件级):
| # | 位置 | 事实 |
|---|---|---|
| 1 | `server.ts#landModels`(改前) | `join(owner.home_dir, '.credentials.yaml')` + **本机 fs** 读 —— 而 `home_dir` 是**worker 上的路径** |
| 2 | 实测 | 47 上 `/var/lib/dshs/users/<guest>/` **只有 `ws/`**、没有 `home/` ⇒ 读到空串(**不报错**) |
| 3 | 后果 | `reconcileCredentials('', [], [...])` ⇒ 无变化 ⇒ 不写盘;但 `writeManaged()` 照写 ⇒ **托管清单被清成空**(平台从此"忘记"自己写过的那行) |
⇒ 影响面**不止本档**:**档案 87 的整套「模型设置页」对"实例不在控制面本机"的用户从来就没生效过**
(guest 的实例在 w-106 ⇒ 用户改模型设置不生效);档案 102 的**语言偏好**(`/api/me/locale`)走的是同一套
`home-files` 写入,**同样命中这个缺陷**(⚠️ 本档**未修**它,见 §七-3)。
**修法**(与既有"文件面"设计对齐 —— `server.ts:848-850` 那段注释原本就写着"文件写到 A、实例起在 B"的教训):
| 层 | 改动 |
|---|---|
| `src/fs/user-fs.ts` | 新增 `readHomeFile` / `writeHomeFile` + **文件名白名单** `HOME_FILE_NAMES = ['settings.yaml','.credentials.yaml']`(⛔ 只收裸文件名,等于"平台只管自己那两个配置") |
| `local-user-fs.ts` | 本机实现(`0600` + chown 给 home 属主);非白名单名 ⇒ `bad_path` |
| `remote-user-fs.ts` | 走 agent 的 `/fs/home-read` `/fs/home-write`,目标是 `hostIdFor` **钉住的那台机**(与实例同机) |
| `worker/agent.ts` | 新增两个端点(**只认白名单裸文件名**,非法即 400 `bad_path`) |
| `web/home-files.ts` | 抽出 `backupHomeFile()`(**平台侧**备份;`writeHomeFile` 改为复用它,命名规则逐字不变) |
| `web/server.ts` | `landModels` 的读写**全部改走 `userFs`**:`readHomeFile(...) ?? ''` ∧ `backupHomeFile(...)` + `userFs.writeHomeFile(...)` |
**为什么这是"必须做"而不是"顺手优化"**:不做 ⇒ 用户这条需求("admin 开了才能在会话中使用")
对 guest 这类用户**永远不成立**(授权改了、实例里什么也没变)。R11「只做正向迭代」不允许留这种"看起来做了"的状态。
**部署面**(`lib/` 是 gitignore 的 ⇒ 回滚点只能物化,⛔ 别指望 `git checkout`):
- **106** `/opt/dshs-cluster/lib`:`fs/*` + `worker/agent.*` 共 12 个(回滚点 `/opt/dsh/backups/seq138b-20260919-125345/opt/dshs-cluster/lib`,先抓旧版再覆盖,对账 0 不一致)
- **47** `/opt/dshs/lib`:42 + 3 + 3 个(同上,回滚点同目录 `/opt/dsh/backups/seq138b-…/opt/dshs/lib`);`restart dshs dshs-worker`
- 顺序:**先 worker(106)后 Manager(47)** —— 新端点必须先在位;两版并存时表现为"落地仍不生效"(= 改前状态),⛔ 不会更糟
- ⚠️ **106 上跑 `ensure-biz-plugins.cjs` 需要补丁**:它 `require('/opt/dshs/node_modules/better-sqlite3')`
而 106 只有 `/opt/dshs-cluster/node_modules`(且该原生模块在 106 上没为 node 22 编译)⇒ 用**临时副本**
`/tmp/ebp-106.cjs`(只改两处:require 置空、用户清单改由 `DSH_USERS_JSON` 注入,清单取自权威 PG)。
⛔ 正本 `scripts/ensure-biz-plugins.cjs` **未动**。
## 六、事故 / 踩坑
1. **用户卷在远端时,"落地"与"备份"要分开**:备份(平台自己的副本)留在控制面 `/opt/dsh/backups` 正合适;
⛔ 不要为了备份再往远端开第二条通道。
2. 🔴 **`pg.ts` 有自己的一份 `USER_COLS`**(与 `repo.ts` 那份是**两份**)⇒ 加列必须**两处都改**。
只改 `repo.ts` 时:SQLite 路径正常、**PG 路径(= 生产)读不到新列**,admin 用户列表**恒显示"未开启"**
(而 DB 里其实是 1)—— 这种"两个方言行为不一致"的缺陷只在生产暴露,本档已实测踩到。
3. **临时会话(`mksess.cjs`)TTL = 10 分钟** ⇒ 验证脚本必须"现建现用";跨多次手工调用必撞 401
(本轮撞了两次,写成了 `seq138-leg.sh` 一次跑完)。
4. `scp [email protected]:` 不通(无对应密钥/别名),要用别名 `test106`。
## 七、未做 / 待办
1. **UI 目视**留给用户(无浏览器验收已全绿):admin 用户列表多一列「共享模型」、未授权时设置页不出现共享区块。
2. **`.lock-131/132/133/134/138` 空目录堆积**(占号后没清)—— 文档库卫生,`docs-audit.py` 不报。
3. ✅ **已修(同轮收口时一并做掉)**:`/api/me/locale`(档案 102 的语言偏好)原先**也走本机 fs**
⇒ 对远端用户同样失效。改法与 `landModels` **完全同构**(`userFs.readHomeFile('settings.yaml')`
+ `backupHomeFile` + `userFs.writeHomeFile`)。
**实测**:guest(实例在 106)`POST /api/me/locale {locale:'en'}` ⇒ **200 `changed:true`**;
106 上 `settings.yaml` 出现 `locale: preference: en`、属主 = 实例属主 `dsh-<hash>`、
既有段(`agent-default-model` / `agent-presets` / `llm-pi-ai`)**逐字保留**;
平台侧备份同步落地 `settings-<userId>-<ts>.yaml`。
⇒ **本档之后,平台侧"写用户 home"只剩 `UserFs` 这一条路**(`readTextOrEmpty` / `writeHomeFile`
的**本地版**仍保留给 `deployMode=local` 的单机形态与 `home-files.ts` 内部复用)。
4. ⚠️ **`shared_model_enabled`(用户偏好)现在只在"已授权"时才有可见效果**:用户侧那条
`POST /api/me/models/shared` 路由**未加门禁**(有意 —— 见 `auth.ts` 该路由注释:让用户能先关掉自己不想用的、
也允许先打开,授权一到即生效;且不泄露"管理员是否授权了别人")。若将来要"授权后用户不得自关",需另立需求。
## 八、回滚
```bash
# ① 控制面代码:从本仓重建 HEAD 版 lib 并覆盖(lib/ 不入仓,⛔ 不能用 git checkout)
git -C <本机仓> show HEAD:src/... # 或直接在 47 侧用回滚点:
cp -r /opt/dsh/backups/seq138b-20260919-125345/opt/dshs/lib/* /opt/dshs/lib/ && systemctl restart dshs
# ② worker(106):同样用该回滚点下的 /opt/dshs-cluster/lib
# ③ 插件:确保产物回落 0.3.23(/opt/dsh/artifacts 保留旧包)
# ④ DB:v11 的 ADD COLUMN 不回滚(SQLite 无 down migration;空列不影响旧代码)
```
⚠️ 回滚 ① 会把 §五 的跨机落地修复一起回退 ⇒ 远端用户的模型落地重新变成空操作(不致命,但那是既有缺陷)。