Files
dsh_shenxian/dsh-server-docs/04-调整方案/65-功能插件启停与web-provider配置联动.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

126 lines
8.3 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.
# 65 · 功能插件启停与 web provider 配置联动
- 日期:2026-09-12
- 触发:用户决定 **AnySearch 接入改走候选池统一机制**("有问题解决问题"),而候选池的启停会打穿 profile 层的 provider 硬绑定
- 结论一句话:**把「插件启停」与「dsh-web 的 provider 选择」从两件互不知情的事,变成一件由平台统一维护的事** —— 启用时写死插件声明的 searchProvider,禁用时整段删除,回到「只有一个可用」的自动选择。
- 状态:✅ **已部署并生效**(服务器 `lib/` 2026-09-13 00:13 构建 → **00:15:23 重启即已载入**;08:02 随档案 78 部署再载入一次)
> **TL;DR**|**结论**:候选池「禁用即坏」的根因不是候选池,而是 profile 层那段**硬绑定**的 provider 覆写 —— 插件一被 `pnpm remove`,配置就指向了不存在的 provider(`CONFIGURED_MISSING`,且 dsh 不回落)。
> **解法**:平台在 `install`/`uninstall` 后按**当前 bundles 重算**托管段,让两态都正确。
> **状态**:✅ 代码完成并已用真实数据验证两态(§七),**待部署**
---
## 一、问题
dsh 的 web provider 选择语义(源码实证):
- **显式配置即硬绑定** —— provider 不在了就报 `WEB_PROVIDER_CONFIGURED_MISSING`,**不回落**
- 未配置时**只有「恰好一个可用」才自动选**,多个可用直接 `WEB_PROVIDER_AMBIGUOUS` 报错
而候选池的禁用是 **`pnpm remove`(真卸载)**,于是:
```
禁用插件 → bundle 移除 → 插件自带 patch(searchProvider: anysearch)不再加载
→ 但 profile 覆写段仍在,仍写死 searchProvider: anysearch
→ 指向未注册的 provider → CONFIGURED_MISSING(搜索直接坏,且用户修不了)
```
**三种静态写法都有坏格子**(详见档案 64 §九.3):写死→禁用即坏;不写→共存即歧义;禁 deepseek→禁用后 0 个可用。
## 二、方案
把 provider 配置从**静态**改成**随 bundles 动态重算**:
| 状态 | profile 托管段 | 结果 |
|---|---|---|
| 插件已启用 | `searchProvider: <插件声明值>` + `fetchProvider: http` | ✅ 走 AnySearch,本地抓取保留 |
| 插件已禁用 | **整段删除** | ✅ 只剩 deepseek 一个可用 → 自动选中 |
**平台策略(本档定)**:`fetchProvider` **恒为本地 `http`**,**忽略**插件对 fetch 的声明 —— 这是「保留本地抓取 + SSRF 防护」这条决策的落地位置。插件若声明了别的 fetch provider,不采纳。
## 三、实现
**位置**:`src/web/routes/business-plugins.ts`
| 新增 | 职责 |
|---|---|
| `collectWebProviderClaims(dir, bundles)` | 扫描已启用 bundle 自带的 `cordis.patch.yml`,只取 `- id: web` 条目下 `config.searchProvider` 的**声明值**(跳过 `@deepseek-ai/*` 官方骨架;正则行扫描,不引入 YAML 依赖) |
| `syncWebProviderPatch(dir)` | 幂等重算:先摘掉旧托管段(含历史遗留的 `platform: anysearch-search`),再按当前 bundles 追加 |
**调用点**:`install()` 与 `uninstall()` 里紧接 `reconcileBundles(dir)` 之后 —— 保证「bundles 已定 → 配置随之重算」的因果顺序。
**托管段标记**(用 marker 包裹,便于幂等替换与人工识别):
```yaml
# >>> platform: web-provider (managed by business-plugins)
# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。
- id: web
config:
searchProvider: anysearch
fetchProvider: http
# <<< platform: web-provider
```
**与既有机制的关系**:
- 与档案 34 的 **profile 快照/还原**天然兼容 —— `SNAPSHOT_FILES` 本就含 `cordis.patch.yml`,探活失败回滚会把这段一起还原。
- 与档案 16 的三层归属模型**不冲突**:这是「功能插件」层内部的实现细节,不新增归属层。
## 四、为什么不让插件自己声明得更明确(备选方案否决记录)
| 方案 | 否决理由 |
|---|---|
| 插件 `package.json` 加 `dsh.webProviders` 字段 | 第三方插件(AnySearch)**不会**按平台的自定义约定声明,等于白设 |
| 平台代码里硬编码插件名 → provider 映射 | 每接一个 provider 插件就要改平台代码;且与「插件自带 patch 已声明」的事实重复 |
| **读插件自带 patch 推导(采用)** | 零配置、通用、自动跟随插件升级 |
## 五、待办与迁移
1. **部署**:`npm run build` → scp `lib/` → `systemctl restart dshs`(**会中断在线用户,R8 需先知会**);
2. **投放候选池**:把 `anysearch-dsh-0.1.4.tgz` 落入 `<dataRoot>/business-plugins/` + 写 `business_plugins` 行(正规入口是门户 `#/plugins` 上传,仅 admin);
3. **迁移 admin**:移除 `scripts/ensure-anysearch-admin.cjs` 写的手工段(`platform: anysearch-search`),改由平台托管段接管 —— 该脚本后续仅保留「铺候选池」职责或整体退役;
4. **验证两态**:启用态(`searchProvider: anysearch`)与禁用态(托管段消失 → 自动选 deepseek)各跑一次 `--dump-config`。
## 六、红线遵守
- **R1/R2**:只改平台自有代码(`src/web/routes/`),不碰官方 dsh 主程序与缓存。
- **R7**:改动仅 1 个源文件(+ 2 处调用);不批量改写、不 `git add -A`。
- **R8**:重启服务前先告知影响并取得确认。
---
## 七、实施与验证(2026-09-12)
### 7.1 代码
`src/web/routes/business-plugins.ts` 单文件改动:
| 位置 | 改动 |
|---|---|
| 模块级(导出) | 新增 `WEB_PROVIDER_OPEN/CLOSE/LEGACY` + `collectWebProviderClaims()` + `syncWebProviderPatch()` |
| `install()` / `uninstall()` | 在 `reconcileBundles(dir)` 之后各加一行 `syncWebProviderPatch(dir)` |
> 函数刻意放在**模块级并导出**(不依赖 `app`):一是便于被真实产物直接验证,二是为后续加单测留口子。
**自检**:`npm run typecheck` exit 0 · `npm run build` exit 0。
### 7.2 真实数据验证(未用生产文件)
方法:把**编译产物**(`lib/web/routes/business-plugins.js`)以 `business-plugins.new.js` 名义放到服务器**同目录**(相对 import 可解析、不覆盖生产文件),再由一次性脚本在 `/tmp` 副本上跑 admin profile 的**真实数据**(真实 `package.json`、真实 profile patch、真实插件包)。脚本与副本用完即删。
| 态 | 输入 | 期望 | 实测 |
|---|---|---|---|
| **A 启用** | bundles 含 `@anysearch/anysearch-dsh`(照抄 admin 现状,patch 里还带着早期**手工直铺段**) | 清掉手工段、写平台托管段 | ✅ `claims={"search":"anysearch"}`;手工 `anysearch-search` 段被移除;写入 `web-provider` 段(`searchProvider: anysearch` + `fetchProvider: http`) |
| **B 禁用** | 从 deps + bundles 移除该插件、删其 `node_modules` | 托管段应**整段消失** | ✅ `claims={"search":null}`;同步后文件里既无 `web-provider` 也无 `anysearch-search` |
| **C 幂等** | 对态 A 再跑一次 | 逐字不变 | ✅ `true` |
| **D 无残留** | 检查态 B 产物 | 不含任一托管段 | ✅ 两个都 `false` |
**结论**:两态都正确 —— 启用时走 AnySearch 且保留本地抓取;禁用时自动回到 DeepSeek,**没有 `CONFIGURED_MISSING` 坏状态**。迁移也顺带完成(手工段被自动清除)。
### 7.3 待办
1. **部署**:`lib/` → 服务器 + `systemctl restart dshs`(**R8:中断在线用户,待确认窗口**);
2. **投放候选池**:tgz 落 `<dataRoot>/business-plugins/` + 写 `business_plugins` 行(正规入口 = 门户 `#/plugins` 上传);
3. ~~**退役 `scripts/ensure-anysearch-admin.cjs` 的手工覆写职责**~~ → ✅ **2026-09-13 08:0x 已完成**:脚本头加**硬拦**(默认 `exit 2`,仅 `DSH_ALLOW_RETIRED_ANYSEARCH=1` 可放行),保留为「考古/回滚」用途、不再日常可跑;备份 `ensure-anysearch-admin.cjs.bak-retire-20260913`;实测默认拒绝 rc=2、显式放行可干跑、`node --check` 语法 OK。**原条目** —— 由平台托管段接管后,该脚本只应保留「铺候选池」或整体退役(**否则两段并存、后者覆盖前者,会产生难以察觉的配置漂移**);
4. **端到端**:admin 在「功能管理」里启用 → 搜一次;再禁用 → 搜一次(应回落到 DeepSeek)。