Files
dsh_shenxian/dsh-server-docs/04-调整方案/57-设置面板用户管理入口与全员安装.md
T

150 lines
11 KiB
Markdown
Raw Normal View History

# 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 在实例启动时加载)。