三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 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 一致)。
15 KiB
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 默认关 ∧ 可开关 ∧ 与偏好互不影响、列表带出该列、未授权 ⇒ 区块不渲染(正反两腿) |
三、🔴 三条判据(都是踩出来的)
- 门禁类判据一律"失败关闭":
getSharedModelGranted对"查不到这个人"返回 false(而getSharedModelEnabled对同样情形返回 true —— 两条故意相反,已在单测里钉死)。 - 门禁判在"使用点",不判在"归属判据"里(本轮红腿实测):
sharedDeepseekKey()同时被 ① 「一次性交接」用来认领老实现写下的那行 key(这是归属问题)② 回退 env 注入(这是授权问题)。 第一版把门禁加在函数内部 ⇒ 被撤销授权的用户认不出自己写过的行 ⇒ 那行永远删不掉 ⇒ "关掉即生效"不成立。修法:函数保持不判,门禁挪到resolveApiKey的调用点。 - 授权路由里的"重启实例"必须尽力而为:授权已落库才是真结果;实例没在跑、或归属租约被别的持有者占着,
都不算开启失败(第一版直接
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未动。
六、事故 / 踩坑
- 用户卷在远端时,"落地"与"备份"要分开:备份(平台自己的副本)留在控制面
/opt/dsh/backups正合适; ⛔ 不要为了备份再往远端开第二条通道。 - 🔴
pg.ts有自己的一份USER_COLS(与repo.ts那份是两份)⇒ 加列必须两处都改。 只改repo.ts时:SQLite 路径正常、PG 路径(= 生产)读不到新列,admin 用户列表恒显示"未开启" (而 DB 里其实是 1)—— 这种"两个方言行为不一致"的缺陷只在生产暴露,本档已实测踩到。 - 临时会话(
mksess.cjs)TTL = 10 分钟 ⇒ 验证脚本必须"现建现用";跨多次手工调用必撞 401 (本轮撞了两次,写成了seq138-leg.sh一次跑完)。 scp [email protected]:不通(无对应密钥/别名),要用别名test106。
七、未做 / 待办
- UI 目视留给用户(无浏览器验收已全绿):admin 用户列表多一列「共享模型」、未授权时设置页不出现共享区块。
.lock-131/132/133/134/138空目录堆积(占号后没清)—— 文档库卫生,docs-audit.py不报。- ✅ 已修(同轮收口时一并做掉):
/api/me/locale(档案 102 的语言偏好)原先也走本机 fs ⇒ 对远端用户同样失效。改法与landModels完全同构(userFs.readHomeFile('settings.yaml')+backupHomeFile+userFs.writeHomeFile)。 实测:guest(实例在 106)POST /api/me/locale {locale:'en'}⇒ 200changed: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内部复用)。 - ⚠️
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;空列不影响旧代码)
⚠️ 回滚 ① 会把 §五 的跨机落地修复一起回退 ⇒ 远端用户的模型落地重新变成空操作(不致命,但那是既有缺陷)。