1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
11 KiB
57 · 设置面板「用户管理」入口:改名、移位、全员安装
- 日期:2026-09-11
- 状态:✅ 已实施并验证(服务器已生效;本地待提交)
- 触发:用户要求「将设置中的平台管理改为用户管理,放在功能插件下面;普通用户也需要有用户管理入口,里面只有退出按钮」
- 关联:档案 16(插件模型)、36/37(功能插件分区)、26 §C4(自建 client bundle 契约)、28(pnpm store 污染修复)、39(可见面收窄)、15(角色 patch:普通用户被隐藏的官方分区)
一、需求拆解与现状
| # | 需求 | 现状(改动前) | 本次处置 |
|---|---|---|---|
| 1 | 「平台管理」→「用户管理」 | portal-entry 注册 section:id: platform、label: 平台管理、order: 100 |
id: user-management、label: 用户管理 |
| 2 | 排到「功能插件」下面 | order 100 < 功能插件 101 → 在上面 | order 102(官方分区:models=10 / plugins=15,故 102 排最后) |
| 3 | 普通用户也要有该入口 | @dsh-local/portal-entry 只装在 admin 的 profile(dsh.profile.bundles);guest 的 bundles 里没有它 → 普通用户设置面板里根本没有这个分区 |
新增安装器 ensure-portal-entry.cjs,全员铺设 |
| 4 | 普通用户进去只有退出按钮 | 原实现固定渲染「平台门户地址」+「打开管理台」+「退出登录」 | 按角色分支:role === 'admin' 才渲染门户地址与「打开管理台」;其余只渲染「退出登录」 |
关键认识:需求 3 不是"改个显隐条件"就能满足的 —— 分区是否出现在普通用户的设置面板,取决于该用户的 profile 有没有加载这个 bundle。所以必须同时解决"插件分发"问题。
「功能插件」名称无需改动:business-plugins 的 section.label 自 v0.2.0 起就是「功能插件」(官方 README 亦用此译名),代码与 UI 里都不存在「系统插件」字样。
二、实现
① 插件本体 @dsh-local/portal-entry → v0.5.1
改动集中在 client 面(lib/client.js),host 面(lib/index.js)未动。
| 项 | 改动 |
|---|---|
| section id | platform → user-management |
| section label | 平台管理 → 用户管理 |
| section order | 100 → 102 |
| 内容 | 新增 React.useState/useEffect 角色探测;管理员渲染「平台门户:」+「打开管理台」(蓝)+「退出登录」(红);非管理员只渲染「退出登录」 |
| 角色来源 | GET <门户>/api/auth/me(requireAuth),credentials: "include" —— 与 business-plugins 调 /api/plugins/mine 同一套跨子域机制 |
| 失败降级 | fail-closed:探测失败 / 非 admin / 未登录 → 按非管理员渲染,绝不展示查看者可能打不开的管理台入口 |
| 保持不变 | 只导出 apply + inject(禁 exports.default,红线 R3);配色继续硬编码(v0.4.3 结论:设置面板是暗色主题,--dsw-alias-label-primary 与 --dsw-alias-button-elevated-fill 同为 #f9fafb,var() fallback 永不生效) |
② 安装器 scripts/ensure-portal-entry.cjs(新增)
幂等:按「node_modules 已装版本 + dep spec 是否指向本次产物 + 是否在 bundles」三条件判定。
安装姿势沿用 ensure-workspace-picker.cjs 的 2026-09-11 修复版:
- 产物从
/opt/dsh/artifacts/portal-entry-*.tgz取版本号最大者(升级只需丢新包,脚本不用改); - 暂存到
<userRoot>/home/.dsh-stage/(/opt/dsh是drwx------ root,用户 uid 读不到其中文件)+chmod 444+chown <uid>; setpriv --reuid <uid> env HOME=<home> pnpm add ... file:<staged>,profile是 pnpm workspace 根时加-w;- store 位置自适应(见 §三.1);
reconcileBundles():把dependencies里声明了dsh.bundle.patch的包并入dsh.profile.bundles;--restart时停该 uid 名下全部dsh-<uid>-*.scope(下次访问自动拉起才加载新 bundle)。
用法:node scripts/ensure-portal-entry.cjs [用户名…] [--all] [--user-id <uuid>] [--tgz <path>] [--dry-run] [--restart]
③ 新用户自动化
| 位置 | 改动 |
|---|---|
/usr/local/bin/provision-new-users.sh |
末尾追加 [portal-entry] 段(与既有 [picker] 段同一时机、同一 best-effort 策略);本次一并入库 scripts/provision-new-users.sh(此前只存在于服务器,仓库里没有) |
/etc/cron.d/dsh-maintenance |
新增 15 4 * * * 每日巡检兜底,日志 /var/log/dsh-ensure-portal-entry.log(与既有 04:30 ensure-biz-plugins 同一模式) |
未改动 TS 代码 —— 因此不需要 build、不需要重启 dshs(即时生效,只在实例重启后加载新 bundle)。
三、踩到的坑(都值得记)
1. ERR_PNPM_UNEXPECTED_STORE —— 存量安装不能换 store 位置
首次执行安装器时两个用户都秒失败:
ERR_PNPM_UNEXPECTED_STORE Unexpected store location
The dependencies at ".../profiles/web/node_modules" are currently linked from the store at
"<userRoot>/ws/.local/share/pnpm/store/v3"
pnpm now wants to use the store at "<userRoot>/home/.pnpm-store/v3"
根因:存量 profile 的 node_modules 全部诞生于「HOME=<ws> pnpm add」时代,node_modules/.modules.yaml 里 storeDir 记的是 ws 内路径;档案 28 把脚本改成了"store 放 <home>/.pnpm-store",但存量从未重装过(版本没变 → 幂等跳过),于是脚本的新姿势与磁盘上的旧安装互相矛盾。pnpm 要求换 store 必须 pnpm install 重建全量依赖(联网重拉整棵 dsh 依赖树)。
处置:安装器改为读 node_modules/.modules.yaml 的 storeDir 并沿用;只有全新 profile(无 node_modules)才用 <home>/.pnpm-store。cache 同理(沿用 <userRoot>/ws/.cache/pnpm 可免重拉 metadata)。
⚠️ 同一个坑仍在
ensure-workspace-picker.cjs里(它无条件用<home>/.pnpm-store)→ 一旦 picker 需要升级(版本号变化),会同样撞UNEXPECTED_STORE。列为待办 §五.1。
2. 上传构建包时漏了一个文件 → 全实例崩溃循环
第一版 portal-entry-0.5.0.tgz 只有 lib/client.js + package.json + cordis.patch.yml,漏了 host 面 lib/index.js(组装构建目录时手工逐个 cp 漏项)。后果是所有实例启动即崩:
failed to import loader entry portal-entry (@dsh-local/portal-entry):
Cannot find module '.../node_modules/@dsh-local/portal-entry/lib/index.js'
→ 崩溃循环(1→2→4→8→16s)→ 熔断 crash-loop-circuit-open
处置:改为 cp -r <源目录>/. <构建目录>/ 整目录复制;打包后强制校验包内清单与源清单一致:
diff <(cd src && find . -type f | sed 's|^\./|package/|' | sort) <(tar tzf out.tgz | sort)
3. pnpm 同版本缓存
同名同版本的 tgz 即使内容变了,pnpm 也会复用缓存里的旧包 → 修复必须升版本号(0.5.0 → 0.5.1),不能原地覆盖。坏包已隔离到 /opt/dsh/backups/portal-entry-0.5.0-BAD-missing-index.tgz。
4. grep -c $'\r' 在 MSYS 下会假阳性
用它检查行尾时报告本机文件"36 / 239 行含 CR",而两端 md5 完全相同 → 该测法不可信。可靠做法是 od -c 看首行字节(实测两端均为 b a s h \n,即 LF,与项目惯例一致)或 file(无 "with CRLF line terminators")。
四、验证记录(服务器实测)
| 项 | 结果 |
|---|---|
| 安装 | admin: ✓ v0.5.1(bundles=5,含本插件=true)、guest: ✓ v0.5.1(-w,bundles=5,含本插件=true) |
| profile 配置 | 两个用户 dependencies["@dsh-local/portal-entry"] → file:<home>/.dsh-stage/portal-entry-0.5.1.tgz(持久位置,不会进 ws 或被清理);dsh.profile.bundles 均含该包 |
| 实例启动 | 停 scope 后访问即拉起,dsh web: http://127.0.0.1:<port>/?token=… 正常打印,无加载错误 |
| bundle 加载 | 实例首页(200 / 24.8KB)的 client bundle 清单含 @dsh-local/portal-entry/client.js,URL 带新 rev=89891954539546d8-44 |
| 服务端内容 | 实例内 client.js 含 user-management ×3、order: 102、/api/auth/me、角色分支 |
| 跨子域 CORS | GET https://alotbuy.com/api/auth/me,Origin: https://admin.alotbuy.com → 200 + access-control-allow-origin: https://admin.alotbuy.com + allow-credentials: true,响应体 {"user":{…,"role":"admin"}} |
| 角色取值 | DB:admin → role=admin(会看到「打开管理台」)、guest → role=active(只看到「退出登录」) |
| 新用户链路 | 执行 provision-new-users.sh → [portal-entry] admin: skip(已装 v0.5.1 且在 bundles)、guest: skip,幂等生效 |
| 仓库状态 | /opt/dshs 仅 ?? scripts/ensure-portal-entry.cjs(新文件),工作树干净 |
未做的验证:浏览器内的实际渲染(本机无 agent-browser/Chromium,安装成本 ~500MB 且需联网下载)。机制链路已全部实测(bundle 加载 + CORS + 角色数据 + 注册代码),渲染机制沿用 v0.4.x 已生产验证的实现。请管理员硬刷新会话页 → 设置面板底部的「用户管理」确认位置与按钮。
五、待办与风险
- P2|
ensure-workspace-picker.cjs的 store 位置隐患(§三.1):它无条件使用<home>/.pnpm-store,与存量node_modules的 storeDir 不一致 → 下次 picker 升级会失败。修法同本档案:读.modules.yaml沿用旧 store。 - P2|存量 dep spec 指向 ws:
business-plugins/workspace-scoped-picker的dependencies仍写file:<userRoot>/ws/*.tgz,而 ws 会被ws-cleanup定期清理 → 将来任何pnpm install都会因源文件缺失而失败(当前不影响运行:node_modules已就位)。新装的 portal-entry 已改用<home>/.dsh-stage/,其余两个包建议照此迁移。 - P3|portal-entry 的 host 面是失效的 PoC:
lib/index.js仍注册portal_ping工具,其实现fetch(http://127.0.0.1:3080)已被档案 39 的 loopback 封锁 → agent 会看到一个永远失败的工具。建议移除或改为不依赖 loopback 的探针(档案 55 已记)。 - P3|插件源码位置不统一:
portal-entry源码在文档库04-调整方案/poc/portal-entry/,business-plugins、workspace-scoped-picker在代码库poc/下。同一类资产两处存放,容易漂移。建议统一到代码库poc/。
六、回滚
# 1. 回退插件到 0.4.3(admin 有旧包;guest 直接卸载即可恢复"没有该入口")
node /opt/dshs/scripts/ensure-portal-entry.cjs --tgz /opt/dsh/artifacts/portal-entry-0.4.3.tgz --all --restart
# 2. 或从 bundles 摘除(普通用户即不再显示该分区)
# 编辑 <userRoot>/home/profiles/web/package.json 的 dsh.profile.bundles,去掉 @dsh-local/portal-entry
# 3. cron 兜底行:注释 /etc/cron.d/dsh-maintenance 里 04:15 那行
# 4. provision 挂载:注释 /usr/local/bin/provision-new-users.sh 末尾的 [portal-entry] 段
回滚后必须重启实例才生效(bundle 在实例启动时加载)。