فهرست منبع

docs(spec): 配置收敛设计——operate↔control 连接组单一数据源 + 死键清理

方案一:共享 tl-shared.config 片段 + <appSettings file=..> 只读合并,
写入由 operate AppConfigHelper 收口;control C# 零改动只加一行 XML。
本轮范围仅 7 个共享连接键 + 删 operate 端换气/CCD 死副本键;
凭据组/front/Java/oplog-config 不动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 روز پیش
والد
کامیت
f547ac92b8

+ 116 - 0
项目文档/需求文档/specs/2026-06-23-配置收敛-operate-control连接组单一数据源-design.md

@@ -0,0 +1,116 @@
+# 配置收敛设计 —— operate↔control 连接组单一数据源 + 死键清理
+
+> 日期:2026-06-23 · 当前任务:operate/control 双进程拆分 · 本专项 = 昨日建议「配置收敛」
+> 范围铁律:**只碰 operate↔control 的 7 个共享连接键 + 删 operate 死键**。front、Java 微服务、凭据组、oplog-config.json 本轮**不动**。
+
+## 一、为什么做(问题)
+
+operate/control 双进程拆分后,同一台机器上两个进程各自带一份 `App.config`,其中约 25 个键几乎逐字重复。control 那份(`control/ivf_tl_ControlHost/App.config`)的注释甚至明写「**内容与 operate App.config 保持同步**」——这是拆分把早先「operate = 单一数据源」(M5-01/M5-02 治理意图)又打破后留下的伤疤。
+
+**真实痛点**:换中间件服务器、或调连接参数时,要在 operate + control 两份文件里各改一遍,漏一处 → 两进程连接目标不一致 → 行为打架,且不易发现。
+
+**已坐实的现状**(codegraph 核实):
+- **control 的 AppData**(`control/ivf_tl_Control/AppData.cs:47-133`):读全部 13 个换气/CCD 业务键 + 连接组 + 凭据。
+- **operate 的 AppData**(`ivf_tl_Operate/AppData.cs:77-112`):只读 urlIp/urlPort/mqttIp/mqttPort/outInter;kfkaIP/kfkaPort 在 `App.xaml.cs` 读;凭据/cacheDisk/Language/houseEnabled 在登录/路径处读。**operate 不读那 13 个换气/CCD 键** → 它们是合并单进程时代的死副本。
+- operate 已有「统一配置」UI(`UnifiedConfigViewModel` + `AppConfigHelper.SaveAll`),编辑连接组+本机+凭据,但 `AppConfigHelper.Save` 经 `OpenExeConfiguration` **只写 operate 自己的 config**,不知道 control 还有一份 → 这就是单一数据源缺口的代码体现。
+- 凭据:operate 存 DPAPI 密文(`SetApp`/`SaveEncrypted` + 启动 `MigratePlaintextCredentials`),control 那份是明文 `123456` → 本身已不一致。
+
+## 二、目标 / 非目标
+
+**目标**:
+1. operate 与 control 共享的连接组(7 键)收敛为**唯一数据源**,换机只改一处。
+2. 清理 operate 里那批换气/CCD **死副本键**,职责清晰(换气/CCD 只归 control)。
+
+**非目标(本轮明确不做)**:
+- 凭据组(userName/passWord/engineerPwd/tlNum)并入共享文件(涉 DPAPI 跨进程解密 + 动 control C# 代码,留独立小决策)。
+- front 的 App.config、Java 8 个 `server.ip`、oplog-config.json 的收敛。
+- control 侧任何 C# 逻辑改动(本设计刻意让 control 只加一行 XML)。
+
+## 三、设计决策(brainstorming 已拍板)
+
+- **范围**:只收敛 operate↔control。
+- **核心目标**:单一数据源 + 清理死键。
+- **机制**:方案一 —— **共享配置片段 + `<appSettings file=>` 只读合并,写入由 operate 收口**。
+  - 选 `file=` 而非 `configSource`:`file=` 属性支持 `..` 上级相对路径(control 在 operate 子目录,要指向父目录的共享文件);`configSource` 禁止 `..`。
+  - 选方案一而非方案二(ProgramData + 自写加载器):方案一对最关键的 control 进程**只加一行 XML、C# 零改动**,风险最低;且复用已有 UI 与 `AppConfigHelper`。
+- **凭据**:本轮维持现状(operate 存密文、control 用自己那份/creds.dat)。
+
+## 四、文件布局
+
+```
+operate 输出根目录/
+├─ ivf_tl_Operate.exe
+├─ ivf_tl_Operate.dll.config        ← 只留 operate 独有键 + 引用共享文件
+├─ tl-shared.config                 ← 【新增·唯一数据源】只放共享连接组
+└─ control/
+   ├─ ivf_tl_ControlHost.exe
+   └─ ivf_tl_ControlHost.dll.config ← 只留 control 独有键 + 引用 ..\tl-shared.config
+```
+
+`tl-shared.config`(独立 appSettings 片段,这是 `file=` 期望的格式):
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<appSettings>
+  <add key="urlIp" value="http://127.0.0.1"/>
+  <add key="urlPort" value="10010"/>
+  <add key="mqttIp" value="192.168.0.108"/>
+  <add key="mqttPort" value="1883"/>
+  <add key="kfkaIP" value="192.168.0.108"/>
+  <add key="kfkaPort" value="9092"/>
+  <add key="outInter" value="0"/>
+</appSettings>
+```
+
+operate `App.config`:`<appSettings file="tl-shared.config"> …operate 独有键 inline… </appSettings>`
+control `App.config`:`<appSettings file="..\tl-shared.config"> …control 独有键 inline… </appSettings>`
+
+## 五、键归属总表
+
+| 类别 | 键 | 处置 |
+|---|---|---|
+| **共享组(7)** | urlIp, urlPort, mqttIp, mqttPort, kfkaIP, kfkaPort, outInter | 从两份 App.config 删除,只存 `tl-shared.config` |
+| **operate 独有** | userName, passWord, engineerPwd, tlNum, cacheDisk, Language, houseEnabled, autoFocus, controlPort, controlExePath | 留 operate config |
+| **control 独有** | csTime, gbTime, VentNum, VentPre, VentWaitTimeB, VentWaitTimeD, AutoWaitTime, CCDAutoWaitTime, CCDError, CCDFailedNumber, CCDFailedWaitTime, QueuAir, StopPro, cacheDisk, 凭据(现状) | 留 control config |
+| **operate 死键(删)** | csTime, gbTime, VentNum, VentPre, VentWaitTimeB, VentWaitTimeD, AutoWaitTime, CCDAutoWaitTime, CCDError, CCDFailedNumber, CCDFailedWaitTime, QueuAir | 从 operate App.config 删除(operate 不读;实现时再 grep 全 operate 兜底确认无其它消费点) |
+
+> 注:cacheDisk 两进程都可能用,各自保留 inline(非"换机要改"的连接类,不进共享文件,无漂移风险)。autoFocus/StopPro/QueuAir 等非连接键不进共享文件。死键删除以"operate 侧确无消费点"为前提,实现时逐键 grep 坐实再删。
+
+## 六、读 / 写数据流
+
+**读(两进程 C# 零改动)**:
+- 各自 App.config 的 `<appSettings file="…tl-shared.config">`,.NET 启动时把共享文件的键合并进 `ConfigurationManager.AppSettings`。
+- operate 的 `AppData`/`AppConfigHelper.GetString`、control 的 `AppData` 照旧 `AppSettings["urlIp"]` 即读到。
+
+**写(只动 operate 侧 `AppConfigHelper`)**:
+- 统一配置 UI 保存共享组 7 键时,`AppConfigHelper` 把它们写进 `tl-shared.config`(用 `XDocument` 直接读写这个小文件,**绕开 ConfigurationManager 写回 `file=` 的不确定行为**),写后 `ConfigurationManager.RefreshSection("appSettings")` 让本进程即时生效。
+- 非共享键(凭据/本机)的写回逻辑不变,仍 `OpenExeConfiguration` 落 operate 自己的 config。
+- 实现要点:`AppConfigHelper` 内部维护一个"共享键集合",`Save(key,value)` 按 key 归属分流到 `tl-shared.config` 或 operate config;`SaveAll` 不改调用方,只改底层落点。
+
+**control 侧**:只在 `ControlHost/App.config` 加 `file="..\tl-shared.config"`,**C# 一行不改**。
+
+## 七、部署 + 迁移
+
+- **构建**:`tl-shared.config` 加进 `ivf_tl_ControlHost` 或 operate 工程,以 `<None><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>` 随 operate 输出根目录;control 子目录**不需要**拷贝(运行期靠相对 `..\` 指过去)。
+  - 注:control 通过 csproj 把 ControlHost 输出到 operate 的 `control\` 子目录;`tl-shared.config` 落在 operate 根 = control 的父目录,`..\tl-shared.config` 命中。
+- **首启迁移(幂等、保老部署不丢值)**:operate 启动时若 `tl-shared.config` 不存在,用当前 operate config 里那 7 个共享键的现值生成它。已存在则不动。
+- **文档同步**:`开发环境/双进程部署指南.md` + `连接配置清单-换服务器必读.md` 第三节:C# 侧换机由"改 operate+control 两份"改为"只改 `tl-shared.config` 一份";`操作端逻辑与配置全景.md` / `control-逻辑与配置全景.md` 的配置键章节标注共享文件来源。
+
+## 八、错误处理 / 边界
+
+- 共享文件缺失:`file=` 指向不存在文件时 .NET 行为是"忽略外部文件、只用 inline"——故首启迁移要尽早生成它;且 `AppConfigHelper.GetString` 已有缺键回退默认值(不裸崩)。
+- 写共享文件失败(权限/占用):`AppConfigHelper` 写操作 try 兜底(沿用现有"写失败不抛出"策略),不阻断 UI/启动。
+- control 独立重拉(看门狗,operate 不在时):control 读的是磁盘上的 `..\tl-shared.config`,值为 operate 上次保存的;持久化在磁盘,标准场景成立。
+- RefreshSection 后本进程重读:operate 写共享文件 + RefreshSection 后,本进程 `AppSettings` 是否即时反映合并后的外部文件值 —— 列入真机验证项(若不即时,UI 保存提示"重启生效"即可,不阻断)。
+
+## 九、测试 / 验证
+
+1. **第一步硬验证(破假设)**:最小样例验证 `<appSettings file="..\x.config">` 在子目录进程能读到父目录共享文件键(真机 Release 编译+跑)。**不过则当场退回方案二**。
+2. **单测**:`AppConfigHelper` 共享键读写(写 `tl-shared.config`→读回一致;非共享键仍落 operate config 不串);死键删除后 operate 编译 0 错。
+3. **真机端到端**:改 `tl-shared.config` 的 urlIp/mqtt/kfka → operate 读到新值 + 看门狗/operate 拉起 control 也读到同一新值 → `/status` 连接正常、operate Release 真外壳登录通;control 启动 0 缺键 NPE / 0 DbException。
+4. **回归**:既有 40 单测 + HIL 硬件在环套件全过;operate Release + control sln 双编译 0 错。
+
+## 十、风险
+
+- `file=` + `..` 不被支持(低概率)→ 第一步硬验证拦截,退方案二。
+- 死键误删(operate 实际仍有隐蔽消费点)→ 删前逐键 grep 全 operate 树坐实。
+- 写回 `file=` 合并的即时生效问题 → 用 XDocument 直写 + RefreshSection 规避;真机验证兜底。