# 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 修复版: 1. 产物从 `/opt/dsh/artifacts/portal-entry-*.tgz` 取**版本号最大**者(升级只需丢新包,脚本不用改); 2. 暂存到 `/home/.dsh-stage/`(`/opt/dsh` 是 `drwx------ root`,用户 uid 读不到其中文件)+ `chmod 444` + `chown `; 3. `setpriv --reuid env HOME= pnpm add ... file:`,`profile` 是 pnpm workspace 根时加 `-w`; 4. **store 位置自适应**(见 §三.1); 5. `reconcileBundles()`:把 `dependencies` 里声明了 `dsh.bundle.patch` 的包并入 `dsh.profile.bundles`; 6. `--restart` 时停该 uid 名下全部 `dsh--*.scope`(下次访问自动拉起才加载新 bundle)。 用法:`node scripts/ensure-portal-entry.cjs [用户名…] [--all] [--user-id ] [--tgz ] [--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 "/ws/.local/share/pnpm/store/v3" pnpm now wants to use the store at "/home/.pnpm-store/v3" ``` **根因**:存量 profile 的 `node_modules` 全部诞生于「`HOME= pnpm add`」时代,`node_modules/.modules.yaml` 里 `storeDir` 记的是 **ws 内路径**;档案 28 把脚本改成了"store 放 `/.pnpm-store`",但**存量从未重装过**(版本没变 → 幂等跳过),于是脚本的新姿势与磁盘上的旧安装互相矛盾。pnpm 要求换 store 必须 `pnpm install` 重建全量依赖(联网重拉整棵 dsh 依赖树)。 **处置**:安装器改为**读 `node_modules/.modules.yaml` 的 `storeDir` 并沿用**;只有**全新 profile**(无 `node_modules`)才用 `/.pnpm-store`。cache 同理(沿用 `/ws/.cache/pnpm` 可免重拉 metadata)。 > ⚠️ **同一个坑仍在 `ensure-workspace-picker.cjs` 里**(它无条件用 `/.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 <源目录>/. <构建目录>/` 整目录复制;**打包后强制校验**包内清单与源清单一致: ```bash 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:/.dsh-stage/portal-entry-0.5.1.tgz`(持久位置,不会进 ws 或被清理);`dsh.profile.bundles` 均含该包 | | 实例启动 | 停 scope 后访问即拉起,`dsh web: http://127.0.0.1:/?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 已生产验证的实现。**请管理员硬刷新会话页 → 设置面板底部的「用户管理」确认位置与按钮。** --- ## 五、待办与风险 1. **P2|`ensure-workspace-picker.cjs` 的 store 位置隐患**(§三.1):它无条件使用 `/.pnpm-store`,与存量 `node_modules` 的 storeDir 不一致 → **下次 picker 升级会失败**。修法同本档案:读 `.modules.yaml` 沿用旧 store。 2. **P2|存量 dep spec 指向 ws**:`business-plugins` / `workspace-scoped-picker` 的 `dependencies` 仍写 `file:/ws/*.tgz`,而 ws 会被 `ws-cleanup` 定期清理 → **将来任何 `pnpm install` 都会因源文件缺失而失败**(当前不影响运行:`node_modules` 已就位)。新装的 portal-entry 已改用 `/.dsh-stage/`,其余两个包建议照此迁移。 3. **P3|portal-entry 的 host 面是失效的 PoC**:`lib/index.js` 仍注册 `portal_ping` 工具,其实现 `fetch(http://127.0.0.1:3080)` 已被档案 39 的 loopback 封锁 → **agent 会看到一个永远失败的工具**。建议移除或改为不依赖 loopback 的探针(档案 55 已记)。 4. **P3|插件源码位置不统一**:`portal-entry` 源码在**文档库** `04-调整方案/poc/portal-entry/`,`business-plugins`、`workspace-scoped-picker` 在**代码库** `poc/` 下。同一类资产两处存放,容易漂移。建议统一到代码库 `poc/`。 --- ## 六、回滚 ```bash # 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 摘除(普通用户即不再显示该分区) # 编辑 /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 在实例启动时加载)。