chore(仓库对齐): 文档库结构治理 + IM/插件线落地
build / build-and-scan (push) Canceled after 0s

文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
This commit is contained in:
admin committed 2026-09-24 07:25:16 +08:00
1 parent 3d8f50e366
commit e6207aa691
239 files changed
+34477 -14633

No files matched your search

@@ -0,0 +1,238 @@
# dsh(DeepSeek Harness)多用户部署与配置改造方案
> ⚠️ **历史文档**(2026-09-08 成稿):本文记录**当时**的规划与架构。**当前部署以 `DEPLOY-本部署.md` 为准**;文中「Docker 容器 / `127.0.0.1:8900` / 旧域名 `dsh.alotbuy.com`」等已被 2026-09-08~11 的架构切换取代(见档案 05 / 22)。SSH 端口为 **22**(非文中笔误的 32022)。
| 项 | 值 |
|---|---|
| 版本 | v1.0(2026-09-08) |
| 适用对象 | 服务器 47.77.182.89(root SSH **端口 22**) |
| 软件 | @deepseek-ai/dsh 0.1.2-rc.1(镜像 dsh-web:0.1.2) |
| 状态 | **P0 迁移已执行(2026-09-08 17:30)**;**域名接入完成(2026-09-08 18:00)**;**架构切换为 dshs 多租户 + account 硬隔离(2026-09-08 18:30)** |
| 文档位置 | 本机 `D:\AI技能\aliyun-work-space\dsh-server-docs\`;服务器 `/opt/dsh/docs/` |
---
## 一、背景与目标
dsh Web UI 已单实例部署并跑通(外网 http://47.77.182.89/,模型对话正常)。需要从「单人单实例」升级为「公司多人使用」形态。
**目标(5 项)**:
1. **多用户使用**:多个编导/账号运营各自登录使用;
2. **数据持久化**:会话、配置、凭据、工作区不随容器重建丢失;
3. **跨用户文件安全**:用户之间互相看不到对方的数据与工作区(严格隔离);
4. **便于使用/复制/恢复**:一个用户 = 一个主目录,备份恢复按目录粒度一键完成;
5. **登录方式可扩展**:前期用账号密码(注册审核制),后期可按需接入飞书 OAuth。
---
## 二、现状盘点(2026-09-08 服务器实测)
```
/opt/dsh/
├── backups/20260908_initial/ # 旧备份(已归档)
├── docs/dsh-improvement-plan.md # 本文档
├── users/main/.dsh/ # ★ 迁移后:容器 /root/.dsh(原 data/)
├── users/main/workspaces/ # ★ 迁移后:容器 /root/workspaces(工作区卷,新约定)
├── .env # 系统模型 key(--env-file)
├── backup.sh / restore.sh # 备份恢复脚本
└── dsh-web-0.1.2.tar # 镜像 tar(140MB)
容器 dsh-web:Up,--network host,--restart always
挂载:/opt/dsh/users/main/.dsh → /root/.dsh
/opt/dsh/users/main/workspaces → /root/workspaces
环境:DEEPSEEK_API_KEY(--env-file /opt/dsh/.env)
端口:容器内 127.0.0.1:8900 → nginx 反代 80(Host/Origin 伪装 127.0.0.1)
访问:token 认证(docker logs 取)→ dsh-auth cookie(30 天)
```
已知问题:容器内工作区 `/root/maiyamcn` 已丢失(重建容器后被清掉,注册表残留失效路径)——**工作区必须卷化持久化**。迁移后此问题已解决,但 storages/workspace.json 中旧路径 `/root/maiyamcn` 记录仍在,需在 Web UI 删除该失效工作区入口。
---
## 三、总体架构(目标态)
**核心原则**:
- **一个用户 = 一个主目录 + 一个实例**:`/opt/dsh/users/<uid>/`,备份/复制/恢复只对这一个目录操作;
- **严格隔离**:每用户独立实例(容器),独立 `~/.dsh` 与工作区,互相不可见;
- **系统标准层(公共模型/插件/技能)只读共享**,用户增量放在个人层,同名覆盖系统层(dsh 原生分层机制,无需自研);
- **配置分层**:dsh 插件 bundle → profile 层 → home 级用户覆盖层 → `--patch`,天然支持「公司统一 + 个人覆盖」。
### 目录布局(目标态)
```
/opt/dsh/
├── .env # 系统级模型 key(DEEPSEEK_API_KEY),容器 --env-file 注入
├── std/ # ★ 系统标准层(只读,管理员维护)
│ ├── agents/08-skills/ # → 容器 /root/.agents/skills(rank400 兜底标准技能库)
│ │ └── mcn-video-script/ # 公司标准技能(SKILL.md + references,只读)
│ └── profile-skel/ # 新用户 profile 种子(含公共插件 bundles 声明)
├── users/ # ★ 每用户 = 一个主目录(实例隔离边界)
│ ├── main/ # ─ 管理员实例(由现有 data/ 迁移而来)─
│ │ ├── .dsh/ # → 容器 /root/.dsh(DSH_HOME)
│ │ │ ├── profiles/web/
│ │ │ │ ├── dsh.profile # bundles 有序清单(官方 + 公共插件)
│ │ │ │ ├── cordis.patch.yml # profile 层配置
│ │ │ │ └── node_modules/ # 个人插件落点(dsh plugin add,只影响本用户)
│ │ │ ├── cordis.patch.yml # ★ home 级用户覆盖层(优先级高于 profile 层)
│ │ │ ├── .credentials.yaml # ★ 个人模型 key(用户在 Models 页填写)
│ │ │ ├── settings.yaml # 用户全局设置
│ │ │ ├── 08-skills/ # ★ 个人技能 rank300(可覆盖系统标准同名技能)
│ │ │ ├── sessions/ # 会话数据
│ │ │ └── storages/ # 存储数据
│ │ └── workspaces/ # → 容器 /root/workspaces
│ │ ├── maiyamcn/ # 工作区项目(=容器内 workspace 根)
│ │ │ ├── .dsh/08-skills/ # 项目级技能 rank100(最高优先,随项目分发)
│ │ │ ├── .agents/08-skills/ # 项目级技能备选 rank200
│ │ │ ├── AGENTS.md # 项目指令
│ │ │ └── ... # 项目文件/素材/脚本
│ │ └── <其他项目>/
│ ├── zhangsan/ # ─ 编导 A(与 main 完全同构)─
│ │ ├── .dsh/
│ │ └── workspaces/
│ └── lisi/ # ─ 编导 B ─
├── backups/ # 备份产物(backup.sh 打包 users/ → 时间戳 tar.gz)
│ └── 20260908_initial/ # 旧备份归档
├── backup.sh # 一键备份
└── restore.sh # 一键恢复
```
### 运行拓扑(多用户)
```
镜像 dsh-web:0.1.2(官方 bundle + 公共插件 + std 标准技能内置/挂载)
├── dsh-web-main --network host → 8900 # 管理员
├── dsh-web-zhangsan --network host → 8901 # 编导 A
└── dsh-web-lisi --network host → 8902 # 编导 B
nginx 外网按子域名/路径分流到 127.0.0.1:890x(沿用现有 Host/Origin 伪装模板)
```
---
## 四、dsh 分层机制要点(方案依据)
dsh 配置与插件天然分层,以下机制是方案成立的前提(无需自研):
1. **配置/插件叠加顺序**(后层覆盖前层):
```
插件 bundle 默认(官方/系统)
→ profile 层 $DSH_HOME/profiles/web/cordis.patch.yml
→ 用户层 $DSH_HOME/cordis.patch.yml
→ 命令行 --patch(最高)
```
2. **SKILL 发现目录**(rank 越小优先级越高,同名高优先覆盖低优先):
| rank | 目录 | 性质 |
|---|---|---|
| 100 | 工作区根 `/.dsh/08-skills/` | 项目级(随项目分发) |
| 200 | 工作区根 `/.agents/08-skills/` | 项目级备选 |
| 300 | `~/.dsh/08-skills/` | 用户个人 |
| 400 | `~/.agents/08-skills/` | 用户级全局(**系统标准库落点,只读兜底**) |
3. **插件安装**:`dsh plugin --profile <name> add <源>`(npm/GitHub/git/tarball/link),写入该 profile 的 bundles 层与 node_modules,**只影响该用户实例**。
4. **模型 provider**:即插件;key 走 credentials service(UI Models 页 → `.credentials.yaml`)或环境变量(`DEEPSEEK_API_KEY`)。
---
## 五、模型 / 插件 / SKILL 归属矩阵
| 能力 | 公共(系统层,管理员维护) | 个人(用户可改) | 项目(随工作区) |
|---|---|---|---|
| 模型 | `.env` 统一 key + profile 层默认 provider;**用户不自配模型**(见安全限制) | `.credentials.yaml` 个人 key(若开放) | — |
| 插件 | 镜像预置公共插件(官方 bundle 同级,人人可用,不可改) | `profiles/web/node_modules` + bundles 声明(`dsh plugin add`,需网络) | — |
| SKILL | `std/agents/skills`(rank400 只读兜底,**同名可被用户覆盖**) | `~/.dsh/skills`(rank300) | 工作区 `.dsh/skills`(100) / `.agents/skills`(200) |
> 一句话:**公共层只读放低优先,个人层可写放高优先**——公司统一给兜底,个人自配天然覆盖,无需把标准配置复制 N 份。
---
## 九、安全边界与已知限制
1. **dsh 禁 `--host 0.0.0.0`**(RCE 防护),容器内只监听 127.0.0.1 → 必须 `--network host` + nginx 反代;
2. **特权 API 强制 loopback-only**(settings/credentials.describe 等),即使 `--trusted-host` 也拒非本机访问 → nginx 必须把 `Host`/`Origin`/`Sec-Fetch-*` 头伪装为 `127.0.0.1:8900`(现有反代模板已含);
3. **Settings/Models 页前端 loopback 检测**:非本机浏览器地址访问时页面禁用(`settings are unavailable in this browser`)→ 外网用户**无法在 UI 自配模型**;普通用户模型一律管理员统一配置;要开放自配需对 web 前端打 `--patch` 放行(待验证项);
4. **插件 = 任意代码执行**:普通用户不开 `dsh plugin add` 权限;公共插件管理员审核后入镜像;
5. **token 认证**:每实例首次启动生成 token,nginx 后建议加一层 Basic Auth 或尽快接 dshs;
6. `.env` 含密钥,权限 600,不进备份包(整机迁移需单独携带或重设)。
---
## 十三、架构切换:容器 → dshs 多租户 + account 硬隔离(2026-09-08 18:30)
**决策**:去掉 Docker 单容器方案,全量迁移到 dshs 进程隔离模式(便于 dsh/插件升级迭代 = 一条 npm 命令);用户确认"立即全量迁移 main" + "account 账号级硬隔离"。
### 最终架构
```
宿主机:Node v22.23.2 + 全局 dsh 0.1.2-rc.1(/usr/local/bin)
dshs(systemd,127.0.0.1:3080,account 模式)
├── /var/lib/dshs/
│ ├── dshs.db # 用户/审核数据
│ ├── secret.key # 每用户 key 库主密钥
│ └── users/<userId>/
│ ├── home/ # DSH_HOME(profiles/sessions/credentials)— 由原 .dsh 迁入
│ ├── ws/ # 工作区(HOME 指向)— 由原 workspaces 迁入
│ └── patches/
nginx:dsh.alotbuy.com + *.dsh.alotbuy.com → 127.0.0.1:3080(端口动态分配由编排器管理)
```
### 关键文件清单
| 文件 | 作用 |
|------|------|
| `/etc/dshs.env` | BASE_DOMAIN=dsh.alotbuy.com / COOKIE_DOMAIN=.dsh.alotbuy.com / ISOLATION_MODE=account / BASE_UID=100000 / DSH_BIN=/usr/local/bin/dsh |
| `/etc/systemd/system/dshs.service` | 编排服务(ExecStart 带 `--db` 指向 dshs.db) |
| `/etc/systemd/system/dsh-provision.path` + `.service` | 监控新用户目录自动建 OS 账号 |
| `/usr/local/bin/provision-new-users.sh` | 建号脚本(用户名截断 20 字符,规避 Linux 32 字符限制;每次 chown 幂等) |
### 已验证
- admin 登录 API ✅(role=admin)
- account 降权:dsh 可跑(0.1.2-rc.1)、读 /root Permission denied、读自己 home 正常 ✅
- nginx 主域/子域 → 3080 路由 ✅(登录门户 HTML 返回)
- 备份/恢复脚本已切换新数据根(backup.sh 打包 /var/lib/dshs + 配置 + systemd 单元)
### ⚠️ 未完成 / 待办
- [x] **HTTPS 通配证书**(2026-09-08 18:40 完成):certbot dns-cloudflare 签 `*.dsh.alotbuy.com`,nginx 443 + HTTP 跳转,全链路登录验证通过
- [ ] **每用户 API Key**:dshs 为每用户独立 key 库(登录后桌面"管理密钥"填),原 .env 统一 key 的模型已不适用(迁移后 admin 需自填 key)
- [ ] 注册普通用户 → 审核 → 桌面/DSH 启动全链路验证(子域 404 需注册后确认)
- [ ] main 旧容器数据 /opt/dsh/users/main 已迁入 admin home,容器已停,可清理
- [ ] 飞书 OAuth 扩展(预留)
### 踩坑记录
1. **db 路径不一致**:bootstrap-admin 写入 `dshs.db`,但服务默认用 `dshs.db`(空)→ systemd ExecStart 必须显式 `--db`
2. **用户名超长**:`dsh-`+uuid=41 字符超 Linux 32 限制 → provision 脚本截断为 `dsh-<uuid去横线前20>`
3. **初始属主不一致**:bootstrap 建目录属主 100001,hash uid 为 114801 → 以 `uid-for-user` 输出为准
4. **中间路径权限**:`/var/lib/dshs` 与 `/users` 需 755(root 700 会挡住降权账号穿透),用户目录本身 700 保持隔离
5. **pkill 自杀坑**:`pkill -f 'lib/cli.js'` 会匹配到远程 shell 自身命令行 → 用 systemctl 管理
---
## 附一、机制索引(2026-09-11 补 · 档案 19 §D1)
> 本文件正文停在 2026-09-08 的架构切换;此后新增/变更的机制以「档案」为准,此表为**快速索引**。
| 机制 | 生效位置 | 档案 |
|---|---|---|
| 访问入口:门户 `https://alotbuy.com`、用户 `<用户名>.alotbuy.com`(旧域 301) | nginx vhost + `BASE_DOMAIN/COOKIE_DOMAIN` | **22** |
| 多租户隔离 = account + bwrap(`--ro-bind /usr //lib64 /etc`、`--dev/--proc`、私有 `/tmp`、`--unshare-pid`、`setpriv` 改 uid) | `src/supervisor/orchestrator.ts#spawnAsUser` + systemd-run scope(512M/150%/TasksMax 128) | 16 |
| 出网护栏:封云元数据端点 `100.100.100.200` + 外联观测 | `/etc/nftables-dsh-egress.nft` + `dsh-egress.service` | 14 |
| 单活跃会话(last-wins)+ 常驻上限(idle-reap / LRU) | `orchestrator.ts`(`instanceIdleTtlSeconds`、`maxIdleInstances`) | 08 |
| 崩溃自愈:指数退避 + 窗口熔断 + 观测(`restarts`/`lastCrashedAt`) | `crash-policy.ts` + `orchestrator.scheduleCrashRestart` | **20** |
| 登录直达会话(enter 等 launch token;401 守卫;冷启动 404 修复) | `routes/dsh.ts`、`web/*.html` | 06 / 13 / 15 |
| 技能分层:共享只读层(bundledSkillDir)+ 个人层 API/页面 | 编排器 env 注入 + `routes/skills.ts` | 10 / 11 |
| 插件三层归属:自带插件(仅 admin)/ 功能插件投放(门户,仅 admin)/ 实例设置启停 | `routes/business-plugins.ts` + `plugins.html` + `@dsh-local/business-plugins` | 16 |
| 目录选择器收敛:仅见自有目录(含 admin) | `@dsh-local/workspace-scoped-picker` + profile patch(insert + disabled) | **18** |
| 角色化 profile patch(非 admin 隐藏模型分区等) | `ensure-role-profile-patch.cjs` → `<profile>/cordis.patch.yml` | 09 / 15 |
| nginx 侧关键配置:`proxy_buffering off`(流式)、`gzip_proxied any`(~930KB bundle 压缩)、`/assets/` 长缓存、CF 真实 IP 还原 | `vhost/nginx/{alotbuy.com.conf,dsh.alotbuy.com.conf}` | 21 / 22 |
| 证书:`alotbuy.com + *.alotbuy.com`(DNS-01 via `/etc/cloudflare.ini`,传播 60s) | certbot + `/etc/letsencrypt/live/alotbuy.com/` | 22 |
@@ -0,0 +1,337 @@
> ⚠️ **历史章节**(2026-09-08):本章「dsh-web 容器 / /opt/dsh/data」形态已废弃(容器 2026-09-09 删除,架构切到 dshs 多租户)。保留作历史参考;**当前运维见 `DEPLOY-本部署.md`**。
## 六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)
> ⚠️ 以下命令在服务器执行(root)。当前容器 `dsh-web` 以 `/opt/dsh/data` 为 `/root/.dsh`。
```bash
# ===== 1. 停容器 + 重组目录 =====
docker stop dsh-web
mkdir -p /opt/dsh/backups
mv /opt/dsh/backup-20260908 /opt/dsh/backups/20260908_initial # 旧备份归档保留
mkdir -p /opt/dsh/users/main
mv /opt/dsh/data /opt/dsh/users/main/.dsh # data 落入主目录
mkdir -p /opt/dsh/users/main/workspaces
# ===== 2. 固化密钥 + 重建容器(挂载主目录内两点) =====
DK=$(docker inspect dsh-web --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^DEEPSEEK_API_KEY=' | cut -d= -f2-)
printf 'DEEPSEEK_API_KEY=%s\n' "$DK" > /opt/dsh/.env
chmod 600 /opt/dsh/.env
docker rm dsh-web
docker run -d --name dsh-web --restart always --network host \
-v /opt/dsh/users/main/.dsh:/root/.dsh \
-v /opt/dsh/users/main/workspaces:/root/workspaces \
--env-file /opt/dsh/.env \
dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89
# ===== 3. 生成备份/恢复脚本 =====
cat > /opt/dsh/backup.sh <<'EOF'
#!/bin/bash
# 一键备份:打包 users/ 全部用户主目录 → backups/时间戳.tar.gz
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/opt/dsh/backups/dsh-backup-${TS}.tar.gz
cd /opt/dsh
docker stop dsh-web 2>/dev/null || true
tar -czf "$OUT" users
docker start dsh-web 2>/dev/null || true
echo "备份完成: $OUT"
ls -lh "$OUT"
EOF
cat > /opt/dsh/restore.sh <<'EOF'
#!/bin/bash
# 一键恢复:./restore.sh <备份tar.gz>(当前数据先挪走可回滚)
set -euo pipefail
TAR=${1:?用法: ./restore.sh /opt/dsh/backups/dsh-backup-xxx.tar.gz}
test -f "$TAR" || { echo "备份文件不存在: $TAR"; exit 1; }
TS=$(date +%Y%m%d_%H%M%S)
cd /opt/dsh
docker stop dsh-web 2>/dev/null || true
[ -d users ] && mv users users.old-$TS
tar -xzf "$TAR"
docker rm -f dsh-web 2>/dev/null || true
docker run -d --name dsh-web --restart always --network host \
-v /opt/dsh/users/main/.dsh:/root/.dsh \
-v /opt/dsh/users/main/workspaces:/root/workspaces \
--env-file /opt/dsh/.env \
dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89
echo "恢复完成(旧数据在 users.old-$TS,确认无误后可删)"
EOF
chmod +x /opt/dsh/backup.sh /opt/dsh/restore.sh
# ===== 4. 验证 =====
docker ps | grep dsh-web
curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \
-H 'Content-Type: application/json' \
-d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}'
# 期望 {"ok":true,"value":[{"id":"deepseek-official",...}]}
/opt/dsh/backup.sh
```
---
## 七、备份与恢复语义
- **备份 = 打包 `users/`**(全部用户主目录,一个 tar);`.env`(部署配置)与 `std/`(可再生成)不进备份;
- **恢复 = 解压 + 重建容器**(restore.sh 已固化容器参数);
- **复制/迁移用户**:拷 `/opt/dsh/users/<uid>` 到新机同路径即可;
- 多用户同构扩展:新用户增加 `users/<uid>/`,脚本无需改动(tar 整体 users/)。
---
## 八、多用户扩展(dshs 落地前的过渡方案)
目标:**每用户独立实例 + 严格隔离**。新增一个用户 = 一套同构目录 + 一个容器 + nginx 分流:
```bash
# 以新增 zhangsan 为例(示例脚本 add-user.sh)
UID_NEW=zhangsan
PORT=8901
mkdir -p /opt/dsh/users/${UID_NEW}/.dsh /opt/dsh/users/${UID_NEW}/workspaces
# 从 main 复制 profile 骨架(不含凭据/会话),或从 std/profile-skel 初始化
docker run -d --name dsh-web-${UID_NEW} --restart always --network host \
-v /opt/dsh/users/${UID_NEW}/.dsh:/root/.dsh \
-v /opt/dsh/users/${UID_NEW}/workspaces:/root/workspaces \
--env-file /opt/dsh/.env \
dsh-web:0.1.2 dsh web --host 127.0.0.1 --port ${PORT} --no-open --trusted-host 47.77.182.89
# nginx:新 server/server_name 分流 → 127.0.0.1:${PORT},沿用 Host/Origin 伪装头模板
```
> 说明:web profile 首次启动会自动初始化模板,新用户空 `.dsh` 直接起容器即可;系统标准技能层由 `std/` 挂载提供。
### 认证路线
| 阶段 | 方案 | 说明 |
|---|---|---|
| 当前 | dsh 自带 token(一次性) | 仅单人过渡 |
| 近期 | **dshs 密码制基座**(每用户独立 DSH 实例 + 注册审核) | 多租户严格隔离的正规托管方案 |
| 后期 | **飞书 OAuth 登录** | 替换/并存 dshs 密码认证,预留扩展点 |
> 备选认知:`@xgone/dsh-remote` / `dsh-passwords` 类认证网关是共享单租户,无法满足严格隔离,不采用。
---
## 十二、域名接入记录(2026-09-08)
**域名**:`dsh.alotbuy.com`(Cloudflare 代理,海外服务器免备案)
| 项 | 状态 |
|---|---|
| DNS | `dsh.alotbuy.com` → Cloudflare(172.67.194.206 / 104.21.44.42),CF 回源服务器 80 |
| **通配 `*.dsh.alotbuy.com`** | ⚠️ **未配置**——dshs 每用户子域路由的前提,落地前需在 CF 加 `*` A 记录 → 47.77.182.89 |
| HTTPS | Cloudflare 边缘证书(源站无 443,CF SSL 需为 Flexible 模式,实测已通) |
| 源站 nginx | 新建站点 `/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`(监听 80,反代 127.0.0.1:8900,Host 伪装 + 剥 Origin/Sec-Fetch,含 WebSocket 头) |
| 验证 | token 登录 303→cookie(host-only 绑 dsh.alotbuy.com)→ 首页 200(24KB)→ API `{"ok":true, deepseek-official}` ✅ |
**访问方式**:`https://dsh.alotbuy.com/?token=<token>`(浏览器打开一次即种 cookie,30 天有效)
**踩坑记录**:
- Windows 原生 curl 写 `/tmp/` 路径失败导致 cookie 不落盘 → 必须用 `C:/...` 路径(历史已知坑,本次复现)
- 对根路径发无 cookie 裸 WS Upgrade 返回 502 为 dsh 正常拒绝(IP 站点同样 502),非配置问题
- nginx 新站点需与 47.77.182.89 站同样伪装头(Host 127.0.0.1:8900 + 空 Origin/Sec-Fetch),否则特权 API 403
## 十四、dsh.alotbuy.com 注册为宝塔面板反代站点(2026-09-08 18:46)
**背景**:手写 conf(`/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`)在面板数据库无站点记录 → 面板重置/批量管理可能误伤。按用户偏好(能走 MCP 尽量走 MCP)用宝塔 MCP 完成注册。
**执行链(MCP 优先,全流程零中断)**:
1. SSH 备份 + 移走手写 conf:`cp` 到 `/opt/dsh/docs/nginx-dsh.alotbuy.com.conf.bak-20260908`,`mv` 出 include 目录(不 reload,服务无感)
2. MCP `ProxyProjectCreate`:domains=`dsh.alotbuy.com`,proxy_pass=`http://127.0.0.1:3080` → 站点 id=2 入面板 DB
3. MCP `ProxyWriteConfig`:把完整精调 conf(HTTP→HTTPS 跳转 ×2 + 443 主域 SSL + 443 通配子域 SSL)覆盖写回同路径
4. SSH `nginx -t` + reload + 本机 curl 验证
**⚠️ 关键坑**:面板反代项目 conf 写入路径与手写 conf 同名同路径(`/www/server/panel/vhost/nginx/<域名>.conf`)——直接 ProxyProjectCreate 会用面板模板覆盖,丢失 SSL/通配子域/301 等精调内容。**必须"先移走手写 conf → 创建 → ProxyWriteConfig 写回精调版"三步走**。
**验证结果**:
| 检查项 | 结果 |
|--------|------|
| SiteList 面板 DB | 2 站点:47.77.182.89(id=1)+ dsh.alotbuy.com(id=2)✅ |
| nginx -t + reload | OK ✅ |
| https://dsh.alotbuy.com(外网直连源站) | 200 ✅ |
| https://*.dsh.alotbuy.com(通配子域,未注册) | 404(dshs 正常拒绝,代理/SSL 层通)✅ |
| http://dsh.alotbuy.com | 301 → https ✅ |
| conf 保留 | 4 段精调 server 块完整(certbot 证书路径原样)✅ |
**遗留说明**:面板站点列表 SSL 状态显示 `enabled:false`——证书为 certbot 手动签发(面板不托管),面板只做站点/conf 管理;如需面板托管 SSL 需换面板 Let's Encrypt 流程(不建议,会与通配证书冲突)。
## 十五、启动 DSH 报"已有运行中"+ 无法对话 排障(2026-09-08 19:00)
**现象**:admin 登录桌面点"启动 DSH",提示"有运行中的 DSH 请先停止",无跳转,无法对话。
**根因一(P0,已修复):DB uid 与系统账号 uid 错配**
- 主 DB(`/var/lib/dshs/dshs.db`,服务 `--db` 显式指向)users.uid=**100001**(bootstrap 写入 `baseUid+rowid`)
- 但 OS 账号按 `hashUid` 建:`dsh-cce6d1cdb376430480f0` uid=**114801**(早期版本 provision 用 hash;后 provision 改为读 DB uid-for-user,但账号没重建)
- 结果:编排器 spawn 用 DB uid=100001 → `setpriv --reuid 100001` → 子进程以**不存在的 uid** 运行 → 打不开 `700 owner=114801` 的用户 home → `EACCES: mkdir .../home/profiles/web` 秒崩
- 编排器 `spawn` 事件即置 status=running,子进程崩后自动重启循环 → 前端一直显示"运行中"→ 再次点击启动返回 409 already_running(即用户看到的提示)
- **修复**:`UPDATE users SET uid=114801 WHERE id='cce6d1cd-...'`(备份在 `/opt/dsh/docs/dshs.db.bak-20260908-uidfix`)+ `systemctl restart dshs` + provision 脚本 `uid-for-user` 补 `--db /var/lib/dshs/dshs.db`(消除默认库分裂)
**根因二(上游集成缺口,未修复):dsh web 强制浏览器 token,编排器"打开"不带 token**
- dsh web(0.1.2-rc.1,`dsh-client-connection` BrowserAuth)强制:首次访问需 `?token=<launch token>`(进程启动随机、打印到 stdout)换取签名 cookie(绑定 authority=`127.0.0.1:<port>`,30 天)
- 上游 dshs 0.1.0:launch 返回 url **不带 token**,desktop.html "打开 DSH" 直接 `window.open(url)` → 首访 401 `dsh web authentication required`;且端口动态分配,实例重启换端口 → 旧 cookie 失效需重新带 token
- 已实测全链路可行:门户 sid + `?token=` → 303 种 dsh-auth cookie → 双 cookie 访问 `https://admin.dsh.alotbuy.com/` 200(24233B SPA)+ `/api/llm/listProviders` ok:true
- **修复方向(待用户定)**:A. 小改上游——编排器解析子进程 stdout 的 token,launch/status url 自动拼 `?token=`(改 src/supervisor/orchestrator.ts + routes/dsh.ts,tsc 可构建);B. 接受现状——每次启动后手动用 journald 里 token 开一次;C. 等上游修复
**排障经验**:`launch folder` 是相对 **ws/** 根的路径(空串=根);`/api/dsh/status` 返回 url 为子域;子域下所有路径(含 /api/dsh/status)都会被代理到实例,故探测实例要用 `/api/llm/*` 等实例 API;cookie 域 `.dsh.alotbuy.com`(sid)与 dsh-auth 双 cookie 缺一不可。
## 附录 A:常用命令速查
```bash
# 备份 / 恢复
/opt/dsh/backup.sh
/opt/dsh/restore.sh /opt/dsh/backups/dsh-backup-<时间戳>.tar.gz
# 容器日志(看 token / 排障)
docker logs --tail 50 dsh-web
# 进入容器
docker exec -it dsh-web bash
# 插件管理(profile web)
dsh plugin --profile web add <npm包名|github:owner/repo|link:/path>
dsh plugin --profile web remove <包名>
dsh --profile web --dump-config # 打印生效插件树
# 模型 API 直测(服务器本机)
curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \
-H 'Content-Type: application/json' \
-d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}'
# 测试 session 生成(**PG 直插**,连接串取自 env / dshs.env / dshs.service.d;10 分钟;user_agent=poc-curl2)
node /opt/dshs/mksess.cjs
# ❗ 勘误(2026-09-17 序⑬ Q1 实测):档案 77 与更早文档对该脚本存储机制的描述属**当时形态**(写 `/var/lib/dshs/dshs.db`);
# 集群化后权威库 = 47 的 PG13(DSHS_DB_URL)、那份文件仅回滚用 ⇒ 现实现 = **PG 直插**,旧描述已失效。
# 普通用户角色化 profile patch(隐藏「模型」设置分区,档案 09;admin 不受影响)
node /opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>
# 新普通用户 SOP:注册→approve→首登一次(建 profile)→ 跑上命令(--restart 使已运行实例生效)
```
## 附录 B:参考链接
- npm:https://www.npmjs.com/package/@deepseek-ai/dsh(0.1.2-rc.1)
- 官方仓库:https://github.com/deepseek-ai/deepseek-harness
- CLI 行为参考:apps/cli/reference/README.md(分层优先级/部署默认)
- dsh skills 安装与优先级:`~/.dsh/skills`、`~/.agents/skills`、项目级 `/.dsh/skills`(社区文档,见当日讨论记录)
- 社区多租户方案调研(dshs / dsh-remote / dsh-passwords / dsh-im):见当日讨论记录
---
## 附录 C:2026-09-11 增补
> **【历史 vs 现行 · 2026-09-11 档案 53】** 正文各章(六~十五)写于 **2026-09-08/09**,属**当时的事实轨迹**(含旧域名 `dsh.alotbuy.com`、旧目录形态 `data/` `main`);**现行状态以本附录 C 与 `DEPLOY-本部署.md` 为准**,正文与附录冲突时**一律以附录为准**。
### C.1 域名与证书(档案 22)
| 项 | 值 |
|---|---|
| 门户 / 用户实例 | `https://ai1net.com` / `https://<用户名>.ai1net.com`(旧域 `alotbuy.com` 自 **2026-09-19** 降为 301 过渡装置) |
| 环境变量 | `DSHS_BASE_DOMAIN=ai1net.com`、`COOKIE_DOMAIN=.ai1net.com`(备份 `/etc/dshs.env.bak-*`) |
| 证书 | `/etc/letsencrypt/live/ai1net.com/`(`ai1net.com + *.ai1net.com`,DNS-01 via `/etc/cloudflare-ai1net.ini`);旧域 `/etc/letsencrypt/live/alotbuy.com/` 仅用于过渡期 301 |
| **签发/续期必须带传播等待** | `certbot … --dns-cloudflare-propagation-seconds 60`(默认 10 s 会失败;renewal conf 已写死该值) |
| 切换脚本 | `/opt/dsh/switch-domain-alotbuy.sh`(改 env → drain → 重启,幂等) |
| Cloudflare | token 仅 DNS 权限;回源实测走 **443**;`*.alotbuy.com` 为共享通配(gao/mao/work 在用,勿改) |
### C.2 nginx 关键配置(改了容易被面板覆盖,务必记住)
| 指令 | 作用 | 失效症状 |
|---|---|---|
| `proxy_buffering off;` | 让流式响应逐块下发 | 「看不到流式输出,结果一次性出现」 |
| `gzip_proxied any;` | 压缩反代响应(那个 ~930 KB 插件 bundle) | 刷新载入极慢 |
| `location ~* ^/assets/ { expires 30d; }` | 哈希静态资源长缓存 | 每次刷新重下 ~1.2 MB |
| `set_real_ip_from <CF段>` + `real_ip_header CF-Connecting-IP;` | 还原真实客户端 IP(**须写在 server 块内**) | 日志/风控里全是 CF 机房 IP |
> ⚠️ 在宝塔面板里保存站点配置会按模板**重写 vhost** → 上述自定义指令可能被抹掉;若症状回归,先查这几行。
### C.3 平台脚本族
| 脚本 | 用途 |
|---|---|
| `/opt/dsh/switch-domain-alotbuy.sh` | 域名切换(含 drain) |
| `/opt/dshs/ensure-role-profile-patch.cjs` | 角色化 profile patch(非 admin;`--force` 升级、`--restart`) |
| `/opt/dshs/07-scripts/ci.sh` | 类型检查 + 构建 + 单测(**不含**需要实例/凭据的 smoke) |
| `/opt/dshs/poc/workspace-scoped-picker/test/poc.mjs` | 目录选择器插件自检(26 项,须以实例 uid 从可读路径运行) |
| `docs/07-scripts/docs-sync-check.sh` | 文档库双端对账(0=全绿) |
### C.4 排障速查(本手册新增条目)
| 症状 | 先查 |
|---|---|
| 用户实例异常/被清 | `journalctl -u dshs | grep -E "crash-restart|circuit-open|idle-reap"` |
| 实例反复起不来 | 同上(含 `crash-loop-circuit-open` = 已熔断,需用户重新进入或人工介入) |
| 会话「连接中」/卡顿 | 先看 nginx 是否仍 `proxy_buffering off`;再看 `/www/wwwlogs/alotbuy.com.log` 的状态码与耗时 |
| 日志里客户端 IP 全是 CF 段 | vhost 的 `real_ip_header CF-Connecting-IP` 是否被覆盖 |
| 证书告警/续期失败 | renewal conf 是否含 `dns_cloudflare_propagation_seconds = 60` |
| 需要看实例资源 | `ps -eo pid,user,pcpu,rss,args | grep "[d]sh --profile"`;scope 名见 journald |
> 注:诊断实例内文件系统时,`pgrep -f "dsh --profile web"` 会先命中 **bwrap 包装进程(root)**,在其命名空间内探测会得出"全部可读写"的**假象**——必须取 `ps -eo user,args` 中 user 为 `dsh-*` 的子进程(档案 17 §一)。
### C.5 本部署**未使用/未验证**的子系统(升级 dshs 时重点回归,档案 19 §C8)
| 子系统 | 文件 | 本部署情况 |
|---|---|---|
| Kubernetes 后端 | `src/supervisor/k8s-spawner.ts`(679 行)、`deploy/*.yaml` | **未使用**(`ISOLATION_MODE=account`);升级时需按"未验证"对待 |
| PostgreSQL 后端 | `src/db/pg.ts`(508 行) | **未使用**(本部署用 sqlite/better-sqlite3) |
| Leader 选举 | `src/cluster/leader.ts`(254 行) | **未使用**(单实例部署) |
| 对账 | `src/supervisor/reconcile.ts` | 仅 k8s 路径使用 |
> 结论:可保留上游能力,但**任何涉及它们的改动都不应在生产直接验证**;升级后若这些文件报编译错误,优先改为"绕过/排除"而非线上调试。
### C.6 上传安全扫描:分级(2026-09-11,档案 19 §C6)
| 档 | 行为 | 规则 |
|---|---|---|
| **P0 阻断** | 命中即 **HTTP 400** 拒绝上传 | 原有 7 条(`rm -rf /`、`curl \| sh`、读系统凭证/SSH 私钥/云凭证/平台数据目录/`.credentials.yaml`)+ 新增:`nc -e` 反弹、`/dev/tcp` 反弹、base64 解码后直接执行、给系统二进制加 setuid、fork 炸弹 |
| **P1 告警** | **不阻断**,写日志 + 作为 findings 返回 | 动态执行子进程、`vm`/`eval`/`new Function`、敏感 env 读取、超长 base64、外部网络访问、隐藏目录/`.git` |
- 覆盖面:文本扩展名 **19 → 31**(含 `.toml/.ini/.conf/.env/.rs/.go/.java/.php/.rb/.pl/.lua/.sql/.ps1/.bat`…);无扩展名按 **shebang** 判定;二进制按 `0x00` 跳过;体积上限 **2MB → 8MB**。
- 排查:`journalctl -u dshs | grep upload-scan-warnings`(P1 告警);P0 直接体现在上传接口的 400 响应里。
- 冒烟(可复跑):`node /tmp/scan-smoke.cjs` 思路见档案 19 §C6 记录(P1 不阻断 / P0 阻断 / 良性零误报)。
### C.7 用户数据清理(档案 28,cron 自动跑)
| 任务 | 时间 | 阈值/年龄 | 日志 |
|---|---|---|---|
| `storage-report.cjs` | 每小时 05 分 | 生成 `/var/run/dsh-storage-report.json`(门户「存储用量」面板的数据源) | `/var/log/dsh-storage-report.log` |
| `ws-cleanup.cjs --apply` | 每天 04:10 | ws ≥ 2048 MB;清 T1 平台产物 + T2 超 90 天顶层脚本(`ws/.keep` 可豁免 T2) | `/var/log/dsh-ws-cleanup.log` |
| `session-gc.cjs --apply` | 每天 04:20 | sessions ≥ 1024 MB;清超 **365 天**会话(并同步回收 projcache) | `/var/log/dsh-session-gc.log` |
| `purge-trash.sh 30` | 每天 04:40 | 回收站超 30 天 | `/var/log/dsh-trash-purge.log` |
- **所有清理都先 `mv` 到 `<userRoot>/trash/<日期>-<任务>/`**(30 天可恢复);只有 `purge-trash.sh` 会真删。
- 手动排查:`node 07-scripts/ws-cleanup.cjs`(dry-run 出画像)、`node 07-scripts/session-gc.cjs`、`node 07-scripts/clean-ws-pollution.cjs`。
- 三档口径:T1 平台产物/明确垃圾直接清;T2 顶层一次性脚本 _超 90 天_ 才清;**T3 其余(含所有目录)永不自动删**。
(域名 / 证书 / nginx / 平台脚本 / 排障速查)
### C.8 备份与恢复(2026-09-11 修复:脚本曾有但不可用)
**问题**:`/opt/dsh/backup.sh` 存在但**无执行权限**(`-rw-------`)且**未接任何调度** → 最后一次全量备份停留在 2026-09-08。
**现状(已修)**:
- 脚本:`/opt/dsh/backup.sh`(副本入库 `07-scripts/backup-platform.sh`),`chmod +x`,并加固为
**① SQLite 一致性快照**(`sqlite3 .backup`,避免运行中只拷到 `-wal` 造成库不一致)
**② 归档内容扩展**:`var/lib/dshs`(用户数据)+ `etc/dshs.env` + systemd 单元 + `opt/dsh/artifacts`(插件包)+ `opt/dshs/scripts` + `etc/cron.d/dsh-*` + `nftables-dsh-egress.nft` + `www/server/panel/vhost/nginx`(可重建)
- 调度:`/etc/cron.d/dsh-backup` —— **每周日 05:00 全量** + 05:30 清理超 60 天的旧备份;日志 `/var/log/dsh-backup.log`
- 产物:`/opt/dsh/backups/dsh-platform-backup-<TS>.tar.gz`(全量)+ `…-db-snapshot.tar.gz`(DB 快照)
**恢复(演练已验证)**:
```bash
B=/opt/dsh/backups/dsh-platform-backup-<TS>.tar.gz
tar -tzf "$B" | head # 先看清单
tar -xzf "$B" -C / # 原路径还原(谨慎!会覆盖现网文件)
sqlite3 /var/lib/dshs/dshs.db "select count(*) from users;" # 校验
```
**实测证据**:首跑全量 **8.7 MB / 6919 条目**(users 6858 + DB + env + artifacts + scripts 20 + cron 2 + nginx vhost 22 + systemd 3);**恢复演练**解出 DB 并查询 `users` = 2 行 ✓。
@@ -0,0 +1,127 @@
# 03 路线图与待办
> 状态(刷新 · 2026-09-12 15:05):本文件长期晚于实际进度(此前停在 09-11 23:05)→ 本次补登记**档案 57–68**、清理已完成行、并把待办口径与 `05-交接单/README.md §一` 对齐(**已规划待执行的以交接单为准**,本文件只保留指针)。原文:**2026-09-11 21:20 合并版**(补回 14~22 与 B1/B2/B3/B4、档案 28、备份加固;保留「MCN 改造暂缓」「白名单源码安装」等新决策)。
## 一、待验证项(Open Questions)结论
| # | 原问题 | 结论(实测日期) |
|---|---|---|
| 1 | `.env` 注入 `DEEPSEEK_API_KEY` vs 用户个人 key 优先级 | ✅ 已定:**统一 admin key 模型**(commit df5cc84,2026-09-09)——resolveApiKey 忽略 userId、取 role=admin 启用 key,spawn env 注入;keys 路由 requireAdmin;无"个人 key 覆盖"场景 |
| 2 | web profile 空 `.dsh` 首启自动初始化(add-user 流程) | ✅ 已解答:dshs 模式无此问题——provision 建 OS 账号 + profile 首次 spawn 即自动初始化;testuser 注册→审核→桌面→DSH 全链路已实测通过 |
| 3 | 前端 loopback 检测能否 `--patch` 放行(用户自配模型) | ✅ 已实测(2026-09-09):proxy.ts 将 Host 伪装为 `127.0.0.1:<port>` → dsh 的 /api trust fence 与 loopback 特权校验**全放行**,Settings 面板在门户代理后可用 → **暴露面成立:登录用户可进 Settings 自配 key/模型**,作为已知安全边界记录(第二道防线),暂无封锁需求 |
| 4 | dshs 对 web Settings 限制 | ✅ 同上实测:Settings 面板可用(portal-entry v0.4.1「平台管理」分区即在此验证) |
| 5 | 容器内 `dsh plugin add` 走 npmmirror | ⏹ 过时归档:架构切换后插件走**宿主全局 dsh + profile 层 pnpm 转发**(`dsh plugin --profile web`);装法 = 本地 `npm pack` 出 tgz → `dsh plugin add <tgz>`(实测 v0.1.0→v0.4.1 全链路) |
## 二、路线图
### 已完成(2026-09-08 ~ 09-09)
- [x] **P0 迁移**:data → users/main(2026-09-08 17:30)
- [x] **P2 调研**:dshs 选型前置调研(2026-09-08)
- [x] **域名接入 + 通配 HTTPS**:dsh.alotbuy.com 通配证书(2026-09-08 18:00-18:40,certbot dns-cloudflare)
- [x] **架构切换**:dshs 多租户 + account 硬隔离(2026-09-08 18:30)
- [x] **登录直达 v1+v2**:POST /api/dsh/enter 按角色分流 + token 自动携带(commit 6f63108 / e39628a / 29c8907)
- [x] **统一 KEY 管理员管控**(df5cc84)
- [x] **用户管理**:注册/审核/删除用户(4943c8c)+ 权限收紧(chmod 系列)
- [x] **实例生命周期**:last-wins 单活跃会话 + idle-reap 常驻上限(928dde1 / 6336c53)
- [x] **门户功能插件化 PoC**:`@dsh-local/portal-entry` v0.4.1 浏览器验收通过(2026-09-09)——设置面板「平台管理」分区(门户地址 + 打开管理台 + 退出登录),档案 05
- [x] **技能管理面**:共享 + 个人技能 API 与页面、zip-only 两阶段替换(2026-09-09,档案 11)
- [x] **文档库路径统一(2026-09-10)**:工作区 `D:\AgentSkill\aliyun-work-space` → `D:\AI技能\aliyun-work-space`;文档目录 `dsh-docker\docs\` → **`dsh-server-docs\`(内容提升到根,与服务器 `/opt/dsh/docs` 同构)**;6 处旧路径改写 + 双端同步,25/25 md5 一致;对账脚本 `07-scripts/docs-sync-check.sh` 落地
- [x] **工作区迁移 D: → E:(2026-09-13)**:工作区 `D:\AI技能\aliyun-dsh-server` → **`E:\ProgramData\AI技能\aliyun-dsh-server`**(`D:\AI技能` 已无实体目录)。同步改的**活路径**:`~/.workbuddy/settings.json`(两个 hook 命令)、`dsh-server-docs/07-scripts/lock-guard-hook.py`(`DSH_DOCS_ROOT` 默认值)、`07-scripts/docs-sync-check.sh`(`DOCS_LOCAL_DIR` 默认值,本库 + 项目根共两份)、`dsh-server-docs/07-scripts/extract-user-voice.py`(目录名示例)、`dsh-server-docs/README.md` 与 `INDEX.md` 的本机路径、档案 73 的 hook 安装片段。**历史档案(01-规范/01-规划与架构 / 04-11 / 04-12 / 09-archive/ 下各篇)中的 `D:\AI技能\...` 保留原值**(写于迁移前,属历史,按「档案只增不改」不回改)。**代码仓 `D:\github\dsh_shenxian` 未变动**(不在 `AI技能` 之下)。
⚠️ 排查期间曾临时建 `D:/AI技能 → E:/ProgramData/AI技能` 目录联接,用于救活**会话启动时快照**里已失效的旧 hook 路径;**已按用户要求移除**。⇒ **改 hook 路径后,已在跑的会话需「完全重启」(关窗 ≠ 退出)或新开会话才生效**(2026-09-13 实测:本会话 06:47 启动、06:55 改配置,07:05 拆联接后写操作仍报旧路径)。
- [x] **档案 78 · 崩溃熔断冷却期与告警(2026-09-13)**:修 **档案 77 §八 遗留 1** —— 原实现熔断即 `resetCrashState()` 清预算 ⇒ 只要有重试路径(用户 F5 / 注入脚本自愈 / 脚本直铺)崩溃循环就能**无限重来**,且只留一行 stderr。改为:熔断态**跨轮存活** + **指数冷却**(10 min → 封顶 6 h),冷却期内 `launch()` 拒绝隐式启动(HTTP 503 `instance_circuit_open`),冷却过后只给**一次**干净预算;熔断**双通道告警**(stderr + `/var/log/dsh-crash-breaker.log`)并把 `breaker` 暴露到 `/api/dsh/status`。改 6 个文件(含 3 条新单测);`npm run build` rc=0、`npm test` **45 pass / 0 fail**。**已部署并验证**(2026-09-13 08:02 重启载入:PID 410287→413817、门户 200、孤儿 scope 0、`/api/dsh/status` 返回新字段 `breaker`)→ 档案 78 §九
- [x] **登录直达冷启动竞态 404 修复(2026-09-10)**:enter 复用/AlreadyRunning 分支等 launch token 到位再返回 URL,杜绝启动窗口 404(commit `fa718d2`,档案 13)
- [x] **敏感信息暴露面审计与加固(2026-09-10,档案 14)**:判定跨租户/提权不成立;封云元数据端点 `100.100.100.200` + 外联观测(`/etc/nftables-dsh-egress.nft` + `dsh-egress.service`)
- [x] **会话失效 401 未跳登录页修复(2026-09-10,档案 15)**:desktop/admin/skills 三页注入 401 守卫;核心插件开关收归 admin
- [x] **页面导航定稿 + 插件三层归属(2026-09-10,档案 16)**:门户 SPA 化、技能/插件独立页面、功能插件候选池投放 + 实例设置启停;阶段 0-2 已实施(`bad6ed7`/`6f86a5f`/`b7fd85d`)
- [x] **实例沙箱隔离(档案 16)**:bwrap + systemd-run scope(512M/CPU 150%/TasksMax 128)+ 私有 /tmp + `--unshare-pid`(commit `449f28b`)
- [x] **工作区选择器暴露面核查(2026-09-10,档案 17)**:判定非越权;记录 P1 写边界=会话 cwd / P2 picker 无根白名单 / P3 nft 文件可读
- [x] **文档库去重 + 单一来源(2026-09-10,档案 19 §D4)**:删根级 05 副本、`04/README.md` 改指针
- [x] **崩溃自愈加固(2026-09-11,档案 20)**:指数退避 + 窗口熔断(5 次/10 min)+ 观测(`restarts`/`lastCrashedAt`)+ handoff 停写;**live 自愈实测通过**(kill → 1 s 后 `[crash-restart]` 拉起);commit `7cba3e3`
- [x] **CI 脚本 + 清理失效 smoke(2026-09-11,档案 19 §C3)**:新增 `07-scripts/ci.sh`;删 `smoke-plugins.mjs`(目标路由已移除)与 `smoke-watchdog.mjs`(watchdog 从不启动)
- [x] **访问域名迁移(2026-09-11,档案 22)**:门户 `alotbuy.com` + 用户 `<用户名>.alotbuy.com`;通配证书 DNS-01(传播 60 s)、CF 真实 IP 还原、旧域 301 含用户名映射
- [x] **nginx 性能修复(2026-09-11,档案 21)**:`proxy_buffering off`(修「一次性输出」)、`gzip_proxied any`(930 KB bundle 压缩)、`/assets/` 30d 缓存
- [x] **官方白名单插件来源(2026-09-11,档案 29)**:门户「插件管理」接入 awesome-dsh-plugin 官方目录(`plugins.json`,3408 条 / 23 分类),admin 搜索/筛选/多选 → 导入功能插件候选池;导入口径 = **仅预构建**(npm registry tarball / release 资产),可导入 **1792 条(52.6%)**、热门插件全覆盖,平台侧不执行任何第三方构建脚本
- [x] **编排器孤儿实例清理(2026-09-11,档案 30)**:spawn 前清 `dsh-<uid>-*` 孤儿 + 启动时清一次;两场景 live 验证通过
- [x] **档案 14~22 全部落地(2026-09-10~11)**:敏感信息暴露面审计(封云元数据端点 + 外联观测)、会话失效 401 跳登录、页面导航定稿 + 功能插件三层归属、实例沙箱隔离(bwrap+scope)、工作区可见面核查、目录选择器收敛 v3、崩溃自愈(退避+熔断+观测)、CI 脚本、**访问域名迁移到 alotbuy.com**(旧域 301)、nginx 性能修复(buffering/gzip/assets)
- [x] **写保护 B1(档案 17 §P1)**:实例内 `--ro-bind-try` 覆盖 `cordis.patch.yml`/`package.json`/`pnpm-lock.yaml`(写测试 ✅ + 真实 dsh 启动验证 ✅)
- [x] **编排器自愈 B2(档案 18 收尾)**:`ensurePickerProfile(userId)` 在 launch 前 + spawn 后各异步补齐(幂等)
- [x] **上传安全扫描分级 B4(档案 19 §C6)**:P0 阻断 / P1 告警 + 扩展名 19→31 + shebang/二进制/8MB(冒烟通过)
- [x] **死代码子集 B3(档案 19 §C5)**:删 `src/runtime.ts`;`folder_plugins`/`workspaces` 加废弃注释(**完整版清理已关闭**:跨 10 文件且含 k8s/PG 未验证路径,删除风险不对称)
- [x] **用户数据清理三件套(档案 28)**:`ws-cleanup`(T1 平台产物 / T2 顶层一次性脚本 >90 天 / T3 永不删)+ `session-gc`(**365 天**保留)+ `purge-trash`(回收站 30 天)+ 每小时用量快照 + 门户「存储用量」面板(`GET /api/admin/storage`)
- [x] **备份可用性修复(档案 02 附录 C.8)**:发现 `backup.sh` **无执行权限且未调度**(最后备份停在 09-08)→ 修好 + SQLite 一致性快照 + 纳入 artifacts/07-scripts/cron/nginx vhost + `/etc/cron.d/dsh-backup` 每周日 05:00(恢复演练已验证)
### 已完成(2026-09-11 ~ 09-12,本次补登记 57–68)
- [x] **档案 57 · 设置面板「用户管理」入口 + 全员安装**(2026-09-12 00:04)
- [x] **档案 58 · 实例内存优化与配额下调**:512M → **384 MiB** + 编译缓存(2026-09-12 00:23)
- [x] **档案 59 · 重连反馈:实例启动中的加载动画**(2026-09-12 01:30)|⚠️ 遗留:`wake.html` 本机 4708B vs 服务器 4591B **待核对同步**
- [x] **档案 60 · 设置面板分区改名「功能插件」→「功能管理」**(2026-09-12 08:30)
- [x] **档案 61 · 插件管理页官方插件列表加高 + 底部留白 200px**(2026-09-12 09:14)
- [x] **档案 62 · 插件目录缓存状态可见化 + 「重新拉取目录」按钮**(2026-09-12 09:21,`be42155`)
- [x] **档案 73 · 让锁真正拦得住人**(2026-09-12 17:0x):复盘发现三把锁**当天被跳过两次**(含本会话)→ 根因①无强制入口(`settings.json` hooks 段为 null)②guard 输出「无锁」被读成「可以开工」。治:① 措辞修正(guard 三处 + 交接单语义)② `07-scripts/lock-guard-hook.py`(PreToolUse 无锁拒写 + SessionStart 提示,**作用域仅本库/本代码库**;四态单点验证通过);⚠️ 钩子待用户在 `/hooks` 审核启用
- [x] **档案 72 · 实例回收/关闭后回到页面自动唤醒并重建连接**(2026-09-12 16:55,`b23e386`):注入脚本原来只认 401,实例回收时代理返回的 `404 not_running` 无人处理 → 用户必须手动刷新。补:① `hit()` 识别 not_running ② `visibilitychange`/`focus`/`pageshow` 时主动探活 `/api/dsh/status` ③ `recover()` 跳 `wake.html`(走 `enter` 拿**新 token**,避免 reload 带旧 token 再撞 401)
- [x] **档案 71 · 插件兼容性预检:导入 / 上传即判定**(2026-09-12 16:35,`8d19e89`):semver 依赖范围 + 运行时导出符号比对,不兼容在导入时即拒收(承接档案 70 的教训)→ 方案 `04-71`
- [x] **档案 70 收尾 · AnySearch 彻底放弃 + 候选池存量体检**(2026-09-12 18:56):用户选 B → 下架 anysearch(`audit` 175);**顺手发现预检只覆盖"新导入"、覆盖不到存量** → 用 `lib/web/plugin-compat.js` 体检池内两条:`dsh-univer-office` = `ok`(保留)、`@liustack/modlens` = `unknown` 且有 `plugin_incident`(audit 172/173)→ 一并下架(`audit` 176,tgz 备份 `/opt/dsh/backups/plugin-pool-20260912/`)。全程**未重启服务、未中断用户**。另落 **R9 红线**(禁止人工删锁/接管)→ 档案 70 §九 · 档案 73 §十一
- [x] **档案 70 · anysearch 插件与 dsh `0.1.2-rc.1` 不兼容 → admin 实例崩溃循环**(2026-09-12 15:32 **已止损**):根因 = 插件在 import 阶段引用 `@deepseek-ai/dsh-llm` 未导出的 `assertNever`,而 dsh 的 plugin tree 加载**全或无** → 启动中止、崩溃自愈反复重启;摘掉该 bundle 即恢复。**✅ 该待办已于 2026-09-12 18:56 关闭**:用户选 B · **彻底放弃**(候选池条目下架 + 存量体检)→ 见本节「档案 70 收尾」行与 §二 待办表「已处置」行
- [x] **档案 69 · 并发治理落地(T04)**(2026-09-12 15:10,`exec-session-C`):文档库 commit 常态化 + 服务器侧操作锁 `/opt/dsh/state/.op-lock/`(实测往返通过)+ 交接单目录权限统一;三把锁进预检脚本 → 单子已归档
- [x] **编号 63 为空号**:该号只出现在当日工作日志的「事故 63」里(清理残留 tgz 致依赖断裂),**无对应档案**,勿补占
- [x] **档案 64 · 接入 AnySearch 搜索 provider(B 方案)**(2026-09-12):平台现用 DeepSeek 官方搜索;AnySearch 前置验证 4 项全绿、**admin 侧已实施**;⚠️ 待办改以**档案 65 §7.3** 为准 —— **2026-09-12 用户拍板:三方插件一律走「admin 导入候选池 → 用户自助启用」,不再向用户 profile 直铺**(§8.3 第 2 条直铺命令已作废)
- [x] **档案 65 · 功能插件启停 ↔ web provider 配置联动**(2026-09-12):根治「候选池一禁用就 `CONFIGURED_MISSING`」→ 平台按当前 bundles **重算托管段**,启/禁两态皆正确;**代码完成 + 两态实测,⏸ 待部署(需 R8 窗口)**
- [x] **档案 66 · 业务插件 P0 误报 → admin 显式信任**(2026-09-12 11:15 已部署):安全检测改 **fail-closed + 逐条回显 + admin 声明信任后放行并留痕**
- [x] **档案 67 · 「功能管理」section 按 UI 规范重做(v0.2.4)**(2026-09-12):用途说明做主视觉 + 字号/组件对齐 `06`
- [x] **档案 68 · 候选池启停的 root 属主污染根治**(2026-09-12):候选池 `install/uninstall` 改 **`setpriv` 降权** + 改插件前自动属主自愈 + 清存量 **561** 项;附带修 `npm pack` 把上一版 tgz 打进产物(加 `.npmignore`)
### 进行中 / 待办
| 优先级 | 事项 | 说明 |
|---|---|---|
| ✅ **已完成** | ~~**档案 65 部署**(功能插件启停 ↔ web provider 托管段联动)~~ | **本就是生效状态**:服务器 `lib/` 00:13 构建 → **00:15:23 重启即已载入**(08:02 再载入);**§7.3 第 3 条已完成**(`ensure-anysearch-admin.cjs` 加硬拦退役,默认 `exit 2`,备份 `.bak-retire-20260913`)|
| ✅ **已完成(留一项 L1)** | ~~**档案 78 部署**(崩溃熔断冷却 + 告警)~~ | **2026-09-13 08:02 已部署并验证**(见档案 78 §九)。**遗留 L1**:故意把实例反复搞崩以验熔断(需 6 次真崩 + 该用户冷却 10 min)→ **留维护窗口**做 |
| ✅ **已完成并归档**(2026-09-13 18:0x) | **T03 · 7 插件整合投放** | **guest 启用已成功**(任务 `c20739a6dbe8975c`:`success`/`restarted=true`;配额自动 672→**800 MiB**); **admin 侧 8 项实证通过**(包结构 v0.3.9 / 上传扫描 P0=0 / 池内 / `[mcn-suite] loaded` 且 duplicate=0 / 旧 7 包已下架 / 技能落 `<home>/08-skills/mcn-short-video` / 幂等跳过 / BRIEF 384M);**剩 6 项需浏览器或造场景**(D 入口逐个点开 / J agent 按名加载 / L 凭据扫描 / N 死引用扫描 / P·Q 撞名三场景)→ 见 `05-交接单/T03` §九 |
| ✅ **已消解(无需窗口)** | ~~**实例内存预算上调**(`--max-old-space-size` 160→256 + cgroup `MemoryMax` 384→512 MiB)~~ | **2026-09-13 13:3x 实测:R1-④ 已把它改成「按插件集合动态计算」,比原方案更优** —— `instanceMemMb()` = 160 + Σ插件预估(clamp 384–1024),`heapMbFor()` = 配额 − 96(cap 256)。运行中实证:guest `NODE_OPTIONS=--max-old-space-size=256` / scope `MemoryMax=544 MiB`(= 160 + univer 384);**admin 也是 256**(不再是 160)。平台 env 里那个写死的 `160` 已被代码侧 `withHeap()` 覆盖、**不是生效值**。⇒ **T03 的 guest 启用不再被容量阻塞**(装上 mcn-suite 后配额会自动变 672 MiB)。原「须重启服务(R8)」的前提已不成立 |
| 🟡 **待排期** | **档案 81 重构总纲的 R2/R4/R5**(**R0/R1/R3 已完成**) | **R2** 管理面就地化 → 🚧 **代码已完成(2026-09-13 17:5x,档案 82)**:插件 `@dsh-local/business-plugins` **0.2.8→0.2.9**,新增原生「平台管理」只读分区(仅 admin;数据调平台只读 API;写操作回跳管理台);`node --check` 通过、tgz 已出;**待投放 + 启用 + 浏览器验收(卡在需 admin 登录态)**;`/admin` 路由族与删 3 桩页**仍未做**。⚠️ **形态已改定(2026-09-13 用户):用「原生弹窗」,不用 iframe**(原话「iframe 不如原生弹窗体验好」)⇒ 改为**在自研插件里原生渲染只读子集**(调平台 `/api/*`,非 iframe 嵌 `portal.html`);仍按 R5 只读优先,登录后 30 天内无需重做门户全量 UI ⇒ 见档案 81 §9.2 修正|**R3** 内部标识统一为 **`dshs`** → ✅ **已完成(2026-09-13 15:4x–16:0x)**:代码 89 文件 / 382 处 + 本机其余 135 文件 / 863 处 + **服务器原子切换**(单元真名 `dshs.service`、`/etc/dshs.env`、`/opt/dshs`、`/var/lib/dshs`、`dshs.db`;含 **WAL 归位**与 DB **VACUUM**);上游具名引用全清(含移除 `git remote upstream`);两仓 git 历史已删重建为单提交|**R4** 文档/代码同仓 → ⏸ **待用户选 a(并入代码仓)/ b(单向导出)**(用户 09-13 反馈「没看懂 R4 要做什么」⇒ 需先讲清动机再选)|**R5** **多语言 i18n** → ✅ **方案已定(2026-09-13 用户):「匹配 dsh 官方方案」= 走官方 locale 体系**(`ctx.locale.addLanguage()` + `register(ns)`,平台页/注入层/自研插件全走官方扩展点、**零官方改动**);⚠️ 覆盖率边界见档案 81 §10.7 ⑥ —— 官方**仍有 20+ 个 UI 包未迁 locale**,那些包切语言不变(属官方自己的待办,不为我们可改范围)|
| ✅ **已修复并验收**(2026-09-13 17:2x) | ~~**档案 76 · `/univer-api/state` 生产持续 400**~~ | **本会话独立复核**:近 2 小时 400 = **0**、近 30 分钟 6 次请求**无 400**;实例 env `UNIVER_DSH_GATEWAY_SOCKET=auto` 已生效(档案 76 追加节=真因+修复+验收)。原述 2026-09-13 07:36 实测:guest 用户在 1 秒内被连续 ~10 次 400(平台侧只记状态码)→ **根因待取响应体**;⚠️ 同时发现 **guest `ws` 下 0 个 `.univer` 文件**(目录在、文件不在)→ 需沿「MCN 生成 `.univer` → Univer 打开」链路查 → 档案 76 追加节 |
| ✅ **已处置** | ~~AnySearch 能力是否保留~~(档案 70 §八) | **用户选 B · 彻底放弃**(2026-09-12 18:56):候选池条目已下架(HTTP 200 / `audit` 175)、凭据无残留、无用户启用过;**顺带体检池内存量** → `dsh-univer-office` = `ok`(保留)、`@liustack/modlens` = `unknown` + 有 incident → 一并下架(`audit` 176,tgz 已备份)。**未重启服务、未中断用户** → 档案 70 §九 |
| ✅ 已完成 | **T05 · 插件兼容性预检**(导入/上传即判定兼容性) | 2026-09-12 16:35 落地(`8d19e89`):判据模块 + 上传/导入双入口接入 + build + 重启;验收全绿 → 方案 `04-71`,单子已归档 |
| **档案内挂起** | 档案 57 四项(picker store 隐患/dep spec 指向 `ws` 会被清理/`portal_ping` 失效工具/插件源码两处存放)|~~档案 59 `wake.html` 本机 4708B vs 服务器 4591B 待核对同步~~ ✅ **2026-09-13 09:4x 实测双端一致**(均 4962 字节、md5 `00b81728…`;变大是因档案 78 给 wake.html 加了熔断文案)|档案 66 三项(官方目录**批量导入**未接信任入口/信任状态未持久化→建议并入 migration v6/`ensure-anysearch-admin.cjs` 覆写段待退役,否则与档案 65 托管段互相覆盖)|档案 68 平台 setpriv 路径待用户会话自然验证|档案 42 三项(③ **已闭环并实测通过**:子代理派发 `bash -c 'echo subagent-ok'` 正常返回 ⇒ shell 可用、平台零改动;见档案 42 末「追加结论 + 实测结果」)/档案 38b 三项/档案 15(`admin.html` 缺页面层 role 拦截)/档案 32(`dsh-market` 可绕过第三层管控,**未成档**) | 详见各档案 §待办段 |
| ✅ **已修复并部署** | ~~**插件启停的两处平台缺陷(档案 79)**~~ | **2026-09-13 13:4x 逐条核实产物(档案 79 §七)**:**D1** `await uninstall(…)` ✅(产物 `:587`/`:617`)|**D2** `remove` 已改 `-w`(`:575`,全仓无效 flag 仅剩 `add` 一处=合法)✅|**加固 a** 路由外层 `.catch()` ✅(`:613`+`:718`)。**D3 已消解**:R1-④ 把堆限与 `MemoryMax` 改为按插件集合动态计算(实测 guest `heap 256` / `MemoryMax 544 MiB`)⇒ 不再需要窗口。⚠️ **仍缺端到端验收**(重现「注定失败的启用」→ 期望 任务 `failed` + profile 回滚 + 服务不退出)→ 建议与 T03 的 guest 启用合并验。**加固 b**(半应用态主动自愈)未做,可选 |
| 触发式 | dsh 升级回归(档案 26 六类耦合点)|会话 GC | 升级 / 容量触发 |
| P3 | 门户「浏览文件」补下载入口 | 档案 56 §七:门户 `#/files` 目前只能列表/上传,可复用 `/api/fs/download` 加"下载"按钮 |
| ✅ | ~~老会话权限档位对齐(检测 + 提示)~~(档案 56) | **已完成(2026-09-11)**:`GET /api/dsh/session-permission` + 实例页顶部提示条(说明原因 + 切档位/新建会话两条路);实测 `stale=true` 触发正常。**不自动改档位**(安全语义变更需用户知情) | 档案 55:档位是**会话级播种** —— 09-10 及更早的会话仍 `workspace-write` → 沙箱 fail-closed,**bash 被拒 11 次**(实测:同一会话 22:16 手动切 `danger-full-access` 后立刻恢复)。建议 `/api/dsh/enter` 检测会话 preset 与平台默认不一致 → **页面提示 + 一键切换**(不自动改) |
| ✅ | ~~实例能力清单(自检)~~(档案 56) | **已完成(2026-09-11)**:`07-scripts/gen-capabilities.cjs`(cron 每日)→ `/opt/dsh/state/capabilities.json` + 共享技能 `platform-capabilities`(agent 可读);实例页「🧭 能力」面板同源展示 | 档案 55:agent 在 16:48 / 18:21 / 22:14 **三轮重复现场探测**,每次撞同样 4 类墙(`/etc` 白名单 / `127.0.0.1` 被 SSRF 拒 / skill 不存在 / 写边界),用户随之三次追问"能力有变化吗" → 平台注入能力说明(可读可写范围、网络边界、工具与技能清单、档位含义) |
| ✅已定 | 平台技能投放现状对齐 | **结论(用户原话):「业务技能要打包进插件里一起安装和使用,不要分开管理」** → 投放方式 = **随功能插件包投放**(见 `05-交接单/T03` §4.1),**不走**门户技能管理单独投放;平台自描述技能 `platform-capabilities` 照旧共享投放。「是否投放待定」的旧表述作废(档案 55 当时的背景是 `bundled-skills` 为空 → agent 撞 `unknown skill`) |
| ❌复核 | ~~管理类插件化~~(档案 05 PoC-2 ①) | **复核结论:不建议做** —— 门户 `portal.html` 已有完整管理面(服务/密钥/用户/技能/插件/运行环境),admin 在会话内经 `portal-entry` 卡片**整页跳门户**;把 admin 能力搬进实例子域反而扩大权限执行面(与档案 39 收窄方向相反) |
| ✅复核 | ~~插件↔门户鉴权令牌~~(档案 05 PoC-2 ②) | **复核结论:已实现(非令牌方式)** —— 共享会话 Cookie(`Domain=.alotbuy.com`)+ `server.ts` 的 CORS 白名单(`isAllowedOrigin` 允许 baseDomain 及其子域 + `Allow-Credentials`),功能插件 v0.2.1 已生产跑通;再做独立令牌属重复建设 |
| ❌复核 | ~~`portal_ping` 端到端~~(档案 05 PoC-2 ③) | **复核结论:原验收项作废** —— 该工具 `fetch(127.0.0.1:3080)`,而档案 39 已封 `127.0.0.0/8`(实测 BLOCKED)→ 必然失败;若要验证"host 插件给 agent 注册工具",需**另立不依赖 loopback 的探针** |
| 暂缓 | **MCN 工作台插件平台化改造**(档案 27)—— **【暂缓】需先测试插件兼容性**(2026-09-11 决策) | 3 处 P0:① `homedir()/.dsh` → `process.env.DSH_HOME ?? …`(config.js 3 处;`mcp.js` 已是正确写法可对照)② 技能路径硬编码 13 处 → 只报技能名 ③ agent preset(`.agent-presets/mcn/`)随包投放;P1:MCP 预装(现在 `npx myai-mcp`)、DB 重建兜底、技能依赖声明 |
| ✅关闭 | ~~白名单源码安装支持~~(档案 29 §七) | **评估结论:不做(2026-09-11)**:源码安装需让第三方构建脚本以 root 在平台机执行(供应链 + 可复现性 + 性能三重风险),与"平台不执行第三方构建脚本"红线冲突。**替代路径**:admin 本地构建后走「上传 tgz」通道(已有 P0/P1 扫描);如需开启须先满足沙箱构建 + `--ignore-scripts` + 仅 admin + 审计等全部前提 |
| 降级 | B5:给 portal-entry / business-plugins 加加载标记 | **降级为"顺手做"(2026-09-11 决策)**:不解决当前问题、源码不在仓库、且仅提升"升级时排查速度";下次改这两个插件时顺手加(零边际成本) |
| ✅ | ~~熔断 live 实测~~(档案 20 附) | **已完成(2026-09-11)**:由真实故障用户(档案 52)的日志完成实测 —— 退避 1000→2000→4000→8000ms、`restartsInWindow` 1→4、第 **5** 次触发 `crash-loop-circuit-open`(窗口 600000ms / 5 of 5),当日 2 次开断;无需再人为 kill 复现 |
| ✅ **已完成**(2026-09-15) | ~~档案 16 阶段 3/4(实例内「我的技能」)~~ → **= 交接单 `T01`,已归档** | **档案 100**|插件 `business-plugins` **0.3.20 → 0.3.21** 已投放两实例。① **形态修正(09-15)**:由「新增 section」改为**并入既有「功能管理」section 内分组**(依据用户口径「不要分开管理」+ 档案 60 分区命名;**仅入口层合并、机制层分离**)→ 见 `T01 §四 决策 6` 与 **`T01 §九`**;② 实现:技能行(名称/来源/状态/事实/动作)+ zip 拖拽上传 + 同名两阶段替换 + 页内确认弹窗 + 锁定行(共享技能)无动作按钮 + zh/en 46 条词条;③ 验收:`npm run verify` 全绿(含新 `07-scripts/verify-my-skills.mjs` 34 条断言)、06 §7 三段式全绿、端到端 **19/19**(409 / 400 守卫通过);④ 阶段 4 收口 = 未新增 section + 未改角色补丁 ⇒ 可见性不变。⚠️ **后续(档案 101)**:该分区已改名「**能力管理**」并改为**页内 tab 分页**(功能插件 / 我的技能)。原述:门户 skills.html 仅 admin → 普通用户需实例内入口 |
| ✅ **已消解** | ~~摘除 guest 仍在启用的已下架插件 `@liustack/modlens`~~ | **09-13 07:0x 实测:已无需处理** —— guest `profile/web` 的 `bundles` **与 `node_modules` 均已无 `@liustack/modlens`**(原「已下架却仍启用」的不一致态已不复存在)。历史:该包 09-12 从候选池下架(档案 70 收尾)时曾留在 bundles |
| ❌已复核 | ~~Cookie 域收窄~~ | **不做(2026-09-12 用户决定:把这条待办删掉)** —— 共享 Cookie(`Domain=.alotbuy.com`)是**有意设计**:支撑「功能插件 ↔ 门户」**免令牌鉴权**(配合 `server.ts` 的 CORS 白名单),功能插件 v0.2.1 已生产跑通 → **收窄会破坏该链路,收益为零**;结论见档案 05 PoC-2 ② |
| 🟡 **待排期** | **T08 集群化的收尾项**(主体与生产切换已完成) | ① `join-worker.sh` 一键装机(现在 join = 装 unit + 起 agent + 注册,三步手工)~~② 隧道服务化~~ ✅ 已收口(本地 20s 定时器自愈,实测 30s 内恢复)③ 集中日志 / metrics ④ `smoke-domain` 定性 ⑤ drop-in 里的 PG 口令建议进一步收权限(现 root 可读)|详见 `05-交接单/T08 §16.5` |
| 🟡 **待排期** | **覆盖网络 / 客户端化这条线**(**本轮零代码、零服务器改动**) | 已落 **档案 103–112**(10 份规划转正式档案;入口 = 工作区根 `接续入口_覆盖网络线_20260916.md`)。📌 **第一件实事 = 把会合 / 中继从 Manager 里拆成可独立部署的组件**(现为绑在 Manager 上的**单中心 SSH 反向隧道**;所有后续多区域 / 多中心 / 骨干层的前置)。**已定口径**:只做技术实现(跨境合规由使用者自负)· **单机自用 ≠ 不需互联** · 按**异构**设计(中继 45% 设计 / 55% 留余量 + **必须补 443/TCP 兜底**)· 权威状态(归属 / 租约 / 资格)**必须单点控制面** · 游戏重点 = **MMORPG(2D/2.5D) / MUD / 传奇类**。⚠️ **唯一待拍板 = 骨干节点服务范围**(A 只服务自己名下设备 / B 服务全网;**倾向 A→B 渐进**,见档案 105 §七)。之后按 **档案 112「只做三件事」** 开工 |
### 历史决策记录(保留,均已落地)
1. **域名决策**(2026-09-08):无域名 → 先解决域名再回来做多用户改造 → 已落地:dsh.alotbuy.com + CF 通配 + 源站 443。
2. **选型决策**(2026-09-08):dshs 模式 A(account 硬隔离)优先,理由:现成密码注册/审核/网页桌面 + 每用户独立实例;替代(自研 Node 网关 + Docker 每用户、dsh-webui-auth 单实例锁)归档为备选。
3. **内存边界**:1.8G 内存 / 2 核 → 1-3 人规模(每用户一个常驻 DSH 子进程),扩容前不超 3 用户。
4. **镜像 v2 / std 技能库归档(2026-09-09)**:旧规划 19 章第十一节的「P1 镜像 v2:内置公共插件 + `std/agents/skills` 标准技能库(rank400 层)」「P1 系统标准技能库内容确认(MCN 脚本创作技能等随镜像/`std/` 分发)」——形态前提是「每用户一个 `dsh-web:<user>` Docker 容器,nginx 按子域/端口分流 + 容器内挂载 `/opt/dsh/std/agents/skills` 兜底层」。**已被 2026-09-08 决策 2(dshs 多租户形态)取代**,无需再做:
- 公共插件分发路径:**profile 层 `dsh plugin --profile <name> add/remove <tgz>`** 官方机制(pnpm 转发,cordis 补丁自动入 bundles);portal-entry v0.1.0→v0.4.3 走的就是这条。
- 标准技能库分发路径:**dsh 原生分层**(项目级 `.dsh/skills` rank100 / `.agents/skills` rank200 / 个人 `~/.dsh/skills` rank300) + 后续「管理员预置 profile-skel 与工作区种子」= 新形态下的 std 技能库等价物(原 P3「编导工作区模板」已于 2026-09-11 删除)。
- 原 dsh-web Docker 容器与镜像(`dsh-web:0.1.2` + `/opt/dsh/dsh-web-0.1.2.tar`)**已全清(2026-09-09)**——确认不回退,回退路径由 `/opt/dsh/backups/` 承担(db.bak 20260909_162739 + profile-web-admin-poc tgz + src-pre-reap tgz)。
- **结论**:原方案归档废弃,不再构建 dsh-web:0.1.x 增强镜像;后续若需"统一技能/插件基线"通过管理员对 profile 模板操作实现,不走镜像层。
---
## 附:红线(硬性,2026-09-09 版)
1. 禁止启动 dsh 时自动获取最新版本;版本升级走独立"升级测试→评估→修复"流程(档案 07)。
2. 不改官方 dsh 主程序与缓存(/usr/local/lib/node_modules/@deepseek-ai/dsh);扩展只走 profile 层 `dsh plugin` 机制。
3. client bundle 严禁 `exports.default`(loader ESM interop 取函数 → 无 inject → 注册静默失败;v0.4.0→v0.4.1 实证)。
4. 服务器 docs 只读归档(root 600);技能/文档同步类红线见各 skill MEMORY 约定。
@@ -0,0 +1,331 @@
# 短视频工作台 UI 规范
> **【本工作区约定 · 2026-09-10 入库 · 适用范围 2026-09-10 明确】**
> **地位**:**关键文档 / 强制基线**——**本工作区内我开发的一切前端页面必须遵循**本文档的 Token、组件与交互约定;冲突时以本文档为准。
> **适用范围(用户明确)**:**除「dsh 自带功能」与「dsh 自带插件页面」外,其余所有功能插件开发(含其 client 面 section)、门户插件、门户页面(desktop / admin / skills / plugins 等)一律参考本规范 + 使用 UI skill。** 即:dsh 官方 UI 与官方插件渲染的部分不在约束内(不改它们),我自己写的页面/插件全部在约束内。
> **权威源**:`E:\ProgramData\AI技能\mcn-short-video\project\短视频脚本创作\V1.0\mcn-work-shop\docs\工作台UI规范.md`(源侧更新后回拷本文件)
> ⚠️ **该路径 2026-09-12 实测已不存在**(glob 无结果;`E:\ProgramData\AI技能\mcn-short-video\` 目录仍在,其下已无 `工作台UI规范.md`)→ **本副本当前即唯一有效版本**。源侧若恢复,需重新建立回拷关系并补同步。
> **UI skill(按场景选对,用错场景等于没用)**:开发/改前端页面时**必须加载并使用对应技能**做视觉判断 ——
> - **`impeccable`** → **本工作区绝大多数页面**(工具类后台、设置面板、管理页、表单、空态)。**用 Operate 模式**。它的描述明确覆盖 settings / empty states / a11y / 信息层级 / 动效,与本规范同向。
> ⚠️ 它自带的机械检测器**未随包提供**(`07-scripts/detect.mjs` 会报 `bundled detector not found`)→ 改为**人工逐条过 `reference/craft-floor.md`** 的 Verify 清单与 Refuse 禁令(2026-09-12 实测的确抓出了真问题,见下)。
> - **`taste-skill`**(frontmatter name 为 `design-taste-frontend`)→ **仅用于落地页 / 作品集 / 整站重设计**。
> ⚠️ 它**自述「Not dashboards, not data tables, not multi-step product UI」** → **本平台的管理页 / 设置面板不在其射程内**,不要拿它做这类页面。
> - **冲突时以本文档的实测 Token 数值为准**(本文档是实测基线,技能是通用审美)。
> - 位置:`E:\ProgramData\.workbuddy\08-skills\<name>\`(2026-09-12 起两个技能已装入全局技能目录,并已过安全审计)。Skill 工具加载不到时**直接读文件**即可。
> **改文档规则**:UI 决策若伴随"为什么这样改",回写源侧文件并回拷,保持两端一致。
> **同步时间戳与回拷流程(档案 19 §D8)**:本文档是**回拷副本**,权威源见上文"权威源"路径。
> - 最近同步:**2026-09-10**(入库)→ 之后如有改动请在此追加一行日期;
> - **2026-09-12**:修订「UI skill」条款 —— 原表述要求"必须同时使用 impeccable + taste-skill",但 taste-skill 自述**不覆盖** dashboards / data tables / 多步产品 UI,与本工作区页面类型不符 → 改为**按场景分流**(impeccable 管本工作区绝大多数页面;taste-skill 仅限落地页 / 作品集 / 重设计)。
> - ⚠️ **本次未回写源侧**:权威源路径(`E:\ProgramData\AI技能\mcn-short-video\...\工作台UI规范.md`)在本机**不存在**(2026-09-12 实测 glob 无结果)→ 修订**只落在本副本**;源侧恢复后需补回拷。
> - 回拷流程:① 改源侧 → ② 回拷到本文件 → ③ 在上一行追加同步日期 → ④ 走文档库对账脚本确认双端一致
> (`bash 07-scripts/docs-sync-check.sh`,0 = 全绿)。
> **定位**:本文件是前端 UI 的**设计规范基线**(配色 / 字体 / 圆角 / 布局 / 组件 / 交互),供新增页面、调整样式时对照,避免重复踩坑。
---
## 1. 设计语言
亮色主题、工具类后台风格。三条主线:
1. **信息密度优先**——表格/列表是主体,行高紧凑(表格 td `7px 10px`,字号 15px)。
2. **克制配色**——蓝(`#2f6fed`)作主色承担交互反馈,橙(`#e8590c`)只用于强调数值/警示,其余靠灰度拉开层次。
3. **轻量质感**——圆角 8/12px、阴影 `0 1px 3px rgba(0,0,0,.08)`,不用重投影和渐变。
---
## 2. 设计 Token
### 2.1 色彩
| 变量 | 值 | 用途 |
|---|---|---|
| `--bg` | `#f5f6f8` | 页面底色 |
| `--panel` | `#ffffff` | 卡片/表格/弹窗面板 |
| `--border` | `#e3e6ea` | 统一描边 |
| `--text` | `#24292f` | 主文本 |
| `--dim` | `#6b7280` | 次要文本、说明、占位 |
| `--primary` | `#2f6fed` | 主色(按钮/激活态/链接 hover) |
| `--primary-soft` | `#eaf1fe` | 主色浅底(hover 行、激活 tab 底) |
| `--accent` | `#e8590c` | 强调(榜单得分、警示) |
| `--danger` | `#d93026` | 危险操作(删除) |
| `--shadow` | `0 1px 3px rgba(0,0,0,.08)` | 卡片/顶栏阴影 |
**扩展色(未进变量,直接写在规则里)**
| 场景 | 值 |
|---|---|
| 表头底色 | `#f6f8fa` |
| 链接蓝 | `#4a90e2` |
| 主色 hover 深一档 | `#2a5fd0` |
| 图标垂直微调 | `vertical-align: -0.18em` |
| 文件类型色 | md `#8250df` / json `#cf222e` / xlsx `#1a7f37` / txt·csv `#57606a` |
| 半透明主色底 | `rgba(47,111,237,.07)`(首页统计条)、`.16`(分隔线) |
**功能图标亮色映射**(未列出的跟随 currentColor):
`trending-up` 橙 · `users` 蓝 · `pen` 粉 `#ec4899` · `flask` 青 `#0ea5e9` · `lightbulb` 黄 `#d99a2b` · `refresh` 青绿 `#0f7f8f` · `chart` 玫红 `#d6336c` · `wrench` 灰 `#5b6470`
### 2.2 字体
```css
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
font-size: 16px; /* body 基准 */
```
代码/JSON 预览:`Consolas, monospace`。
**字号阶梯**(实测归纳,不是硬 token):
| 级别 | 字号 | 用例 |
|---|---|---|
| 特大 | 26px / 21px | 首页统计数字、面包屑主标题 |
| 标题 | 18px / 17px | 弹窗标题、区块标题、卡片标题 |
| 正文 | 16px / 15px | 正文、按钮、表格、输入框 |
| 次要 | 14px / 13px | 说明文字、数值列、徽章、tab(小) |
### 2.3 圆角 / 阴影 / 层级
| 类型 | 值 | 用例 |
|---|---|---|
| 圆角 | 6px | 小控件(btn-sm、侧栏切换组、树节点) |
| | **8px** | 默认(按钮、输入框、表格容器、信息卡、评分块) |
| | 10px | 徽章、榜单容器、卡片 |
| | **12px** | 大卡片(首页 data-card、func-card、统计条) |
| | 14px | 弹窗 |
| 阴影 | `var(--shadow)` | 卡片、顶栏 |
| | `0 8px 30px rgba(0,0,0,.18)` | 弹窗 |
| | `0 4px 12px rgba(47,111,237,.15)` | 卡片 hover(主色扩散) |
| z-index | 10 顶栏 / 100 弹窗遮罩 / 200 Toast | 表头 sticky 为 1 |
### 2.4 间距
没有 token 化,实际使用序列:**2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 24px**。常用组合:
- 卡片内边距 `16px 18px`;信息卡 `14px 16px`;表格单元 `7px 10px`;表头 `8px 10px`
- 页面外边距 `20px 24px`;首页/详情内容 `24px`;区块间距 14~24px
- 工具条内部 gap 8~10px;表单行 gap 10px
---
## 3. 布局框架
```
┌─ header.topbar(55px,sticky,白底 + 底部 1px 边框 + shadow,z-index 10)
│ brand(logo) · crumbs(面包屑) · top-actions(首页/帮助/设置,右侧)
├─ main#view height: calc(100vh - 55px) overflow: auto ← 唯一滚动容器
│ .home(max-width 960) / .page(max-width 1440)
└─ 弹窗层 .modal-mask(fixed inset 0,z-index 100)
```
**滚动(坑点,勿改)**
| 规则 | 原因 |
|---|---|
| `html, body { overflow: hidden }` | 消除顶栏 1px 边框引发的第二层滚动条 |
| `main#view { height: calc(100vh - 55px) }` | **55px 是实测值**(padding 20 + 行高 34 + 边框 1)。写成 54px 底部会溢出 1px,触发第二层滚动条 |
| 长内容滚动交给内部容器(`.rw-sec-body`、`.rw-content`、`.file-tree`) | 保持页面级不滚 |
**内容宽度**:首页 `.home` max-width **960px**;功能页 `.page` max-width **1440px**(padding 20 24);弹窗 `.rw-modal` 宽 `min(1600px, 100% - 100px)`、高 `calc(100vh - 60px)`。
---
## 4. 组件规范
### 4.1 按钮
| 类 | 外观 | 用例 |
|---|---|---|
| `.btn` | 白底 + border + `6px 14px` + r8 + 15px;hover 边框/文字变 primary;`:disabled` opacity .5 | 默认 |
| `.btn-primary` | primary 底白字;hover `#2a5fd0` | 主行动 |
| `.btn-ghost` | 透明底 + border | 次要/弹窗内 |
| `.btn-danger` | danger 系 | 删除 |
| `.btn-sm` | `3px 10px` / 14px / r6 | 表格功能列 |
| `.btn-icon` | `5px 9px`,纯图标 | 图标按钮 |
| `.btn-busy` | primary-soft 底 + primary 字 + opacity .8,内含图标旋转动画 | 异步进行中 |
| `.btn-import` | 白底 + `#d0d4da` 边 + `#495057` 字 | **保存类**(浅灰底深字) |
| `.btn-view` | 白底 + primary 边 + primary 字 | **查看类** |
> 功能列成对出现时:**保存=浅灰底深字(.btn-import),查看=白底蓝字(.btn-view)**,靠底色深浅区分而非都用蓝。
顶栏三按钮用 `.ghost`(白底 + border,`6px 14px`,r8)。
### 4.2 输入框
`.input`(`6px 10px`,min-width 200px)· `.input-select`(`6px 8px`,筛选下拉更窄)。均 1px border + r8 + 15px + 白底。
### 4.3 表格
```css
.table-wrap overflow auto + 1px border + r8 + 白底
.table width 100%, border-collapse collapse, 15px
.table th sticky top 0, 背景 #f6f8fa, 8px 10px, 600, nowrap, z-index 1
.table td 7px 10px, nowrap, 底部 1px border
tbody tr:hover 背景 var(--primary-soft)
```
| 修饰类 | 作用 |
|---|---|
| `.num`(td/th) | **右对齐** + `font-size: 13px` + `font-variant-numeric: tabular-nums`(等宽数字,必须加,否则数字跳动) |
| `.th-wide` | 数值列表头 min-width 90px + 右对齐 |
| `.sortable` | cursor pointer,hover 变 primary |
| `.cell-content` | max-width 220px + 省略号 |
| `.cell-loc` | 账号定位列缩窄 110px |
| `.th-act` / `.cell-act` | 功能列居中 |
| `.title-plain` | 视频标题:默认同正文色,**hover 才变蓝+下划线**(避免整列花掉) |
| `.link` / `.link.strong` | `#4a90e2`;`.strong` 加粗 |
**列宽策略(关键)**
| 场景 | 做法 |
|---|---|
| 榜单表格 `.table-rank` | **必须 `table-layout: fixed`**——auto 布局下 100% 表格的富余空间会按比例摊薄,**min-width 压不住**;fixed 下 width 才是硬约束,账号列自动吃剩余宽度 |
| 普通数据表格 | auto 布局,靠 `.cell-content` max-width + 省略号收口 |
**行高对齐(关键)**:榜单三个 tab 的行高必须统一,否则切换时整表跳动——
- 表格 `.table-rank td { height: 42px }`(功能列 `.btn-sm` 撑到 42px,其他 tab 原本只有 34.7px)
- 首页预览 `.rank-preview-list .rank-row:not(.rank-head) { min-height: 40px }`
### 4.4 Tab(两套并存,注意覆盖)
| 位置 | 样式 |
|---|---|
| 顶栏 | 胶囊式:r8,hover 浅蓝底蓝字,active **蓝底白字 + 600** |
| 数据页(**重新定义同名 `.tabs/.tab`**) | 下划线式:无背景无圆角,`border-bottom: 2px transparent` + `margin-bottom: -1px`(压住容器分隔线),active **蓝字 + 蓝色下划线** |
> ⚠️ 同名类二次定义,**数据页规则在后会覆盖顶栏规则**。新增页面若在下划线式区域用 `.tab`,拿到的是下划线式;需要胶囊式请另起类名。
榜单 tab `.rank-tab`:r8 8px 0 0(上圆角),active 浅蓝底 + 蓝字 + 底部 2px 蓝线。
首页预览里需 `align-self: center`(全局 `.rank-tabs` 用 stretch,会让 tab 组相对兄弟控件偏上约 5px)。
### 4.5 卡片
| 类 | 规格 |
|---|---|
| `.data-card`(首页入口) | r12,`16px 18px`,shadow,hover **上浮** `translateY(-2px)` + 蓝边 + `0 4px 12px rgba(47,111,237,.15)`,transition .15s |
| `.info-card`(信息区) | r8,`14px 16px`,margin-bottom 14px;标题 15px/600 + 底部 1px 分隔线 |
| `.recent-card`(脚本快捷卡) | r10,`14px 16px`,min-height **172px**(保证网格等高),hover 蓝边 + 淡影 |
| `.func-card` | r12,`16px 18px` |
### 4.6 徽章 / 标签
`.badge`:`2px 8px`,r10,13px。
- `.badge-signed` 浅蓝底蓝字(签约/内部)
- `.badge-ext` `#fff4e5` 底 + accent 橙字
- `.rw-ver-new` primary 底白字 r9 12px(最新版本标记,附在版本条最右)
- `.recent-card-score` 12px/600 r10
### 4.7 弹窗
```css
.modal-mask fixed inset 0, rgba(0,0,0,.35), flex 居中, z-index 100
.modal 白底 r14, 22px 26px, width 520px, max-width 92vw, shadow 0 8px 30px
.req-modal 760px × min(840px, 92vh) /* AI创作 */
.rw-modal min(1600px, 100% - 100px) × calc(100vh - 60px) /* 脚本对比/查看 */
```
> ⚠️ `.modal-mask[hidden] { display: none }` **必须显式写**——`.modal-mask` 是 `display:flex`,会覆盖 HTML `hidden` 属性导致弹窗关不掉。
**对比弹窗三段式(`.rw-*`)**:`.rw-stack`(flex row, gap 12)→ `.rw-sec`(flex 1, min-width 0, r8, overflow hidden)→ 内分 `.rw-sec-head`(8px 12px,白底,底部边框)/ `.rw-sec-body`(12px 14px,overflow auto)/ `.rw-sec-foot`(**min-height 46px**,右对齐,顶部边框)。
> `.rw-sec-foot` 的 min-height 是为了**空 foot(源脚本列占位)与其他列等高**,不能删。
### 4.8 Toast
固定在底部居中(`bottom: 32px`,`translateX(-50%)`),深底 `#24292f` 白字,`10px 18px` r8,z-index 200,**显示 1.8 秒后自动消失**。用于复制成功、保存结果等轻量反馈;失败重试类提示走行内 `.toolbar-note` / `.empty`。
### 4.9 空状态
`.empty-state`:纵向居中,`padding: 56px 20px`,gap 10px——图标(dim + opacity .45)→ 标题(17px/600)→ 说明(14px/dim)→ 行动按钮(margin-top 10px)。
轻量占位可用 `.empty`(`padding: 30px`,居中,dim)。
### 4.10 分页 / 批量
`.pager` 右对齐(gap 12,`padding: 12px 4px`);`.list-footer` 用 space-between 把批量操作条放左、分页放右;批量条 `.list-batchbar` 出现时与分页同行。
### 4.11 评分条
`.score-row`:名称 `.score-name` 固定 **88px** → 进度条 `.score-bar`(flex 1,height 6px,r3,底 `#eef1f5`)→ 分值 `.score-val` 固定 **36px** 右对齐。三档宽度固定,保证多行对齐。
### 4.12 图标
```js
svgIcon(name, size = 16, color) ;生成 class="ic" 的 SVG
```
- Lucide 风格:`viewBox 0 0 24 24`,`stroke-width: 2`,`fill: none`,`stroke: currentColor`
- 静态 HTML 用 `<i data-icon="home">` 占位,`initIcons()` 统一替换
- `.ic { vertical-align: -0.18em; flex-shrink: 0 }` ——**垂直微调 -0.18em,去掉会和文字基线错开**
---
## 5. 关键交互约定
| 约定 | 说明 |
|---|---|
| **数值一律右对齐 + 等宽数字** | `text-align: right` + `font-variant-numeric: tabular-nums`,列表头同步右对齐 |
| **字段对齐用 grid 列轨道共享** | 账号信息/视频详情的多行字段,用同一个 grid 让列跨行对齐;每行独立 flex 会因内容长短把后续字段顶偏(R44/R45 教训) |
| **超宽字段移出共享网格** | 如「账号定位」整行留在网格内会把 4 条轨道撑成等宽(各 232.5px),右侧大片留白 |
| **滚动联动单向** | AI 脚本列滚动驱动分镜列对齐,**不做双向耦合**(双向会互相抖动) |
| **长文本截断三件套** | `overflow: hidden` + `text-overflow: ellipsis` + `white-space: nowrap`,并挂 `title` 显示全文 |
| **hover 才提示可点** | 列表中的标题类文本用 `.title-plain`(默认同正文色,hover 变蓝+下划线),避免整列都是蓝字 |
---
## 6. 已知坑与规避
| # | 坑 | 规避 |
|---|---|---|
| 1 | `main#view` 写成 `calc(100vh - 54px)` → 底部溢出 1px,出现第二层滚动条 | 用 **55px**(padding 20 + 行高 34 + 边框 1) |
| 2 | 榜单表格用 auto 布局 → 列宽被富余空间摊薄,`min-width` 失效 | 加 `.table-rank { table-layout: fixed }` + 显式列宽 |
| 3 | 榜单三 tab 行高不一致 → 切换时整表跳动 | 表格 td 固定 42px;首页预览行 min-height 40px |
| 4 | `.modal-mask` 的 `display:flex` 覆盖 `hidden` → 弹窗关不掉 | 显式写 `.modal-mask[hidden] { display: none }` |
| 5 | 数值列没加 `tabular-nums` → 滚动/翻页时数字左右跳 | 数值列统一加 |
| 6 | 图标与文字基线错开 | `.ic` 的 `vertical-align: -0.18em` 不能删 |
| 7 | 首页 `.rank-tabs` 相对兄弟控件偏上 ~5px | 预览场景加 `align-self: center; margin-bottom: 0` |
---
## 7. UI 改动的生效链路与验收(2026-09-12 实战确立,**所有 UI 改动必走**)
> **起因**:v0.2.6 把「功能管理」列表改卡片后,**磁盘、服务端、实例三处都验过是新的,用户却一直看到旧 UI** —— 排查耗了 40+ 步。
> 根因不是"改错地方",而是**链路上有两道时序/缓存**:client bundle 只在**实例启动时**加载;而浏览器按 URL 里的 `rev` 决定要不要重新拉。
> ⇒ 为让**所有** UI 改动能被稳定验收,确立以下机制。
### 7.1 生效链路(改完到"用户看见",要过四关)
| 关 | 环节 | 关键条件 |
|---|---|---|
| ① | 改源码 → `npm pack` → 打包 | **必须升版本号** —— 同版本改内容会让 pnpm 判定"无需更新",白改 |
| ② | 铺到用户 profile(`ensure-biz-plugins.cjs` 等) | 改完立刻生效(**磁盘层**) |
| ③ | **实例加载 bundle** | **必须重启实例** —— client bundle 在**实例启动时**加载;不重启则实例内存里仍是旧代码 |
| ④ | 浏览器取到新代码 | 由 URL 里的 `rev`(**内容 hash**)决定;平台已加 `no-cache` 兜底 |
### 7.2 平台侧机制(2026-09-12 落地,**勿删**)
- **`Cache-Control: no-cache`**:平台代理层对实例的 `/plugins/` 响应统一加此头 —— **允许缓存但每次回源校验**,bundle 一变浏览器就能拿到新的。
- 实现位置:`src/supervisor/proxy.ts`,搜索 `平台缓存治理`
- ⚠️ `/assets/` 另有 nginx `location ~* ^/assets/` 的 `expires 30d` 会覆盖此头 —— 但 `/assets/` 是 dsh 构建产物(**文件名带 hash**),30d 合理,**无需处理**
- **`rev` 由 dsh 官方按内容计算**(`dsh-client-modules`:`framedHash("combo", [sourceBytes, sourceMap])`),平台**不得改**(红线 R2);且 dsh **会拒绝 `rev` 不匹配的请求**(官方注释:*mismatched revisions are rejected instead of serving newer bytes*)—— 这恰好成了"实例是否已换新"的**判据**。
### 7.3 验收口径(三段式,缺一不可)
| 查什么 | 怎么查 | 期望 |
|---|---|---|
| **磁盘** | `grep -c <新代码特征> <profile>/node_modules/<pkg>/lib/*.js` | ≥ 1 |
| **实例已加载** | 抓实例页 HTML,比对 URL 里的 **`rev`** | **与改前不同**(变了 = 实例加载的是新内容) |
| **服务端返回** | 用 HTML 里那条**完整** bundle URL 请求 | `HTTP 200` + 含新代码特征 |
### 7.4 四个常见误判(都是本次踩过的)
1. **拿单包 URL 请求** → 404(`/plugins/??` 只认**完整包列表**,不是单包)
2. **去服务器上 `/opt/dshs/poc/**` 的源码副本里 grep** → 那是**源码**、不是实例读取路径(实例读 `profile/node_modules`)→ 会误判成"根本没改"
3. **只看"实例是否重启过"** → 还要看 HTML 里 `rev` 是否真的变了
4. **取实例页 HTML 漏参数** → 必须 `curl -L --compressed -c jar -b jar -H "Accept: text/html"`(缺一即被 gzip 拦截,拿到空内容)
5. **client bundle 的 `inject` 写成数组** → **必须是函数**(原包写法 `inject: () => ({})`,返回「要注入的服务映射」对象)。写成数组 `["@deepseek-ai/dsh-client-runtime", …]` 会被 dsh 理解为「**在等这些服务**」⇒ 条目**永久 pending**:
```
web boot: 1 entry did not activate
<pkg>: pending (waiting for services: @deepseek-ai/dsh-client-runtime, …)
```
⚠️ **这个错只在浏览器里暴露**——服务端日志显示 `[xxx] loaded` 一切正常,`inject` 类型错误**完全看不出来**(2026-09-12 实测)。
6. **整合包的 client bundle 必须注册「包名自己」** → 拼接多个原包时,它们各自 `load({id: "<原包名>"})`,**包名本身不会自动注册**;dsh 按包名找 ⇒ 报 `loaded without registering "<包名>" via __ModuleLoader__.load`。
正确做法:子包的 `load` 改为「收集」,再由本包 `load({id: 包名, factory})` 统一注册,**并在 factory 内驱动子包 factory**(否则子包 UI 永不注册)。
@@ -0,0 +1,84 @@
# 07 · 实例 UI 分区登记表(settings.section 谁提供、源码在哪、能不能改)
- 日期:2026-09-15
- 触发:用户问「**为什么查代码要这么久 是不是反应 代码结构有问题或者没有 工程结构的索引文件**」——
当时为了搞清「设置面板里的『用户设置』是谁渲染的」,我在 6 个官方包 + 3 个自研包里逐个 grep,
绕了 ~30 次工具调用。**根因见 §三**(其中两条是真结构缺口)。
- 状态:✅ 表格为**现行值**;改动分区时**同步改本表**(与 `INDEX.md` 一样属于"要维护的入口")
> **本表只回答四个问题**:这个分区**id** 是什么 → **谁提供** → **源码在哪** → **我能不能改**。
> 单查某功能的实现细节仍去 `04-调整方案/`。
---
## 一、实例「设置」面板的分区总表(现行)
> **本表由 `node 07-scripts/find-ui.mjs --md` 生成**(扫的是**活着的 profile + 官方包**,不是仓里快照)。
> ⛔ **改分区 ⇒ 同一次改动里刷新本表**。下表的 label 是从代码里**解码**出来的(含 `\uXXXX` 转义形态)。
| order | id | label | 提供者 | 来源 | 源码 / 可改性 |
|---|---|---|---|---|---|
| 0 | `general` | 通用设置 | `@deepseek-ai/dsh-client-ui-settings-general` | 官方 | 官方包,⛔ **不可改**(R2);**官方语言切换就在这一页**(`settings.general.item`) |
| 10 | `models` | (官方 locale 键) | `@deepseek-ai/dsh-client-ui-settings-models` | 官方 | ⛔ 不可改;⚠️ **被角色补丁禁用**(本环境打开必报错,档案 87) |
| 15 | `plugins` | (官方 locale 键) | `@deepseek-ai/dsh-client-ui-settings-plugins` | 官方 | ⛔ 不可改;⚠️ 角色补丁禁用 |
| 20 | `agent-presets` | (内部无 label) | `@deepseek-ai/dsh-client-ui-agent-preset` | 官方 | ⛔ 不可改 |
| 100 | `model-settings` | 模型设置 | `business-plugins` | **自研** | 仓 `poc/business-plugins/lib/client.js` ✅ **可改** |
| 101 | `business-plugins` | **能力管理** | `business-plugins` | **自研** | 同上 ✅ 可改(原「功能管理」,档案 101 改名 + tab 分页) |
| 102 | `platform-admin` | 系统管理 | `business-plugins` | **自研** | 同上 ✅ 可改(**仅 admin**) |
| 103 | `user-settings` | **用户设置** | `portal-entry` | **自研** | 仓 `poc/portal-entry/lib/client.js` ✅ 可改(账号 / **界面语言** / 退出登录) |
> 附注:官方包的 label 走各自的 locale 词典(表里显示 `t("…")` 即"未在包内解析到"),
> 而**官方那 4 个我们本来也不该改**,所以不再深挖;真要读文案去该包 `README.zh.md`。
> `preferences`(偏好设置,自研)已于 2026-09-15 **撤除**(档案 102)。
## 二、定位一个 UI 分区的**最快路径**(照这个顺序,别再绕)
1. **先看"活着的实例里装了什么"**,而不是先翻官方包:
```bash
P=/var/lib/dshs/users/<uid>/home/profiles/web
ls $P/node_modules/@dsh-local/ # 自研 bundle
grep -l "settings.section" $P/node_modules/@dsh-local/*/lib/client.js # 谁注册了分区
```
⛔ 我当时的错:先去 `/usr/local/...` 官方树里找、再去 0.1.2 备份里找、又怀疑第三方插件 —— 最后才查 profile。
2. **官方包的根**(不是 profile):
`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/`
3. **搜中文 UI 文案必须同时搜两种形态**:
```bash
grep -rn "用户设置" . # UTF-8 字面量
grep -rn 'u7528u6237u8bbeu7f6e' . # \u 转义形态 ← portal-entry 就是这种,只搜上一条永远 0 命中
```
(可用 `python3 -c 'print(open("f").read().encode("unicode_escape").decode())'` 反推转义形态。)
4. **别被副本误导**(三处都有同名不同版本的旧拷贝):
- `dsh-server-docs/04-调整方案/poc/*` = **历史快照**(如 portal-entry 停在 0.5.1);
- profile 的 `node_modules/.pnpm/` 里堆着 `@dsh-local+business-plugins@…0.2.3/0.3.4/0.3.8/0.3.11` 等历史 tgz;
- `/opt/dsh/backups/` 下有升级前的整套官方包(如 `dsh-0.1.2-rc.1`)。
⇒ **判"现行"只认两处**:仓内 `poc/<name>/` + profile 里 `node_modules/@dsh-local/<name>/`。
---
## 三、为什么会绕这么久(诊断结论)
| # | 原因 | 性质 | 已做的处置 |
|---|---|---|---|
| 1 | 标签用 **`\uXXXX` 转义**存(`"\u7528\u6237\u8bbe\u7f6e"`),按 UTF-8 搜永远 0 命中 | **代码侧小缺陷**(可维护性) | 记入本表 §二 第 3 条;⛔ 新增文案**建议直接写中文**(官方 bundle 亦混用,但可 grep 的优先) |
| 2 | **源码没入仓**:`portal-entry` 不在 `poc/` 里,线上只有 tgz ⇒ 只能从产物反推 | **真结构缺口** | ✅ 已把 0.5.3 源码补进 `poc/portal-entry/`;**约定:产物只从仓内源码构建** |
| 3 | **没有"哪个包提供哪个 UI"的索引**,id/order/label 散在 9 个包里 | **真结构缺口** | ✅ 本文件(`01-规范/07-实例UI分区登记表.md`) |
| 4 | 集群切换后"实例侧实测"变难(新用户落 `w-106`、47→106 无 SSH 路由) | 环境复杂度 | 见 `PLAYBOOK §9.1`;新用户落点见 `03-路线图` |
---
## 四、维护约定
- **改任何分区(增/删/改名/调 order)⇒ 同一次改动里更新本表**;⛔ 不要只改代码。
- 新增自研 bundle 时,**源码必须落在仓内 `poc/<name>/`**,并在本表登记。
- 本表**不写版本号**(会漂)—— 版本去 `poc/<name>/package.json` 与 `/opt/dsh/artifacts/` 看。
## 五、长效机制(2026-09-15 建,针对 §三 的三条根因)
| 根因 | 机制 | 怎么用 |
|---|---|---|
| 找不齐「谁提供哪个 UI 分区」 | **`node 07-scripts/find-ui.mjs [关键词] [--md] [--grep]`** —— 扫活 profile + 官方包,一次给全:id / order / label(**含转义解码**)/ 提供者 / 源码路径 / **能不能改** | 改任何实例 UI 前**先跑它**(本来要 30 次 grep 的事 → 1 条命令) |
| 中文文案搜不到(`\uXXXX` 转义) | 由 `find-ui.mjs` 内建(**两种形态都搜**);`PLAYBOOK §9.2` 也记了反推转义的方法 | 手写 grep 时记得两种形态都试 |
| 自研 bundle 源码不在仓里 | **约定:`poc/<name>/` 是唯一源码位置,产物只从仓内源码构建**;`portal-entry` 已补入 | 发现某自研包不在 `poc/` ⇒ 先从部署产物取出入仓,再改 |
| 改完忘了同步断言 / 文档 | `npm run verify`(含 `verify-my-skills` / `verify-portal-entry` 的结构断言)+ **本表** | 每次改动收尾跑一次 |
| 打包把上一版 tgz 打进去(0.2.5 / 0.5.4 两次同坑) | 各包 `.npmignore` 排除 `*.tgz` + `verify-portal-entry.mjs` 里的机械断言 | 已有防线,新包照抄 |
@@ -0,0 +1,216 @@
# 08 · 插件开发与对接规范
> **读者**:写插件的会话(自研 / 第三方 / 端侧同源)—— 提案、开发、交付时都按本文。
> **维护方**:插件投放与分库线(平台侧)|**落文** 2026-09-23|**长期维护**(跟改本文件即可)
> **上位权威(冲突时以它们为准)**:`03-数据库/DB-03-插件数据面规范.md`(数据面)· `05-交接单/插件投放与分库线-01共享只读包库与插件数据面.md`(投放链路)· `DEPLOY-本部署.md §6.4`(建库权限)
> **本文只写「插件侧必须照做的契约」**;平台内部实现(怎么物化、怎么路由)不在此复制。
---
## §0 一句话
插件只做三件事:**把声明放对位置**(可选)、**用一个函数取目录**、**把包交上来**。
其余全部在平台侧 —— 装到哪台机、怎么装、库怎么建、凭据怎么投,插件**一概不管、也不许假设**。
## §1 开工四问(先答,答案决定后面走哪条路)
| # | 问题 | 怎么判 | 结论 |
|---|---|---|---|
| 1 | 有「使用中数据」要持久化吗? | 换个实例/重启后必须还在的数据 | **有** ⇒ 走 §3;**无** ⇒ **一个字都不写**(§3-0) |
| 2 | 有文件产物吗? | 缓存在实例本地、丢了可重建 | 落 §4 目录;⛔ 不用 `homedir()` |
| 3 | 需要凭据吗? | 连外部服务要 token / key | 与 §4 同目录、**平台或运维投递**,插件**只读** |
| 4 | 装到哪台机? | —— | **平台决定**(Manager / worker 都可能)⇒ ⛔ 插件不得写死本机路径 |
## §2 包形态(硬要求)
- **打包**:`npm pack` 出 `.tgz` 交付;包名用 scope 形态(现网为 `@dsh-local/<name>`)。
- **`package.json` 三块**(`dsh` 字段),缺哪块就没有对应能力:
```json
{
"name": "@dsh-local/dsh-plugin-<name>",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web", "inject": [] },
"data": { "schema": "./dsh.data.yaml" }
},
"peerDependencies": { "@deepseek-ai/dsh": "^0.1.5-rc.1" },
"dependencies": {}
}
```
- `dsh.bundle.patch` / `dsh.client` 与既有插件同族;`dsh.data` **仅当有数据面时写**(§3)。
- **依赖**:`dependencies` 尽量留空;⛔ 不许把数据库驱动(`pg` / `better-sqlite3` / `node:sqlite`)、Redis / MQ 客户端打进包。
- **体积与内存**:实例**硬顶 1024 MiB**、V8 堆按配额推导 ⇒ ⛔ 不引入重依赖、不常驻大内存结构。
- ⛔ **不许改 `@deepseek-ai/dsh-*`**(平台红线 R2,官方核心零改动);⛔ 不许要求平台"给某用户铺 profile"(投放只有候选池 → 用户自助启用一条路)。
## §3 数据面(**有持久化数据才看本节**)
### §3-0 第一步:先判「有没有」
**没有使用中数据**(业务数据在远端系统 / 只做工具面转发 / 凭据由平台投递)⇒ **不要在 `package.json` 里加 `dsh.data`,也不要放 `dsh.data.yaml`**。
三条后果必须知道:
1. 无 `dsh.data` ⇒ 内核判 `level:'none'` ⇒ **数据面门禁直接放行**,插件可正常发布与启用。
2. 若写一个**空声明**(`tables: []`)⇒ 判 `invalid` ⇒ 状态**恒为 `blocked`**,**永远发不出去**。
3. 只在包内放一个 `dsh.data.yaml` 但 `package.json` 里不引用它 ⇒ **根本不会被读取**(平台**不自动探测**该文件)。
### §3-1 声明放哪(唯一入口)
**唯一入口 = `package.json` 的 `dsh.data.schema`**,两种写法等价:
- **内联对象**:`"schema": { "schemaVersion": 1, "tables": [ … ] }`
- **包内路径**:`"schema": "./dsh.data.yaml"`(字符串,只接受包内 `.yaml` / `.yml` / `.json`;⛔ 不许 `../` 逃出包)
> ⚠️ **口径澄清(2026-09-23)**:`DB-03 §三` 与平台回复里说的「插件在 `im.data` 上声明表」,指的是**运行时对象的措辞**;**声明的存放位置**只认 `dsh.data.schema`(源码 `src/db/plugin-data/schema.ts:701-746` 现读)。按本文写,不要按别的写法试。
### §3-2 声明模板(可复制)
```yaml
schemaVersion: 1 # ≥1 的整数;库结构一变就 +1(只增不减)
tables:
- name: character_sheet # 实际表名 => p_<pluginId>_character_sheet
scope: room # room | user(必填,归属列强制)
columns:
- { name: pc_name, type: text, notNull: true, maxBytes: 128 }
- { name: sheet, type: json, notNull: true, maxBytes: 16384 }
- { name: sheet_rev, type: integer, default: 1 }
indexes:
- { columns: [pc_name], unique: true }
```
### §3-3 校验器实际会拒的东西(照抄避免返工)
| 项 | 规则 |
|---|---|
| `schemaVersion` | 必须 **≥1 的整数** |
| `tables` | **1 ~ 20** 张;表名 `^[a-z][a-z0-9_]{0,40}$`;⛔ 不许重名 |
| `scope` | **必填**,只能 `user` 或 `room`(缺 ⇒ 拒) |
| `columns` | 每表 **1 ~ 20** 列;列名同表名正则;⛔ 不许重名 |
| 类型 | 仅 9 种中性类型:`text` `bigtext` `integer` `bigint` `real` `boolean` `json` `timestamp` `uuid` |
| `json` 列 | `maxBytes` **必填** |
| `indexes` | 可选;`columns` 必须是**本表已声明**的列 |
| `pluginId` | ⛔ **不要在声明里写** —— 由**包名**推导(去 scope、小写、非 `[a-z0-9_]` 折 `_`),写了也不生效 |
**内核自动补齐**(⛔ 不用也不能自己写):库名 `dshs_pl_<pluginId>`、表名前缀 `p_<pluginId>_`、**归属列**(`user_id` / `room_id`)、审计列 `created_at` / `updated_at`。
### §3-4 运行时读写(SDK 面)
- 读写一律走内核数据 API:`im.data.table<T>(name).insert / update / delete / find / count`;
- 同库内多表原子:`im.data.tx([...])`(⛔ **跨库无事务**,PG 跨 database 没有原生事务);
- 归属由内核强制:`scope: user` 只能读写**自己**的行;`scope: room` 复用房间成员判定。
### §3-5 迁移:只增不减
- **允许**:加表 / 加列(带默认值)/ 加索引 ⇒ `schemaVersion` +1。
- **⛔ 一律拒**:删列 / 改类型 / 重命名 ⇒ 迁移预演 `executable=false` ⇒ 执行返回 **`409 plan_blocked`**,库**一字不动**。
- 🔴 **两个版本号是两件事**:包 `version`(代码版本)与 `schemaVersion`(库结构版本)—— 判「库该不该动」**只看 `schemaVersion`**。
- 平台在**非首次建库**的迁移前会先做 `pg_dump --schema-only` 结构备份;备份失败 ⇒ **不执行迁移**(`500 schema_backup_failed`)。
### §3-6 红线(违反即不予启用)
⛔ 不直连数据库|⛔ 不写原生 SQL(含触发器/存储过程)|⛔ 不访问别的插件库|⛔ 不自组跨库事务|⛔ 不绕过归属判定|⛔ **不以实例本地库 / 本地文件当数据权威**(可当缓存,权威必须在插件库)。
**机器判据**(提包前自己跑,必须零命中):
```bash
grep -rE "CREATE TABLE|ALTER TABLE|DROP TABLE|better-sqlite3|from 'pg'|require\('pg'\)" <包目录>
```
## §4 文件落点(D4)
**落点 = `$DSH_HOME/.dsh/plugins/<pluginId>/`**(平台侧同一绝对路径 = `/var/lib/dshs/users/<uid>/home/.dsh/plugins/<pluginId>/`;实例内**不重映射**)。
**取目录只有一种姿势**:
```js
const base = process.env.DSH_HOME // 平台注入,= <userRoot>/home,必存在
if (!base) throw new Error('DSH_HOME 缺失:无法定位插件数据目录') // fail-fast
const dir = join(base, '.dsh', 'plugins', 'dsh_plugin_<name>') // <pluginId>,与库名同一个 token
```
- ⛔ **禁止 `homedir()` 回退**:实例内 `HOME` = `<userRoot>/ws`,而 `ws` 会被平台按产物清理 ⇒ 回退不是"降级可用",是**把数据写到会被清掉的位置**。
- ⛔ **不要去找 `im.paths.pluginData()`** —— **该 API 不存在**(2026-09-23 实测:源仓 `src/`、宿主仓 `deepseek-harness`、`node_modules` 三处零命中)。
- **目录自己建**:插件首次用时 `mkdir -p`(幂等)。平台侧**不碰**该目录(用户 home 的托管文件名白名单只有 3 个裸文件名)。
- **凭据 / 投递类文件与插件数据同目录**(同一清理单元)、文件 `0600`、属主 = **该用户 uid**;插件**只读**,⛔ 不覆盖投递来的文件名。
- ⚠️ **跨机**:`<userRoot>` 是**那台机上的**路径。用户实例在 worker 上时,在管理机写 `<userRoot>/home/…` = **静默空操作**(平台已踩过)⇒ 投递必须发生在**实例所在那台机**。
- **实例内其他已知事实**:共享包库 `/var/lib/dshs/bundled-plugins/` 是**只读**挂载(`touch` ⇒ `Read-only file system`);`HOME` = `<userRoot>/ws`、`DSH_HOME` = `<userRoot>/home`。
**`pluginId` 速查**:`@dsh-local/dsh-plugin-carbon` ⇒ `dsh_plugin_carbon`(⛔ 不是 `carbon`)。目录名与库名后缀**同一个 token**。
## §5 投放链路(谁做什么)
```
admin 上传 tgz
└─ 平台侧物化:解包 → chown -R root:root → pnpm install --prod(store 在共享层内)
⇒ /var/lib/dshs/bundled-plugins/<扁平包名>/ ← 节点级「共享只读包库」(D2)
└─ 检测三项:安全 + 兼容 + **数据面声明校验** ⇒ 通过 = pending
└─ admin 点 [建库](仅"有数据面"的插件需要)⇒ CREATE DATABASE dshs_pl_<pluginId>
└─ [发布] ⇒ 用户才能启用
用户(任意登录用户)在实例「功能管理」点启用
└─ POST /api/plugins/mine/apply ⇒ 路由到实例所在那台机
└─ worker: POST /plugins/apply ⇒ profile 写依赖引用 "pkg": "link:<共享层绝对路径>"
└─ pnpm 建 node_modules/<pkg> 链接 ⇒ 共享层**实体一份**、用户侧只有链接
```
**插件侧要做的只有**:把包交上来(并说明"有无数据面""是否需要凭据")。⛔ 上传 / 建库 / 发布 / 启用**都不是插件侧动作**。
**D2 判据**:启用后用户侧 `node_modules/<pkg>` 必须是**链接**、用户目录下**零实体**(`link:` 协议,2026-09-23 落地)。
⚠️ **只进池不发布 ⇒ 用户启用必撞 `plugin_bundle_missing`**(装配目标就是共享层)—— 这是平台侧操作顺序问题,插件侧只需知道这个错误码含义。
## §6 错误码表(看到什么该判给谁)
| 错误码 / 现象 | 含义 | 归属 |
|---|---|---|
| `plugin_bundle_missing` | 包不在共享层(没上传/没发布/该节点未同步) | **平台侧**(含跨节点内容分发缺口) |
| `datastore_not_ready` | 有声明但未建库 / 版本不满足。⚠️ **无数据面插件不该出现它** | 平台侧(admin 未建库) |
| `invalid_plugin_db_name`(400) | 库名不合规(正常不该发生,由包名推导) | 平台侧 |
| `409` 检测不过 | 安全 / 兼容 / 声明校验未过(**声明非法会在一次响应里列全**) | **插件侧**(改声明或改包) |
| `409 plan_blocked` | 迁移含删列 / 改类型 / 重命名 | **插件侧**(改 schemaVersion 策略) |
| `500 schema_backup_failed` | 迁移前结构备份失败 ⇒ 未执行迁移 | 平台侧(运维) |
| `PG_CREATEDB_MISSING`(503) | 平台未授 `CREATEDB` | 平台侧(运维,见 `DEPLOY-本部署.md §6.4`) |
| 插件在实例内 `EACCES` / `EROFS` | 写到只读区(共享层)或路径不对 | **插件侧**(照 §4 取目录) |
## §7 提包前自测清单(插件侧自己跑完再交)
1. `grep` 红线机器判据 **零命中**(§3-6)。
2. 有数据面 ⇒ 声明通过 §3-3 全部规则;无数据面 ⇒ **确认 `package.json` 里没有 `dsh.data`**。
3. `npm pack` 后 `tar -tzf <tgz> | head` 确认包根有 `package.json`、`lib/`、`cordis.patch.yml`(有 client 的还有 `lib/client.js`)。
4. `dsh` 三块字段形态与 §2 一致(`bundle.patch` / `client` / 按需 `data`)。
5. 交付时**一并带上**:tgz + `sha256` + 声明快照(或"无数据面")+ 上述自测输出。
## §8 端侧边界
端侧(桌面壳 / 浏览器)访问**同一个平台 API**:
- ✅ 只保证「能看见 + 能开通」;⛔ **不要求端侧下载或安装插件包**。
- ⛔ **不下发平台级凭据到客户端**(客户端只持有该用户自己的会话)。
- ⛔ 端侧**不落插件数据**;插件数据在服务端该用户的主目录与插件库。
## §9 回报格式(交给平台侧 / 对接方时)
必须带齐,否则平台侧无法定位:
1. 包名 + 版本 + `sha256`;
2. 「有数据面 / 无数据面」明确写一句;有则附声明快照;
3. 失败时**把错误码与文案原样贴回**(⛔ 不要转述、不要只写"失败了");
4. 涉及的用户 / 实例标识(userId、hostId),以及**看到现象的时间点**;
5. 自测清单(§7)的输出。
## §10 别做清单(一次看全)
⛔ 改 `@deepseek-ai/dsh-*` ・⛔ 要求平台铺 profile ・⛔ 自己上传 / 建库 / 发布(那不是插件侧动作)・⛔ 走 handoff / 打开 `DSHS_ENABLE_PATCH` ・⛔ 扩大权限面 ・⛔ 引入外部服务进程(Redis / MQ)・⛔ 写裸 SQL / 直连 DB ・⛔ 以本地库为权威 ・⛔ 用 `homedir()` 拼路径 ・⛔ 提交空数据面声明 ・⛔ 假设实例在哪台机。
---
## §11 变更记录
| 日期 | 变更 | 依据 |
|---|---|---|
| 2026-09-23 | 首版:包形态 / 数据面(含"零数据面不要写空声明"与声明唯一入口澄清)/ 文件落点 D4 / 投放链路 / 错误码 / 自测清单 / 边界 | 现读源码 `src/db/plugin-data/schema.ts`、`src/web/routes/business-plugins.ts`、`src/fs/user-fs.ts`、`src/supervisor/orchestrator.ts`;`DB-03`;carbon 线两问裁定(`dsh-plugin-carbon/对接文档_carbon插件-平台侧两处阻塞_20260922.md §L`) |
@@ -0,0 +1,134 @@
# IM 插件 SDK 与扩展点契约
> 面向要给 IM 房间加场景能力的插件作者。内核只提供通用地基,**场景差异全部下沉到这里**。
> 依据:`05-交接单/IM群组-04-插件SDK与扩展点契约.md`|需求基线:`04-调整方案/142-IM群组对话-需求基线与方案.md §3.6`
## 1. 这是什么
IM 房间内核(`src/im/{types,db,store,clock,hub,ws}.ts`)只定义**消息信封**(`id` / `seq` / `ts` /
作者 / `via` / `visibility`)与房间生命周期。办公群的任务卡、MUD 的指令流、跑团的回合制,
**都不写进内核** —— 写法是注册一个插件,声明它要用到哪些扩展点。
SDK = `src/im/sdk/`:
| 文件 | 内容 |
|---|---|
| `types.ts` | 七个扩展点的契约类型 + 两档传输 + 数据面声明 |
| `host.ts` | 宿主实现:注册面、超时熔断、预算继承、声明式建表、审计 |
| `index.ts` | 对外 Facade(插件 `import` 这一个就够) |
## 2. 七个扩展点
| # | 扩展点 | 契约 | 一句话 |
|---|---|---|---|
| 1 | 房间类型注册 | `RoomTypeContract` | 声明 `room_type` + 默认配置 + 走哪一档传输 |
| 2 | 消息载荷 | `PayloadContract` | 插件自己定义 `payload` 结构与校验 |
| 3 | 成员角色 | `RoleContract` | 业务角色(DM / 玩家 / 负责人);⛔ 不提升内核权限 |
| 4 | 发言规则 hook | `SpeakRuleContract` | 自由并发 / 回合制 / 限速 |
| 5 | 能力机器人 | `BotContract` | 注册 bot + @ 路由(形态 A 的来源) |
| 6 | 事件订阅 | `EventSubscriber` | **拨出式**:宿主调插件,⛔ 插件不开监听端口 |
| 7 | 房间内 UI | `PanelContract` | 声明面板并挂 dsh 既有 slots |
**无插件 ⇒ 默认自由并发**:不注册发言规则时,房间行为与改造前一致,⛔ 不报错。
## 3. 三条平台保护约束
写在契约里、由宿主强制执行 —— 不是文档约定:
1. **插件不直连 DB**:要读写数据只能经注入的 `ImDataPort`(`insert` / `update` / `remove` /
`find` / `count`)。插件侧代码里出现 SQL 即违约。
2. **插件故障不拖垮房间**:每个扩展点**必须**声明 `timeoutMs` + `circuit`(缺一注册不通过)。
回调挂起 ⇒ 超时记失败并把该次交给内核默认行为;连续失败 ⇒ 开熔断,冷却期内不再调用插件。
3. **插件能力吃预算**:发言判定走**内核**提供的 `ImBudgetPort`。插件只能申请,⛔ 不得自带一套限速。
## 4. 写一个插件
最小形状 = 一个 `manifest` 对象 + 一个 `apply(ctx, host)`:
```js
export const manifest = {
pluginId: 'im_plugin_demo', // = 包名去 scope、小写、连字符转下划线
label: '示例',
packageName: '@dsh-local/im-plugin-demo',
version: '0.1.0',
roomTypes: [{
type: 'demo', label: '示例房',
transport: 'chat', // 或 'realtime'
defaultConfig: { topic: '' },
timeoutMs: 50, circuit: { threshold: 5, cooldownMs: 30_000 }, // 🔴 必填
}],
payloads: [{
kind: 'ping',
validate: (p) => typeof p?.text === 'string',
timeoutMs: 50, circuit: { threshold: 5, cooldownMs: 30_000 },
}],
tables: [{
name: 'pings', scope: 'room', schemaVersion: 1,
columns: [{ name: 'text', type: 'text' }], // 归属列 room_id / user_id 由内核自动补
}],
}
export function apply(ctx, host) {
host.register(manifest)
ctx?.on?.('dispose', () => host.unregister(manifest.pluginId))
}
```
打包与投递**复用既有通道**(`cordis.patch.yml` + `package.json#dsh` → 门户候选池 →
实例「能力管理」自助启用)。⛔ SDK 不新造分发机制。
## 5. 两档传输
| 档 | `fanoutBatch` | `heartbeatMs` | `maxJitterMs` | `latencyBudgetMs` |
|---|---|---|---|---|
| `chat`(档案 107 口径) | 200 | 25 000 | 1 000 | 1 000 |
| `realtime`(档案 108 游戏口径) | 50 | 5 000 | **20** | 100 |
房间类型声明自己走哪一档;**两档参数不同源**,⛔ 不是同一份参数改个名。
未声明 ⇒ 回落 `chat`(⛔ 不默认给实时档)。
## 6. 数据面
**判落点,再谈建表**(`03-数据库/DB-03-插件数据面规范.md`):
| 数据种类 | 落点 | 归属列 |
|---|---|---|
| 跟着**房间**走的(角色卡 / 地图 / 任务卡) | 该插件自己的库 `dshs_pl_<pluginId>`,表 `p_<pluginId>_*` | `room_id` |
| 只属于**某用户自己**的 | 同一个库 | `user_id` |
| **缓存 / 临时**(丢了可重建) | 实例本地 | — ⛔ 不得当权威 |
规则:
- **建表 = 声明式**:字段只用中性类型(`text` / `integer` / `bigint` / `real` / `boolean` /
`json` / `timestamp` / `uuid`),内核翻译成 sqlite + pg 两套 DDL。⛔ 禁裸 `CREATE TABLE`、
厂商专有类型、触发器、存储过程、跨前缀 `JOIN`。
- **迁移只增不减**:`schemaVersion` 必填;只允许加列(带默认值)/ 加表 / 加索引。
任一步失败 ⇒ **拒绝启用**(⛔ 不做半迁移)。
- **访问必走内核 API**:房间维度**自动注入 ACL**(复用 `canSee`);跨前缀 / 内核表
**看不到也查不了**;事务仅限同前缀多表。
- **卸载数据默认保留 30 天**;`DROP TABLE` 不可逆 ⇒ 需 admin 显式确认 + 先出受影响清单。
## 7. 三个示例插件
`poc/business-plugins-im/` 下三类场景各一个,可直接照抄:
| 目录 | 包名 | 类型 | 演示的扩展点 |
|---|---|---|---|
| `office-tasks/` | `@dsh-local/im-plugin-office` | `office`(聊天档) | 任务卡载荷 + 面板 + 事件落库 |
| `mud-rate/` | `@dsh-local/im-plugin-mud` | `mud`(**实时档**) | **限速**发言规则 + 指令流载荷 |
| `trpg-turn/` | `@dsh-local/im-plugin-trpg` | `trpg`(**实时档**) | **回合制**发言规则 + 业务角色 + 骰子载荷 |
## 8. 怎么验
```bash
export PATH="E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3:$PATH"
npm run build
node --test test/im-sdk.test.mjs # 期望:退出码 0(66 用例全过)
git diff -- src/im/store.ts src/im/hub.ts src/web/routes/im.ts # 期望:无输出
```
最后一条是**硬判据**:非聊天场景跑通**不改内核**(142 §五-6)。若必须改内核 ⇒ 扩展点没设计对,
回头改扩展点设计,⛔ 不是改内核。