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

15 KiB
Raw Blame History

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 该路由注释:让用户能先关掉自己不想用的、 也允许先打开,授权一到即生效;且不泄露"管理员是否授权了别人")。若将来要"授权后用户不得自关",需另立需求。

八、回滚

# ① 控制面代码:从本仓重建 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;空列不影响旧代码)

⚠️ 回滚 ① 会把 §五 的跨机落地修复一起回退 ⇒ 远端用户的模型落地重新变成空操作(不致命,但那是既有缺陷)。