Эх сурвалжийг харах

docs(plan): 配置收敛实现计划(6 任务 TDD)+ spec 细化(SharedConfigStore/发布取值)

Task1 file=+.. 硬验证 → Task2 SharedConfigStore+operate单测工程 →
Task3 AppConfigHelper.Save 分流 → Task4 operate接file=删共享/死键 →
Task5 control接..\tl-shared.config → Task6 真机E2E+回归+文档同步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 өдөр өмнө
parent
commit
c9f1c70

+ 666 - 0
项目文档/开发计划/2026-06-23-配置收敛-实现计划.md

@@ -0,0 +1,666 @@
+# 配置收敛(operate↔control 连接组单一数据源 + 死键清理)实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 operate 与 control 共享的 7 个连接键收敛为唯一数据源 `tl-shared.config`(换机只改一处),并清理 operate 端 12 个换气/CCD 死副本键。
+
+**Architecture:** 方案一——新建共享 appSettings 片段 `tl-shared.config` 放 operate 输出根;operate/control 各自 App.config 用 `<appSettings file="…tl-shared.config">` 只读合并(两进程读取 C# 零改动);写入由 operate 侧 `AppConfigHelper` 经新纯逻辑单元 `SharedConfigStore`(XDocument 直写)收口。control 仅加一行 XML、C# 与 csproj 均不改。凭据组/front/Java/oplog-config 本轮不动。
+
+**Tech Stack:** C# / .NET 6 (net6.0-windows) / WPF / xUnit / System.Configuration / System.Xml.Linq。
+
+**对应设计:** `项目文档/需求文档/specs/2026-06-23-配置收敛-operate-control连接组单一数据源-design.md`
+
+**分支:** `feature/config-consolidation`(已建,spec 已提交 f547ac9)。
+
+**真机口径:** UAC 静默可提权、当前无 control 在跑/无活体培养,可自由启停;EEPROM/连接验证无电机风险。验证用部署布局(control 在 operate 根的 `control\` 子目录)。
+
+---
+
+## 关键文件一览
+
+- 新建 `ivf_tl_operate_2.0/ivf_tl_Operate/tl-shared.config` —— 唯一数据源(7 连接键)。
+- 新建 `ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/SharedConfigStore.cs` —— 纯 XDocument 读写 appSettings 片段。
+- 新建 `ivf_tl_operate_2.0/ivf_tl_Operate.Tests/`(xUnit)—— operate 首个单测工程,测 SharedConfigStore。
+- 改 `ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/AppConfigHelper.cs` —— Save 按 key 归属分流。
+- 改 `ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj` —— 拷贝 tl-shared.config 到输出根。
+- 改 `ivf_tl_operate_2.0/ivf_tl_Operate/App.config` —— 加 `file=` + 删 7 共享键 + 删 12 死键。
+- 改 `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/App.config` —— 加 `file="..\tl-shared.config"` + 删 7 共享键。
+- 改文档:`开发环境/双进程部署指南.md`、`开发环境/连接配置清单-换服务器必读.md`、续接三件套。
+
+---
+
+## Task 1: 硬验证 `<appSettings file="..\x.config">` 子目录可读父目录(破假设)
+
+> 方案一命脉。过不了当场退方案二。临时 harness 放 gitignore 的 `临时文件/`,不入库。
+
+**Files:**
+- Create: `临时文件/ConfigFileProbe/ConfigFileProbe.csproj`
+- Create: `临时文件/ConfigFileProbe/Program.cs`
+- Create: `临时文件/ConfigFileProbe/App.config`
+
+- [ ] **Step 1: 写 harness 工程**
+
+`临时文件/ConfigFileProbe/ConfigFileProbe.csproj`:
+```xml
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>disable</Nullable>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.0" />
+  </ItemGroup>
+</Project>
+```
+
+`临时文件/ConfigFileProbe/App.config`:
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<configuration>
+  <appSettings file="..\probe-shared.config">
+    <add key="inlineKey" value="INLINE"/>
+  </appSettings>
+</configuration>
+```
+
+`临时文件/ConfigFileProbe/Program.cs`:
+```csharp
+using System;
+using System.Configuration;
+
+class Program
+{
+    static int Main()
+    {
+        var shared = ConfigurationManager.AppSettings["probeKey"];
+        var inline = ConfigurationManager.AppSettings["inlineKey"];
+        Console.WriteLine($"probeKey={shared}|inlineKey={inline}");
+        return shared == "HELLO" ? 0 : 1;
+    }
+}
+```
+
+- [ ] **Step 2: 构建并按"子目录布局"部署**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/临时文件/ConfigFileProbe"
+dotnet build -c Release
+# 模拟部署布局:把 exe 放进 sub/,共享文件放 sub 的父目录
+mkdir -p deploy/sub
+cp bin/Release/net6.0-windows/ConfigFileProbe.exe deploy/sub/
+cp bin/Release/net6.0-windows/ConfigFileProbe.dll deploy/sub/
+cp bin/Release/net6.0-windows/ConfigFileProbe.runtimeconfig.json deploy/sub/
+cp bin/Release/net6.0-windows/ConfigFileProbe.dll.config deploy/sub/
+cp bin/Release/net6.0-windows/System.Configuration.ConfigurationManager.dll deploy/sub/ 2>/dev/null || true
+printf '<?xml version="1.0" encoding="utf-8"?>\n<appSettings>\n  <add key="probeKey" value="HELLO"/>\n</appSettings>\n' > deploy/probe-shared.config
+```
+
+- [ ] **Step 3: 运行,确认子目录进程读到父目录共享键**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/临时文件/ConfigFileProbe/deploy/sub"
+./ConfigFileProbe.exe; echo "exit=$?"
+```
+Expected: 输出 `probeKey=HELLO|inlineKey=INLINE`,`exit=0`。
+
+> ✅ 过 → `file=`+`..` 子目录可读父目录成立,继续 Task 2。
+> ❌ 不过(probeKey 空/exit=1)→ **停下,退回方案二(ProgramData+自写加载器),回写 spec 与本计划**,不要继续。
+
+- [ ] **Step 4: 不提交(临时文件已 gitignore)**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0" && git status --porcelain 临时文件/ | head
+```
+Expected: 无输出(`临时文件/` 已被 gitignore)。无需提交。
+
+---
+
+## Task 2: SharedConfigStore 纯逻辑单元 + operate 单测工程(TDD)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj`
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate.Tests/SharedConfigStoreTests.cs`
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/SharedConfigStore.cs`
+
+- [ ] **Step 1: 建 operate 单测工程并加入 sln**
+
+`ivf_tl_operate_2.0/ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj`:
+```xml
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>disable</Nullable>
+    <IsPackable>false</IsPackable>
+    <UseWPF>true</UseWPF>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
+    <PackageReference Include="xunit" Version="2.6.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\ivf_tl_Operate\ivf_tl_Operate.csproj" />
+  </ItemGroup>
+</Project>
+```
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet sln ivf_tl_Operate.sln add ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj
+```
+Expected: `Project ... added`。
+
+- [ ] **Step 2: 写失败测试**
+
+`ivf_tl_operate_2.0/ivf_tl_Operate.Tests/SharedConfigStoreTests.cs`:
+```csharp
+using System.IO;
+using ivf_tl_Operate.Helpers;
+using Xunit;
+
+namespace ivf_tl_Operate.Tests
+{
+    public class SharedConfigStoreTests
+    {
+        private static string TempFile() =>
+            Path.Combine(Path.GetTempPath(), "tl-shared-test-" + Path.GetRandomFileName() + ".config");
+
+        [Fact]
+        public void Read_missing_file_returns_null()
+        {
+            var path = TempFile();
+            Assert.Null(SharedConfigStore.Read(path, "urlIp"));
+        }
+
+        [Fact]
+        public void Write_then_Read_roundtrips()
+        {
+            var path = TempFile();
+            try
+            {
+                SharedConfigStore.Write(path, "urlIp", "http://10.0.0.5");
+                SharedConfigStore.Write(path, "urlPort", "10010");
+                Assert.Equal("http://10.0.0.5", SharedConfigStore.Read(path, "urlIp"));
+                Assert.Equal("10010", SharedConfigStore.Read(path, "urlPort"));
+            }
+            finally { File.Delete(path); }
+        }
+
+        [Fact]
+        public void Write_existing_key_updates_value()
+        {
+            var path = TempFile();
+            try
+            {
+                SharedConfigStore.Write(path, "mqttIp", "192.168.0.108");
+                SharedConfigStore.Write(path, "mqttIp", "192.168.0.200");
+                Assert.Equal("192.168.0.200", SharedConfigStore.Read(path, "mqttIp"));
+            }
+            finally { File.Delete(path); }
+        }
+
+        [Fact]
+        public void Written_file_is_appSettings_fragment()
+        {
+            var path = TempFile();
+            try
+            {
+                SharedConfigStore.Write(path, "kfkaIP", "192.168.0.108");
+                var text = File.ReadAllText(path);
+                Assert.Contains("<appSettings>", text);
+                Assert.Contains("key=\"kfkaIP\"", text);
+            }
+            finally { File.Delete(path); }
+        }
+    }
+}
+```
+
+- [ ] **Step 3: 运行,确认编译失败(类型不存在)**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet test ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj
+```
+Expected: 编译失败 `CS0103/CS0246 SharedConfigStore` 不存在 = RED。
+
+- [ ] **Step 4: 实现 SharedConfigStore**
+
+`ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/SharedConfigStore.cs`:
+```csharp
+using System.IO;
+using System.Xml.Linq;
+
+namespace ivf_tl_Operate.Helpers
+{
+    /// <summary>
+    /// 配置收敛:operate↔control 共享连接键的唯一数据源文件(tl-shared.config)读写。
+    /// 文件格式 = 独立 appSettings 片段(&lt;appSettings&gt;&lt;add key.. value../&gt;&lt;/appSettings&gt;),
+    /// 正是 &lt;appSettings file="…"&gt; 期望的外部文件格式 → 两进程经 file= 只读合并即读到。
+    /// 用 XDocument 直写,绕开 ConfigurationManager 写回 file= 的不确定行为。
+    /// </summary>
+    public static class SharedConfigStore
+    {
+        /// <summary>读片段文件里某键值;文件不存在或键缺失返回 null。</summary>
+        public static string Read(string path, string key)
+        {
+            if (!File.Exists(path)) return null;
+            try
+            {
+                var doc = XDocument.Load(path);
+                foreach (var add in doc.Descendants("add"))
+                {
+                    if ((string)add.Attribute("key") == key)
+                        return (string)add.Attribute("value");
+                }
+                return null;
+            }
+            catch
+            {
+                return null;
+            }
+        }
+
+        /// <summary>写某键值(存在则更新,不存在则新增);文件不存在则创建片段骨架。</summary>
+        public static void Write(string path, string key, string value)
+        {
+            XDocument doc;
+            if (File.Exists(path))
+            {
+                try { doc = XDocument.Load(path); }
+                catch { doc = NewDoc(); }
+            }
+            else
+            {
+                doc = NewDoc();
+            }
+
+            var root = doc.Root ?? new XElement("appSettings");
+            if (doc.Root == null) doc.Add(root);
+
+            XElement target = null;
+            foreach (var add in root.Elements("add"))
+            {
+                if ((string)add.Attribute("key") == key) { target = add; break; }
+            }
+            if (target == null)
+            {
+                target = new XElement("add", new XAttribute("key", key), new XAttribute("value", value ?? ""));
+                root.Add(target);
+            }
+            else
+            {
+                target.SetAttributeValue("value", value ?? "");
+            }
+            doc.Save(path);
+        }
+
+        private static XDocument NewDoc() =>
+            new XDocument(new XDeclaration("1.0", "utf-8", null), new XElement("appSettings"));
+    }
+}
+```
+
+- [ ] **Step 5: 运行,确认 4 测试全绿**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet test ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj
+```
+Expected: `Passed! - Failed: 0, Passed: 4`。
+
+- [ ] **Step 6: 提交**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0"
+git add ivf_tl_operate_2.0/ivf_tl_Operate.Tests ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/SharedConfigStore.cs ivf_tl_operate_2.0/ivf_tl_Operate.sln
+git commit -m "feat(config): SharedConfigStore 共享配置片段读写 + operate 单测工程(TDD 4 绿)
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 3: AppConfigHelper 写入按 key 归属分流到共享文件
+
+> 读取保持不变(经 ConfigurationManager 的 file= 合并);只改 Save 的落点。
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/AppConfigHelper.cs`
+
+- [ ] **Step 1: 加共享键集合 + 共享文件路径 + Save 分流**
+
+在 `AppConfigHelper` 类内,把现有 `Save` 方法(约 :58-74)替换为下面这段(新增 `SharedKeys`/`SharedConfigPath`/`SaveShared`,改 `Save`):
+```csharp
+        /// <summary>配置收敛:operate↔control 共享的连接键(唯一数据源 tl-shared.config)。</summary>
+        private static readonly System.Collections.Generic.HashSet<string> SharedKeys =
+            new System.Collections.Generic.HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
+            { "urlIp", "urlPort", "mqttIp", "mqttPort", "kfkaIP", "kfkaPort", "outInter" };
+
+        /// <summary>共享文件路径 = operate 输出根目录\tl-shared.config(control 经 ..\ 指此)。</summary>
+        public static string SharedConfigPath =>
+            System.IO.Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "tl-shared.config");
+
+        /// <summary>
+        /// 写回普通键。配置收敛:共享键落唯一数据源 tl-shared.config(SharedConfigStore),
+        /// 非共享键沿用 OpenExeConfiguration 落 operate 自己的 config。写后刷新本进程缓存。
+        /// </summary>
+        public static void Save(string key, string value)
+        {
+            try
+            {
+                if (SharedKeys.Contains(key))
+                {
+                    SharedConfigStore.Write(SharedConfigPath, key, value);
+                }
+                else
+                {
+                    Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
+                    if (config.AppSettings.Settings[key] == null)
+                        config.AppSettings.Settings.Add(key, value);
+                    else
+                        config.AppSettings.Settings[key].Value = value;
+                    config.Save(ConfigurationSaveMode.Modified);
+                }
+                ConfigurationManager.RefreshSection("appSettings");
+            }
+            catch
+            {
+                // 写失败不抛出(避免阻断 UI/启动);[M7] 运行环境核查落盘。
+            }
+        }
+```
+
+- [ ] **Step 2: 编译 operate(Release)确认 0 错**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet build ivf_tl_Operate/ivf_tl_Operate.csproj -c Release
+```
+Expected: `Build succeeded. 0 Error(s)`。(若报 MSB3021 DLL 锁,先停掉在跑的 operate/control 再编。)
+
+- [ ] **Step 3: 复跑 operate 单测确认未回归**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet test ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj
+```
+Expected: `Passed: 4`。
+
+- [ ] **Step 4: 提交**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0"
+git add ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/AppConfigHelper.cs
+git commit -m "feat(config): AppConfigHelper.Save 共享键落 tl-shared.config、非共享键不变
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 4: 新建 tl-shared.config + operate csproj 拷贝 + operate App.config 接 file= 并删共享键/死键
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate/tl-shared.config`
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj`(在已有 oplog-config.json 的 ItemGroup 旁加一项)
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/App.config`
+
+- [ ] **Step 1: 创建唯一数据源 tl-shared.config(当前 108 值)**
+
+`ivf_tl_operate_2.0/ivf_tl_Operate/tl-shared.config`:
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 配置收敛:operate↔control 共享连接键的【唯一数据源】。
+     换中间件服务器/改连接参数只改本文件一处。
+     operate 经 App.config <appSettings file="tl-shared.config"> 读;
+     control 经 ..\tl-shared.config 读。两进程读取代码零改动。 -->
+<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>
+```
+
+- [ ] **Step 2: operate csproj 拷贝到输出根(仿 oplog-config.json)**
+
+在 `ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj` 已有的 `oplog-config.json` 那段(:171-174 附近)下方,同一 `<ItemGroup>` 内追加:
+```xml
+    <None Remove="tl-shared.config" />
+    <Content Include="tl-shared.config">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+```
+
+- [ ] **Step 3: operate App.config 接 file= + 删 7 共享键 + 删 12 死键**
+
+把 `ivf_tl_operate_2.0/ivf_tl_Operate/App.config` 的 `<appSettings>` 开标签改为:
+```xml
+	<appSettings file="tl-shared.config">
+```
+然后**删除**这 7 个共享键行(连同其上的注释):`outInter`、`urlIp`、`urlPort`、`mqttIp`、`mqttPort`、`kfkaIP`、`kfkaPort`。
+再**删除**这 12 个换气/CCD 死键行(连同注释):`CCDError`、`csTime`、`gbTime`、`VentNum`、`VentPre`、`VentWaitTimeB`、`VentWaitTimeD`、`AutoWaitTime`、`CCDAutoWaitTime`、`CCDFailedWaitTime`、`CCDFailedNumber`、`QueuAir`。
+
+保留(operate 独有):`autoFocus`、`userName`、`passWord`、`engineerPwd`、`tlNum`、`houseEnabled`、`Language`、`StopPro`、`cacheDisk`、`controlPort`、`controlExePath`。
+
+- [ ] **Step 4: 删死键前逐键 grep 坐实 operate 树无消费点**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0/ivf_tl_Operate"
+for k in CCDError csTime gbTime VentNum VentPre VentWaitTimeB VentWaitTimeD AutoWaitTime CCDAutoWaitTime CCDFailedWaitTime CCDFailedNumber QueuAir; do
+  echo "== $k =="; grep -rn "AppSettings\[\"$k\"\]\|GetString(\"$k\"\|GetInt(\"$k\"" . --include=*.cs;
+done
+```
+Expected: 每个键**无输出**(operate 端不消费)。若某键有输出 → 该键**不删**、保留并在交接卡记录例外。
+
+- [ ] **Step 5: 编译 operate(Release)0 错**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet build ivf_tl_Operate/ivf_tl_Operate.csproj -c Release
+```
+Expected: `Build succeeded. 0 Error(s)`;输出目录 `bin/Release/net6.0-windows/tl-shared.config` 存在。
+
+Run(确认落盘):
+```bash
+ls -l "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0/ivf_tl_Operate/bin/Release/net6.0-windows/tl-shared.config"
+```
+Expected: 文件存在。
+
+- [ ] **Step 6: 提交**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0"
+git add ivf_tl_operate_2.0/ivf_tl_Operate/tl-shared.config ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj ivf_tl_operate_2.0/ivf_tl_Operate/App.config
+git commit -m "feat(config): operate 接 tl-shared.config(file=)+删7共享键+删12换气CCD死键
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 5: control App.config 接 ..\tl-shared.config 并删 7 共享键
+
+> control 只动 App.config 一处:加 file= 指父目录 + 删共享键。**C#/csproj 不动**。换气/CCD 业务键 control 仍读 → 保留。
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/App.config`
+
+- [ ] **Step 1: control App.config 接 file= + 删 7 共享键**
+
+把 `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/App.config` 的 `<appSettings>` 开标签改为:
+```xml
+	<appSettings file="..\tl-shared.config">
+```
+然后**删除**这 7 个共享键行(连同注释):`urlIp`、`urlPort`、`mqttIp`、`mqttPort`、`kfkaIP`、`kfkaPort`、`outInter`。
+
+保留(control 独有,缺则 NPE):`userName`、`passWord`、`engineerPwd`、`tlNum`、`houseEnabled`、`Language`、`autoFocus`、`CCDError`、`csTime`、`gbTime`、`VentNum`、`VentPre`、`VentWaitTimeB`、`VentWaitTimeD`、`AutoWaitTime`、`CCDAutoWaitTime`、`CCDFailedWaitTime`、`CCDFailedNumber`、`QueuAir`、`StopPro`、`cacheDisk`、`controlPort`。
+
+- [ ] **Step 2: 编译 control 全 sln 0 错**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0/control"
+dotnet build ivf_tl_Control.sln -c Release
+```
+Expected: `Build succeeded. 0 Error(s)`。
+
+- [ ] **Step 3: 提交**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0"
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/App.config
+git commit -m "feat(config): control 接 ..\\tl-shared.config(file=)+删7共享键,C#零改动
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## Task 6: 真机端到端验证 + 回归 + 文档同步 + 收尾提交
+
+> 验证用部署布局:operate 输出根 + 其下 `control\`(ControlHost)+ 根的 `tl-shared.config`。UAC 静默提权,无活体培养可自由启停。
+
+- [ ] **Step 1: 按部署布局部署 + 改共享文件为可辨识值**
+
+Run(部署 control 到 operate 根的 control\ 子目录、确认共享文件就位):
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+OPDIR="ivf_tl_Operate/bin/Release/net6.0-windows"
+mkdir -p "$OPDIR/control"
+cp -r control/ivf_tl_ControlHost/bin/Release/net6.0-windows/* "$OPDIR/control/"
+ls -l "$OPDIR/tl-shared.config" "$OPDIR/control/ivf_tl_ControlHost.dll.config"
+```
+Expected: 两文件都在。`control/` 下**没有** tl-shared.config(靠 ..\ 指父目录)。
+
+- [ ] **Step 2: 单进程读取验证(operate 读 file= 合并)**
+
+> 用 Task1 的方式直接验 control 端读取:提权静默启动 control.exe,查 /status 连接来自共享文件的值。先把共享文件 mqttIp 改个可辨识值再启。
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+OPDIR="ivf_tl_Operate/bin/Release/net6.0-windows"
+# 提权启动 control(独立进程,读 ..\tl-shared.config)
+powershell -Command "Start-Process -Verb RunAs -FilePath '$PWD/$OPDIR/control/ivf_tl_ControlHost.exe'"
+sleep 12
+curl -s http://127.0.0.1:38080/status; echo
+```
+Expected: `/status` 返回 `{"ok":true,"pid":…,"tlSn":"NEO-1-20230411","started":…}`,证明 control 读到了 `..\tl-shared.config` 的 url/gateway(否则连不上、started 不会 true / 进程起不来)。
+
+- [ ] **Step 3: 改共享文件单点 → 两进程同时生效(核心验证)**
+
+Run(停 control → 改共享文件 kfkaIP 为占位 → 重启 → 看日志/连接反映新值):
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+OPDIR="ivf_tl_Operate/bin/Release/net6.0-windows"
+curl -s -X POST http://127.0.0.1:38080/shutdown -d 'token=tl13579'; echo   # 受护栏停 control
+sleep 3
+# 仅改共享文件一处(模拟换机)
+sed -i 's#key="mqttIp" value="192.168.0.108"#key="mqttIp" value="192.168.0.108"#' "$OPDIR/tl-shared.config"
+grep mqttIp "$OPDIR/tl-shared.config"
+powershell -Command "Start-Process -Verb RunAs -FilePath '$PWD/$OPDIR/control/ivf_tl_ControlHost.exe'"
+sleep 12
+curl -s http://127.0.0.1:38080/status; echo
+```
+Expected: control 正常起、`/status` started,证明改**一处** `tl-shared.config` 即对 control 生效(operate 同机制读同文件 → 同样生效)。验证后把 mqttIp 改回 108。
+
+- [ ] **Step 4: operate Release 真外壳 E2E(读 file= 合并 + 登录)**
+
+> 沿用既往 E2E 脚手架口径(OPERATE_E2E=1 绕僵尸 Mutex、用 App.config 凭据走真实 AppDataInit)。确认 operate 经 file= 读到 urlIp 后真服务器登录成功 + 拉起 control。
+
+Run(若僵尸 operate 仍占主 Mutex,用 E2E 包装;否则直接起):
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+OPDIR="ivf_tl_Operate/bin/Release/net6.0-windows"
+# 提权启动真 operate.exe(它经 file= 读 tl-shared.config 的 urlIp → 登录 → 拉起 control)
+powershell -Command "Start-Process -Verb RunAs -FilePath '$PWD/$OPDIR/ivf_tl_Operate.exe'"
+sleep 20
+curl -s http://127.0.0.1:38080/status; echo
+```
+Expected: operate 登录成功(不闪退)+ 10s 后拉起 control、`/status` started:true。证明 operate 端 file= 合并读取通。
+
+> 若僵尸 PID 20268 占 operate 主 Mutex 致真 operate 起不来:记录"operate 外壳受僵尸门控、需重启清",但 Step2/3 已用真 control 进程坐实 file= 读取与单点生效;operate 与 control 读取机制同源(均 ConfigurationManager+file=),可据此判定。
+
+- [ ] **Step 5: 回归——operate 单测 + control 40 单测 + HIL 套件 + 双编译**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0/ivf_tl_operate_2.0"
+dotnet test ivf_tl_Operate.Tests/ivf_tl_Operate.Tests.csproj
+dotnet test control/ivf_tl_SerialHelper.Tests/ivf_tl_SerialHelper.Tests.csproj
+dotnet test control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj
+dotnet build ivf_tl_Operate/ivf_tl_Operate.csproj -c Release
+dotnet build control/ivf_tl_Control.sln -c Release
+```
+Expected: operate 4 绿;SerialHelper 40 绿;HIL 零写入 2 过 2 跳(无写开关);双编译 0 错。
+
+- [ ] **Step 6: 停掉测试用 control + 清残留**
+
+Run:
+```bash
+curl -s -X POST http://127.0.0.1:38080/shutdown -d 'token=tl13579'; echo
+sleep 3
+curl -s http://127.0.0.1:38080/ping; echo "  <- 应连不上=已退出"
+```
+Expected: /ping 连不上(control 干净退出、7 COM 释放)。
+
+- [ ] **Step 7: codegraph sync**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0" && codegraph sync
+```
+Expected: 增量同步完成。
+
+- [ ] **Step 8: 文档同步(回写协议 §3.3)**
+
+更新下列文档,与代码对齐后再提交:
+- `项目文档/开发环境/连接配置清单-换服务器必读.md` 第三节:operate/control 的 C# 换机由"改 operate+control 两份 App.config"改为"**只改 operate 根的 `tl-shared.config` 一份**";标注共享键来源。
+- `项目文档/开发环境/双进程部署指南.md`:产物清单加 `tl-shared.config`(operate 根,control 经 ..\ 读);自检清单加"确认 tl-shared.config 在 operate 根、control\ 下无此文件";升级注意 PreserveNewest 保站点值。
+- `项目文档/需求文档/操作端逻辑与配置全景.md` + `control-逻辑与配置全景.md`:连接键章节标注"共享键移入 tl-shared.config、operate 删 12 换气/CCD 死键"。
+- 续接三件套:`进度状态.yaml`(覆盖断点)、`工作计划表.md`(配置收敛状态)、`交接卡.md`(追加本次)、`进度数据.js`。
+
+- [ ] **Step 9: 收尾提交**
+
+Run:
+```bash
+cd "C:/Users/AIVFO/Documents/trae_projects/TLProject/aivfo-tl-3.0"
+git add 项目文档/
+git commit -m "docs(config): 配置收敛文档同步——换机清单/部署指南/两全景/续接三件套
+
+Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
+```
+
+---
+
+## 自查(spec 覆盖核对)
+
+- spec §四 文件布局 → Task 4(tl-shared.config + csproj)、Task 5(control file=)。✓
+- spec §五 键归属(7 共享 / operate 死键 12 / 各独有保留)→ Task 4 Step3-4、Task 5 Step1。✓
+- spec §六 读零改动 / 写经 SharedConfigStore 分流 → Task 2(SharedConfigStore)、Task 3(AppConfigHelper.Save 分流)。✓
+- spec §七 构建拷贝 / PreserveNewest → Task 4 Step2;部署/升级 → Task 6 Step1、Step8。✓
+- spec §八 边界(缺文件可见失败 / 写失败兜底 / RefreshSection)→ AppConfigHelper try 兜底(Task3)、Task6 Step3 RefreshSection 即时生效验证。✓
+- spec §九 测试(file= 硬验证 / SharedConfigStore 单测 / 真机 E2E / 回归)→ Task1 / Task2 / Task6 Step2-4 / Step5。✓
+- spec §十 风险(file=+.. / 死键误删 / 写回即时生效)→ Task1 拦截 / Task4 Step4 grep / Task6 Step3。✓
+
+**类型一致性核对:** `SharedConfigStore.Read(path,key)` / `Write(path,key,value)`(Task2 定义)= AppConfigHelper 调用(Task3)= 测试调用(Task2)签名一致。✓

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

@@ -82,17 +82,20 @@ control `App.config`:`<appSettings file="..\tl-shared.config"> …control 独有
 - 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` 不改调用方,只改底层落点。
+- 新增纯逻辑单元 `SharedConfigStore`(operate `Helpers/`):给定文件路径,用 `XDocument` 读写 appSettings 片段文件(`Read(path,key)` / `Write(path,key,value)`),**绕开 ConfigurationManager 写回 `file=` 的不确定行为**;纯文件 I/O、可用临时文件单测。
+- `AppConfigHelper` 维护一个"共享键集合"(urlIp/urlPort/mqttIp/mqttPort/kfkaIP/kfkaPort/outInter);`Save(key,value)` 按 key 归属分流:共享键 → `SharedConfigStore.Write(共享文件路径,…)`,非共享键(凭据/本机)→ 原 `OpenExeConfiguration` 落 operate 自己的 config(逻辑不变)。写后 `ConfigurationManager.RefreshSection("appSettings")` 让本进程即时生效。
+- `SaveAll` 调用方不改,只改 `AppConfigHelper.Save` 底层落点。
+- 共享文件路径 = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tl-shared.config")`(operate 输出根)。
 
 **control 侧**:只在 `ControlHost/App.config` 加 `file="..\tl-shared.config"`,**C# 一行不改**。
 
 ## 七、部署 + 迁移
 
-- **构建**:`tl-shared.config` 加进 `ivf_tl_ControlHost` 或 operate 工程,以 `<None><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>` 随 operate 输出根目录;control 子目录**不需要**拷贝(运行期靠相对 `..\` 指过去)。
+- **构建**:`tl-shared.config`(含当前 108 环境的 7 键默认值)放 `ivf_tl_Operate` 工程,仿照已有 `oplog-config.json` 写法 `<Content Include><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></Content>` 拷到 operate 输出根目录;**control 的 csproj 不动**(运行期靠相对 `..\tl-shared.config` 指过去)。
   - 注:control 通过 csproj 把 ControlHost 输出到 operate 的 `control\` 子目录;`tl-shared.config` 落在 operate 根 = control 的父目录,`..\tl-shared.config` 命中。
-- **首启迁移(幂等、保老部署不丢值)**:operate 启动时若 `tl-shared.config` 不存在,用当前 operate config 里那 7 个共享键的现值生成它。已存在则不动。
+- **发布/升级取值**:随构建发布默认 `tl-shared.config`;`PreserveNewest` 在目标已存在该文件时**保留站点自定义值**,不覆盖。换站点 = 改这一个文件(部署指南写明)。
+  - 现阶段(单台 dev = 108 环境)默认值即 108;真实客户站点升级前若改过仓库内默认值,需在部署后核对站点 `tl-shared.config`(部署指南列为 checklist 项)。
+  - 文件缺失兜底:`AppConfigHelper.GetString` 缺键返回默认值不裸崩;operate 侧 url 取空 → 登录可见失败(对医疗设备"可见失败"优于"静默连错"),不引入第三处硬编码默认。
 - **文档同步**:`开发环境/双进程部署指南.md` + `连接配置清单-换服务器必读.md` 第三节:C# 侧换机由"改 operate+control 两份"改为"只改 `tl-shared.config` 一份";`操作端逻辑与配置全景.md` / `control-逻辑与配置全景.md` 的配置键章节标注共享文件来源。
 
 ## 八、错误处理 / 边界
@@ -105,8 +108,8 @@ control `App.config`:`<appSettings file="..\tl-shared.config"> …control 独有
 ## 九、测试 / 验证
 
 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。
+2. **单测**:新建 operate 侧单测工程 `ivf_tl_Operate.Tests`(xUnit/net6.0-windows,operate 首个单测工程),测 `SharedConfigStore` 读写(写共享文件→读回一致、文件不存在时 Read 返回 null/默认、片段格式正确);死键删除后 operate 编译 0 错。
+3. **真机端到端**:改 `tl-shared.config` 的 urlIp/mqtt/kfka → operate 读到新值 + 看门狗/operate 拉起 control 也读到同一新值 → `/status` 连接正常、operate Release 真外壳登录通;control 启动 0 缺键 NPE / 0 DbException。**验证用部署布局**(control 部署在 operate 根的 `control\` 子目录,`tl-shared.config` 在 operate 根)。
 4. **回归**:既有 40 单测 + HIL 硬件在环套件全过;operate Release + control sln 双编译 0 错。
 
 ## 十、风险