Files
dsh_shenxian/dsh-server-docs/04-调整方案/57-设置面板用户管理入口与全员安装.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
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 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

151 lines
11 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.
# 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` 角色探测;管理员渲染「平台门户:<url>」+「打开管理台」(蓝)+「退出登录」(红);**非管理员只渲染「退出登录」** |
| 角色来源 | `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. 暂存到 `<userRoot>/home/.dsh-stage/`(`/opt/dsh` 是 `drwx------ root`,用户 uid 读不到其中文件)+ `chmod 444` + `chown <uid>`;
3. `setpriv --reuid <uid> env HOME=<home> pnpm add ... file:<staged>`,`profile` 是 pnpm workspace 根时加 `-w`;
4. **store 位置自适应**(见 §三.1);
5. `reconcileBundles()`:把 `dependencies` 里声明了 `dsh.bundle.patch` 的包并入 `dsh.profile.bundles`;
6. `--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 <源目录>/. <构建目录>/` 整目录复制;**打包后强制校验**包内清单与源清单一致:
```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:<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 已生产验证的实现。**请管理员硬刷新会话页 → 设置面板底部的「用户管理」确认位置与按钮。**
---
## 五、待办与风险
1. **P2|`ensure-workspace-picker.cjs` 的 store 位置隐患**(§三.1):它无条件使用 `<home>/.pnpm-store`,与存量 `node_modules` 的 storeDir 不一致 → **下次 picker 升级会失败**。修法同本档案:读 `.modules.yaml` 沿用旧 store。
2. **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/`,其余两个包建议照此迁移。
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 摘除(普通用户即不再显示该分区)
# 编辑 <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 在实例启动时加载)。