Pārlūkot izejas kodu

用户令2: 统一配置目录/文件与日志目录/命名/格式 —— P.config 唯一取用口 + configs/registry.yaml + config_audit(R1-R8) + src/logfile.py + log_audit(L1-L5); 修场定义 yaml 配了看不见/坏模板/死路径; 39 个构建日志从产物归位 logs/build 并同步台账

zhouyang.xie 3 nedēļas atpakaļ
vecāks
revīzija
fd72722c8c

+ 0 - 13
configs/farms/_模板.json.example

@@ -1,13 +0,0 @@
-{
-  "name": "示例风电场",
-  "raw_station": "示例场",
-  "turbines": "WTG{:02d}:1-25",
-  "rated_kw": 3000,
-  "src_10min": "/Volumes/WINDDATA/DATA2/示例场/scada_10min",
-  "src_1min": "/Volumes/WINDDATA/DATA2/示例场/scada_1min",
-  "src_alarm": "/Volumes/WINDDATA/DATA2/示例场/故障报警",
-  "src_workorder": "/Volumes/WINDDATA/DATA2/示例场/风机故障记录",
-  "src_oil": "/Volumes/WINDDATA/DATA2/示例场/油样报告",
-  "store": "outputs/示例场/windscada",
-  "contract": "reference/示例场/windscada_contract.yaml"
-}

+ 33 - 0
configs/farms/_模板.yaml.example

@@ -0,0 +1,33 @@
+# 观澜 · 场配置模板 (复制成 <场名>.yaml 后再改)
+#
+# 约定 (2026-09-17 用户令 2「统一配置」):
+#   · 场定义一律 **YAML**: configs/farms/<场名>.yaml   (历史 .json 仍可加载, 但新场写 yaml)
+#   · 必需键见 src/windscada/config.py::REQUIRED —— 缺任何一个都会在加载时**响亮报错**
+#   · 路径写 **安装根相对** 形式 (data/… 、outputs/…); 不要写 C:\ / /Volumes/… 这类本机绝对路径
+#   · 内置场 rudong 不需要本文件 (零配置也能起); 本文件只在加新场时复制使用
+#   · 文件名以 _ 开头 = 模板, 不会被 available() 当成场
+#
+# 另注意: configs/farms/ 下还有一类**同名但不同 schema** 的文件 (机型/场站物理约束 profile:
+#   顶层 meta + physical_constraints), 那是另一条分析链的配置, **不是场定义**, 两者别混
+#   (见 docs §11 与 configs/registry.yaml)。
+
+name: <场站中文名>                     # 必填: 例 "如东海上风电场"
+raw_station: <data/raw 下的目录名>      # 必填: 例 "如东" (扫描辨识首选; 可用 src_farm_names 给别名)
+n_turbines: 25                        # 必填
+turbines: "WTG{:02d}:1-25"            # 必填: 生成机组号列表的写法 (或直接列 ["WTG01", ...])
+rated_kw: 3000                        # 必填: 单机额定功率 kW
+
+# 以下 3 个 "src_*" 与 store **必填** (REQUIRED 里的 src_10min/src_alarm/store);
+# 显式给了就以它为准, 不给则按 data/raw 扫描结果派生 —— 常规做法是**留空让扫描派生**。
+src_10min: <data/raw/<场站>/scada_10min>
+src_1min:  <data/raw/<场站>/scada_1min>
+src_alarm: <data/raw/<场站>/故障报警>
+src_workorder: <data/raw/<场站>/风机故障记录>
+src_oil:   <data/raw/<场站>/油样报告>
+src_windcms:   <data/raw/<场站>/windcms>       # 振动线 CMS 原始导出 (可选)
+src_m5:        <data/raw/<场站>/m5_cms_tcm>    # 振动线 handoff (可选)
+store: outputs/<场名>/windscada                # 必填: L0 标准仓 (parquet) 落点
+contract: reference/<场名>/windscada_contract.yaml
+
+# 原始件里的**场站名写法** (台账/报警按这些别名把本场行筛出来; 集团导出件常与配置名不同)
+src_farm_names: [<别名1>, <别名2>]

+ 98 - 0
configs/registry.yaml

@@ -0,0 +1,98 @@
+# 观澜 · 配置登记表 (2026-09-17 用户令 2「统一系统的配置目录及配置文件」)
+#
+# 这份表回答三件事, 且**由机器核对**(scripts/config_audit.py):
+#   ① configs/ 下每个目录/文件是什么、谁读它 (消费者), 不允许"有件不知谁用";
+#   ② 代码引用的配置到底在不在 (悬空引用 = 文件没随包, 代码却在读);
+#   ③ 命名/格式/内容三条底线 (无备份垃圾文件、无本机绝对路径、顶层只放运行期单件配置)。
+#
+# 约定 (与 src/paths.py 的"配置目录约定"一节同源, 代码只许走 P.config()/P.config_dir()):
+#   configs/serve.json          运行期单件: 端口/路径真源
+#   configs/models.json         运行期单件: 本机模型档
+#   configs/portal_pages.yaml   运行期单件: 门户页面归口登记表 (docs §10)
+#   configs/<域>/…              域 = canonical | contracts | farms | terms
+#   新域必须在本表登记 (kind + consumers), 否则审计报"未登记域"。
+version: 1
+
+top_level:
+  - file: serve.json
+    kind: 运行期单件配置
+    consumers: [guanlan.py, scripts/guanlan_ops.py, scripts/page_fingerprint.py, scripts/guanlan_gateway.py, scripts/check_transferable.py]
+    schema: "{host, gateway, detail, cms, sim, sim_sys, viewer, ollama, python?, raw_dir?}"
+  - file: models.json
+    kind: 运行期单件配置
+    consumers: [guanlan.py, src/ontology/llm_gate.py, src/ontology/fast_agent.py]
+    schema: "{tiers: {<档>: {model, pull_commands?}}, ...}"
+  - file: portal_pages.yaml
+    kind: 运行期单件配置 (页面归口登记表)
+    consumers: [scripts/pages_audit.py]
+    schema: "见 docs §10 / 本文件头"
+
+domains:
+  - dir: canonical
+    kind: 通道/测点 canonical 字典 (内部说法 ↔ 现场说法的单一真源)
+    consumers: [src/sop/contract_gate.py, src/sop/discriminators.py]
+    consumed_by: 随包代码 + 未随包的分析链 (见 docs §7)
+    files: 3
+  - dir: canonical/alias
+    kind: canonical 别名字典 (场站/机型/测点别名)
+    consumers: []
+    consumed_by: 未随包的分析链 (本包 0 处按名读取; 保留为数据字典)
+    files: 71
+    no_reader_ok: true
+    why: 别名表由分析链与本体侧的术语映射消费; 本包在位的同类是 configs/terms/display_map.yaml
+  - dir: contracts
+    kind: 字段契约 / 机型-场站对照表 (csv 与 yaml 混放)
+    consumers: [scripts/sim_hub/wake_anim.py, scripts/sim_hub/wake_real.py]
+    files: 74
+  - dir: farms
+    kind: 场配置 (两种 schema 混放, 见下两条)
+    consumers: [src/windscada/config.py]
+    files: 15
+    subkinds:
+      - name: 场定义 (windscada 用)
+        match: "*.yaml|*.json"
+        require_keys: [name, n_turbines, turbines, src_10min, src_alarm, store, rated_kw]
+        note: "约定用 YAML (新场写 yaml); 这类文件必须能被 available() 列出并加载"
+        present: 0
+      - name: 机型/场站物理约束 profile (非场定义)
+        match: "*.yaml"
+        require_keys: [meta, physical_constraints]
+        note: "另一条分析链的 schema (物理约束/脱敏词表); 本包**无读者** ⇒ 不参与场加载, 由 config_audit 记账"
+        present: 10
+      - name: CSV 交付/对照表
+        match: "*.csv"
+        note: "放在 farms/ 下的数据表 (机组清单/改造矩阵); 属数据而非配置, 见 docs §11 的处置"
+        present: 4
+  - dir: terms
+    kind: 术语库与规则 (显示层映射 / OEM 术语基准 / 行话规则)
+    consumers: [src/windscada/terms.py, src/windscada/ui/build.py, scripts/audit_chinese_terms.py, scripts/curate_oem_lexicon.py]
+    files: 3
+
+# 代码引用的配置, 在包里**不存在** —— 不是"忘了拷", 而是那条线的配置从未随包 (与 docs §7 的缺口同源)。
+# 审计按"悬空引用"逐条报出; 每条的**影响**必须写清 (缺了会怎样), 不许含糊。
+known_missing:
+  - file: analysis_lock.yaml
+    referenced_by: [src/sop/analysis_lock.py]
+    impact: 分析锁校验不可用 (该模块会返回"找不到锁"并给出建文件指引, 不会崩)
+    fix: 需要哪一场就建 configs/analysis_lock_<场>.yaml (六项冻结字段见模块头)
+    also_missing: [analysis_lock_<场>.yaml]
+  - file: scenario_registry.yaml
+    referenced_by: [src/sop/scenarios.py]
+    impact: SOP 场景解析不可用 (load_registry 直接 FileNotFoundError; 本包无调用方, 故不影响页面)
+    fix: 从研发侧取回场景注册表; 或该功能不使用则明确删除该模块的入口引用
+  - file: discriminator_registry.yaml
+    referenced_by: [src/sop/cases.py]
+    impact: 案例标题 → 判别器正式 id 的外键校验缺失 (cases 侧退化为 slug)
+    fix: 随包补齐
+  - file: analysis_modules.yaml
+    referenced_by: [src/sop/schemas.py]
+    impact: 分析模块标准卡的必填键校验缺单源 (schema 校验只在内存里做)
+    fix: 随包补齐
+  - file: value_assumptions/<场>.yaml
+    referenced_by: [src/sop/wrapup.py]
+    impact: 经济性换算的价参假设取不到 ⇒ 退回命令行参数/内置假设 (wrapup 里已显式标注 benchmark_assumed)
+    fix: 建 configs/value_assumptions/<场>.yaml (price 段)
+  - file: terms/oem_lexicon.yaml
+    referenced_by: [scripts/build_oem_lexicon.py]
+    impact: 无 (它是该脚本的**输出**, 首次运行生成)
+    fix: 跑一次 scripts/build_oem_lexicon.py 即生成 (需要 --roots 指向文档根)

+ 0 - 17
configs/serve.json.bak-bomfix

@@ -1,17 +0,0 @@
-{
-    "host":  "127.0.0.1",
-    "gateway":  28084,
-    "detail":  18033,
-    "cms":  18020,
-    "sim":  18791,
-    "sim_sys":  18792,
-    "viewer":  64292,
-    "ollama":  11434,
-    "release_dir":  "release",
-    "viewer_dir":  "release/viewer",
-    "sim_dir":  "resources/oem_envision_sc1_rudong2014",
-    "python":  "F:\\temp\\guanlan-rudong-v2_0.2.0\\.venv\\Scripts\\python.exe",
-    "_note":  "绔彛鍙湪杩欓噷鏀?(缃戝叧璺敱琛?scripts/guanlan_gateway.py 閲岀殑涓婃父绔彛椤诲悓姝? v0.1 浠嶆槸纭紪鐮? 瑙佽鏄庝功 搂7)",
-    "raw_dir":  "data/raw",
-    "_note_raw":  "鍘熷 SCADA/鎶€鏈祫鏂欑洰褰?(鐩稿鏈洰褰曟垨缁濆璺緞, 濡?D:\\\\guanlan\\\\data\\\\raw); 鍚姩鍣ㄥ鍑轰负 WINDSCADA_RUDONG_SRC; 鍘熷浠朵笉闅忓寘鍒嗗彂"
-}

+ 6 - 2
docs/数据目录结构与落位约定_v0.2.md

@@ -26,10 +26,14 @@
 │    ├─ m5_cms_tcm\                  振动线 handoff 与 TCM 兼容件
 │    ├─ m5_cms_tcm\                  振动线 handoff 与 TCM 兼容件
 │    ├─ guanlan\facts_contract_v0.json  事实契约 (问答引文的依据)
 │    ├─ guanlan\facts_contract_v0.json  事实契约 (问答引文的依据)
 │    └─ sop\                         SOP 中间件/评审/台账 (含少量历史装配脚本)
 │    └─ sop\                         SOP 中间件/评审/台账 (含少量历史装配脚本)
-├─ configs\                          端口/模型/场配置/机型契约
+├─ configs\                          端口/模型/场配置/机型契约 (目录约定见 系统设计说明 §11)
 │    ├─ serve.json                   端口、发布目录、Python 路径 (install 脚本写)
 │    ├─ serve.json                   端口、发布目录、Python 路径 (install 脚本写)
 │    ├─ models.json                  Ollama 档位与 pull 命令
 │    ├─ models.json                  Ollama 档位与 pull 命令
-│    ├─ farms\<场名>.json             外场配置 (内置 rudong 可不配); _模板.json.example 是模板
+│    ├─ portal_pages.yaml            门户页面归口登记表 (哪些页是产物/该不该随数据变, §10)
+│    ├─ registry.yaml                配置登记表 (每个域是什么、谁读它、缺哪些)
+│    ├─ farms\<场名>.yaml             外场配置 (**YAML**, 内置 rudong 可不配); _模板.yaml.example 是模板
+│    │                               ★ 同目录还有一类 schema 不同的"机型/物理约束 profile"(meta+physical_constraints),
+│    │                                 不是场定义, 不参与场加载 (见 §11)
 │    ├─ contracts\                   机型契约 (变桨/偏航/发电机…判据参数)
 │    ├─ contracts\                   机型契约 (变桨/偏航/发电机…判据参数)
 │    ├─ canonical\ terms\            术语与词典 (canonical 决策/字典/手册; terms 显示映射)
 │    ├─ canonical\ terms\            术语与词典 (canonical 决策/字典/手册; terms 显示映射)
 ├─ src\                              库源码: windscada(分析) / windcms(振动) / ontology(本体) / sop(方法)
 ├─ src\                              库源码: windscada(分析) / windcms(振动) / ontology(本体) / sop(方法)

+ 84 - 0
docs/系统设计说明.md

@@ -281,6 +281,7 @@
 | 2026-09-17 | **入口脚本的换行/编码事故两连** —— 这类问题都不会在开发机上暴露,只在目标机双击时炸:<br>① `install.ps1` 的 UTF-8 BOM 被编辑工具吃掉(我在规范化之后又改了一次文件)⇒ PS 5.1 按 GBK 解码 ⇒ 中文乱码 + 级联 ParserError,目标机 `install` **1 秒即退出**。开箱验证当场逮到(`install 退出码 1, 耗时 1s` + `The '<' operator is reserved for future use`)。<br>② `install.sh` **从写出来那天起就是 CRLF**(git HEAD 的 blob 就是 CRLF,不是某次编辑造成的)⇒ POSIX 语义下每个词尾粘 `\r`:`set -e` 变非法选项、`RT=""` 变 `RT="\r"` 让后续 `-n "$RT"` 判断翻面 ⇒ **此前所有交付包的 Linux/macOS 安装脚本都是坏的**。<br>加固:`src/entry_refs.py::encoding_problems()` 成文守则(`.ps1` = UTF-8 BOM + CRLF;`.bat` = CRLF 无 BOM;`.sh` = LF),在**打包、开箱验证第①b 步、装机自检**三处强制执行(违反就不出包 / 直接判 FAIL);`guanlan.py check` 增一行;新增 `.gitattributes` 把 checkout 也钉死(`*.bat/*.ps1 eol=crlf`、`*.sh eol=lf`)。变异测试:去掉 BOM / 改 CRLF / .bat 存成 LF 三种改法都被逐条报出 |
 | 2026-09-17 | **入口脚本的换行/编码事故两连** —— 这类问题都不会在开发机上暴露,只在目标机双击时炸:<br>① `install.ps1` 的 UTF-8 BOM 被编辑工具吃掉(我在规范化之后又改了一次文件)⇒ PS 5.1 按 GBK 解码 ⇒ 中文乱码 + 级联 ParserError,目标机 `install` **1 秒即退出**。开箱验证当场逮到(`install 退出码 1, 耗时 1s` + `The '<' operator is reserved for future use`)。<br>② `install.sh` **从写出来那天起就是 CRLF**(git HEAD 的 blob 就是 CRLF,不是某次编辑造成的)⇒ POSIX 语义下每个词尾粘 `\r`:`set -e` 变非法选项、`RT=""` 变 `RT="\r"` 让后续 `-n "$RT"` 判断翻面 ⇒ **此前所有交付包的 Linux/macOS 安装脚本都是坏的**。<br>加固:`src/entry_refs.py::encoding_problems()` 成文守则(`.ps1` = UTF-8 BOM + CRLF;`.bat` = CRLF 无 BOM;`.sh` = LF),在**打包、开箱验证第①b 步、装机自检**三处强制执行(违反就不出包 / 直接判 FAIL);`guanlan.py check` 增一行;新增 `.gitattributes` 把 checkout 也钉死(`*.bat/*.ps1 eol=crlf`、`*.sh eol=lf`)。变异测试:去掉 BOM / 改 CRLF / .bat 存成 LF 三种改法都被逐条报出 |
 | 2026-09-17 | 打包后**对着 zip 条目逐件复核**(3,733 件全部与工作树逐字节相同)+ 开箱验证:解压 3,734 件 → ①b 闭合与编码守则通过 → 离线安装 rc=0(172 s)→ ②b 快捷方式在位 + `console_hwnd=0` 无窗口成立 + 包内无 `.vbs` → 页面 4/5(`/` 与主包同字节;`/cms/` 503 属无产物预期) |
 | 2026-09-17 | 打包后**对着 zip 条目逐件复核**(3,733 件全部与工作树逐字节相同)+ 开箱验证:解压 3,734 件 → ①b 闭合与编码守则通过 → 离线安装 rc=0(172 s)→ ②b 快捷方式在位 + `console_hwnd=0` 无窗口成立 + 包内无 `.vbs` → 页面 4/5(`/` 与主包同字节;`/cms/` 503 属无产物预期) |
 | 2026-09-17 | **用户令 1「页面是否属于产物」的落地(见 §10)**:新建登记表 `configs/portal_pages.yaml`(20 个页面/子页 + 五类 kind + 逐条判定依据)与检查器 `scripts/pages_audit.py`(五条机器规则;**陈旧检测** rc=7:内嵌快照的 `source_sha256` 与当前产物不符即报"数据变了页面没变");`guanlan.py check` 增一行;`rebuild_all.py` 增 ⑧b「重装门户(把新产物灌进门户结论段)」与 ⑧c「页面归口审计」;文档增 §10(含用户问的三页逐页判定)。**结论**:`#findings` 静态、非产物;`#sim` 本体是图纸派生的冻结资料包、其中"实际运行回放"面板属产物;`#documents` 里治理清单/报告是冻结交付件,而脱敏状态一览与取数单属产物且缺生成端与溯源(已入 §7 缺口表) |
 | 2026-09-17 | **用户令 1「页面是否属于产物」的落地(见 §10)**:新建登记表 `configs/portal_pages.yaml`(20 个页面/子页 + 五类 kind + 逐条判定依据)与检查器 `scripts/pages_audit.py`(五条机器规则;**陈旧检测** rc=7:内嵌快照的 `source_sha256` 与当前产物不符即报"数据变了页面没变");`guanlan.py check` 增一行;`rebuild_all.py` 增 ⑧b「重装门户(把新产物灌进门户结论段)」与 ⑧c「页面归口审计」;文档增 §10(含用户问的三页逐页判定)。**结论**:`#findings` 静态、非产物;`#sim` 本体是图纸派生的冻结资料包、其中"实际运行回放"面板属产物;`#documents` 里治理清单/报告是冻结交付件,而脱敏状态一览与取数单属产物且缺生成端与溯源(已入 §7 缺口表) |
+| 2026-09-17 | **用户令 2「统一配置与日志」的落地(见 §11)**:① 配置——`src/paths.py` 增唯一取用口 `P.config()/P.config_dir()/P.farm_config()`,9 个模块不再手拼 configs 路径;新增登记表 `configs/registry.yaml` 与检查器 `scripts/config_audit.py`(R1–R8);修掉三处真问题:**10 个场定义 YAML 因 `available()` 只 glob `*.json` 而"配了看不见"**(并查明它们其实是另一条链的机型/物理约束 profile,逐件登记在册)、坏模板 `_模板.json.example`(GBK 乱码 + 写死 `/Volumes/WINDDATA`)重写为 `_模板.yaml.example`、`sop/wrapup.py` 拼了不存在的 `configs/<场>/` 路径;删除 `serve.json.bak-bomfix`;5 处"代码引用但包里没有"的配置登进 `known_missing` 并写明影响。② 日志——新增 `src/logfile.py`(目录/命名/行格式唯一口径 + `prefix_stdout()` 流包装 + 动作日志保留 20 份/30 天 + `logs/build` 归位口)与 `scripts/log_audit.py`(L1–L5);6 个服务 + 启动器 + 运维动作全部接入统一格式(`viewer`/`sim` 改走新增的 `scripts/static_server.py`);**把 39 个躺在产物目录里的构建日志搬到 `logs/build/` 并同步重算产物台账**(2309→2270 件,`呼应校验` 仍 rc=0)、22 份历史动作日志与 8 个服务日志的旧内容归档 `logs/legacy/`;`guanlan.py check` 增两行 |
 
 
 ---
 ---
 
 
@@ -394,3 +395,86 @@
 | `embed_sc1_local` SC1 本地占位 (门户内嵌) | `static` | 否 | 0.4 KB 占位面板 |
 | `embed_sc1_local` SC1 本地占位 (门户内嵌) | `static` | 否 | 0.4 KB 占位面板 |
 
 
 <!-- PAGES:END -->
 <!-- PAGES:END -->
+
+---
+
+## 11. 配置与日志的统一口径(2026-09-17 用户令 2)
+
+### 11.1 配置目录与配置文件
+
+**唯一取用口**:代码只许用 `src/paths.py` 的 `P.config(...)` / `P.config_dir(...)` / `P.farm_config(...)`,
+不许再手拼 `"configs" / …`(统一前实测 9 个模块各拼各的:`llm_gate`、`windscada/terms`、`sop/analysis_lock`、
+`sop/scenarios`、`sop/wrapup`、`guanlan.py`、`guanlan_ops`、`page_fingerprint`、`check_transferable`,
+另有 `audit_chinese_terms.py` 要**试三个位置**才找得到 terms 库)。
+
+| 位置 | 放什么 | 谁读 |
+|---|---|---|
+| `configs/serve.json` | 运行期单件:端口/路径真源(`install` 脚本写) | `guanlan.py`、网关、运维控制台、指纹、移植检查 |
+| `configs/models.json` | 运行期单件:本机模型档与 pull 命令 | `guanlan.py check`、`src/ontology/llm_gate.py` |
+| `configs/portal_pages.yaml` | 运行期单件:门户页面归口登记表(§10) | `scripts/pages_audit.py` |
+| `configs/registry.yaml` | **配置登记表**:每个域是什么、谁读、缺哪些 | `scripts/config_audit.py` |
+| `configs/canonical/`(71 别名 + 3 决策) | 通道/测点 canonical 字典与别名字典 | `src/sop/contract_gate.py`、`discriminators.py`;别名表由未随包的分析链消费 |
+| `configs/contracts/`(74) | 机型契约(判据参数)与机型-场站对照表 | `scripts/sim_hub/wake_*.py`;(历史)分析链 |
+| `configs/farms/`(15) | **场定义**(YAML 为准,历史 `.json` 兼容) | `src/windscada/config.py` |
+| `configs/terms/`(3) | 显示层术语映射、OEM 术语基准、行话规则 | `src/windscada/terms.py`、`ui/build.py`、`audit_chinese_terms.py` |
+
+**本次修掉的三处真问题**:
+1. **场定义"配了看不见"**:`configs/farms/` 下 10 个 YAML 里 0 个能被 `available()` 列出(它只 glob `*.json`),
+   `farm('FUSHAN')` 直接报"未知场"。现在 yaml/json 都认、约定 YAML,并且——**更重要的**——查清了那 10 个
+   YAML 其实**不是场定义**而是另一条链的"机型/场站物理约束 profile"(顶层 `meta` + `physical_constraints`),
+   于是 `available()` 只列**能加载**的场、`foreign_farm_files()` 把 profile 逐件报出来(登记表里已声明),
+   并把 `farm()` 的报错写得能看懂("该文件存在但不是场定义…")。
+2. **配置模板是坏的**:`configs/farms/_模板.json.example` 是 GBK 乱码("绀轰緥椋庣數鍦?")且写死 `/Volumes/WINDDATA`,
+   与实物格式(YAML)相反。已重写为 `configs/farms/_模板.yaml.example`(UTF-8、YAML、`<场站名称>` 占位),
+   并同步 `docs/数据目录结构与落位约定_v0.2.md`。
+3. **散件与悬空引用**:删掉 `configs/serve.json.bak-bomfix`(历史备份垃圾);`sop/wrapup.py` 原来拼的是
+   `configs/<场>/value_assumptions.yaml`(那个目录根本不存在)→ 改为 `configs/value_assumptions/<场>.yaml`。
+   另有 5 处**代码引用的配置不在包里**(`analysis_lock*`、`scenario_registry`、`discriminator_registry`、
+   `analysis_modules`、`value_assumptions/<场>`),逐条登进 `registry.yaml::known_missing` 并写明**影响**
+   (例如场景注册表缺失 ⇒ SOP 场景解析不可用,但本包无调用方,故不影响页面)。
+
+**机器规则(`scripts/config_audit.py`)**:R1 顶层只许放登记过的运行期单件配置;R2 无备份垃圾(`*.bak*` 等);
+R3 命名(空格判错、中文/大写只提示);R4 内容不得含本机绝对路径——**区分字段**:说明性字段/注释里记的
+"当时数据在哪台机器"算溯源信息(提示),**被读取的字段**里出现 `C:\`、`/Volumes/…` 才是失配(判错);
+R5 代码引用的配置必须存在(未登记的按悬空引用判错,登记过的记 `i`);R6 不许手拼 configs 路径;
+R7 每个域必须声明 consumers 或写明"无人读";R8 `configs/farms/` 每个文件要么是能加载的场定义,
+要么在登记表里声明为 profile/CSV 数据表。现状:**171 件配置、5 个域、1 个场定义、10 件机型 profile、
+0 不一致**(14 条已知缺口、7 条提示)。
+
+### 11.2 日志目录、命名与内容格式
+
+**统一前**(实测,不是推测):`logs/` 顶层堆了 34 个文件,其中 22 个是历史动作日志(无保留策略);
+**39 个构建日志躺在产物目录里**(`outputs/<场>/windscada/*.log` 等),还被 `_provenance.json` 登记成
+`shipped` 随包件——产物台账里混着日志;`detail.log` 首行是 `b'windscada serve :18033\r\n'`(CRLF),
+`cms.log` 首行是一条历史 `SyntaxWarning`;**行里没有时间戳也没有级别**,`import logging` 的文件数 = 0。
+
+| 位置 | 放什么 | 命名 |
+|---|---|---|
+| `logs/<组件>.log` | 长驻服务与启动器(组件 = `serve.json` 的键 + `gateway`/`serve`/`start_hidden`) | `gateway.log`、`detail.log`、`cms.log`、`sim.log`、`sim_sys.log`、`viewer.log`、`serve.log`、`start_hidden.log` |
+| `logs/ops/` | 运维动作(自动保留最近 **20 份 / 30 天**,写前清理) | `<动作>_<YYYYmmdd-HHMMSS>.log` |
+| `logs/audit/` | 机器审计流水(一行一条 JSON) | `llm_audit.jsonl`、`cloud_qa.jsonl`、`terms_audit.json` |
+| `logs/build/<场>/…` | 构建/摄入脚本的日志(按产物子路径归档) | 沿用原相对路径 |
+| `logs/legacy/` | 统一格式**之前**的日志(启动器与动作的历史留档,可随时删) | 原名 + 时间戳 |
+
+**行格式**(每一行都要满足,正则 `src/logfile.py::LINE_RE`):
+`YYYY-MM-DD HH:MM:SS LEVEL 组件 消息`,级别 ∈ DEBUG/INFO/WARN/ERROR;UTF-8 无 BOM、行尾 LF、无 ANSI 颜色码。
+
+**怎么保证 6 个服务都合规**:不要求各服务改写自己的 print —— 入口处调一次
+`src/logfile.py::prefix_stdout('<组件>')`,之后 stdout/stderr 的每一行自动带前缀(含把 stderr 与含
+`error/traceback/失败` 字样的行标成 `ERROR`)。`viewer`/`sim` 原先是 `python -m http.server`(Common Log
+格式,挂不上前缀),改用新增的 `scripts/static_server.py`(同样带统一格式)。运维动作日志改由
+`logfile.action_log()` 生成并带保留策略;本体模型闸与云问答审计改落 `logs/audit/`。
+
+**本次的实际搬迁(都做了台账/留档,不是悄悄删)**:
+* **39 个构建日志** `outputs/**/*.log` → `logs/build/<场>/<原相对路径>`;同时把 `_provenance.json` 里这 39 条
+  删掉并**重算计数**(2309 → 2270 件;shipped 569 → 530;raw-derived 1740 不变),台账里写明"因日志归位移出"。
+  搬迁后 `inventory_products.py --check` 仍 **rc=0 呼应正常**。
+* **22 份历史动作日志**(`ops_*.log`,2026-09-12 那批)与 `_proc_reg.log` → `logs/legacy/`;
+* 8 个服务日志里"统一格式之前"的内容 → `logs/legacy/<名>.<时间戳>.log`,现场文件只保留合规行
+  (服务以 append 持句柄,原地截断不影响继续写);`logs/llm_audit.jsonl` → `logs/audit/`。
+
+**机器规则(`scripts/log_audit.py`)**:L1 运行日志只在 `logs/` 下(白名单:产物附件 `analyze_stdout.log`、
+第三方工具缓存 `.npm-cache/`、`.qa-profile/`、`node_modules/`,各写了理由);L2 命名;L3 **按末尾 200 行**校验
+行格式(启用前的旧行不算数);L4 行尾/颜色(历史行只提示);L5 `logs/ops/` 保留策略 + `logs/audit/*.jsonl`
+每行必须是合法 JSON。现状:**8 服务日志 + 39 构建日志 + 1 审计流水,0 不一致**(39 条"历史构建日志"提示,
+写它们的人不在本包);`guanlan.py check` 各报一行。

+ 28 - 4
guanlan.py

@@ -12,6 +12,8 @@ from __future__ import annotations
 import json, os, socket, subprocess, sys, time, urllib.request, warnings, webbrowser, signal
 import json, os, socket, subprocess, sys, time, urllib.request, warnings, webbrowser, signal
 from pathlib import Path
 from pathlib import Path
 ROOT = Path(__file__).resolve().parent; RUN = ROOT / "run"; LOGS = ROOT / "logs"; PIDS = RUN / "pids.json"
 ROOT = Path(__file__).resolve().parent; RUN = ROOT / "run"; LOGS = ROOT / "logs"; PIDS = RUN / "pids.json"
+sys.path.insert(0, str(ROOT))
+from src import paths as P          # 配置/路径唯一取用口 (P.SERVE_JSON / P.config()…, 见 docs §11)
 WIN = os.name == "nt"
 WIN = os.name == "nt"
 
 
 
 
@@ -38,7 +40,7 @@ def jload(p: Path):
 
 
 
 
 def cfg():
 def cfg():
-    c = dict(DEFAULT); p = ROOT / "configs/serve.json"
+    c = dict(DEFAULT); p = P.SERVE_JSON          # 端口/路径真源 (配置取用口 = src/paths.py)
     if p.exists():
     if p.exists():
         try: c.update(jload(p))
         try: c.update(jload(p))
         except Exception as ex:  # 配置写坏不该让整个产品起不来: 退回内置默认端口, 但要说清楚
         except Exception as ex:  # 配置写坏不该让整个产品起不来: 退回内置默认端口, 但要说清楚
@@ -77,9 +79,11 @@ def services(c):
     return [
     return [
         ("detail", c["detail"], [P, "scripts/windscada_serve.py", "--host", h, "--port", str(c["detail"])]),
         ("detail", c["detail"], [P, "scripts/windscada_serve.py", "--host", h, "--port", str(c["detail"])]),
         ("cms", c["cms"], [P, "scripts/windcms.py", "serve", "--farm", "rudong", "--port", str(c["cms"])]),
         ("cms", c["cms"], [P, "scripts/windcms.py", "serve", "--farm", "rudong", "--port", str(c["cms"])]),
-        ("viewer", c["viewer"], [P, "-m", "http.server", str(c["viewer"]), "--bind", h, "--directory", str(ROOT / c["viewer_dir"])]),
+        ("viewer", c["viewer"], [P, "scripts/static_server.py", "--port", str(c["viewer"]), "--host", h,
+                                 "--dir", str(ROOT / c["viewer_dir"]), "--comp", "viewer"]),
         ("sim_sys", c["sim_sys"], [P, "-u", str(ROOT / c["release_dir"] / "sim_sys_server.py")]),
         ("sim_sys", c["sim_sys"], [P, "-u", str(ROOT / c["release_dir"] / "sim_sys_server.py")]),
-        ("sim", c["sim"], [P, "-m", "http.server", str(c["sim"]), "--bind", h, "--directory", str(ROOT / c["sim_dir"])]),
+        ("sim", c["sim"], [P, "scripts/static_server.py", "--port", str(c["sim"]), "--host", h,
+                               "--dir", str(ROOT / c["sim_dir"]), "--comp", "sim"]),
         ("gateway", c["gateway"], [P, "scripts/guanlan_gateway.py", "--host", h, "--port", str(c["gateway"])]),
         ("gateway", c["gateway"], [P, "scripts/guanlan_gateway.py", "--host", h, "--port", str(c["gateway"])]),
     ]
     ]
 
 
@@ -198,6 +202,25 @@ def cmd_check(c):
         row("页面归口: 页面是否随数据变 / 是否陈旧 (configs/portal_pages.yaml)", _rc == 0, _note)
         row("页面归口: 页面是否随数据变 / 是否陈旧 (configs/portal_pages.yaml)", _rc == 0, _note)
     except Exception as _e:
     except Exception as _e:
         row("页面归口审计", False, f"{type(_e).__name__}: {_e}")
         row("页面归口审计", False, f"{type(_e).__name__}: {_e}")
+    # 配置与日志的统一口径 (2026-09-17 用户令 2): 配置目录/文件、日志目录/命名/格式。
+    # 两个检查器都是"看一眼实物 + 代码"的静态检查, 很便宜; 有问题就 FAIL 并指到具体文件。
+    for _script, _label in (("config_audit.py", "配置统一: 目录约定/命名/内容/引用闭合/不手拼路径"),
+                            ("log_audit.py", "日志统一: logs/ 唯一落点/命名/行格式/保留策略")):
+        try:
+            import importlib.util as _ilu2
+            _sp = _ilu2.spec_from_file_location(f'_{_script[:-3]}', ROOT / "scripts" / _script)
+            _mod = _ilu2.module_from_spec(_sp)
+            _sp.loader.exec_module(_mod)
+            _rc2, _res2, _info2 = _mod.audit()
+            _lvl = {}
+            for _l in _res2:
+                _lvl[_l[0]] = _lvl.get(_l[0], 0) + 1
+            _bad2 = [r for r in _res2 if r[0] in ("X", "!")]
+            row(_label, _rc2 == 0,
+                (f"rc={_rc2}: " + "; ".join(f"{r[1]}: {r[2][:70]}" for r in _bad2[:2])) if _bad2
+                else f"无不一致 · 已知缺口/白名单 {_lvl.get('i', 0)} · 提示 {_lvl.get('?', 0)}")
+        except Exception as _e:
+            row(_label, False, f"{type(_e).__name__}: {_e}")
     for m in ("numpy", "pandas", "pyarrow", "polars", "yaml", "matplotlib", "plotly", "jinja2", "docx"):
     for m in ("numpy", "pandas", "pyarrow", "polars", "yaml", "matplotlib", "plotly", "jinja2", "docx"):
         try: __import__(m); row(f"依赖 {m}", True)
         try: __import__(m); row(f"依赖 {m}", True)
         except Exception as ex: row(f"依赖 {m}", False, f"未安装: {ex.__class__.__name__} (运行 install 脚本)")
         except Exception as ex: row(f"依赖 {m}", False, f"未安装: {ex.__class__.__name__} (运行 install 脚本)")
@@ -225,7 +248,7 @@ def cmd_check(c):
     # 模型闸: 探针 (generate 非 tags) + 档位模型是否都在本机 + 摘要记录; 无模型时问答不可用, 其余页面不受影响
     # 模型闸: 探针 (generate 非 tags) + 档位模型是否都在本机 + 摘要记录; 无模型时问答不可用, 其余页面不受影响
     sys.path.insert(0, str(ROOT))
     sys.path.insert(0, str(ROOT))
     try:
     try:
-        from src.ontology import llm_gate; mcfg = jload(ROOT / "configs/models.json")
+        from src.ontology import llm_gate; mcfg = jload(P.MODELS_JSON)
         pr = llm_gate.probe(mcfg["tiers"]["default_qa"]["model"], timeout=90); row("本机模型 Ollama 探针 (默认档)", pr["ok"], f"{pr.get('latency_s')} s, digest {pr.get('digest')}" if pr["ok"] else pr.get("err", "") + " (启动 Ollama 并按 configs/models.json 的 pull_commands 拉模型)")
         pr = llm_gate.probe(mcfg["tiers"]["default_qa"]["model"], timeout=90); row("本机模型 Ollama 探针 (默认档)", pr["ok"], f"{pr.get('latency_s')} s, digest {pr.get('digest')}" if pr["ok"] else pr.get("err", "") + " (启动 Ollama 并按 configs/models.json 的 pull_commands 拉模型)")
         try:
         try:
             with urllib.request.urlopen(f"http://{c['host']}:{c['ollama']}/api/tags", timeout=10) as r: have = {m["name"] for m in json.load(r).get("models", [])}
             with urllib.request.urlopen(f"http://{c['host']}:{c['ollama']}/api/tags", timeout=10) as r: have = {m["name"] for m in json.load(r).get("models", [])}
@@ -238,6 +261,7 @@ def cmd_check(c):
 
 
 
 
 def cmd_serve(c):
 def cmd_serve(c):
+    from src import logfile as _lf; _lf.prefix_stdout('serve')      # 统一日志格式 (用户令 2)
     RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True); pids = jload(PIDS) if PIDS.exists() else {}; e = env(c)
     RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True); pids = jload(PIDS) if PIDS.exists() else {}; e = env(c)
     for name, port, cmd in services(c):
     for name, port, cmd in services(c):
         if up(c["host"], port): print(f"  {name} {port} 已在运行 (复用)"); continue
         if up(c["host"], port): print(f"  {name} {port} 已在运行 (复用)"); continue

+ 74 - 71
release/sim_sys_server.py

@@ -1,71 +1,74 @@
-import html, re, zipfile
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from urllib.parse import unquote, urlparse
-
-import pathlib
-ARCHIVE = str(pathlib.Path(__file__).resolve().parent / '如东SWT40_控制律仿真台_20260906.zip')
-
-def readable(name):
-    try:
-        return name.encode('cp437').decode('utf-8')
-    except UnicodeError:
-        return name
-
-with zipfile.ZipFile(ARCHIVE) as archive:
-    PAGES = {
-        readable(name.rsplit('/', 1)[-1]): archive.read(name).decode('utf-8')
-        for name in archive.namelist()
-        if name.endswith('.html') and readable(name.rsplit('/', 1)[-1])[:1] in {'0', '1', '2', '3', '4'}
-    }
-
-THEME = '''
-<style id="guanlan-subsystem-theme">
-:root{--bg:#edf7f9;--panel:#ffffff;--ink:#133047;--ink2:#28536b;--ink3:#61788b;--line:#cbe2eb;--line2:#e1eef3;--spec:#0e8898;--specf:#dff4f4;--meas:#2077a8;--measf:#e5f2fa;--ok:#23875b;--warn:#d48818;--bad:#d94b43}
-html,body{background:var(--bg)!important;color:var(--ink)!important}.wrap{max-width:1420px!important;padding:20px 28px 44px!important}.guanlan-top{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:0 0 18px;padding:16px 20px;border-radius:18px;background:linear-gradient(110deg,#10384d,#0e6470);color:#fff;box-shadow:0 12px 30px #0c50661c}.guanlan-top b{font-size:15px;letter-spacing:.04em}.guanlan-top a{color:#e8fbff;text-decoration:none;border:1px solid #a7dce3;border-radius:999px;padding:8px 13px;font-size:13px}.hd{border-radius:20px!important;overflow:hidden}.panel,.ctl,.note,.box{border-color:var(--line)!important;box-shadow:0 8px 22px #1450660b!important}.ctl{background:#f8fcfd!important}.ctl label{color:var(--ink2)!important}.ctl input,.ctl select{accent-color:#0e8898!important}.card,.panel{background:#fff!important}.btn,.primary{background:#0e8898!important;color:white!important;border-color:#0e8898!important}.back{margin:0!important}#th{display:none!important}
-.thermal-band{grid-column:1/-1;display:grid;grid-template-columns:1.2fr 2.8fr;gap:16px;align-items:center;background:linear-gradient(100deg,#f7fbfc,#e9f8f8);border:1px solid #bcdfe5;border-radius:16px;padding:14px 16px;margin:3px 0 10px}.thermal-band h2{font-size:16px;margin:0 0 4px;color:#133047}.thermal-band p{margin:0;color:#61788b;font-size:13px}.thermal-meter{position:relative;height:24px;border-radius:999px;background:linear-gradient(90deg,#2a9d68 0 50%,#e2aa2a 50% 84%,#dc5148 84%);box-shadow:inset 0 0 0 1px #9fc7d2}.thermal-meter::before{content:'';position:absolute;top:-4px;bottom:-4px;left:calc(var(--temp-pct,22)*1%);width:4px;border-radius:3px;background:#133047;box-shadow:0 0 0 3px #fff}.thermal-meter::after{content:attr(data-temp);position:absolute;top:-30px;left:calc(var(--temp-pct,22)*1%);transform:translateX(-50%);white-space:nowrap;font:700 13px/1.1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#133047}.thermal-scale{display:flex;justify-content:space-between;margin-top:7px;color:#61788b;font:11px ui-monospace,SFMono-Regular,Menlo,monospace}.thermal-meta{display:flex;justify-content:flex-end;gap:10px;margin-top:9px;color:#45677a;font-size:12px}.thermal-meta strong{color:#0e6e7a}
-@media(max-width:640px){.wrap{padding:12px 12px 30px!important}.guanlan-top{padding:12px 14px;border-radius:14px}.guanlan-top b{font-size:13px}.guanlan-top a{font-size:12px;padding:7px 10px}.thermal-band{grid-template-columns:1fr;padding:13px}.thermal-meter{margin-top:14px}.thermal-meta{justify-content:flex-start;flex-wrap:wrap}.ctl{gap:10px!important}.ctl label{min-width:100%!important}}
-</style>'''
-
-THERMAL = '''
-<script id="guanlan-thermal-strip">
-(()=>{const mount=()=>{const controls=document.querySelector('.ctl');if(!controls||document.querySelector('.thermal-band'))return;const band=document.createElement('section');band.className='thermal-band';band.innerHTML='<div><h2>定子温度与保护裕度</h2><p>温度条按 0–155 °C 标尺显示;深色标记为当前定子真值。</p></div><div><div class="thermal-meter" id="thermalMeter" data-temp="-- °C"></div><div class="thermal-scale"><span>0 °C</span><span>80 °C 风机 1 级</span><span>100 °C 风机 2 级</span><span>130.2 °C 告警</span><span>155 °C 保护</span></div><div class="thermal-meta"><span id="thermalRead">定子真值:--</span><span id="thermalMargin">保护余量:--</span></div></div>';controls.insertAdjacentElement('afterend',band);const sync=()=>{const found=document.body.innerText.match(/★定子真值\\s+([0-9.]+)\\s*°C/);const temp=found?Number(found[1]):null;const meter=document.getElementById('thermalMeter'),read=document.getElementById('thermalRead'),margin=document.getElementById('thermalMargin');if(!Number.isFinite(temp)){meter.dataset.temp='计算中';read.textContent='定子真值:计算中';margin.textContent='保护余量:计算中'}else{const pct=Math.max(1,Math.min(99,temp/155*100));meter.style.setProperty('--temp-pct',pct.toFixed(1));meter.dataset.temp=temp.toFixed(1)+' °C';read.textContent='定子真值:'+temp.toFixed(1)+' °C';margin.textContent='保护余量:'+(155-temp).toFixed(1)+' °C'}};setInterval(sync,120);sync()};if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount);else mount()})();
-</script>'''
-
-
-# 对外脱敏 —— 规则表唯一正本在 release/scrub_rules.py, 禁止在此另抄一份
-from scrub_rules import scrub, residual
-
-def page_for(name):
-    raw = PAGES[name]
-    # Current delivered generator page references two removed top-level fields. Map them
-    # to their structured source fields before its own simulation loop starts.
-    if name.startswith('3_'):
-        raw = raw.replace('TH.A_noload', 'TH.stator_model.A').replace('TH.B_copper', 'TH.stator_model.B')
-    if name.startswith('0_'):
-        raw = raw.replace('如东 SWT-4.0-130 控制律仪表台', '如东 SWT-4.0-130 · 四系统联调')
-    raw = raw.replace('<html lang="zh-CN">', '<html lang="zh-CN" data-theme="light">', 1)
-    raw = raw.replace('</head>', THEME + '</head>', 1)
-    header = '<div class="guanlan-top"><b>DASHENG · 分系统仿真</b><a href="http://127.0.0.1:18084/大生_样板_门户_单文件.html#sim">← 返回仿真中心</a></div>'
-    raw = raw.replace('<div class="wrap">', '<div class="wrap">' + header, 1)
-    raw = raw.replace('$("#th").onclick=()=>{', '$("#th").style.display="none"; $("#th").onclick=()=>{', 1)
-    if name.startswith('3_'):
-        raw = raw.replace('</body>', THERMAL + '</body>', 1)
-    raw = scrub(raw)          # 最后一道: 连注入的 header/THEME 一起洗
-    return raw
-
-class Handler(BaseHTTPRequestHandler):
-    def do_GET(self):
-        name = unquote(urlparse(self.path).path).lstrip('/') or '0_四系统合页.html'
-        if name not in PAGES:
-            self.send_error(404, 'simulation page not found')
-            return
-        body = page_for(name).encode('utf-8')
-        self.send_response(200)
-        self.send_header('Content-Type', 'text/html; charset=utf-8')
-        self.send_header('Content-Length', str(len(body)))
-        self.end_headers()
-        self.wfile.write(body)
-    def log_message(self, fmt, *args):
-        print('[18792]', fmt % args, flush=True)
-
-ThreadingHTTPServer(('127.0.0.1', 18792), Handler).serve_forever()
+import html, re, zipfile
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import unquote, urlparse
+
+import pathlib
+ARCHIVE = str(pathlib.Path(__file__).resolve().parent / '如东SWT40_控制律仿真台_20260906.zip')
+
+def readable(name):
+    try:
+        return name.encode('cp437').decode('utf-8')
+    except UnicodeError:
+        return name
+
+with zipfile.ZipFile(ARCHIVE) as archive:
+    PAGES = {
+        readable(name.rsplit('/', 1)[-1]): archive.read(name).decode('utf-8')
+        for name in archive.namelist()
+        if name.endswith('.html') and readable(name.rsplit('/', 1)[-1])[:1] in {'0', '1', '2', '3', '4'}
+    }
+
+THEME = '''
+<style id="guanlan-subsystem-theme">
+:root{--bg:#edf7f9;--panel:#ffffff;--ink:#133047;--ink2:#28536b;--ink3:#61788b;--line:#cbe2eb;--line2:#e1eef3;--spec:#0e8898;--specf:#dff4f4;--meas:#2077a8;--measf:#e5f2fa;--ok:#23875b;--warn:#d48818;--bad:#d94b43}
+html,body{background:var(--bg)!important;color:var(--ink)!important}.wrap{max-width:1420px!important;padding:20px 28px 44px!important}.guanlan-top{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:0 0 18px;padding:16px 20px;border-radius:18px;background:linear-gradient(110deg,#10384d,#0e6470);color:#fff;box-shadow:0 12px 30px #0c50661c}.guanlan-top b{font-size:15px;letter-spacing:.04em}.guanlan-top a{color:#e8fbff;text-decoration:none;border:1px solid #a7dce3;border-radius:999px;padding:8px 13px;font-size:13px}.hd{border-radius:20px!important;overflow:hidden}.panel,.ctl,.note,.box{border-color:var(--line)!important;box-shadow:0 8px 22px #1450660b!important}.ctl{background:#f8fcfd!important}.ctl label{color:var(--ink2)!important}.ctl input,.ctl select{accent-color:#0e8898!important}.card,.panel{background:#fff!important}.btn,.primary{background:#0e8898!important;color:white!important;border-color:#0e8898!important}.back{margin:0!important}#th{display:none!important}
+.thermal-band{grid-column:1/-1;display:grid;grid-template-columns:1.2fr 2.8fr;gap:16px;align-items:center;background:linear-gradient(100deg,#f7fbfc,#e9f8f8);border:1px solid #bcdfe5;border-radius:16px;padding:14px 16px;margin:3px 0 10px}.thermal-band h2{font-size:16px;margin:0 0 4px;color:#133047}.thermal-band p{margin:0;color:#61788b;font-size:13px}.thermal-meter{position:relative;height:24px;border-radius:999px;background:linear-gradient(90deg,#2a9d68 0 50%,#e2aa2a 50% 84%,#dc5148 84%);box-shadow:inset 0 0 0 1px #9fc7d2}.thermal-meter::before{content:'';position:absolute;top:-4px;bottom:-4px;left:calc(var(--temp-pct,22)*1%);width:4px;border-radius:3px;background:#133047;box-shadow:0 0 0 3px #fff}.thermal-meter::after{content:attr(data-temp);position:absolute;top:-30px;left:calc(var(--temp-pct,22)*1%);transform:translateX(-50%);white-space:nowrap;font:700 13px/1.1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#133047}.thermal-scale{display:flex;justify-content:space-between;margin-top:7px;color:#61788b;font:11px ui-monospace,SFMono-Regular,Menlo,monospace}.thermal-meta{display:flex;justify-content:flex-end;gap:10px;margin-top:9px;color:#45677a;font-size:12px}.thermal-meta strong{color:#0e6e7a}
+@media(max-width:640px){.wrap{padding:12px 12px 30px!important}.guanlan-top{padding:12px 14px;border-radius:14px}.guanlan-top b{font-size:13px}.guanlan-top a{font-size:12px;padding:7px 10px}.thermal-band{grid-template-columns:1fr;padding:13px}.thermal-meter{margin-top:14px}.thermal-meta{justify-content:flex-start;flex-wrap:wrap}.ctl{gap:10px!important}.ctl label{min-width:100%!important}}
+</style>'''
+
+THERMAL = '''
+<script id="guanlan-thermal-strip">
+(()=>{const mount=()=>{const controls=document.querySelector('.ctl');if(!controls||document.querySelector('.thermal-band'))return;const band=document.createElement('section');band.className='thermal-band';band.innerHTML='<div><h2>定子温度与保护裕度</h2><p>温度条按 0–155 °C 标尺显示;深色标记为当前定子真值。</p></div><div><div class="thermal-meter" id="thermalMeter" data-temp="-- °C"></div><div class="thermal-scale"><span>0 °C</span><span>80 °C 风机 1 级</span><span>100 °C 风机 2 级</span><span>130.2 °C 告警</span><span>155 °C 保护</span></div><div class="thermal-meta"><span id="thermalRead">定子真值:--</span><span id="thermalMargin">保护余量:--</span></div></div>';controls.insertAdjacentElement('afterend',band);const sync=()=>{const found=document.body.innerText.match(/★定子真值\\s+([0-9.]+)\\s*°C/);const temp=found?Number(found[1]):null;const meter=document.getElementById('thermalMeter'),read=document.getElementById('thermalRead'),margin=document.getElementById('thermalMargin');if(!Number.isFinite(temp)){meter.dataset.temp='计算中';read.textContent='定子真值:计算中';margin.textContent='保护余量:计算中'}else{const pct=Math.max(1,Math.min(99,temp/155*100));meter.style.setProperty('--temp-pct',pct.toFixed(1));meter.dataset.temp=temp.toFixed(1)+' °C';read.textContent='定子真值:'+temp.toFixed(1)+' °C';margin.textContent='保护余量:'+(155-temp).toFixed(1)+' °C'}};setInterval(sync,120);sync()};if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount);else mount()})();
+</script>'''
+
+
+# 对外脱敏 —— 规则表唯一正本在 release/scrub_rules.py, 禁止在此另抄一份
+from scrub_rules import scrub, residual
+
+def page_for(name):
+    raw = PAGES[name]
+    # Current delivered generator page references two removed top-level fields. Map them
+    # to their structured source fields before its own simulation loop starts.
+    if name.startswith('3_'):
+        raw = raw.replace('TH.A_noload', 'TH.stator_model.A').replace('TH.B_copper', 'TH.stator_model.B')
+    if name.startswith('0_'):
+        raw = raw.replace('如东 SWT-4.0-130 控制律仪表台', '如东 SWT-4.0-130 · 四系统联调')
+    raw = raw.replace('<html lang="zh-CN">', '<html lang="zh-CN" data-theme="light">', 1)
+    raw = raw.replace('</head>', THEME + '</head>', 1)
+    header = '<div class="guanlan-top"><b>DASHENG · 分系统仿真</b><a href="http://127.0.0.1:18084/大生_样板_门户_单文件.html#sim">← 返回仿真中心</a></div>'
+    raw = raw.replace('<div class="wrap">', '<div class="wrap">' + header, 1)
+    raw = raw.replace('$("#th").onclick=()=>{', '$("#th").style.display="none"; $("#th").onclick=()=>{', 1)
+    if name.startswith('3_'):
+        raw = raw.replace('</body>', THERMAL + '</body>', 1)
+    raw = scrub(raw)          # 最后一道: 连注入的 header/THEME 一起洗
+    return raw
+
+class Handler(BaseHTTPRequestHandler):
+    def do_GET(self):
+        name = unquote(urlparse(self.path).path).lstrip('/') or '0_四系统合页.html'
+        if name not in PAGES:
+            self.send_error(404, 'simulation page not found')
+            return
+        body = page_for(name).encode('utf-8')
+        self.send_response(200)
+        self.send_header('Content-Type', 'text/html; charset=utf-8')
+        self.send_header('Content-Length', str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+    def log_message(self, fmt, *args):
+        print('[18792]', fmt % args, flush=True)
+
+import pathlib as _pl, sys as _sys0
+_sys0.path.insert(0, str(_pl.Path(__file__).resolve().parents[1]))
+from src import logfile as _lf; _lf.prefix_stdout('sim_sys')     # 统一日志格式 (用户令 2)
+ThreadingHTTPServer(('127.0.0.1', 18792), Handler).serve_forever()

+ 91 - 91
scripts/_ops_run.py

@@ -1,91 +1,91 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""运维控制台的动作执行器 (2026-09-12) —— 跑一条命令, 并把**真实退出码**写回 run/ops_job.json。
-
-为什么不让网关直接 Popen 就完事: 那样只知道"进程还在不在", 进程一结束就只能猜它成功没成功 ——
-实测就这么吃过一次(停止服务脚本崩在 TypeError, 页面却显示"已停组件服务")。由本执行器包一层,
-它在子进程结束后把 `status/rc/finished` 落盘, 页面就能显示"完成(退出码 0)"或"失败(退出码 1)"。
-
-日志: 本进程的 stdout/stderr 已被调用方重定向到 logs/ops_<tag>_<时间>.log, 子命令**继承**它,
-所以命令自己的输出原样进日志(页面显示的就是这些尾巴)。
-
-用法: python scripts/_ops_run.py --tag rebuild -- <命令与参数...>
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import os
-import pathlib
-import subprocess
-import sys
-import threading
-import time
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import proc as _proc            # 无窗口子进程 (2026-09-16 用户令: 不弹命令窗口)
-JOB = ROOT / 'run' / 'ops_job.json'
-HEARTBEAT_S = 15
-
-
-def _write(j: dict):
-    JOB.parent.mkdir(exist_ok=True)
-    JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
-
-
-def _heartbeat(stop_evt: threading.Event):
-    """每 15 s 更新 job 文件的 mtime —— 页面据此区分"还在跑"与"被强杀"(pid 会被复用, 只看 pid 会误判)。"""
-    while not stop_evt.wait(HEARTBEAT_S):
-        try:
-            os.utime(JOB, None)
-        except OSError:
-            pass
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--tag', required=True)
-    ap.add_argument('cmd', nargs=argparse.REMAINDER, help='-- 之后是真正的命令')
-    a = ap.parse_args()
-    cmd = [c for c in a.cmd if c != '--']
-    if not cmd:
-        print('[X] 没给命令'); return 2
-
-    job = {}
-    try:
-        job = json.loads(JOB.read_text(encoding='utf-8'))
-    except Exception:
-        pass
-    job.update(kind=a.tag, cmd=' '.join(cmd), pid=os.getpid(), status='running',
-               started=job.get('started') or time.strftime('%Y-%m-%d %H:%M:%S'))
-    _write(job)
-
-    print(f'$ {" ".join([sys.executable] + cmd)}\n', flush=True)
-    stop_evt = threading.Event()
-    threading.Thread(target=_heartbeat, args=(stop_evt,), daemon=True).start()
-    t0 = time.time()
-    try:
-        # 走 src.proc.run: 本进程是被无窗口起的 (没有可见控制台), 裸 spawn 会让 Windows
-        # 给子进程**新建一个可见控制台窗口** —— 从页面点"重算"就是这个窗口在跑 20 分钟 (2026-09-16 实测)。
-        # ★stdout/stderr **显式**传本进程的句柄: CREATE_NO_WINDOW 会新建控制台并把标准句柄重指过去,
-        #   不显式传的话, 子进程 (以及它的子进程) 的输出会掉进那个隐形控制台 —— 2026-09-16 实逮:
-        #   页面上"重算中"但 logs/ops_rebuild_*.log 里除了命令行一个字都没有。
-        rc = _proc.run([sys.executable] + cmd, cwd=str(ROOT), env=dict(os.environ),
-                       stdout=sys.stdout, stderr=sys.stderr).returncode
-    except Exception as e:
-        print(f'[X] 命令起不来: {type(e).__name__}: {e}', flush=True)
-        rc = 99
-    stop_evt.set()
-    dt = time.time() - t0
-    job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'))
-    _write(job)
-    print(f'\n[ops] {a.tag} 结束: 退出码 {rc}, 耗时 {dt:.1f}s', flush=True)
-    return rc
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""运维控制台的动作执行器 (2026-09-12) —— 跑一条命令, 并把**真实退出码**写回 run/ops_job.json。
+
+为什么不让网关直接 Popen 就完事: 那样只知道"进程还在不在", 进程一结束就只能猜它成功没成功 ——
+实测就这么吃过一次(停止服务脚本崩在 TypeError, 页面却显示"已停组件服务")。由本执行器包一层,
+它在子进程结束后把 `status/rc/finished` 落盘, 页面就能显示"完成(退出码 0)"或"失败(退出码 1)"。
+
+日志: 本进程的 stdout/stderr 已被调用方重定向到 logs/ops/<tag>_<时间戳>.log (统一日志口径), 子命令**继承**它,
+所以命令自己的输出原样进日志(页面显示的就是这些尾巴)。
+
+用法: python scripts/_ops_run.py --tag rebuild -- <命令与参数...>
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import subprocess
+import sys
+import threading
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import proc as _proc            # 无窗口子进程 (2026-09-16 用户令: 不弹命令窗口)
+JOB = ROOT / 'run' / 'ops_job.json'
+HEARTBEAT_S = 15
+
+
+def _write(j: dict):
+    JOB.parent.mkdir(exist_ok=True)
+    JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
+
+
+def _heartbeat(stop_evt: threading.Event):
+    """每 15 s 更新 job 文件的 mtime —— 页面据此区分"还在跑"与"被强杀"(pid 会被复用, 只看 pid 会误判)。"""
+    while not stop_evt.wait(HEARTBEAT_S):
+        try:
+            os.utime(JOB, None)
+        except OSError:
+            pass
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--tag', required=True)
+    ap.add_argument('cmd', nargs=argparse.REMAINDER, help='-- 之后是真正的命令')
+    a = ap.parse_args()
+    cmd = [c for c in a.cmd if c != '--']
+    if not cmd:
+        print('[X] 没给命令'); return 2
+
+    job = {}
+    try:
+        job = json.loads(JOB.read_text(encoding='utf-8'))
+    except Exception:
+        pass
+    job.update(kind=a.tag, cmd=' '.join(cmd), pid=os.getpid(), status='running',
+               started=job.get('started') or time.strftime('%Y-%m-%d %H:%M:%S'))
+    _write(job)
+
+    print(f'$ {" ".join([sys.executable] + cmd)}\n', flush=True)
+    stop_evt = threading.Event()
+    threading.Thread(target=_heartbeat, args=(stop_evt,), daemon=True).start()
+    t0 = time.time()
+    try:
+        # 走 src.proc.run: 本进程是被无窗口起的 (没有可见控制台), 裸 spawn 会让 Windows
+        # 给子进程**新建一个可见控制台窗口** —— 从页面点"重算"就是这个窗口在跑 20 分钟 (2026-09-16 实测)。
+        # ★stdout/stderr **显式**传本进程的句柄: CREATE_NO_WINDOW 会新建控制台并把标准句柄重指过去,
+        #   不显式传的话, 子进程 (以及它的子进程) 的输出会掉进那个隐形控制台 —— 2026-09-16 实逮:
+        #   页面上"重算中"但 logs/ops/ops_rebuild_*.log 里除了命令行一个字都没有。
+        rc = _proc.run([sys.executable] + cmd, cwd=str(ROOT), env=dict(os.environ),
+                       stdout=sys.stdout, stderr=sys.stderr).returncode
+    except Exception as e:
+        print(f'[X] 命令起不来: {type(e).__name__}: {e}', flush=True)
+        rc = 99
+    stop_evt.set()
+    dt = time.time() - t0
+    job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'))
+    _write(job)
+    print(f'\n[ops] {a.tag} 结束: 退出码 {rc}, 耗时 {dt:.1f}s', flush=True)
+    return rc
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 123 - 115
scripts/audit_chinese_terms.py

@@ -1,115 +1,123 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""中文表达审核 (基准 = OEM 中文文档术语库 configs/terms/terms_baseline.yaml + 规则 configs/terms/jargon_rules.yaml).
-
-机器给候选, 人裁 —— 不自动改文案。两类输出:
-  ① 规则命中: 内部实现词/自造黑话/有更常用说法 (jargon_rules 逐条带建议写法与状态);
-  ② 无根词: 界面文本里的领域词在四家 OEM 中文文档中**一次都没出现过** → 可能是自造词 (按出现次数排, 供人裁)。
-用法: python scripts/audit_chinese_terms.py [--root .] [--out logs/terms_audit.json] [--md] [--strict]
-  --strict: 规则状态=已裁改 的条目仍命中 → exit 2 (CI 闸); 默认只报不拦。"""
-from __future__ import annotations
-import argparse, collections, json, re, sys, time
-from pathlib import Path
-import yaml
-CJK_RUN = re.compile(r"[一-鿿][一-鿿0-9A-Za-z·%°/\-]{1,60}")
-TERM = re.compile(r"[一-鿿]{2,8}")
-DEFAULT_TARGETS = ["src/windscada/lang.py", "src/windscada/ui/app.js", "scripts/windscada_serve.py", "src/windcms/ui.py", "src/ontology/fast_agent.py"]
-
-
-def strings_of(p: Path):
-    """逐行取中文串 (含行号); 跳过注释行 —— 注释不进界面."""
-    out = []
-    for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
-        s = line.strip()
-        if s.startswith("#") or s.startswith("//") or s.startswith("*"): continue
-        for m in CJK_RUN.finditer(line):
-            t = m.group(0).strip()
-            if len(t) >= 2: out.append((i, t))
-    return out
-
-
-def main():
-    ap = argparse.ArgumentParser(); ap.add_argument("--root", default="."); ap.add_argument("--baseline"); ap.add_argument("--rules")
-    ap.add_argument("--targets", nargs="*"); ap.add_argument("--out", default="logs/terms_audit.json")
-    ap.add_argument("--md", action="store_true"); ap.add_argument("--strict", action="store_true"); ap.add_argument("--top", type=int, default=40); a = ap.parse_args()
-    root = Path(a.root).resolve(); here = Path(__file__).resolve().parent
-
-    def pick(arg, name):
-        """基准/规则解析序: 显式参数 > 被审仓 configs/terms/ > 脚本同目录 (skill 自带正本)."""
-        for c in ([Path(arg)] if arg else []) + [root / "configs/terms" / name, here / name, here.parent / "configs/terms" / name]:
-            if c.exists(): return c
-        raise SystemExit(f"找不到 {name} (给 --baseline/--rules 或放到 configs/terms/)")
-    bp, rp = pick(a.baseline, "terms_baseline.yaml"), pick(a.rules, "jargon_rules.yaml")
-    # 显示层映射: 会在渲染时被换掉的词, **用户看不到** → 只记为 fixed_by_display, 不算违规 (strict 不拦)。
-    try:
-        dm = yaml.safe_load(pick(None, "display_map.yaml").read_text(encoding="utf-8"))
-        DISP = sorted([(str(i["from"]), str(i["to"])) for i in (dm.get("items") or [])], key=lambda kv: -len(kv[0]))
-    except SystemExit: DISP = []
-
-    def humanized(x):
-        for u, v in DISP:
-            if u in x: x = x.replace(u, v)
-        return x
-    base = yaml.safe_load(bp.read_text(encoding="utf-8")); known = {x["term"] for x in base["items"]}
-    known_sub = set()
-    for t in known:                       # 允许基准词作为子串命中 (如「主轴承温度」含「主轴承」「温度」)
-        known_sub.add(t)
-    _rdoc = yaml.safe_load(rp.read_text(encoding="utf-8")); rules = _rdoc["items"]
-    # 豁免面: 模型提示词/内部注释/日志 —— 规则只约束**用户看得见的文本**
-    EXEMPT_PATH = [e for e in (_rdoc.get("exempt") or []) if e.get("path")]
-    EXEMPT_LINE = [(re.compile(e["line_re"]), e["why"]) for e in (_rdoc.get("exempt") or []) if e.get("line_re")]
-    for r in rules: r["_re"] = re.compile(r["pattern"])
-    targets = [root / t for t in (a.targets or DEFAULT_TARGETS)]
-    hits, unknown = [], collections.Counter(); unknown_where = collections.defaultdict(list); n_strings = 0
-    for p in targets:
-        if not p.exists(): continue
-        rel = p.relative_to(root).as_posix()
-        ex_path = next((e["why"] for e in EXEMPT_PATH if e["path"] in rel), None)
-        raw_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
-        for ln, s in strings_of(p):
-            n_strings += 1
-            for r in rules:
-                m = r["_re"].search(s)
-                if not m: continue
-                cur = raw_lines[ln - 1] if ln <= len(raw_lines) else ""
-                ctx = "\n".join(raw_lines[max(0, ln - 3):ln + 2])   # 日志调用常跨行 (print(... 换行 file=sys.stderr))
-                cpos = min([i for i in (cur.find("//"), cur.find("#")) if i >= 0], default=-1)
-                in_comment = cpos >= 0 and cur.find(m.group(0)) > cpos   # 行尾注释里的词不算界面文本
-                ex = ex_path or ("行内注释" if in_comment else None) or next((w for rx, w in EXEMPT_LINE if rx.search(ctx)), None)
-                hits.append(dict(file=rel, line=ln, text=s[:80], matched=m.group(0), exempt=ex,
-                                 fixed_by_display=(humanized(s) != s), **{k: r[k] for k in ("class", "why", "suggest", "status", "pattern")}))
-            for w in TERM.findall(s):
-                if w in known_sub: continue
-                if any(k in w for k in known_sub if len(k) >= 3): continue     # 含已知术语的复合词不算无根
-                unknown[w] += 1
-                if len(unknown_where[w]) < 3: unknown_where[w].append(f"{p.name}:{ln}")
-    ruled = [h for h in hits if h["status"] == "已裁改" and not h["fixed_by_display"] and not h["exempt"]]   # 显示层能换掉的不算违规
-    rep = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), root=str(root), baseline=str(bp), rules=str(rp), baseline_terms=len(known), n_strings=n_strings,
-               n_rule_hits=len(hits), n_fixed_by_display=sum(1 for h in hits if h["fixed_by_display"]), n_exempt=sum(1 for h in hits if h["exempt"]), n_ruled_violations=len(ruled),
-               by_class=dict(collections.Counter(h["class"] for h in hits)),
-               rule_hits=hits, unrooted_terms=[dict(term=w, n=n, where=unknown_where[w]) for w, n in unknown.most_common(a.top)])
-    o = root / a.out; o.parent.mkdir(parents=True, exist_ok=True); o.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
-    print(f"扫 {len(targets)} 文件 / {n_strings} 条中文串; 规则命中 {len(hits)} ({rep['by_class']}); 其中显示层已覆盖 {rep['n_fixed_by_display']}, 内部面豁免 {rep['n_exempt']}, 仍待处理 {sum(1 for h in hits if not h['fixed_by_display'] and not h['exempt'])}; 无根词 top{a.top} (共 {len(unknown)})")
-    for h in [x for x in hits if not x["fixed_by_display"] and not x["exempt"]][:12]: print(f"  [{h['class']}·{h['status']}] {h['file']}:{h['line']} 「{h['matched']}」 → 建议: {h['suggest'][:40]}")
-    print("  无根词:", [f"{w}×{n}" for w, n in unknown.most_common(15)])
-    if a.md:
-        L = [f"# 中文表达审核 · {rep['ts']}\n", f"基准 {len(known)} 词 (四家 OEM 中文文档实证) · 扫 {n_strings} 条界面中文串\n",
-             "## 一 规则命中 (逐条裁: 改 / 豁免)\n", "| # | 类 | 文件:行 | 命中 | 现文 | 为什么难懂 | 建议写法 | 状态 |", "|---|---|---|---|---|---|---|---|"]
-        for i, h in enumerate(hits, 1): L.append(f"| {i} | {h['class']} | {h['file']}:{h['line']} | {h['matched']} | {h['text'][:28]} | {h['why'][:34]} | {h['suggest'][:34]} | {h['status']} |")
-        L += ["\n## 二 无根词 (OEM 中文文档零出现; 可能自造, 逐条裁)\n", "| # | 词 | 次数 | 出处 | 裁决 |", "|---|---|---|---|---|"]
-        for i, u in enumerate(rep["unrooted_terms"], 1): L.append(f"| {i} | {u['term']} | {u['n']} | {' '.join(u['where'])} |  |")
-        m = o.with_suffix(".md"); m.write_text("\n".join(L) + "\n", encoding="utf-8"); print("  屏:", m)
-    if a.strict and ruled:
-        print(f"✗ {len(ruled)} 条已裁定必改、且显示层也换不掉的表达仍在界面里:"); [print("   ", h["file"], h["line"], h["matched"]) for h in ruled[:10]]; return 2
-    return 0
-
-
-if __name__ == "__main__":
-    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
-    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
-    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
-    import sys as _sys
-    for _s in (_sys.stdout, _sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""中文表达审核 (基准 = OEM 中文文档术语库 configs/terms/terms_baseline.yaml + 规则 configs/terms/jargon_rules.yaml).
+
+机器给候选, 人裁 —— 不自动改文案。两类输出:
+  ① 规则命中: 内部实现词/自造黑话/有更常用说法 (jargon_rules 逐条带建议写法与状态);
+  ② 无根词: 界面文本里的领域词在四家 OEM 中文文档中**一次都没出现过** → 可能是自造词 (按出现次数排, 供人裁)。
+用法: python scripts/audit_chinese_terms.py [--root .] [--out logs/terms_audit.json] [--md] [--strict]
+  --strict: 规则状态=已裁改 的条目仍命中 → exit 2 (CI 闸); 默认只报不拦。"""
+from __future__ import annotations
+import argparse, collections, json, re, sys, time
+from pathlib import Path
+import yaml
+CJK_RUN = re.compile(r"[一-鿿][一-鿿0-9A-Za-z·%°/\-]{1,60}")
+TERM = re.compile(r"[一-鿿]{2,8}")
+DEFAULT_TARGETS = ["src/windscada/lang.py", "src/windscada/ui/app.js", "scripts/windscada_serve.py", "src/windcms/ui.py", "src/ontology/fast_agent.py"]
+
+
+def strings_of(p: Path):
+    """逐行取中文串 (含行号); 跳过注释行 —— 注释不进界面."""
+    out = []
+    for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
+        s = line.strip()
+        if s.startswith("#") or s.startswith("//") or s.startswith("*"): continue
+        for m in CJK_RUN.finditer(line):
+            t = m.group(0).strip()
+            if len(t) >= 2: out.append((i, t))
+    return out
+
+
+def main():
+    ap = argparse.ArgumentParser(); ap.add_argument("--root", default="."); ap.add_argument("--baseline"); ap.add_argument("--rules")
+    ap.add_argument("--targets", nargs="*"); ap.add_argument("--out", default="logs/audit/terms_audit.json")   # 统一: 审计流水进 logs/audit/ (用户令 2)
+    ap.add_argument("--md", action="store_true"); ap.add_argument("--strict", action="store_true"); ap.add_argument("--top", type=int, default=40); a = ap.parse_args()
+    root = Path(a.root).resolve(); here = Path(__file__).resolve().parent
+
+    def pick(arg, name):
+        """基准/规则解析序: 显式参数 > 被审仓 configs/terms/ > 脚本同目录 (skill 自带正本)。
+
+        configs/terms/ 那一项走 `src/paths.py` 的配置取用口 (P.config_dir('terms')),
+        不再手拼配置目录字符串 —— 用户令 2「统一配置」口径见 docs §11。
+        """
+        import sys as _sys
+        _sys.path.insert(0, str(here.parent))
+        from src import paths as _P
+        cands = ([Path(arg)] if arg else []) + [_P.config_dir('terms') / name, here / name, here.parent / "configs" / "terms" / name]  # config-path-allow: 末项是 skill 自带正本
+        for c in cands:
+            if c.exists(): return c
+        raise SystemExit(f"找不到 {name} (给 --baseline/--rules 或放到 configs/terms/)")
+    bp, rp = pick(a.baseline, "terms_baseline.yaml"), pick(a.rules, "jargon_rules.yaml")
+    # 显示层映射: 会在渲染时被换掉的词, **用户看不到** → 只记为 fixed_by_display, 不算违规 (strict 不拦)。
+    try:
+        dm = yaml.safe_load(pick(None, "display_map.yaml").read_text(encoding="utf-8"))
+        DISP = sorted([(str(i["from"]), str(i["to"])) for i in (dm.get("items") or [])], key=lambda kv: -len(kv[0]))
+    except SystemExit: DISP = []
+
+    def humanized(x):
+        for u, v in DISP:
+            if u in x: x = x.replace(u, v)
+        return x
+    base = yaml.safe_load(bp.read_text(encoding="utf-8")); known = {x["term"] for x in base["items"]}
+    known_sub = set()
+    for t in known:                       # 允许基准词作为子串命中 (如「主轴承温度」含「主轴承」「温度」)
+        known_sub.add(t)
+    _rdoc = yaml.safe_load(rp.read_text(encoding="utf-8")); rules = _rdoc["items"]
+    # 豁免面: 模型提示词/内部注释/日志 —— 规则只约束**用户看得见的文本**
+    EXEMPT_PATH = [e for e in (_rdoc.get("exempt") or []) if e.get("path")]
+    EXEMPT_LINE = [(re.compile(e["line_re"]), e["why"]) for e in (_rdoc.get("exempt") or []) if e.get("line_re")]
+    for r in rules: r["_re"] = re.compile(r["pattern"])
+    targets = [root / t for t in (a.targets or DEFAULT_TARGETS)]
+    hits, unknown = [], collections.Counter(); unknown_where = collections.defaultdict(list); n_strings = 0
+    for p in targets:
+        if not p.exists(): continue
+        rel = p.relative_to(root).as_posix()
+        ex_path = next((e["why"] for e in EXEMPT_PATH if e["path"] in rel), None)
+        raw_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
+        for ln, s in strings_of(p):
+            n_strings += 1
+            for r in rules:
+                m = r["_re"].search(s)
+                if not m: continue
+                cur = raw_lines[ln - 1] if ln <= len(raw_lines) else ""
+                ctx = "\n".join(raw_lines[max(0, ln - 3):ln + 2])   # 日志调用常跨行 (print(... 换行 file=sys.stderr))
+                cpos = min([i for i in (cur.find("//"), cur.find("#")) if i >= 0], default=-1)
+                in_comment = cpos >= 0 and cur.find(m.group(0)) > cpos   # 行尾注释里的词不算界面文本
+                ex = ex_path or ("行内注释" if in_comment else None) or next((w for rx, w in EXEMPT_LINE if rx.search(ctx)), None)
+                hits.append(dict(file=rel, line=ln, text=s[:80], matched=m.group(0), exempt=ex,
+                                 fixed_by_display=(humanized(s) != s), **{k: r[k] for k in ("class", "why", "suggest", "status", "pattern")}))
+            for w in TERM.findall(s):
+                if w in known_sub: continue
+                if any(k in w for k in known_sub if len(k) >= 3): continue     # 含已知术语的复合词不算无根
+                unknown[w] += 1
+                if len(unknown_where[w]) < 3: unknown_where[w].append(f"{p.name}:{ln}")
+    ruled = [h for h in hits if h["status"] == "已裁改" and not h["fixed_by_display"] and not h["exempt"]]   # 显示层能换掉的不算违规
+    rep = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), root=str(root), baseline=str(bp), rules=str(rp), baseline_terms=len(known), n_strings=n_strings,
+               n_rule_hits=len(hits), n_fixed_by_display=sum(1 for h in hits if h["fixed_by_display"]), n_exempt=sum(1 for h in hits if h["exempt"]), n_ruled_violations=len(ruled),
+               by_class=dict(collections.Counter(h["class"] for h in hits)),
+               rule_hits=hits, unrooted_terms=[dict(term=w, n=n, where=unknown_where[w]) for w, n in unknown.most_common(a.top)])
+    o = root / a.out; o.parent.mkdir(parents=True, exist_ok=True); o.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
+    print(f"扫 {len(targets)} 文件 / {n_strings} 条中文串; 规则命中 {len(hits)} ({rep['by_class']}); 其中显示层已覆盖 {rep['n_fixed_by_display']}, 内部面豁免 {rep['n_exempt']}, 仍待处理 {sum(1 for h in hits if not h['fixed_by_display'] and not h['exempt'])}; 无根词 top{a.top} (共 {len(unknown)})")
+    for h in [x for x in hits if not x["fixed_by_display"] and not x["exempt"]][:12]: print(f"  [{h['class']}·{h['status']}] {h['file']}:{h['line']} 「{h['matched']}」 → 建议: {h['suggest'][:40]}")
+    print("  无根词:", [f"{w}×{n}" for w, n in unknown.most_common(15)])
+    if a.md:
+        L = [f"# 中文表达审核 · {rep['ts']}\n", f"基准 {len(known)} 词 (四家 OEM 中文文档实证) · 扫 {n_strings} 条界面中文串\n",
+             "## 一 规则命中 (逐条裁: 改 / 豁免)\n", "| # | 类 | 文件:行 | 命中 | 现文 | 为什么难懂 | 建议写法 | 状态 |", "|---|---|---|---|---|---|---|---|"]
+        for i, h in enumerate(hits, 1): L.append(f"| {i} | {h['class']} | {h['file']}:{h['line']} | {h['matched']} | {h['text'][:28]} | {h['why'][:34]} | {h['suggest'][:34]} | {h['status']} |")
+        L += ["\n## 二 无根词 (OEM 中文文档零出现; 可能自造, 逐条裁)\n", "| # | 词 | 次数 | 出处 | 裁决 |", "|---|---|---|---|---|"]
+        for i, u in enumerate(rep["unrooted_terms"], 1): L.append(f"| {i} | {u['term']} | {u['n']} | {' '.join(u['where'])} |  |")
+        m = o.with_suffix(".md"); m.write_text("\n".join(L) + "\n", encoding="utf-8"); print("  屏:", m)
+    if a.strict and ruled:
+        print(f"✗ {len(ruled)} 条已裁定必改、且显示层也换不掉的表达仍在界面里:"); [print("   ", h["file"], h["line"], h["matched"]) for h in ruled[:10]]; return 2
+    return 0
+
+
+if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 360 - 359
scripts/check_transferable.py

@@ -1,359 +1,360 @@
-# -*- coding: utf-8 -*-
-r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
-
-比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
-
-  ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
-     —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
-  ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
-     机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
-  ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
-  ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
-  ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
-
-用法:
-    python scripts/check_transferable.py            # 全量检查 + 结论
-    python scripts/check_transferable.py --brief    # 只报问题
-    python scripts/check_transferable.py --simulate # **模拟移植**(推荐换机前跑): 把包拷成一份副本 →
-                                                    # 在副本里跑安装 → 起服务核验页面 → 报结果 → 清理副本。
-                                                    # 默认拷全量(除 data/.git/.venv/暂存区); --keep 保留副本。
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import os
-import pathlib
-import re
-import shutil
-import socket
-import subprocess
-import sys
-import tempfile
-import time
-import urllib.request
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-
-# 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
-RE_ABS = re.compile(r"""(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/home/[a-z][A-Za-z0-9_.\-]*|/opt/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+)""")
-# 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
-TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
-SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
-SKIP_PATH_HINT = ('_products_off_prev',)
-# 扫描范围里要**排除现场原始件**: data/raw/** 是现场给的测量导出 (SCADA csv、Brande TCM JSON ——
-# 2026-09-12 落位后单这一个目录就是 2.5 万个 .json / 150 GB), 且**不随包分发** (pack_dist.py 的
-# EXCLUDE_GLOBS 含 'data')。文本闸门扫它: ①对"换机可用"零信息量 (那些文件里就算有绝对路径也不是我们的
-# 接线) ②每次验收要多读几十 GB 文本 (8 MB 以下的一律会读进内存), 把闸门从秒级拖成小时级。
-SKIP_RAW_PREFIX = ('data/raw/', 'data/_incoming/')
-# 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
-ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
-              'D:/guanlan', '示例', '例:', '例如')
-
-
-def walk_files():
-    for p in ROOT.rglob('*'):
-        if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
-            continue
-        rel = p.relative_to(ROOT).as_posix()
-        parts = set(p.relative_to(ROOT).parts)
-        if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
-            continue
-        if rel.startswith(SKIP_RAW_PREFIX):
-            continue
-        if rel.startswith(SKIP_RAW_PREFIX):
-            continue
-        if p.stat().st_size > 8 * 1024 * 1024:      # 超大文本(如 20MB 门户)另算, 见下
-            continue
-        yield p
-
-
-def scan_paths(verbose=True):
-    hits = []
-    for p in walk_files():
-        try:
-            text = p.read_text(encoding='utf-8', errors='replace')
-        except Exception:
-            continue
-        for i, line in enumerate(text.splitlines(), 1):
-            if any(a in line for a in ALLOW_LINE):
-                continue
-            if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
-                continue
-            m = RE_ABS.search(line)
-            if m:
-                hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
-    print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
-    if hits and verbose:
-        for rel, i, found, line in hits[:25]:
-            print(f'     {rel}:{i}  ← {found}\n        {line}')
-        if len(hits) > 25:
-            print(f'     … 另有 {len(hits) - 25} 处')
-    return hits
-
-
-def scan_big_text():
-    """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
-    out = []
-    for p in ROOT.rglob('*.html'):
-        rel = p.relative_to(ROOT).as_posix()
-        if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
-            continue
-        try:
-            t = p.read_text(encoding='utf-8', errors='replace')
-        except Exception:
-            continue
-        n = len(RE_ABS.findall(t))
-        if n:
-            out.append((rel, n, p.stat().st_size))
-    if out:
-        print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
-        for rel, n, sz in out[:10]:
-            print(f'     {rel}  {n} 处 ({sz/1e6:.1f} MB)')
-    else:
-        print('② HTML 里没有烘进的绝对路径')
-    return out
-
-
-def check_venv():
-    cfg = ROOT / '.venv' / 'pyvenv.cfg'
-    print('③ 虚拟环境')
-    if not cfg.is_file():
-        print('     [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
-        return 'absent'
-    kv = {}
-    for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
-        if '=' in l:
-            k, v = l.split('=', 1)
-            kv[k.strip()] = v.strip()          # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
-    home = (kv.get('home') or '').strip()
-    print(f'     home(基础解释器) = {home}')
-    print(f'     版本 {kv.get("version", "?").strip()}')
-    here = pathlib.Path(home).is_dir() if home else False
-    print(f'     本机存在该目录: {here}')
-    print('     ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
-          '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
-    return kv
-
-
-def check_wheels():
-    w = ROOT / 'wheels'
-    print('④ 离线依赖轮子')
-    if not w.is_dir():
-        print('     [X] 没有 wheels/ 目录 —— 装依赖需要联网')
-        return []
-    dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
-    print(f'     平台目录: {dirs or "(无)"}')
-    req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
-    req = [r for r in req if r]
-    names = {r.split('==')[0].lower().replace('-', '_') for r in req}
-    for d in dirs:
-        have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
-        miss = sorted(n for n in names if n not in have)
-        tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
-        print(f'     {d}: {len(list((w / d).glob("*.whl")))} 个轮子  {tag}')
-    plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
-    if plat not in dirs:
-        print(f'     [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
-    return dirs
-
-
-def check_vendor():
-    v = ROOT / 'vendor'
-    print('⑤ 便携运行时 / 离线件')
-    if not v.is_dir():
-        print('     [--] 没有 vendor/')
-        return
-    for p in sorted(v.rglob('*')):
-        if p.is_file():
-            print(f'     {p.relative_to(ROOT).as_posix()}  {p.stat().st_size/1e6:.1f} MB')
-    man = v / 'MANIFEST.json'
-    if man.is_file():
-        m = json.loads(man.read_text(encoding='utf-8'))
-        files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
-        undoc = sorted(files - set(m))
-        if undoc:
-            print(f'     [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
-
-
-def check_configs():
-    print('⑥ 配置与端口')
-    p = ROOT / 'configs' / 'serve.json'
-    if p.is_file():
-        c = json.loads(p.read_text(encoding='utf-8-sig'))
-        print(f'     serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
-              f'raw_dir={c.get("raw_dir")!r}')
-        for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
-            v = str(c.get(k) or '')
-            if re.match(r'^[A-Za-z]:[\\/]|^/', v):
-                print(f'     [!] {k} 是绝对路径: {v}')
-    # 写死的本机端口/入口(允许出现, 但要知道)
-    n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
-    print(f'     提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
-
-
-def _free_port(start=28700, tries=40):
-    for p in range(start, start + tries):
-        with socket.socket() as s:
-            try:
-                s.bind(('127.0.0.1', p)); return p
-            except OSError:
-                continue
-    return None
-
-
-def _busy(port, host='127.0.0.1'):
-    with socket.socket() as s:
-        s.settimeout(0.3)
-        return s.connect_ex((host, port)) == 0
-
-
-IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
-                                '_products_off', '_products_off_prev_*', '*.pyc')
-
-
-def install_in(dst: pathlib.Path) -> pathlib.Path | None:
-    """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
-    print(f'   在 {dst} 里跑安装 (离线) …')
-    # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
-    # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
-    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
-           if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
-    t0 = time.time()
-    r = subprocess.run(cmd, cwd=str(dst))
-    print(f'   install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
-    py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
-    if not py.is_file():
-        print(f'   [X] 副本里没有 {py} —— 安装没成')
-        return None
-    v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
-                       capture_output=True, text=True, errors='replace')
-    print(f'   副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
-    return py
-
-
-def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
-    """在副本里起服务并核验页面 —— **不动正在跑的实例**: 网关改用空闲端口, 组件端口若被占则跳过。
-
-    原先网关端口被占就整段跳过(实测踩到: 主实例在跑 → 模拟移植只做了拷贝+安装)。现在:
-      · 组件端口(18033 等)空闲 → 用它们; 网关端口(28084)被占 → 换成空闲端口(如 28700);
-      · 网关 ROUTES 里上游端口是写死的(文档 §三 的已知限制), 所以组件端口**必须**用默认值, 不能随网关一起改。
-    """
-    comp_ports = (18033, 18020, 18791, 18792, 64292)
-    busy_comp = [p for p in comp_ports if _busy(p)]
-    if busy_comp:
-        print(f'   [!] 组件端口被占用 {busy_comp} —— 跳过启动核验(先停掉正在跑的实例再试)')
-        return 0
-    gw = base_port if not _busy(base_port) else _free_port(28700)
-    if gw is None:
-        print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
-    if gw != base_port:
-        cfg_p = dst / 'configs' / 'serve.json'
-        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
-        cfg['gateway'] = gw
-        cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
-        print(f'   网关端口 {base_port} 已被占用 → 副本改用 {gw} (不打扰正在跑的实例)')
-    t0 = time.time()
-    rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
-    print(f'   serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
-    ok = 0
-    try:
-        # ★门户期望字节数**不写死** (2026-09-16): 原来常量 20225828 是某一版门户"服务出来"的字节数,
-        #   门户外壳一改 (例如按用户令在菜单里加「数据重算」) 它就过期, 于是核验会打印一条永远不成立的
-        #   "★与主包不同", 看起来像移植出了问题。改为**从正在跑的主实例现量**: 主实例没起就不比这一项。
-        exp_portal = None
-        try:
-            with urllib.request.urlopen(f'http://127.0.0.1:{base_port}/', timeout=10) as rr:
-                exp_portal = len(rr.read())
-                print(f'   参照: 主实例门户 {exp_portal:,} B (端口 {base_port})')
-        except Exception as e:
-            print(f'   参照: 主实例 ({base_port}) 未响应 → 门户字节数不做同字节比对 ({type(e).__name__})')
-        urls = [('/', exp_portal), ('/detail/', None), ('/cms/', None), ('/ops', None), ('/healthz', None)]
-        for path, exp_size in urls:
-            try:
-                with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
-                    body = rr.read()
-                    tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
-                    print(f'   {path:10s} HTTP {rr.status}  {len(body):,} B{tag}')
-                    ok += 1
-            except Exception as e:
-                print(f'   {path:10s} 失败: {type(e).__name__} {e}')
-        print(f'   页面可用 {ok}/{len(urls)}')
-    finally:
-        subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
-    return ok
-
-
-def simulate(into=None, keep=False) -> int:
-    """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
-    dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
-    if dst.exists():
-        print(f'  清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
-    print(f'① 拷副本 → {dst}   (排除 data/.git/.venv/暂存区/logs/run)')
-    t0 = time.time()
-    shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
-    n = sum(1 for _ in dst.rglob('*') if _.is_file())
-    mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
-    print(f'   完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
-
-    # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
-    prod_dst = dst / 'outputs' / 'rudong'
-    if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
-        for src in sorted(ROOT.glob('_products_off*/rudong')):
-            if (src / 'windscada' / 'alarms.parquet').is_file():
-                print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
-                shutil.copytree(src, prod_dst, dirs_exist_ok=True)
-                break
-        else:
-            print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
-    else:
-        print('② 副本已带产物 (从主包拷来)')
-
-    print('③ 在副本里跑安装 (离线)')
-    py = install_in(dst)
-    if py is None:
-        return 1
-
-    print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
-    ok = run_and_probe(dst, py)
-
-    print('⑤ 收尾')
-    if keep:
-        print(f'   副本保留在 {dst} (--keep); 用完手工删掉即可')
-    else:
-        shutil.rmtree(dst, ignore_errors=True)
-        print('   副本已删除')
-    print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
-    return 0
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--brief', action='store_true')
-    ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
-    ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
-    ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
-    a = ap.parse_args()
-    print(f'移植性大扫描 @ {ROOT}\n')
-    if a.simulate:
-        rc = simulate(a.into, a.keep)
-        print()
-    hits = scan_paths(verbose=not a.brief)
-    big = scan_big_text()
-    kv = check_venv()
-    dirs = check_wheels()
-    check_vendor()
-    check_configs()
-    bad = len(hits) + len(big)
-    print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
-          f' 虚拟环境/轮子/便携运行时见上面各节 —— '
-          f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
-    return 0 if not bad else 1
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try: _s.reconfigure(errors='replace')
-        except Exception: pass
-    sys.exit(main())
+# -*- coding: utf-8 -*-
+r"""移植性大扫描 (2026-09-12) —— 换电脑/换盘/换系统之前跑它。
+
+比 `scripts/check_portability.py`(只看 .py 的路径写法) 查得更宽, 因为"能不能独立运行"还取决于:
+
+  ① **所有文件类型**里的机器相关绝对路径 (.py/.bat/.sh/.ps1/.json/.md/.txt/.html/.yaml/.cfg …)
+     —— 不只是源码; 文档里的示例路径写死也会误导现场, 产物里烘进去的路径换机后就是死链。
+  ② **虚拟环境的可移植性**: `.venv/pyvenv.cfg` 的 `home` 指向基础解释器 —— 换台没有同路径 Python 的
+     机器, `.venv\Scripts\python.exe` 直接起不来(这是"拷包就能跑"最常见的误解)。
+  ③ **离线依赖是否为本平台备齐**: `wheels/<平台>` 有没有; 缺了就只能联网装。
+  ④ **便携运行时**是否在 (`vendor/python/…`), 以及 `vendor/MANIFEST.json` 有没有把它登记全。
+  ⑤ **配置与端口**是否相对/可改, 有没有写死本机 IP。
+
+用法:
+    python scripts/check_transferable.py            # 全量检查 + 结论
+    python scripts/check_transferable.py --brief    # 只报问题
+    python scripts/check_transferable.py --simulate # **模拟移植**(推荐换机前跑): 把包拷成一份副本 →
+                                                    # 在副本里跑安装 → 起服务核验页面 → 报结果 → 清理副本。
+                                                    # 默认拷全量(除 data/.git/.venv/暂存区); --keep 保留副本。
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+# 机器相关绝对路径: 盘符路径 / mac 用户目录 / 家目录 / 本机用户名
+RE_ABS = re.compile(r"""(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/home/[a-z][A-Za-z0-9_.\-]*|/opt/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+)""")
+# 扫描范围: 源码/脚本/配置/文档/外壳页面 (跳过 .venv、依赖、二进制、大体积产物里的二进制)
+TEXT_EXT = {'.py', '.bat', '.sh', '.ps1', '.cmd', '.json', '.md', '.txt', '.html', '.htm', '.yaml', '.yml', '.cfg', '.ini', '.toml'}
+SKIP_DIRS = {'.venv', '.git', '__pycache__', 'node_modules', '_products_off', 'logs', 'run'}
+SKIP_PATH_HINT = ('_products_off_prev',)
+# 扫描范围里要**排除现场原始件**: data/raw/** 是现场给的测量导出 (SCADA csv、Brande TCM JSON ——
+# 2026-09-12 落位后单这一个目录就是 2.5 万个 .json / 150 GB), 且**不随包分发** (pack_dist.py 的
+# EXCLUDE_GLOBS 含 'data')。文本闸门扫它: ①对"换机可用"零信息量 (那些文件里就算有绝对路径也不是我们的
+# 接线) ②每次验收要多读几十 GB 文本 (8 MB 以下的一律会读进内存), 把闸门从秒级拖成小时级。
+SKIP_RAW_PREFIX = ('data/raw/', 'data/_incoming/')
+# 允许的例外: 文档里明确在讲"路径约定"或演示用的占位
+ALLOW_LINE = ('portability-allow', '<安装目录>', '<现场包目录>', '<场站名称>', 'D:\\guanlan', '/opt/guanlan',
+              'D:/guanlan', '示例', '例:', '例如')
+
+
+def walk_files():
+    for p in ROOT.rglob('*'):
+        if not p.is_file() or p.suffix.lower() not in TEXT_EXT:
+            continue
+        rel = p.relative_to(ROOT).as_posix()
+        parts = set(p.relative_to(ROOT).parts)
+        if parts & SKIP_DIRS or any(h in rel for h in SKIP_PATH_HINT):
+            continue
+        if rel.startswith(SKIP_RAW_PREFIX):
+            continue
+        if rel.startswith(SKIP_RAW_PREFIX):
+            continue
+        if p.stat().st_size > 8 * 1024 * 1024:      # 超大文本(如 20MB 门户)另算, 见下
+            continue
+        yield p
+
+
+def scan_paths(verbose=True):
+    hits = []
+    for p in walk_files():
+        try:
+            text = p.read_text(encoding='utf-8', errors='replace')
+        except Exception:
+            continue
+        for i, line in enumerate(text.splitlines(), 1):
+            if any(a in line for a in ALLOW_LINE):
+                continue
+            if line.lstrip().startswith(('#', 'rem ', '::')) and 'src' not in line and '--' in line:
+                continue
+            m = RE_ABS.search(line)
+            if m:
+                hits.append((p.relative_to(ROOT).as_posix(), i, m.group(0), line.strip()[:110]))
+    print(f'① 文本文件里的机器相关绝对路径: {len(hits)} 处')
+    if hits and verbose:
+        for rel, i, found, line in hits[:25]:
+            print(f'     {rel}:{i}  ← {found}\n        {line}')
+        if len(hits) > 25:
+            print(f'     … 另有 {len(hits) - 25} 处')
+    return hits
+
+
+def scan_big_text():
+    """大文件(门户 20MB 等)单独扫: 里面烘进的绝对路径换机后就是死链。"""
+    out = []
+    for p in ROOT.rglob('*.html'):
+        rel = p.relative_to(ROOT).as_posix()
+        if any(x in rel for x in SKIP_PATH_HINT) or '.venv' in rel or p.stat().st_size < 1024:
+            continue
+        try:
+            t = p.read_text(encoding='utf-8', errors='replace')
+        except Exception:
+            continue
+        n = len(RE_ABS.findall(t))
+        if n:
+            out.append((rel, n, p.stat().st_size))
+    if out:
+        print(f'② HTML 里烘进的绝对路径: {len(out)} 个文件')
+        for rel, n, sz in out[:10]:
+            print(f'     {rel}  {n} 处 ({sz/1e6:.1f} MB)')
+    else:
+        print('② HTML 里没有烘进的绝对路径')
+    return out
+
+
+def check_venv():
+    cfg = ROOT / '.venv' / 'pyvenv.cfg'
+    print('③ 虚拟环境')
+    if not cfg.is_file():
+        print('     [--] 没有 .venv/pyvenv.cfg (还没装? 目标机上跑 install.bat / sh install.sh)')
+        return 'absent'
+    kv = {}
+    for l in cfg.read_text(encoding='utf-8', errors='replace').splitlines():
+        if '=' in l:
+            k, v = l.split('=', 1)
+            kv[k.strip()] = v.strip()          # ★键要去空格: pyvenv.cfg 写的是 "home = D:\..." (等号前有空格)
+    home = (kv.get('home') or '').strip()
+    print(f'     home(基础解释器) = {home}')
+    print(f'     版本 {kv.get("version", "?").strip()}')
+    here = pathlib.Path(home).is_dir() if home else False
+    print(f'     本机存在该目录: {here}')
+    print('     ★ 换台机器若没有同路径的 Python, .venv 里的解释器起不来 —— 移植时**必须在目标机重跑一次安装**'
+          '(install.bat / sh install.sh; 离线, 用包内轮子或便携运行时)。')
+    return kv
+
+
+def check_wheels():
+    w = ROOT / 'wheels'
+    print('④ 离线依赖轮子')
+    if not w.is_dir():
+        print('     [X] 没有 wheels/ 目录 —— 装依赖需要联网')
+        return []
+    dirs = sorted(d.name for d in w.iterdir() if d.is_dir())
+    print(f'     平台目录: {dirs or "(无)"}')
+    req = [l.split('#')[0].strip() for l in (ROOT / 'requirements.txt').read_text(encoding='utf-8').splitlines()]
+    req = [r for r in req if r]
+    names = {r.split('==')[0].lower().replace('-', '_') for r in req}
+    for d in dirs:
+        have = {p.name.split('-')[0].lower().replace('-', '_') for p in (w / d).glob('*.whl')}
+        miss = sorted(n for n in names if n not in have)
+        tag = 'OK' if not miss else f'缺 {len(miss)}: {miss[:6]}'
+        print(f'     {d}: {len(list((w / d).glob("*.whl")))} 个轮子  {tag}')
+    plat = 'win_amd64' if os.name == 'nt' else ('linux_x86_64' if sys.platform.startswith('linux') else 'macos')
+    if plat not in dirs:
+        print(f'     [!] 本机平台 {plat} 没有对应轮子目录: Linux/macOS 上 install.sh 会自动**改成联网安装**')
+    return dirs
+
+
+def check_vendor():
+    v = ROOT / 'vendor'
+    print('⑤ 便携运行时 / 离线件')
+    if not v.is_dir():
+        print('     [--] 没有 vendor/')
+        return
+    for p in sorted(v.rglob('*')):
+        if p.is_file():
+            print(f'     {p.relative_to(ROOT).as_posix()}  {p.stat().st_size/1e6:.1f} MB')
+    man = v / 'MANIFEST.json'
+    if man.is_file():
+        m = json.loads(man.read_text(encoding='utf-8'))
+        files = {x.relative_to(v).as_posix() for x in v.rglob('*') if x.is_file() and x.name != 'MANIFEST.json'}
+        undoc = sorted(files - set(m))
+        if undoc:
+            print(f'     [!] MANIFEST.json 没登记: {undoc} (只影响校验, 不影响运行)')
+
+
+def check_configs():
+    from src import paths as P      # 配置唯一取用口 (顶层 import 会与 pack_dist 的动态加载打架, 故就近 import)
+    print('⑥ 配置与端口')
+    p = P.SERVE_JSON                      # 配置唯一取用口
+    if p.is_file():
+        c = json.loads(p.read_text(encoding='utf-8-sig'))
+        print(f'     serve.json: host={c.get("host")} gateway={c.get("gateway")} python={c.get("python")!r} '
+              f'raw_dir={c.get("raw_dir")!r}')
+        for k in ('python', 'raw_dir', 'release_dir', 'viewer_dir', 'sim_dir'):
+            v = str(c.get(k) or '')
+            if re.match(r'^[A-Za-z]:[\\/]|^/', v):
+                print(f'     [!] {k} 是绝对路径: {v}')
+    # 写死的本机端口/入口(允许出现, 但要知道)
+    n_port = sum(1 for p in walk_files() if '28084' in p.read_text(encoding='utf-8', errors='replace'))
+    print(f'     提到 28084 的文本文件: {n_port} 个 (端口真源是 configs/serve.json, 其余多为文档)')
+
+
+def _free_port(start=28700, tries=40):
+    for p in range(start, start + tries):
+        with socket.socket() as s:
+            try:
+                s.bind(('127.0.0.1', p)); return p
+            except OSError:
+                continue
+    return None
+
+
+def _busy(port, host='127.0.0.1'):
+    with socket.socket() as s:
+        s.settimeout(0.3)
+        return s.connect_ex((host, port)) == 0
+
+
+IGNORE = shutil.ignore_patterns('data', '.git', '.venv', '__pycache__', 'logs', 'run',
+                                '_products_off', '_products_off_prev_*', '*.pyc')
+
+
+def install_in(dst: pathlib.Path) -> pathlib.Path | None:
+    """在 dst 里跑一次离线安装 (Windows: install.ps1; POSIX: install.sh), 返回该副本的解释器路径。"""
+    print(f'   在 {dst} 里跑安装 (离线) …')
+    # -NoDesktopShortcut: install.ps1 默认会在**桌面**建「观澜·如东样板 v2」快捷方式, 而这里是临时副本,
+    # 建出来的快捷方式指向马上要被删掉的目录 (2026-09-16 加)。副本里的 `启动观澜.lnk` 照建 —— 那正是要核验的东西。
+    cmd = (['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(dst / 'install.ps1'), '-NoDesktopShortcut']
+           if os.name == 'nt' else ['sh', str(dst / 'install.sh')])
+    t0 = time.time()
+    r = subprocess.run(cmd, cwd=str(dst))
+    print(f'   install 退出码 {r.returncode}, 耗时 {time.time() - t0:.0f}s')
+    py = dst / ('.venv/Scripts/python.exe' if os.name == 'nt' else '.venv/bin/python')
+    if not py.is_file():
+        print(f'   [X] 副本里没有 {py} —— 安装没成')
+        return None
+    v = subprocess.run([str(py), '-c', 'import sys,pandas,pyarrow,polars,yaml;print(sys.version.split()[0])'],
+                       capture_output=True, text=True, errors='replace')
+    print(f'   副本解释器: {v.stdout.strip() or v.stderr.strip()[:120]}')
+    return py
+
+
+def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
+    """在副本里起服务并核验页面 —— **不动正在跑的实例**: 网关改用空闲端口, 组件端口若被占则跳过。
+
+    原先网关端口被占就整段跳过(实测踩到: 主实例在跑 → 模拟移植只做了拷贝+安装)。现在:
+      · 组件端口(18033 等)空闲 → 用它们; 网关端口(28084)被占 → 换成空闲端口(如 28700);
+      · 网关 ROUTES 里上游端口是写死的(文档 §三 的已知限制), 所以组件端口**必须**用默认值, 不能随网关一起改。
+    """
+    comp_ports = (18033, 18020, 18791, 18792, 64292)
+    busy_comp = [p for p in comp_ports if _busy(p)]
+    if busy_comp:
+        print(f'   [!] 组件端口被占用 {busy_comp} —— 跳过启动核验(先停掉正在跑的实例再试)')
+        return 0
+    gw = base_port if not _busy(base_port) else _free_port(28700)
+    if gw is None:
+        print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
+    if gw != base_port:
+        cfg_p = dst / 'configs' / P.SERVE_JSON.name      # config-path-allow: 改的是**副本**里的那份配置
+        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
+        cfg['gateway'] = gw
+        cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
+        print(f'   网关端口 {base_port} 已被占用 → 副本改用 {gw} (不打扰正在跑的实例)')
+    t0 = time.time()
+    rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
+    print(f'   serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
+    ok = 0
+    try:
+        # ★门户期望字节数**不写死** (2026-09-16): 原来常量 20225828 是某一版门户"服务出来"的字节数,
+        #   门户外壳一改 (例如按用户令在菜单里加「数据重算」) 它就过期, 于是核验会打印一条永远不成立的
+        #   "★与主包不同", 看起来像移植出了问题。改为**从正在跑的主实例现量**: 主实例没起就不比这一项。
+        exp_portal = None
+        try:
+            with urllib.request.urlopen(f'http://127.0.0.1:{base_port}/', timeout=10) as rr:
+                exp_portal = len(rr.read())
+                print(f'   参照: 主实例门户 {exp_portal:,} B (端口 {base_port})')
+        except Exception as e:
+            print(f'   参照: 主实例 ({base_port}) 未响应 → 门户字节数不做同字节比对 ({type(e).__name__})')
+        urls = [('/', exp_portal), ('/detail/', None), ('/cms/', None), ('/ops', None), ('/healthz', None)]
+        for path, exp_size in urls:
+            try:
+                with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
+                    body = rr.read()
+                    tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
+                    print(f'   {path:10s} HTTP {rr.status}  {len(body):,} B{tag}')
+                    ok += 1
+            except Exception as e:
+                print(f'   {path:10s} 失败: {type(e).__name__} {e}')
+        print(f'   页面可用 {ok}/{len(urls)}')
+    finally:
+        subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
+    return ok
+
+
+def simulate(into=None, keep=False) -> int:
+    """模拟移植: 拷副本 → 副本内安装 → 起服务核验 → 清理。"""
+    dst = pathlib.Path(into) if into else pathlib.Path(tempfile.gettempdir()) / 'guanlan_portsim'
+    if dst.exists():
+        print(f'  清理旧副本 {dst}'); shutil.rmtree(dst, ignore_errors=True)
+    print(f'① 拷副本 → {dst}   (排除 data/.git/.venv/暂存区/logs/run)')
+    t0 = time.time()
+    shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=False)
+    n = sum(1 for _ in dst.rglob('*') if _.is_file())
+    mb = sum(_.stat().st_size for _ in dst.rglob('*') if _.is_file()) / 1e6
+    print(f'   完成: {n} 件 / {mb:.0f} MB, 耗时 {time.time() - t0:.0f}s')
+
+    # 产物: 主包当前可能被"清除产物"挪走了 → 从暂存区补进副本, 好让页面有数可验
+    prod_dst = dst / 'outputs' / 'rudong'
+    if not (prod_dst / 'windscada' / 'alarms.parquet').is_file():
+        for src in sorted(ROOT.glob('_products_off*/rudong')):
+            if (src / 'windscada' / 'alarms.parquet').is_file():
+                print(f'② 主包产物不在位 → 从 {src.relative_to(ROOT)} 拷进副本')
+                shutil.copytree(src, prod_dst, dirs_exist_ok=True)
+                break
+        else:
+            print('② 找不到可用产物(主包与暂存区都没有) —— 页面会是"无产物", 属预期')
+    else:
+        print('② 副本已带产物 (从主包拷来)')
+
+    print('③ 在副本里跑安装 (离线)')
+    py = install_in(dst)
+    if py is None:
+        return 1
+
+    print('④ 起服务核验 (副本独占端口; 组件端口被占则跳过这一步)')
+    ok = run_and_probe(dst, py)
+
+    print('⑤ 收尾')
+    if keep:
+        print(f'   副本保留在 {dst} (--keep); 用完手工删掉即可')
+    else:
+        shutil.rmtree(dst, ignore_errors=True)
+        print('   副本已删除')
+    print('\n模拟移植结论: 见上面各步 —— 安装成 + 页面与主包同字节 = 换机可行(记得在目标机也跑一次 install)')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--brief', action='store_true')
+    ap.add_argument('--simulate', action='store_true', help='拷副本→副本内安装→起服务核验→清理')
+    ap.add_argument('--into', default=None, help='--simulate 的副本目录 (默认 %%TEMP%%/guanlan_portsim)')
+    ap.add_argument('--keep', action='store_true', help='--simulate 后保留副本')
+    a = ap.parse_args()
+    print(f'移植性大扫描 @ {ROOT}\n')
+    if a.simulate:
+        rc = simulate(a.into, a.keep)
+        print()
+    hits = scan_paths(verbose=not a.brief)
+    big = scan_big_text()
+    kv = check_venv()
+    dirs = check_wheels()
+    check_vendor()
+    check_configs()
+    bad = len(hits) + len(big)
+    print(f'\n结论: {"路径层面干净" if not bad else f"{bad} 处机器相关路径要处理"};'
+          f' 虚拟环境/轮子/便携运行时见上面各节 —— '
+          f'"拷包就能跑"只在**同路径同基础 Python**时成立, 换机请按 README 先跑一次 install。')
+    return 0 if not bad else 1
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 256 - 0
scripts/config_audit.py

@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""配置审计 —— 把"配置目录/文件统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
+
+## 查什么 (每条都能指到具体文件/代码行)
+
+    R1 顶层约定    configs/ 顶层只许放 `src/paths.py::TOP_LEVEL_CONFIGS` 里的运行期单件配置 +
+                   已登记的域目录 + README.md/registry.yaml。散件一律报出来。
+    R2 备份垃圾    任何 `*.bak*` `*~` `*.old` `*.orig` `*.tmp` `*.rej` 都是垃圾 (实测 configs/ 里
+                   躺过一个 `serve.json.bak-bomfix`)。
+    R3 命名        配置名不得含空格/中文; 全大写视为"命名不统一"(提示级, 因为场名/机型名有历史约定)。
+    R4 内容绝对路径 配置**内容**里不得出现本机绝对路径 (`C:\` `D:\` `F:\` `/Users/…` `/Volumes/…` `/home/<人名>`)。
+    R5 引用闭合    代码里 `P.config('x')` / `P.config_dir('x')` 指向的文件/目录必须存在; 不存在且**未登记**
+                   在 `configs/registry.yaml::known_missing` 的, 按"悬空引用"报错 (登记过的记 `i`)。
+    R6 手拼路径    代码里不许再拼 `"configs" / …` 字符串 (白名单: src/paths.py 自身、审计脚本、注释/文档串)。
+    R7 无读者      configs/ 下每个文件要么被登记表的 consumers 覆盖, 要么所在域声明了 `no_reader_ok` + 理由。
+    R8 场配置      `configs/farms/` 下每个文件要么是**能加载的场定义**(必需键齐), 要么在登记表里
+                   声明为"机型/物理约束 profile"或"CSV 数据表" —— 否则就是"配了看不见"的东西。
+
+## 退出码 (给 check / rebuild_all 用)
+    0 全部通过 · 5 结构问题(散件/垃圾/未登记域/未登记文件) · 6 悬空引用 · 7 内容含本机绝对路径 · 8 手拼路径 · 9 场配置不合约定
+
+## 用法
+    python scripts/config_audit.py            # 表 + 检查
+    python scripts/config_audit.py --list     # 只打配置目录表
+    python scripts/config_audit.py --json
+"""
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import json
+import os
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                          # noqa: E402
+
+REGISTRY = P.config('registry.yaml')
+CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv', '.example', '.md')
+JUNK = ('*.bak', '*.bak-*', '*~', '*.old', '*.orig', '*.tmp', '*.rej', '*.swp')
+RE_JUNK = re.compile(r'\.(?:bak|old|orig|tmp|rej|swp)(?:$|[-.])|~$')
+RE_BADNAME = re.compile(r'[\s]')                       # 空格会切断脚本参数, 一律不许
+RE_CN_NAME = re.compile(r'[\u4e00-\u9fff]')            # 中文文件名: 项目里大量存在, 只提示不判错
+RE_ABS = re.compile(r'(?<![\w:/])(?:[A-Za-z]:[\\/](?![\\/])|/Users/[A-Za-z0-9_.\-]+|/Volumes/[A-Za-z0-9_\- ]+|/home/[a-z][A-Za-z0-9_.\-]*)')
+# 配置调用的参数可能不止一个: P.config('value_assumptions', f'{farm}.yaml') —— 要把字面量参数全抓下来拼成相对路径
+RE_CONFIG_CALL = re.compile(r'P\.config(?:_dir)?\(([^)]*)\)')
+RE_STR_ARG = re.compile(r'[\'"]([^\'"]+)[\'"]')
+RE_HANDMADE = re.compile(r'''(?:ROOT|root|REPO|here|dst)\s*/\s*['"]configs['"]|['"]configs/[\w.-]+['"]''')
+# 说明性字段 (记录"当时数据在哪台机器上") 里的本机路径算**溯源信息**, 不算 bug —— 但只限注释行与这些键
+RE_DESCRIPTIVE_KEY = re.compile(r'^\s*#|^\s*(?:data_location|data_source|source_root|source_note|note|comment|说明|来源)\s*:')
+
+SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor',
+             'outputs', 'data', 'logs', 'run', 'release', 'resources', 'reference'}
+
+
+def load_registry():
+    import yaml
+    if not REGISTRY.is_file():
+        raise SystemExit(f'缺配置登记表 {P.rel(REGISTRY)}')
+    return yaml.safe_load(REGISTRY.read_text(encoding='utf-8')) or {}
+
+
+def cfg_files():
+    out = []
+    for dp, dn, fns in os.walk(P.CONFIGS):
+        dn[:] = [d for d in dn if d not in {'__pycache__'}]
+        for f in fns:
+            out.append(pathlib.Path(dp) / f)
+    return sorted(out)
+
+
+def py_files():
+    out = []
+    for dp, dn, fns in os.walk(ROOT):
+        dn[:] = [d for d in dn if d not in SKIP_WALK]
+        out += [pathlib.Path(dp) / f for f in fns if f.endswith('.py')]
+    return out
+
+
+def audit():
+    """→ (rc, results, info)。results = [(level, item, note, rc)]。"""
+    reg = load_registry()
+    res: list[tuple[str, str, str, int]] = []
+    top_ok = {t['file'] for t in reg.get('top_level') or []}
+    domain_dirs = {d['dir'] for d in reg.get('domains') or []}
+    known_missing = set()
+    known_why = {}
+    for m in reg.get('known_missing') or []:
+        for name in [m['file']] + list(m.get('also_missing') or []):
+            known_missing.add(name)
+            known_why[name] = m.get('impact', '')
+
+    # ── R1/R2/R3/R4: 文件层
+    for f in cfg_files():
+        rel = f.relative_to(P.CONFIGS).as_posix()
+        if f.name == 'registry.yaml' or f.suffix == '.md':
+            continue
+        if '/' not in rel:
+            if f.name not in top_ok:
+                res.append(('X', rel, 'configs/ 顶层只许放已登记的运行期单件配置 (见登记表 top_level)', 5))
+        else:
+            d = rel.split('/')[0]
+            if d not in domain_dirs:
+                res.append(('X', rel, f'所在域 {d}/ 未在登记表登记 (新域要写明 kind + consumers)', 5))
+        if RE_JUNK.search(f.name) or any(fnmatch.fnmatch(f.name, j) for j in JUNK):
+            res.append(('X', rel, '备份/垃圾文件 (配置文件只留正本; 改历史靠 git)', 5))
+        if RE_BADNAME.search(f.name):
+            res.append(('X', rel, '文件名含空格 —— 空格会切断脚本参数, 配置名一律用 [a-z0-9_.-]', 5))
+        elif RE_CN_NAME.search(f.name):
+            res.append(('?', rel, '文件名含中文 (项目里常见, 不是错; 注意某些工具/编码环境会踩)', 0))
+        elif re.search(r'[A-Z]', f.stem) and '/' in rel:
+            res.append(('?', rel, '文件名含大写 (历史约定, 不是错; 新文件建议小写)', 0))
+        if f.suffix.lower() in ('.yaml', '.yml', '.json', '.csv'):
+            try:
+                body = f.read_text(encoding='utf-8-sig', errors='replace')
+            except OSError:
+                body = ''
+            hit_desc = hit_code = None
+            for ln in body.splitlines():
+                m = RE_ABS.search(ln)
+                if not m:
+                    continue
+                if RE_DESCRIPTIVE_KEY.match(ln):
+                    hit_desc = hit_desc or m.group(0)
+                else:
+                    hit_code = hit_code or m.group(0)
+            if hit_code:
+                res.append(('X', rel, f'内容含本机绝对路径 {hit_code[:24]!r} (在**被读取的字段**里 ⇒ 换机必失配)', 7))
+            elif hit_desc:
+                res.append(('?', rel, f'说明性字段/注释里记了本机路径 {hit_desc[:24]!r} —— 属溯源信息(当时数据在哪台机器), 不算失配', 0))
+
+    # ── R5/R6: 代码层
+    seen_conf = set()
+    for p in py_files():
+        t = p.read_text(encoding='utf-8', errors='replace')
+        if p.name != 'config_audit.py':            # 本脚本的说明文字里就有 P.config('x') 之类的例子, 别自证其罪
+            for args in RE_CONFIG_CALL.findall(t):
+                parts = RE_STR_ARG.findall(args)
+                if not parts:
+                    continue
+                rel_call = '/'.join(parts).replace('\\', '/')
+                # f-string 参数 (P.config(f'analysis_lock_{farm}.yaml')) 在源码里是字面量带花括号:
+                # 把 {…} 当通配符, 只要有任一匹配文件就算"在位", 否则报缺。
+                if '{' in rel_call:
+                    import glob as _glob
+                    pat = str(P.CONFIGS / re.sub(r'\{[^}]*\}', '*', rel_call))
+                    if _glob.glob(pat):
+                        continue
+                    wildcard = rel_call
+                    known = known_missing | {k.replace('<场>', '*') for k in known_missing}
+                    if any(fnmatch.fnmatch(wildcard, k) for k in known):
+                        res.append(('i', f'{P.rel(p)} → configs/{wildcard}',
+                                    '悬空引用(已知): per-场配置未随包 (见登记表 known_missing)', 0))
+                    else:
+                        res.append(('X', f'{P.rel(p)} → configs/{wildcard}',
+                                    '代码引用的 per-场配置一个都不存在, 且未登记 ⇒ 补文件或删引用', 6))
+                    continue
+                seen_conf.add(rel_call)
+                target = P.CONFIGS / rel_call
+                if target.exists():
+                    continue
+                if rel_call in known_missing or any(rel_call.startswith(k.replace('<场>', '')) for k in known_missing):
+                    res.append(('i', f'{P.rel(p)} → configs/{rel_call}',
+                                f'悬空引用(已知): {known_why.get(rel_call, "见登记表 known_missing")[:80]}', 0))
+                else:
+                    res.append(('X', f'{P.rel(p)} → configs/{rel_call}',
+                                '代码引用的配置不存在, 且未登记在 known_missing ⇒ 补文件或删引用', 6))
+        for i, ln in enumerate(t.splitlines(), 1):
+            s = ln.strip()
+            if s.startswith('#') or p.name in ('config_audit.py',) or p == P.SRC / 'paths.py':
+                continue
+            if 'config-path-allow' in ln or 'portability-allow' in ln:
+                continue
+            if RE_HANDMADE.search(ln):
+                res.append(('!', f'{P.rel(p)}:{i}', f'手拼 configs 路径: {s[:70]} ⇒ 改用 P.config()/P.config_dir()', 8))
+
+    # ── R7: 无读者
+    declared = json.dumps(reg, ensure_ascii=False)
+    for d in reg.get('domains') or []:
+        if (d.get('consumers') or []) or d.get('no_reader_ok'):
+            continue
+        res.append(('X', f'{d["dir"]}/', '未声明 consumers, 也没写 no_reader_ok + 理由', 5))
+    for f in cfg_files():
+        rel = f.relative_to(P.CONFIGS).as_posix()
+        if '/' not in rel or f.suffix == '.md' or f.name in top_ok:
+            continue
+        # 逐件"有没有人提过它"太吵 (170 件里绝大多数靠域级 consumers 覆盖) —— 只查**域级声明**。
+        # 域级没声明 consumers 又没写 no_reader_ok 的, 由上面的域检查报出来。
+        _ = rel
+
+    # ── R8: 场配置
+    from src.windscada import config as WC
+    for f, keys in WC.foreign_farm_files():
+        res.append(('i', f'configs/farms/{f.name}', f'非场定义 (顶层键 {keys}) ⇒ 不参与场加载, 已登记为机型/物理约束 profile', 0))
+    farm_dom = next((d for d in reg.get('domains') or [] if d['dir'] == 'farms'), {})
+    n_prof = sum(1 for f, _ in WC.foreign_farm_files())
+    declared_prof = next((s for s in (farm_dom.get('subkinds') or []) if 'profile' in s.get('name', '')), {})
+    if declared_prof and int(declared_prof.get('present', -1)) not in (-1, n_prof):
+        res.append(('X', 'configs/farms/', f'登记表写 profile {declared_prof.get("present")} 个, 实际 {n_prof} 个', 9))
+    for name, src in WC.available().items():
+        if src != '内置' and not (P.ROOT / src).is_file():
+            res.append(('X', f'configs/farms/{name}', f'available() 列出了 {src}, 但文件不在', 9))
+
+    info = dict(config_files=len(cfg_files()),
+                domains=len(domain_dirs),
+                top_level=sorted(top_ok),
+                known_missing=sorted(known_missing),
+                farm_defs=list(WC.available()),
+                farm_profiles=n_prof)
+    rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
+    return (max(rc_map) if rc_map else 0), res, info
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--list', action='store_true', help='只打配置目录表')
+    ap.add_argument('--json', action='store_true')
+    a = ap.parse_args()
+    rc, res, info = audit()
+    if a.json:
+        print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
+                              info=info), ensure_ascii=False, indent=1))
+        return rc
+    if a.list or True:
+        print('== 配置目录约定 (configs/registry.yaml) ==')
+        print(f'   运行期单件: {", ".join(info["top_level"])}')
+        for d in load_registry().get('domains') or []:
+            print(f'   {d["dir"]:20s} {d.get("kind", "")[:52]}')
+        print(f'   配置文件合计 {info["config_files"]} 件 · 域 {info["domains"]} 个 · '
+              f'场定义 {len(info["farm_defs"])} 个 · 机型 profile {info["farm_profiles"]} 件')
+    if not a.list:
+        lvl = {}
+        for level, item, note, r in res:
+            lvl[level] = lvl.get(level, 0) + 1
+        print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · '
+              f'已知缺口 {lvl.get("i", 0)} ==')
+        for level, item, note, r in res:
+            if level in ('X', '!'):
+                print(f'   [{level}] {item}: {note}')
+        seen = set()
+        for level, item, note, r in res:
+            if level == 'i' and note not in seen:
+                seen.add(note)
+                print(f'   [i] {item}: {note}')
+        print(f'结论: {"全部符合约定" if rc == 0 else "见上"}; 退出码 {rc}')
+    return rc
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 324 - 322
scripts/guanlan_cloud_qa.py

@@ -1,322 +1,324 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""观澜云端版 · 服务端 /api/qa 代理 (草案, 未部署; 部署包 CLAUDE_CODE_部署提示词.md §安全边界 / §账号与权限 / §B 云端可用性; 交接 2026-09-06).
-
-边界 (逐条对应部署包):
-- 密钥只在服务端环境变量 DEEPSEEK_API_KEY; 缺失 → 模式 "不可用" + 明确文案, **不伪造回答**, 不落任何文件/日志/回包.
-- 浏览器不直连 DeepSeek: 本进程绑 127.0.0.1, 前面放 nginx (HTTPS); 上游 401 / 429 / 超时 / 5xx → 回包 ok=false + 脱敏错误码, answer 恒 null.
-- 鉴权: 服务端会话 (HttpOnly cookie, 随机 token, 服务端过期); 账号 = 拼音显示名 + 临时密码 (PBKDF2, 10 天有效, 可撤销, 建/撤销/登录全部进审计).
-- 限流 (按用户 + 按 IP, 固定窗) · 请求长度 (body / 问题字数) · 上游超时 · 审计日志 (JSONL: 只记 问题 sha256+长度, 不记原文, 不记密钥) · 错误脱敏 (上游原文只进服务端 stderr 且先去密钥).
-- 模式标签: 每个回包带 mode ∈ {云端 DeepSeek, 本机 DeepSeek, 演示回答, 不可用}; 演示回答只在 GUANLAN_QA_DEMO=1 且无密钥时出现, 且 answer 前缀明示 "[演示回答]".
-- 出口脱敏: 模型答案过 src/windscada/deid_public.scrub (与 windscada_ask_serve 同款; 导入失败 = 拒绝启动).
-- 接地: 问题先在云端脱敏面孔 (outputs/rudong/guanlan/cloud/claims_public.json) 里按关键词取 ≤5 条作 system 上下文, 回包 refs 列 claim_id; 面孔缺失 → 不带引用, refs=[] (不伪造引用).
-不做: 用户管理 HTTP 接口 (只走 CLI, 缩小攻击面) · 多轮对话 · 流式.
-用法: serve [--port 8090] | user add <pinyin> [--role user|admin] [--days 10] | user revoke <pinyin> | user list | selftest
-环境变量 (名, 不含值): DEEPSEEK_API_KEY · DEEPSEEK_BASE_URL (默认 https://api.deepseek.com) · DEEPSEEK_MODEL (默认 deepseek-chat) · GUANLAN_QA_USERS (users.json 路径) · GUANLAN_QA_AUDIT (audit.jsonl 路径) · GUANLAN_QA_DEMO · GUANLAN_QA_INSECURE_COOKIE (=1 时 cookie 不带 Secure, 仅本机 http 测试)
-上游调用经 transport 注入 (ask(q, user, transport=...)), 测试用桩模拟 401/429/超时/5xx, 不真调云.
-"""
-import argparse, base64, datetime as dt, hashlib, hmac, http.client, http.cookies, json, os, pathlib, re, secrets, socket, sys, threading, time, urllib.parse, uuid
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT / "src"))
-from windscada import deid_public as DP  # noqa: E402  ★出口脱敏不可用 = 拒绝启动 (看着有闸实则没有, 比没闸更坏)
-
-MODES = ("云端 DeepSeek", "本机 DeepSeek", "演示回答", "不可用")
-LIMITS = dict(max_body_bytes=16 * 1024, max_q_chars=2000, rate_user=(10, 300), rate_ip=(30, 300), upstream_timeout_s=60, max_answer_chars=6000, temp_pw_days=10, session_hours=12, max_refs=5)
-CLOUD_CLAIMS = ROOT / "outputs/rudong/guanlan/cloud/claims_public.json"
-USERS_PATH = pathlib.Path(os.environ.get("GUANLAN_QA_USERS", str(ROOT / "outputs/rudong/guanlan/cloud/_private/users.json")))
-AUDIT_PATH = pathlib.Path(os.environ.get("GUANLAN_QA_AUDIT", str(ROOT / "outputs/rudong/guanlan/cloud/_private/audit.jsonl")))
-SYS_PROMPT = ("你是风电场 SCADA/CMS 分析结论的解释助手。只能依据下面给出的『契约结论』回答; 结论里没有的数字不许编, 没有依据就说『契约里没有这条』。"
-              "不得批准检修、不得替代工程师判断、不得补造缺失数据。回答用中文, 引用结论时写 claim_id。")
-ERR_TEXT = {"no_key": "云端模型未配置 (服务端缺 DEEPSEEK_API_KEY), 问答不可用", "upstream_401": "云端模型凭证无效 (服务端配置问题), 问答不可用", "upstream_429": "云端模型限流, 请稍后再试",
-            "upstream_timeout": "云端模型超时, 本次未得到回答", "upstream_5xx": "云端模型服务异常, 本次未得到回答", "upstream_net": "云端模型不可达, 本次未得到回答", "upstream_bad": "云端模型回包无法解析, 本次未得到回答"}
-_KEY_RE = re.compile(r"sk-[A-Za-z0-9]{8,}|Bearer\s+\S+")
-
-
-# ── 密钥 / 脱敏 ────────────────────────────────────────────────────
-def api_key(): return (os.environ.get("DEEPSEEK_API_KEY") or "").strip()
-
-
-def redact(s):
-    """任何要落日志/回包的字符串先过这里: 去掉密钥形态串与真实密钥值."""
-    s = str(s); k = api_key()
-    if k: s = s.replace(k, "[REDACTED]")
-    return _KEY_RE.sub("[REDACTED]", s)
-
-
-def mode_now():
-    if api_key(): return "云端 DeepSeek"
-    return "演示回答" if os.environ.get("GUANLAN_QA_DEMO") == "1" else "不可用"
-
-
-# ── 审计 ──────────────────────────────────────────────────────────
-_AUDIT_LOCK = threading.Lock()
-
-
-def audit(event, **kw):
-    rec = {"ts": dt.datetime.now().isoformat(timespec="seconds"), "event": event}
-    rec.update({k: (redact(v) if isinstance(v, str) else v) for k, v in kw.items()})
-    AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
-    with _AUDIT_LOCK, AUDIT_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n")
-    return rec
-
-
-# ── 账号 (服务端管理; 拼音显示名 + 临时密码) ──────────────────────
-PBKDF2_ITERS = 200_000
-
-
-def _hash_pw(pw, salt): return hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ITERS).hex()
-
-
-class Users:
-    def __init__(self, path=USERS_PATH):
-        self.path = pathlib.Path(path); self.d = json.loads(self.path.read_text(encoding="utf-8")) if self.path.is_file() else {"users": {}}
-
-    def _save(self):
-        self.path.parent.mkdir(parents=True, exist_ok=True); self.path.write_text(json.dumps(self.d, ensure_ascii=False, indent=1), encoding="utf-8")
-        try: os.chmod(self.path, 0o600)
-        except OSError: pass
-
-    def add(self, name, role="user", days=LIMITS["temp_pw_days"], by="cli"):
-        """建/重置账号 → 临时密码 (只返回这一次, 不落盘明文). 最小权限: 默认 user."""
-        if not re.fullmatch(r"[a-z][a-z0-9_]{1,31}", name): raise ValueError("显示名须为拼音小写 [a-z0-9_], 2~32 字")
-        if role not in ("user", "admin"): raise ValueError("role ∈ user|admin")
-        pw = base64.b32encode(secrets.token_bytes(10)).decode().rstrip("=").lower(); salt = secrets.token_hex(16)
-        self.d["users"][name] = {"role": role, "salt": salt, "hash": _hash_pw(pw, salt), "created": dt.datetime.now().isoformat(timespec="seconds"),
-                                 "expires": (dt.datetime.now() + dt.timedelta(days=days)).isoformat(timespec="seconds"), "revoked": False}
-        self._save(); audit("user_add", user=name, role=role, days=days, by=by); return pw
-
-    def revoke(self, name, by="cli"):
-        if name not in self.d["users"]: raise KeyError(name)
-        self.d["users"][name]["revoked"] = True; self._save(); audit("user_revoke", user=name, by=by)
-
-    def verify(self, name, pw, now=None):
-        """→ (user dict | None, reason ∈ ok|no_user|bad_pw|expired|revoked). 不区分文案给客户端 (统一 401), reason 只进审计."""
-        u = self.d["users"].get(name or "")
-        if not u: _hash_pw(pw or "", "00" * 16); return None, "no_user"  # 等时: 无账号也算一次 hash
-        if not hmac.compare_digest(_hash_pw(pw or "", u["salt"]), u["hash"]): return None, "bad_pw"
-        if u.get("revoked"): return None, "revoked"
-        if (now or dt.datetime.now()) > dt.datetime.fromisoformat(u["expires"]): return None, "expired"
-        return {"name": name, "role": u["role"], "expires": u["expires"]}, "ok"
-
-    def list(self): return [{"name": n, "role": u["role"], "expires": u["expires"], "revoked": u.get("revoked", False), "created": u["created"]} for n, u in self.d["users"].items()]
-
-
-class Sessions:
-    def __init__(self, hours=LIMITS["session_hours"]): self.s = {}; self.hours = hours; self.lock = threading.Lock()
-
-    def create(self, user):
-        tok = secrets.token_urlsafe(32)
-        with self.lock: self.s[tok] = dict(user, exp=time.time() + self.hours * 3600)
-        return tok
-
-    def get(self, tok):
-        with self.lock:
-            u = self.s.get(tok or "")
-            if u and (u["exp"] < time.time() or dt.datetime.now() > dt.datetime.fromisoformat(u["expires"])): self.s.pop(tok, None); return None
-            return u
-
-    def drop(self, tok):
-        with self.lock: self.s.pop(tok or "", None)
-
-
-class RateLimiter:
-    """固定窗: key 在 window 秒内最多 n 次 → (ok, retry_after_s)."""
-    def __init__(self): self.w = {}; self.lock = threading.Lock()
-
-    def allow(self, key, n, window, now=None):
-        now = now if now is not None else time.time()
-        with self.lock:
-            t0, c = self.w.get(key, (now, 0))
-            if now - t0 >= window: t0, c = now, 0
-            if c >= n: return False, int(window - (now - t0)) + 1
-            self.w[key] = (t0, c + 1); return True, 0
-
-
-# ── 接地引用 (云端脱敏面孔) ─────────────────────────────────────────
-_CLAIMS = {"key": None, "rows": []}
-
-
-def claims_public():
-    if not CLOUD_CLAIMS.is_file(): return []
-    st = CLOUD_CLAIMS.stat(); key = (st.st_mtime_ns, st.st_size)
-    if _CLAIMS["key"] != key: _CLAIMS.update(key=key, rows=json.loads(CLOUD_CLAIMS.read_text(encoding="utf-8")).get("claims", []))
-    return _CLAIMS["rows"]
-
-
-def pick_refs(q, k=LIMITS["max_refs"]):
-    """关键词重叠取 top-k 契约条 (只用脱敏面孔; 面孔缺 → []). 不是检索系统, 只是把回答钉在契约上."""
-    toks = {t for t in re.findall(r"[一-鿿]{2,}|[A-Za-z][A-Za-z0-9\-]{2,}", q or "")}
-    grams = {t[i:i + 2] for t in toks for i in range(len(t) - 1)} | toks
-    if not grams: return []
-    scored = []
-    for c in claims_public():
-        text = (c.get("title_public") or "") + " " + (c.get("missing_evidence") or "")
-        sc = sum(1 for g in grams if g in text) + (2 if c.get("claim_id", "").lower() in (q or "").lower() else 0)
-        if sc: scored.append((sc, c))
-    scored.sort(key=lambda x: (-x[0], x[1]["claim_id"]))
-    return [c for _, c in scored[:k]]
-
-
-def build_messages(q, refs):
-    ctx = "\n".join(f"- {c['claim_id']} [{c['verdict']}] {c['title_public'][:200]}" + (f" | 缺失证据: {c['missing_evidence'][:120]}" if c.get("missing_evidence") else "") for c in refs) or "(契约里没有与本问题相关的结论)"
-    return [{"role": "system", "content": SYS_PROMPT + "\n\n契约结论:\n" + ctx}, {"role": "user", "content": q}]
-
-
-# ── 上游 ──────────────────────────────────────────────────────────
-class UpstreamError(Exception):
-    def __init__(self, code, detail=""): super().__init__(code); self.code = code; self.detail = redact(detail)[:300]
-
-
-def deepseek_transport(messages, timeout=LIMITS["upstream_timeout_s"]):
-    """真上游 (只此一处发网络请求). → 答案文本; 失败 raise UpstreamError(code ∈ upstream_401|upstream_429|upstream_timeout|upstream_5xx|upstream_net|upstream_bad)."""
-    key = api_key()
-    if not key: raise UpstreamError("no_key")
-    base = urllib.parse.urlparse(os.environ.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com")
-    body = json.dumps({"model": os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat", "messages": messages, "stream": False, "max_tokens": 1200}).encode("utf-8")
-    try:
-        conn = (http.client.HTTPSConnection if base.scheme == "https" else http.client.HTTPConnection)(base.hostname, base.port, timeout=timeout)
-        conn.request("POST", (base.path.rstrip("/") or "") + "/chat/completions", body, {"Content-Type": "application/json", "Authorization": "Bearer " + key})
-        r = conn.getresponse(); raw = r.read(); conn.close()
-    except socket.timeout as e: raise UpstreamError("upstream_timeout", str(e))
-    except (OSError, http.client.HTTPException) as e: raise UpstreamError("upstream_net", str(e))
-    if r.status == 401 or r.status == 403: raise UpstreamError("upstream_401", raw[:200].decode("utf-8", "replace"))
-    if r.status == 429: raise UpstreamError("upstream_429", raw[:200].decode("utf-8", "replace"))
-    if r.status >= 500: raise UpstreamError("upstream_5xx", f"{r.status} " + raw[:200].decode("utf-8", "replace"))
-    if r.status != 200: raise UpstreamError("upstream_bad", f"{r.status} " + raw[:200].decode("utf-8", "replace"))
-    try: return json.loads(raw)["choices"][0]["message"]["content"]
-    except Exception as e: raise UpstreamError("upstream_bad", f"{type(e).__name__}: {raw[:200].decode('utf-8', 'replace')}")
-
-
-# ── 核心 (纯函数, transport 可注入) ─────────────────────────────────
-def ask(q, user, transport=None, ip="-"):
-    """→ {ok, mode, answer|None, err|None, err_code|None, refs, model, secs, request_id}. 任何失败 answer 恒 None; 绝不在失败时给出貌似回答的文本."""
-    rid = uuid.uuid4().hex[:12]; t0 = time.time(); q = (q or "").strip(); qsha = hashlib.sha256(q.encode("utf-8")).hexdigest()[:16]
-    base = dict(request_id=rid, refs=[], model=os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat", answer=None, err=None, err_code=None)
-    def done(**kw):
-        out = dict(base, **kw); out["secs"] = round(time.time() - t0, 2)
-        audit("qa", request_id=rid, user=user.get("name"), role=user.get("role"), ip=ip, q_sha16=qsha, q_len=len(q), mode=out["mode"], ok=out["ok"], err_code=out["err_code"], secs=out["secs"], n_refs=len(out["refs"]))
-        return out
-    if not q: return done(ok=False, mode=mode_now(), err="问题为空", err_code="empty")
-    if len(q) > LIMITS["max_q_chars"]: return done(ok=False, mode=mode_now(), err=f"问题超长 (>{LIMITS['max_q_chars']} 字)", err_code="too_long")
-    refs = pick_refs(q); base["refs"] = [c["claim_id"] for c in refs]
-    if not api_key():
-        if os.environ.get("GUANLAN_QA_DEMO") == "1":
-            demo = "[演示回答] 云端模型未配置, 以下不是模型输出, 只是契约里与问题最接近的结论原文:\n" + ("\n".join(f"- {c['claim_id']} [{c['verdict']}] {c['title_public'][:160]}" for c in refs) or "- (契约里没有相关结论)")
-            return done(ok=True, mode="演示回答", answer=demo, model=None)
-        return done(ok=False, mode="不可用", err=ERR_TEXT["no_key"], err_code="no_key", model=None)
-    try:
-        txt = (transport or deepseek_transport)(build_messages(q, refs))
-    except UpstreamError as e:
-        print(f"[qa] {rid} upstream {e.code}: {e.detail}", file=sys.stderr, flush=True)  # 上游原文只进服务端 stderr (已去密钥), 不进回包
-        return done(ok=False, mode="不可用", err=ERR_TEXT.get(e.code, ERR_TEXT["upstream_bad"]), err_code=e.code)
-    except Exception as e:
-        print(f"[qa] {rid} internal {type(e).__name__}: {redact(str(e))[:200]}", file=sys.stderr, flush=True)
-        return done(ok=False, mode="不可用", err="服务端内部错误, 本次未得到回答", err_code="internal")
-    txt = re.sub(r"<think>.*?</think>", "", str(txt or ""), flags=re.S).strip()
-    if not txt: return done(ok=False, mode="不可用", err=ERR_TEXT["upstream_bad"], err_code="upstream_bad")
-    return done(ok=True, mode="云端 DeepSeek", answer=DP.scrub(redact(txt))[:LIMITS["max_answer_chars"]])
-
-
-# ── HTTP ──────────────────────────────────────────────────────────
-USERS = None; SESS = Sessions(); RL = RateLimiter(); COOKIE = "guanlan_sid"
-
-
-class H(BaseHTTPRequestHandler):
-    server_version = "guanlan-qa/0.1"; sys_version = ""
-
-    def log_message(self, *a): pass
-
-    def _json(self, obj, code=200, extra=None):
-        b = json.dumps(obj, ensure_ascii=False).encode("utf-8")
-        self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8"); self.send_header("Content-Length", str(len(b)))
-        self.send_header("Cache-Control", "no-store"); self.send_header("X-Content-Type-Options", "nosniff")
-        for k, v in (extra or {}).items(): self.send_header(k, v)
-        self.end_headers(); self.wfile.write(b)
-
-    def _ip(self): return self.headers.get("X-Forwarded-For", self.client_address[0]).split(",")[0].strip()
-
-    def _sid(self):
-        c = http.cookies.SimpleCookie(self.headers.get("Cookie", "")); return c[COOKIE].value if COOKIE in c else None
-
-    def _user(self): return SESS.get(self._sid())
-
-    def _body(self):
-        n = int(self.headers.get("Content-Length") or 0)
-        if n > LIMITS["max_body_bytes"]: return None
-        try: return json.loads(self.rfile.read(n) or b"{}")
-        except Exception: return {}
-
-    def do_GET(self):
-        p = urllib.parse.urlparse(self.path).path
-        if p == "/healthz": return self._json({"ok": True, "mode": mode_now(), "service": "guanlan-qa"})
-        u = self._user()
-        if not u: return self._json({"ok": False, "err": "未登录", "err_code": "unauthorized", "mode": mode_now()}, 401)
-        if p == "/api/me": return self._json({"ok": True, "user": u["name"], "role": u["role"], "expires": u["expires"], "mode": mode_now()})
-        if p == "/api/qa/status": return self._json({"ok": True, "mode": mode_now(), "model": (os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat") if api_key() else None, "limits": {k: v for k, v in LIMITS.items() if k in ("max_q_chars", "rate_user", "upstream_timeout_s")}, "refs_available": bool(claims_public())})
-        if p == "/api/admin/users":
-            if u["role"] != "admin": audit("forbidden", user=u["name"], path=p, ip=self._ip()); return self._json({"ok": False, "err": "权限不足", "err_code": "forbidden"}, 403)
-            return self._json({"ok": True, "users": USERS.list()})
-        return self._json({"ok": False, "err": "not found"}, 404)
-
-    def do_POST(self):
-        p = urllib.parse.urlparse(self.path).path; ip = self._ip()
-        ok, wait = RL.allow("ip:" + ip, *LIMITS["rate_ip"])
-        if not ok: audit("rate_limited", ip=ip, path=p); return self._json({"ok": False, "err": "请求过于频繁", "err_code": "rate_limited", "mode": mode_now()}, 429, {"Retry-After": str(wait)})
-        body = self._body()
-        if body is None: return self._json({"ok": False, "err": "请求体过大", "err_code": "too_large", "mode": mode_now()}, 413)
-        if p == "/api/login":
-            u, why = USERS.verify(body.get("name"), body.get("password")); audit("login", user=body.get("name"), ok=why == "ok", reason=why, ip=ip)
-            if not u: return self._json({"ok": False, "err": "用户名或密码错误, 或账号已过期/撤销", "err_code": "unauthorized"}, 401)
-            tok = SESS.create(u); flags = "; HttpOnly; SameSite=Strict; Path=/" + ("" if os.environ.get("GUANLAN_QA_INSECURE_COOKIE") == "1" else "; Secure")
-            return self._json({"ok": True, "user": u["name"], "role": u["role"], "expires": u["expires"], "mode": mode_now()}, 200, {"Set-Cookie": f"{COOKIE}={tok}{flags}"})
-        u = self._user()
-        if not u: return self._json({"ok": False, "err": "未登录", "err_code": "unauthorized", "mode": mode_now()}, 401)
-        if p == "/api/logout": SESS.drop(self._sid()); audit("logout", user=u["name"], ip=ip); return self._json({"ok": True}, 200, {"Set-Cookie": f"{COOKIE}=; Max-Age=0; Path=/"})
-        if p == "/api/qa":
-            ok, wait = RL.allow("user:" + u["name"], *LIMITS["rate_user"])
-            if not ok: audit("rate_limited", user=u["name"], ip=ip); return self._json({"ok": False, "err": "本账号提问过于频繁, 请稍后", "err_code": "rate_limited", "mode": mode_now()}, 429, {"Retry-After": str(wait)})
-            r = ask(body.get("q"), u, ip=ip)
-            return self._json(r, 200 if r["ok"] else {"too_long": 413, "empty": 400}.get(r["err_code"], 503))
-        return self._json({"ok": False, "err": "not found"}, 404)
-
-
-def selftest():
-    """不联网: 用桩走一遍 无密钥 / 演示 / 401 / 429 / 超时 / 5xx / 成功, 打印每种模式标签."""
-    saved = {k: os.environ.get(k) for k in ("DEEPSEEK_API_KEY", "GUANLAN_QA_DEMO")}; u = {"name": "selftest", "role": "user"}; out = []
-    try:
-        os.environ.pop("DEEPSEEK_API_KEY", None); os.environ.pop("GUANLAN_QA_DEMO", None); out.append(("无密钥", ask("叶根螺栓", u)))
-        os.environ["GUANLAN_QA_DEMO"] = "1"; out.append(("演示", ask("叶根螺栓", u))); os.environ.pop("GUANLAN_QA_DEMO")
-        os.environ["DEEPSEEK_API_KEY"] = "sk-selftest-not-a-real-key-000000"
-        for code in ("upstream_401", "upstream_429", "upstream_timeout", "upstream_5xx"):
-            def bad(m, code=code): raise UpstreamError(code, "Bearer sk-selftest-not-a-real-key-000000 detail")
-            out.append((code, ask("叶根螺栓", u, transport=bad)))
-        out.append(("成功", ask("叶根螺栓", u, transport=lambda m: "如东 WTG31 叶根螺栓断裂仍在发生 (RD-2026-08-003)")))
-    finally:
-        for k, v in saved.items():
-            if v is None: os.environ.pop(k, None)
-            else: os.environ[k] = v
-    for n, r in out: print(f"{n:16} mode={r['mode']:12} ok={r['ok']!s:5} err_code={r['err_code']} answer={(r['answer'] or '')[:60]!r} refs={r['refs'][:3]}")
-    return out
-
-
-def main():
-    global USERS
-    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
-    s = sub.add_parser("serve"); s.add_argument("--port", type=int, default=8090); s.add_argument("--bind", default="127.0.0.1")
-    uu = sub.add_parser("user"); uu.add_argument("op", choices=["add", "revoke", "list"]); uu.add_argument("name", nargs="?"); uu.add_argument("--role", default="user", choices=["user", "admin"]); uu.add_argument("--days", type=int, default=LIMITS["temp_pw_days"])
-    sub.add_parser("selftest"); a = ap.parse_args(); USERS = Users()
-    if a.cmd == "user":
-        if a.op == "add": print(f"账号 {a.name} ({a.role}) 临时密码 (只显示这一次, {a.days} 天有效):", USERS.add(a.name, a.role, a.days)); return 0
-        if a.op == "revoke": USERS.revoke(a.name); print("已撤销", a.name); return 0
-        for r in USERS.list(): print(json.dumps(r, ensure_ascii=False))
-        return 0
-    if a.cmd == "selftest": selftest(); return 0
-    print(f"[qa] :{a.port} mode={mode_now()} users={len(USERS.list())} refs={len(claims_public())} audit={AUDIT_PATH}", file=sys.stderr, flush=True)
-    if not api_key(): print("[qa] 警告: 未配置 DEEPSEEK_API_KEY, /api/qa 全部返回 不可用" + (" (演示回答)" if mode_now() == "演示回答" else ""), file=sys.stderr, flush=True)
-    ThreadingHTTPServer((a.bind, a.port), H).serve_forever()
-
-
-if __name__ == "__main__":
-    sys.exit(main() or 0)
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""观澜云端版 · 服务端 /api/qa 代理 (草案, 未部署; 部署包 CLAUDE_CODE_部署提示词.md §安全边界 / §账号与权限 / §B 云端可用性; 交接 2026-09-06).
+
+边界 (逐条对应部署包):
+- 密钥只在服务端环境变量 DEEPSEEK_API_KEY; 缺失 → 模式 "不可用" + 明确文案, **不伪造回答**, 不落任何文件/日志/回包.
+- 浏览器不直连 DeepSeek: 本进程绑 127.0.0.1, 前面放 nginx (HTTPS); 上游 401 / 429 / 超时 / 5xx → 回包 ok=false + 脱敏错误码, answer 恒 null.
+- 鉴权: 服务端会话 (HttpOnly cookie, 随机 token, 服务端过期); 账号 = 拼音显示名 + 临时密码 (PBKDF2, 10 天有效, 可撤销, 建/撤销/登录全部进审计).
+- 限流 (按用户 + 按 IP, 固定窗) · 请求长度 (body / 问题字数) · 上游超时 · 审计日志 (JSONL: 只记 问题 sha256+长度, 不记原文, 不记密钥) · 错误脱敏 (上游原文只进服务端 stderr 且先去密钥).
+- 模式标签: 每个回包带 mode ∈ {云端 DeepSeek, 本机 DeepSeek, 演示回答, 不可用}; 演示回答只在 GUANLAN_QA_DEMO=1 且无密钥时出现, 且 answer 前缀明示 "[演示回答]".
+- 出口脱敏: 模型答案过 src/windscada/deid_public.scrub (与 windscada_ask_serve 同款; 导入失败 = 拒绝启动).
+- 接地: 问题先在云端脱敏面孔 (outputs/rudong/guanlan/cloud/claims_public.json) 里按关键词取 ≤5 条作 system 上下文, 回包 refs 列 claim_id; 面孔缺失 → 不带引用, refs=[] (不伪造引用).
+不做: 用户管理 HTTP 接口 (只走 CLI, 缩小攻击面) · 多轮对话 · 流式.
+用法: serve [--port 8090] | user add <pinyin> [--role user|admin] [--days 10] | user revoke <pinyin> | user list | selftest
+环境变量 (名, 不含值): DEEPSEEK_API_KEY · DEEPSEEK_BASE_URL (默认 https://api.deepseek.com) · DEEPSEEK_MODEL (默认 deepseek-chat) · GUANLAN_QA_USERS (users.json 路径) · GUANLAN_QA_AUDIT (audit.jsonl 路径) · GUANLAN_QA_DEMO · GUANLAN_QA_INSECURE_COOKIE (=1 时 cookie 不带 Secure, 仅本机 http 测试)
+上游调用经 transport 注入 (ask(q, user, transport=...)), 测试用桩模拟 401/429/超时/5xx, 不真调云.
+"""
+import argparse, base64, datetime as dt, hashlib, hmac, http.client, http.cookies, json, os, pathlib, re, secrets, socket, sys, threading, time, urllib.parse, uuid
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+from windscada import deid_public as DP  # noqa: E402  ★出口脱敏不可用 = 拒绝启动 (看着有闸实则没有, 比没闸更坏)
+
+MODES = ("云端 DeepSeek", "本机 DeepSeek", "演示回答", "不可用")
+LIMITS = dict(max_body_bytes=16 * 1024, max_q_chars=2000, rate_user=(10, 300), rate_ip=(30, 300), upstream_timeout_s=60, max_answer_chars=6000, temp_pw_days=10, session_hours=12, max_refs=5)
+CLOUD_CLAIMS = ROOT / "outputs/rudong/guanlan/cloud/claims_public.json"
+USERS_PATH = pathlib.Path(os.environ.get("GUANLAN_QA_USERS", str(ROOT / "outputs/rudong/guanlan/cloud/_private/users.json")))
+# 审计流水统一落 logs/audit/ (用户令 2): 与本体模型闸的 llm_audit.jsonl 同一处, 便于一起查;
+# 云上部署若要把审计留在私有区, 用 GUANLAN_QA_AUDIT 指回去即可 (仍受同一行格式约束)。
+AUDIT_PATH = pathlib.Path(os.environ.get("GUANLAN_QA_AUDIT", str(ROOT / "logs/audit/cloud_qa.jsonl")))
+SYS_PROMPT = ("你是风电场 SCADA/CMS 分析结论的解释助手。只能依据下面给出的『契约结论』回答; 结论里没有的数字不许编, 没有依据就说『契约里没有这条』。"
+              "不得批准检修、不得替代工程师判断、不得补造缺失数据。回答用中文, 引用结论时写 claim_id。")
+ERR_TEXT = {"no_key": "云端模型未配置 (服务端缺 DEEPSEEK_API_KEY), 问答不可用", "upstream_401": "云端模型凭证无效 (服务端配置问题), 问答不可用", "upstream_429": "云端模型限流, 请稍后再试",
+            "upstream_timeout": "云端模型超时, 本次未得到回答", "upstream_5xx": "云端模型服务异常, 本次未得到回答", "upstream_net": "云端模型不可达, 本次未得到回答", "upstream_bad": "云端模型回包无法解析, 本次未得到回答"}
+_KEY_RE = re.compile(r"sk-[A-Za-z0-9]{8,}|Bearer\s+\S+")
+
+
+# ── 密钥 / 脱敏 ────────────────────────────────────────────────────
+def api_key(): return (os.environ.get("DEEPSEEK_API_KEY") or "").strip()
+
+
+def redact(s):
+    """任何要落日志/回包的字符串先过这里: 去掉密钥形态串与真实密钥值."""
+    s = str(s); k = api_key()
+    if k: s = s.replace(k, "[REDACTED]")
+    return _KEY_RE.sub("[REDACTED]", s)
+
+
+def mode_now():
+    if api_key(): return "云端 DeepSeek"
+    return "演示回答" if os.environ.get("GUANLAN_QA_DEMO") == "1" else "不可用"
+
+
+# ── 审计 ──────────────────────────────────────────────────────────
+_AUDIT_LOCK = threading.Lock()
+
+
+def audit(event, **kw):
+    rec = {"ts": dt.datetime.now().isoformat(timespec="seconds"), "event": event}
+    rec.update({k: (redact(v) if isinstance(v, str) else v) for k, v in kw.items()})
+    AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
+    with _AUDIT_LOCK, AUDIT_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n")
+    return rec
+
+
+# ── 账号 (服务端管理; 拼音显示名 + 临时密码) ──────────────────────
+PBKDF2_ITERS = 200_000
+
+
+def _hash_pw(pw, salt): return hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ITERS).hex()
+
+
+class Users:
+    def __init__(self, path=USERS_PATH):
+        self.path = pathlib.Path(path); self.d = json.loads(self.path.read_text(encoding="utf-8")) if self.path.is_file() else {"users": {}}
+
+    def _save(self):
+        self.path.parent.mkdir(parents=True, exist_ok=True); self.path.write_text(json.dumps(self.d, ensure_ascii=False, indent=1), encoding="utf-8")
+        try: os.chmod(self.path, 0o600)
+        except OSError: pass
+
+    def add(self, name, role="user", days=LIMITS["temp_pw_days"], by="cli"):
+        """建/重置账号 → 临时密码 (只返回这一次, 不落盘明文). 最小权限: 默认 user."""
+        if not re.fullmatch(r"[a-z][a-z0-9_]{1,31}", name): raise ValueError("显示名须为拼音小写 [a-z0-9_], 2~32 字")
+        if role not in ("user", "admin"): raise ValueError("role ∈ user|admin")
+        pw = base64.b32encode(secrets.token_bytes(10)).decode().rstrip("=").lower(); salt = secrets.token_hex(16)
+        self.d["users"][name] = {"role": role, "salt": salt, "hash": _hash_pw(pw, salt), "created": dt.datetime.now().isoformat(timespec="seconds"),
+                                 "expires": (dt.datetime.now() + dt.timedelta(days=days)).isoformat(timespec="seconds"), "revoked": False}
+        self._save(); audit("user_add", user=name, role=role, days=days, by=by); return pw
+
+    def revoke(self, name, by="cli"):
+        if name not in self.d["users"]: raise KeyError(name)
+        self.d["users"][name]["revoked"] = True; self._save(); audit("user_revoke", user=name, by=by)
+
+    def verify(self, name, pw, now=None):
+        """→ (user dict | None, reason ∈ ok|no_user|bad_pw|expired|revoked). 不区分文案给客户端 (统一 401), reason 只进审计."""
+        u = self.d["users"].get(name or "")
+        if not u: _hash_pw(pw or "", "00" * 16); return None, "no_user"  # 等时: 无账号也算一次 hash
+        if not hmac.compare_digest(_hash_pw(pw or "", u["salt"]), u["hash"]): return None, "bad_pw"
+        if u.get("revoked"): return None, "revoked"
+        if (now or dt.datetime.now()) > dt.datetime.fromisoformat(u["expires"]): return None, "expired"
+        return {"name": name, "role": u["role"], "expires": u["expires"]}, "ok"
+
+    def list(self): return [{"name": n, "role": u["role"], "expires": u["expires"], "revoked": u.get("revoked", False), "created": u["created"]} for n, u in self.d["users"].items()]
+
+
+class Sessions:
+    def __init__(self, hours=LIMITS["session_hours"]): self.s = {}; self.hours = hours; self.lock = threading.Lock()
+
+    def create(self, user):
+        tok = secrets.token_urlsafe(32)
+        with self.lock: self.s[tok] = dict(user, exp=time.time() + self.hours * 3600)
+        return tok
+
+    def get(self, tok):
+        with self.lock:
+            u = self.s.get(tok or "")
+            if u and (u["exp"] < time.time() or dt.datetime.now() > dt.datetime.fromisoformat(u["expires"])): self.s.pop(tok, None); return None
+            return u
+
+    def drop(self, tok):
+        with self.lock: self.s.pop(tok or "", None)
+
+
+class RateLimiter:
+    """固定窗: key 在 window 秒内最多 n 次 → (ok, retry_after_s)."""
+    def __init__(self): self.w = {}; self.lock = threading.Lock()
+
+    def allow(self, key, n, window, now=None):
+        now = now if now is not None else time.time()
+        with self.lock:
+            t0, c = self.w.get(key, (now, 0))
+            if now - t0 >= window: t0, c = now, 0
+            if c >= n: return False, int(window - (now - t0)) + 1
+            self.w[key] = (t0, c + 1); return True, 0
+
+
+# ── 接地引用 (云端脱敏面孔) ─────────────────────────────────────────
+_CLAIMS = {"key": None, "rows": []}
+
+
+def claims_public():
+    if not CLOUD_CLAIMS.is_file(): return []
+    st = CLOUD_CLAIMS.stat(); key = (st.st_mtime_ns, st.st_size)
+    if _CLAIMS["key"] != key: _CLAIMS.update(key=key, rows=json.loads(CLOUD_CLAIMS.read_text(encoding="utf-8")).get("claims", []))
+    return _CLAIMS["rows"]
+
+
+def pick_refs(q, k=LIMITS["max_refs"]):
+    """关键词重叠取 top-k 契约条 (只用脱敏面孔; 面孔缺 → []). 不是检索系统, 只是把回答钉在契约上."""
+    toks = {t for t in re.findall(r"[一-鿿]{2,}|[A-Za-z][A-Za-z0-9\-]{2,}", q or "")}
+    grams = {t[i:i + 2] for t in toks for i in range(len(t) - 1)} | toks
+    if not grams: return []
+    scored = []
+    for c in claims_public():
+        text = (c.get("title_public") or "") + " " + (c.get("missing_evidence") or "")
+        sc = sum(1 for g in grams if g in text) + (2 if c.get("claim_id", "").lower() in (q or "").lower() else 0)
+        if sc: scored.append((sc, c))
+    scored.sort(key=lambda x: (-x[0], x[1]["claim_id"]))
+    return [c for _, c in scored[:k]]
+
+
+def build_messages(q, refs):
+    ctx = "\n".join(f"- {c['claim_id']} [{c['verdict']}] {c['title_public'][:200]}" + (f" | 缺失证据: {c['missing_evidence'][:120]}" if c.get("missing_evidence") else "") for c in refs) or "(契约里没有与本问题相关的结论)"
+    return [{"role": "system", "content": SYS_PROMPT + "\n\n契约结论:\n" + ctx}, {"role": "user", "content": q}]
+
+
+# ── 上游 ──────────────────────────────────────────────────────────
+class UpstreamError(Exception):
+    def __init__(self, code, detail=""): super().__init__(code); self.code = code; self.detail = redact(detail)[:300]
+
+
+def deepseek_transport(messages, timeout=LIMITS["upstream_timeout_s"]):
+    """真上游 (只此一处发网络请求). → 答案文本; 失败 raise UpstreamError(code ∈ upstream_401|upstream_429|upstream_timeout|upstream_5xx|upstream_net|upstream_bad)."""
+    key = api_key()
+    if not key: raise UpstreamError("no_key")
+    base = urllib.parse.urlparse(os.environ.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com")
+    body = json.dumps({"model": os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat", "messages": messages, "stream": False, "max_tokens": 1200}).encode("utf-8")
+    try:
+        conn = (http.client.HTTPSConnection if base.scheme == "https" else http.client.HTTPConnection)(base.hostname, base.port, timeout=timeout)
+        conn.request("POST", (base.path.rstrip("/") or "") + "/chat/completions", body, {"Content-Type": "application/json", "Authorization": "Bearer " + key})
+        r = conn.getresponse(); raw = r.read(); conn.close()
+    except socket.timeout as e: raise UpstreamError("upstream_timeout", str(e))
+    except (OSError, http.client.HTTPException) as e: raise UpstreamError("upstream_net", str(e))
+    if r.status == 401 or r.status == 403: raise UpstreamError("upstream_401", raw[:200].decode("utf-8", "replace"))
+    if r.status == 429: raise UpstreamError("upstream_429", raw[:200].decode("utf-8", "replace"))
+    if r.status >= 500: raise UpstreamError("upstream_5xx", f"{r.status} " + raw[:200].decode("utf-8", "replace"))
+    if r.status != 200: raise UpstreamError("upstream_bad", f"{r.status} " + raw[:200].decode("utf-8", "replace"))
+    try: return json.loads(raw)["choices"][0]["message"]["content"]
+    except Exception as e: raise UpstreamError("upstream_bad", f"{type(e).__name__}: {raw[:200].decode('utf-8', 'replace')}")
+
+
+# ── 核心 (纯函数, transport 可注入) ─────────────────────────────────
+def ask(q, user, transport=None, ip="-"):
+    """→ {ok, mode, answer|None, err|None, err_code|None, refs, model, secs, request_id}. 任何失败 answer 恒 None; 绝不在失败时给出貌似回答的文本."""
+    rid = uuid.uuid4().hex[:12]; t0 = time.time(); q = (q or "").strip(); qsha = hashlib.sha256(q.encode("utf-8")).hexdigest()[:16]
+    base = dict(request_id=rid, refs=[], model=os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat", answer=None, err=None, err_code=None)
+    def done(**kw):
+        out = dict(base, **kw); out["secs"] = round(time.time() - t0, 2)
+        audit("qa", request_id=rid, user=user.get("name"), role=user.get("role"), ip=ip, q_sha16=qsha, q_len=len(q), mode=out["mode"], ok=out["ok"], err_code=out["err_code"], secs=out["secs"], n_refs=len(out["refs"]))
+        return out
+    if not q: return done(ok=False, mode=mode_now(), err="问题为空", err_code="empty")
+    if len(q) > LIMITS["max_q_chars"]: return done(ok=False, mode=mode_now(), err=f"问题超长 (>{LIMITS['max_q_chars']} 字)", err_code="too_long")
+    refs = pick_refs(q); base["refs"] = [c["claim_id"] for c in refs]
+    if not api_key():
+        if os.environ.get("GUANLAN_QA_DEMO") == "1":
+            demo = "[演示回答] 云端模型未配置, 以下不是模型输出, 只是契约里与问题最接近的结论原文:\n" + ("\n".join(f"- {c['claim_id']} [{c['verdict']}] {c['title_public'][:160]}" for c in refs) or "- (契约里没有相关结论)")
+            return done(ok=True, mode="演示回答", answer=demo, model=None)
+        return done(ok=False, mode="不可用", err=ERR_TEXT["no_key"], err_code="no_key", model=None)
+    try:
+        txt = (transport or deepseek_transport)(build_messages(q, refs))
+    except UpstreamError as e:
+        print(f"[qa] {rid} upstream {e.code}: {e.detail}", file=sys.stderr, flush=True)  # 上游原文只进服务端 stderr (已去密钥), 不进回包
+        return done(ok=False, mode="不可用", err=ERR_TEXT.get(e.code, ERR_TEXT["upstream_bad"]), err_code=e.code)
+    except Exception as e:
+        print(f"[qa] {rid} internal {type(e).__name__}: {redact(str(e))[:200]}", file=sys.stderr, flush=True)
+        return done(ok=False, mode="不可用", err="服务端内部错误, 本次未得到回答", err_code="internal")
+    txt = re.sub(r"<think>.*?</think>", "", str(txt or ""), flags=re.S).strip()
+    if not txt: return done(ok=False, mode="不可用", err=ERR_TEXT["upstream_bad"], err_code="upstream_bad")
+    return done(ok=True, mode="云端 DeepSeek", answer=DP.scrub(redact(txt))[:LIMITS["max_answer_chars"]])
+
+
+# ── HTTP ──────────────────────────────────────────────────────────
+USERS = None; SESS = Sessions(); RL = RateLimiter(); COOKIE = "guanlan_sid"
+
+
+class H(BaseHTTPRequestHandler):
+    server_version = "guanlan-qa/0.1"; sys_version = ""
+
+    def log_message(self, *a): pass
+
+    def _json(self, obj, code=200, extra=None):
+        b = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+        self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8"); self.send_header("Content-Length", str(len(b)))
+        self.send_header("Cache-Control", "no-store"); self.send_header("X-Content-Type-Options", "nosniff")
+        for k, v in (extra or {}).items(): self.send_header(k, v)
+        self.end_headers(); self.wfile.write(b)
+
+    def _ip(self): return self.headers.get("X-Forwarded-For", self.client_address[0]).split(",")[0].strip()
+
+    def _sid(self):
+        c = http.cookies.SimpleCookie(self.headers.get("Cookie", "")); return c[COOKIE].value if COOKIE in c else None
+
+    def _user(self): return SESS.get(self._sid())
+
+    def _body(self):
+        n = int(self.headers.get("Content-Length") or 0)
+        if n > LIMITS["max_body_bytes"]: return None
+        try: return json.loads(self.rfile.read(n) or b"{}")
+        except Exception: return {}
+
+    def do_GET(self):
+        p = urllib.parse.urlparse(self.path).path
+        if p == "/healthz": return self._json({"ok": True, "mode": mode_now(), "service": "guanlan-qa"})
+        u = self._user()
+        if not u: return self._json({"ok": False, "err": "未登录", "err_code": "unauthorized", "mode": mode_now()}, 401)
+        if p == "/api/me": return self._json({"ok": True, "user": u["name"], "role": u["role"], "expires": u["expires"], "mode": mode_now()})
+        if p == "/api/qa/status": return self._json({"ok": True, "mode": mode_now(), "model": (os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat") if api_key() else None, "limits": {k: v for k, v in LIMITS.items() if k in ("max_q_chars", "rate_user", "upstream_timeout_s")}, "refs_available": bool(claims_public())})
+        if p == "/api/admin/users":
+            if u["role"] != "admin": audit("forbidden", user=u["name"], path=p, ip=self._ip()); return self._json({"ok": False, "err": "权限不足", "err_code": "forbidden"}, 403)
+            return self._json({"ok": True, "users": USERS.list()})
+        return self._json({"ok": False, "err": "not found"}, 404)
+
+    def do_POST(self):
+        p = urllib.parse.urlparse(self.path).path; ip = self._ip()
+        ok, wait = RL.allow("ip:" + ip, *LIMITS["rate_ip"])
+        if not ok: audit("rate_limited", ip=ip, path=p); return self._json({"ok": False, "err": "请求过于频繁", "err_code": "rate_limited", "mode": mode_now()}, 429, {"Retry-After": str(wait)})
+        body = self._body()
+        if body is None: return self._json({"ok": False, "err": "请求体过大", "err_code": "too_large", "mode": mode_now()}, 413)
+        if p == "/api/login":
+            u, why = USERS.verify(body.get("name"), body.get("password")); audit("login", user=body.get("name"), ok=why == "ok", reason=why, ip=ip)
+            if not u: return self._json({"ok": False, "err": "用户名或密码错误, 或账号已过期/撤销", "err_code": "unauthorized"}, 401)
+            tok = SESS.create(u); flags = "; HttpOnly; SameSite=Strict; Path=/" + ("" if os.environ.get("GUANLAN_QA_INSECURE_COOKIE") == "1" else "; Secure")
+            return self._json({"ok": True, "user": u["name"], "role": u["role"], "expires": u["expires"], "mode": mode_now()}, 200, {"Set-Cookie": f"{COOKIE}={tok}{flags}"})
+        u = self._user()
+        if not u: return self._json({"ok": False, "err": "未登录", "err_code": "unauthorized", "mode": mode_now()}, 401)
+        if p == "/api/logout": SESS.drop(self._sid()); audit("logout", user=u["name"], ip=ip); return self._json({"ok": True}, 200, {"Set-Cookie": f"{COOKIE}=; Max-Age=0; Path=/"})
+        if p == "/api/qa":
+            ok, wait = RL.allow("user:" + u["name"], *LIMITS["rate_user"])
+            if not ok: audit("rate_limited", user=u["name"], ip=ip); return self._json({"ok": False, "err": "本账号提问过于频繁, 请稍后", "err_code": "rate_limited", "mode": mode_now()}, 429, {"Retry-After": str(wait)})
+            r = ask(body.get("q"), u, ip=ip)
+            return self._json(r, 200 if r["ok"] else {"too_long": 413, "empty": 400}.get(r["err_code"], 503))
+        return self._json({"ok": False, "err": "not found"}, 404)
+
+
+def selftest():
+    """不联网: 用桩走一遍 无密钥 / 演示 / 401 / 429 / 超时 / 5xx / 成功, 打印每种模式标签."""
+    saved = {k: os.environ.get(k) for k in ("DEEPSEEK_API_KEY", "GUANLAN_QA_DEMO")}; u = {"name": "selftest", "role": "user"}; out = []
+    try:
+        os.environ.pop("DEEPSEEK_API_KEY", None); os.environ.pop("GUANLAN_QA_DEMO", None); out.append(("无密钥", ask("叶根螺栓", u)))
+        os.environ["GUANLAN_QA_DEMO"] = "1"; out.append(("演示", ask("叶根螺栓", u))); os.environ.pop("GUANLAN_QA_DEMO")
+        os.environ["DEEPSEEK_API_KEY"] = "sk-selftest-not-a-real-key-000000"
+        for code in ("upstream_401", "upstream_429", "upstream_timeout", "upstream_5xx"):
+            def bad(m, code=code): raise UpstreamError(code, "Bearer sk-selftest-not-a-real-key-000000 detail")
+            out.append((code, ask("叶根螺栓", u, transport=bad)))
+        out.append(("成功", ask("叶根螺栓", u, transport=lambda m: "如东 WTG31 叶根螺栓断裂仍在发生 (RD-2026-08-003)")))
+    finally:
+        for k, v in saved.items():
+            if v is None: os.environ.pop(k, None)
+            else: os.environ[k] = v
+    for n, r in out: print(f"{n:16} mode={r['mode']:12} ok={r['ok']!s:5} err_code={r['err_code']} answer={(r['answer'] or '')[:60]!r} refs={r['refs'][:3]}")
+    return out
+
+
+def main():
+    global USERS
+    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
+    s = sub.add_parser("serve"); s.add_argument("--port", type=int, default=8090); s.add_argument("--bind", default="127.0.0.1")
+    uu = sub.add_parser("user"); uu.add_argument("op", choices=["add", "revoke", "list"]); uu.add_argument("name", nargs="?"); uu.add_argument("--role", default="user", choices=["user", "admin"]); uu.add_argument("--days", type=int, default=LIMITS["temp_pw_days"])
+    sub.add_parser("selftest"); a = ap.parse_args(); USERS = Users()
+    if a.cmd == "user":
+        if a.op == "add": print(f"账号 {a.name} ({a.role}) 临时密码 (只显示这一次, {a.days} 天有效):", USERS.add(a.name, a.role, a.days)); return 0
+        if a.op == "revoke": USERS.revoke(a.name); print("已撤销", a.name); return 0
+        for r in USERS.list(): print(json.dumps(r, ensure_ascii=False))
+        return 0
+    if a.cmd == "selftest": selftest(); return 0
+    print(f"[qa] :{a.port} mode={mode_now()} users={len(USERS.list())} refs={len(claims_public())} audit={AUDIT_PATH}", file=sys.stderr, flush=True)
+    if not api_key(): print("[qa] 警告: 未配置 DEEPSEEK_API_KEY, /api/qa 全部返回 不可用" + (" (演示回答)" if mode_now() == "演示回答" else ""), file=sys.stderr, flush=True)
+    ThreadingHTTPServer((a.bind, a.port), H).serve_forever()
+
+
+if __name__ == "__main__":
+    sys.exit(main() or 0)

+ 3 - 0
scripts/guanlan_gateway.py

@@ -329,6 +329,9 @@ class H(BaseHTTPRequestHandler):
 
 
 
 
 def main():
 def main():
+    # 统一日志口径 (用户令 2): 之后本进程 stdout/stderr 的每一行都带 时间戳/级别/组件
+    from src import logfile as _logfile        # noqa: E402
+    _logfile.prefix_stdout('gateway')
     ap = argparse.ArgumentParser(); ap.add_argument("--port", type=int, default=28084); ap.add_argument("--host", default="127.0.0.1"); ap.add_argument("--check", action="store_true", help="只打印 healthz 后退出"); a = ap.parse_args()
     ap = argparse.ArgumentParser(); ap.add_argument("--port", type=int, default=28084); ap.add_argument("--host", default="127.0.0.1"); ap.add_argument("--check", action="store_true", help="只打印 healthz 后退出"); a = ap.parse_args()
     if a.check: print(json.dumps(healthz(), ensure_ascii=False, indent=1)); return 0
     if a.check: print(json.dumps(healthz(), ensure_ascii=False, indent=1)); return 0
     srv = ThreadingHTTPServer((a.host, a.port), H); srv.daemon_threads = True
     srv = ThreadingHTTPServer((a.host, a.port), H); srv.daemon_threads = True

+ 503 - 499
scripts/guanlan_ops.py

@@ -1,499 +1,503 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""观澜运维控制台的后端 (2026-09-12) —— 把"停服务 / 起服务 / 重算 / 清产物"做成一个可视化页面。
-
-## 它解决什么
-
-原来这四件事都得在黑窗口里敲命令 (顺序还不能错), 且没有任何"当前能不能做"的约束。
-本模块给网关加三个东西:
-  · `GET  /ops`                  一个自适应控制台页面(单文件, 无外部依赖);
-  · `GET  /ops/api/state`        真实状态: 各服务端口通不通 · 产物在不在 · 有没有任务在跑 · 上次结果;
-  · `POST /ops/api/<动作>`       停服务 · 起服务 · 重算 · 清产物。
-
-## 三条设计纪律
-
-1. **页面活着的服务不能被自己停掉。** 控制台由网关(28084)提供, 所以"停服务"默认**保留网关**,
-   否则按钮刚点完页面就没了、再也没法"启动服务"。要连网关一起停, 用 `include_gateway=True`,
-   这时用**分离进程**先停再起(页面会断开十几秒, 之后自动重连)。
-2. **一次只允许一个动作。** 各动作互相冲突(重算要重启服务、清产物要删产物), 所以有一把锁:
-   `run/ops_job.json` 里记着当前任务; 任务在跑时, 所有会冲突的按钮在后端**与前端都会被禁掉**
-   (后端拒绝 = 真生效, 前端禁用只是提示)。判断以后端为准。
-3. **日志与状态落盘。** 动作全部 `Popen` 到 `logs/ops_<动作>_<时间>.log`, 页面轮询 job 状态并把日志尾巴显示出来 ——
-   用户看得见"它在干什么", 而不是按钮转圈。
-
-## 按钮什么时候该灰 (后端 state 直接给出, 前端只管照用)
-
-    停服务    : 至少有一个组件服务在监听
-    启动服务  : 至少有一个组件服务没在监听
-    执行重算  : 没有任务在跑
-    清除产物  : 没有任务在跑 且 产物在位 (**直接删除, 不留备份** —— 2026-09-16 用户令; 无"恢复产物"按钮)
-"""
-from __future__ import annotations
-
-import datetime as dt
-import json
-import os
-import pathlib
-import socket
-import subprocess
-import sys
-import time
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import paths as P                                        # noqa: E402
-from src import proc as _proc                                     # noqa: E402 无窗口子进程
-
-RUN = ROOT / 'run'
-LOGS = ROOT / 'logs'
-JOB = RUN / 'ops_job.json'
-OFF = ROOT / '_products_off'
-OFF_MANIFEST = OFF / 'manifest.json'
-PY = P.venv_python() if hasattr(P, 'venv_python') else sys.executable
-
-# 组件服务 (不含网关): 名字 → 端口。端口真源在 configs/serve.json, 这里只是网关不可用时的兜底。
-DEFAULT_PORTS = dict(detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292)
-GATEWAY_PORT = 28084
-KEEP_WHEN_STOPPING = 'gateway'      # 停服务默认保留网关(控制台自己在上面)
-
-
-def ports() -> dict:
-    p = ROOT / 'configs' / 'serve.json'
-    cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
-    out = dict(DEFAULT_PORTS)
-    out['gateway'] = int(cfg.get('gateway') or GATEWAY_PORT)
-    host = cfg.get('host') or '127.0.0.1'
-    for k in list(out):
-        if k in cfg:
-            out[k] = int(cfg[k])
-    return dict(host=host, **out)
-
-
-def listening(host: str, port: int, timeout=0.35) -> bool:
-    """端口有没有人在听。注意: 本机实测"连已关闭端口会一直等到超时"(不回 RST), 所以超时必须短。"""
-    try:
-        with socket.create_connection((host, port), timeout=timeout):
-            return True
-    except OSError:
-        return False
-
-
-def _job() -> dict:
-    try:
-        return json.loads(JOB.read_text(encoding='utf-8'))
-    except Exception:
-        return {}
-
-
-def job_running() -> tuple[bool, dict]:
-    """(是否在跑, 任务记录)。在跑 = 状态是 running 且执行器进程还活着。
-
-    注意: 退出码由执行器 scripts/_ops_run.py 写回 job (不在跑时才可信), 所以这里只判"活没活";
-    进程没了就把 running 收尾成 done(执行器正常收尾时会自己写, 这里兜的是被强杀的情况)。
-    """
-    j = _job()
-    if not j or j.get('status') != 'running':
-        return False, j
-    pid = j.get('pid')
-    # 心跳优先: 执行器每 15 s 更新 job 文件; 超过 STALE_S 没动静 = 被强杀(pid 可能被复用, 只看 pid 会误判)
-    STALE_S = 150
-    try:
-        age = time.time() - JOB.stat().st_mtime
-    except OSError:
-        age = 0
-    if age > STALE_S:
-        j.update(status='done', rc=None, finished=time.strftime('%Y-%m-%d %H:%M:%S'),
-                 note=(j.get('note') or '') + f' (心跳停了 {int(age)} s —— 任务多半被强杀, 未拿到退出码)')
-        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
-        return False, j
-    alive = False
-    if pid:
-        try:
-            if os.name == 'nt':
-                # errors='replace': tasklist 输出是控制台代码页(GBK), 而本进程可能是 PYTHONUTF8=1
-                # → 严格解码会失败并使 stdout 为 None (guanlan.py 的 alive() 踩过同一个坑)
-                # ★ 走 src.proc.run_text: 本模块跑在网关进程里 (无控制台), 裸 spawn tasklist 会让
-                #   Windows 新建可见控制台 —— 而 /ops 页面每 2 s 轮询一次 ⇒ **反复闪窗** (2026-09-16 实测)。
-                out = _proc.run_text(['tasklist', '/FI', f'PID eq {pid}']).stdout
-                alive = bool(out) and str(pid) in out
-            else:
-                os.kill(pid, 0); alive = True
-        except Exception:
-            alive = False
-    if not alive:
-        j.update(status=j.get('status') if j.get('rc') is not None else 'done',
-                 finished=j.get('finished') or time.strftime('%Y-%m-%d %H:%M:%S'),
-                 note=(j.get('note') or '') + (' (进程已结束但没写回退出码 —— 多半是被强杀)' if j.get('rc') is None else ''))
-        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
-        return False, j
-    return True, j
-
-
-def _log_tail(path, n=40) -> list:
-    if not path:
-        return []
-    f = pathlib.Path(path)
-    if not f.is_file():
-        return []
-    lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
-    return lines[-n:]
-
-
-def spawn(args: list, tag: str, detached=False) -> dict:
-    """起一个动作: 经 `_ops_launch.py` 二次启动 `_ops_run.py`。
-
-    两层是**必须的**(见 _ops_launch.py 注释): 直接 Popen 的话, 任务是网关的子进程, 而
-    `guanlan.py stop` 用 `taskkill /T` —— 停网关时会连带杀掉正在执行的任务(实测把自己杀了)。
-    执行器负责写回真实退出码; 日志落 logs/ops_<tag>_<时间>.log。
-    """
-    RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True)
-    log = LOGS / f'ops_{tag}_{time.strftime("%Y%m%d_%H%M%S")}.log'
-    env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
-    # 走 src.proc.run_text: 网关进程无控制台, 裸 spawn 会让这次"拉启动器"也闪一下窗
-    r = _proc.run_text([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
-                       cwd=str(ROOT), env=env, timeout=60)
-    pid = None
-    for line in reversed((r.stdout or '').strip().splitlines()):
-        if line.strip().isdigit():
-            pid = int(line.strip()); break
-    if pid is None:
-        print('  启动器输出异常:', r.stdout, r.stderr)
-    j = dict(kind=tag, cmd=' '.join(args), pid=pid, log=str(log), status='running', rc=None,
-             started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
-    JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
-    return j
-
-
-# ─────────────────────────────────────────────────────────── 状态
-def products_state() -> dict:
-    store = P.store()                                     # outputs/<场>/windscada
-    fam = store.parent                                    # outputs/<场> —— 产物仓的根
-    n = sum(1 for x in fam.rglob('*') if x.is_file()) if fam.is_dir() else 0
-    stores = {}
-    if fam.is_dir():
-        for d in sorted(x for x in fam.iterdir() if x.is_dir()):
-            stores[d.name] = sum(1 for _ in d.rglob('*') if _.is_file())
-    prov = fam / '_provenance.json'
-    prov_d = json.loads(prov.read_text(encoding='utf-8')) if prov.is_file() else None
-    # ★2026-09-16 用户令"不要备份清除的产物": 清除 = 真删除, 没有暂存区可列。
-    #   这里只报"有没有旧设计留下的 _products_off*"(有就提示手工删掉), 不再提供"恢复产物"。
-    legacy = sorted(x.name for x in ROOT.glob('_products_off*') if x.is_dir())
-    return dict(
-        fam=str(fam.relative_to(ROOT)).replace('\\', '/'), store=str(store.relative_to(ROOT)).replace('\\', '/'),
-        files=n, in_place=n > 0, stores=stores,
-        cleared=n == 0,
-        no_backup=True,                                  # 现口径: 清除不留备份
-        legacy_backups=legacy,                           # 旧设计残留 (按用户令不自动删, 只提示)
-        provenance=(dict(counts=prov_d.get('counts'), at=prov_d.get('at')) if prov_d else None),
-    )
-
-def state() -> dict:
-    pt = ports(); host = pt['host']
-    svc = {}
-    for name, port in pt.items():
-        if name == 'host':
-            continue
-        svc[name] = dict(port=port, up=listening(host, port), is_gateway=(name == 'gateway'))
-    comps = {k: v for k, v in svc.items() if not v['is_gateway']}
-    gw_up = svc.get('gateway', {}).get('up', False)
-    running, j = job_running()
-    pr = products_state()
-    any_comp_up = any(v['up'] for v in comps.values())
-    any_comp_down = any(not v['up'] for v in comps.values())
-    return dict(
-        now=dt.datetime.now().isoformat(timespec='seconds'),
-        host=host, services=svc, gateway_up=gw_up,
-        products=pr,
-        job=(dict(kind=j.get('kind'), status=j.get('status'), started=j.get('started'), note=j.get('note'),
-                  cmd=j.get('cmd'), rc=j.get('rc'), seconds=j.get('seconds'), finished=j.get('finished'),
-                  log_tail=_log_tail(j.get('log'))) if j else None),
-        # 按钮可用性 —— 前端只照这个渲染, 后端还会再拒一次(见 check())
-        buttons=dict(
-            stop_services=any_comp_up and not running,
-            start_services=any_comp_down and not running,
-            rebuild=not running,
-            products_off=pr['in_place'] and not running,
-        ),
-        anchors=dict(alarms=_rows(f'{pr["fam"]}/windscada/alarms.parquet'),
-                     workorders=_rows(f'{pr["fam"]}/windscada/workorders.parquet'),
-                     temp_monthly=_rows(f'{pr["fam"]}/windscada/temp_monthly.parquet'),
-                     objects=_objects_count(f'{pr["fam"]}/ontology/objects.json')),
-    )
-
-
-def _rows(rel: str):
-    """某件 parquet 的行数 (路径是相对安装根的), 用于页面上的"验收锚点"自检。"""
-    f = ROOT / rel
-    if not f.is_file():
-        return None
-    try:
-        import pandas as pd
-        return int(len(pd.read_parquet(f)))
-    except Exception:
-        return None
-
-
-def _objects_count(rel: str):
-    f = ROOT / rel
-    try:
-        return len(json.loads(f.read_text(encoding='utf-8'))) if f.is_file() else None
-    except Exception:
-        return None
-
-
-# ─────────────────────────────────────────────────────────── 动作
-def _guard(what: str) -> str | None:
-    running, j = job_running()
-    if running:
-        return f'已有任务在跑 ({j.get("kind")}, 起于 {j.get("started")}) —— 等它结束再操作。'
-    return None
-
-
-def _svc_flags() -> tuple[bool, bool]:
-    """(有组件在跑, 有组件没跑) —— 供动作前置检查, 与 state() 里 buttons 用的是同一判据。"""
-    pt = ports(); host = pt['host']
-    ups = [listening(host, port) for name, port in pt.items() if name != 'host' and name != 'gateway']
-    return any(ups), any(not u for u in ups)
-
-
-def act_stop(body: dict) -> tuple[int, dict]:
-    pt = ports(); include_gw = bool(body.get('include_gateway'))
-    if (e := _guard('stop')):
-        return 409, dict(err=e)
-    any_up, _ = _svc_flags()
-    if not any_up:
-        # 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
-        return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
-    if include_gw:
-        # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来。
-        # 现在经 _ops_launch 二次启动 → 这个任务不是网关的子孙, stop 里的 taskkill /T 杀不到它,
-        # 所以"起回来"那半一定执行得到(此前会让整个栈停在半路)。
-        j = spawn(['-c', 'import subprocess,sys,time;'
-                         f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
-                         'time.sleep(2);'
-                         f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all')
-        j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
-        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
-        return 200, dict(ok=True, job=j, note=j['note'])
-    j = spawn(['scripts/_ops_stop_keep_gateway.py'], 'stop_keep_gw')
-    return 200, dict(ok=True, job=j, note='已停组件服务, 保留网关(控制台) —— 页面继续可用')
-
-
-def act_start(body: dict) -> tuple[int, dict]:
-    if (e := _guard('start')):
-        return 409, dict(err=e)
-    _, any_down = _svc_flags()
-    if not any_down:
-        return 409, dict(err='组件服务都在运行, 无需启动 (按钮在页面上是禁用的)。')
-    open_browser = body.get('open_browser', True)
-    j = spawn(['scripts/_ops_start_and_open.py'] + ([] if open_browser else ['--no-open']), 'start')
-    return 200, dict(ok=True, job=j,
-                     note='正在启动全部服务' + ('; 起来后自动打开 http://127.0.0.1:%d/' % ports()['gateway'] if open_browser else ''))
-
-
-def act_rebuild(body: dict) -> tuple[int, dict]:
-    if (e := _guard('rebuild')):
-        return 409, dict(err=e)
-    args = ['scripts/rebuild_all.py']
-    if body.get('skip_scada', True):
-        args.append('--skip-scada')
-    if body.get('with_verify'):
-        args.append('--with-verify')
-    src = (body.get('src') or '').strip()
-    if src:
-        args += ['--src', src]
-    j = spawn(args, 'rebuild')
-    return 200, dict(ok=True, job=j, note='重算已开始(含重启服务步骤); 进度看日志尾巴')
-
-
-def act_products_off(body: dict) -> tuple[int, dict]:
-    """清除产物 —— 2026-09-16 用户令: **直接删除, 不留备份**。
-
-    原实现是 `products_state.py --off`(移动到 `_products_off/`, 可 `--on` 还原)。现口径: 真删,
-    故这里传 `--yes`(脚本对不可恢复操作要求显式确认; 前端已 confirm 过一次)。
-    恢复缺失的**随包件**改用交付包补齐:
-    `python scripts/products_restore_missing.py --stash <交付包.zip>`。
-    """
-    if (e := _guard('products_off')):
-        return 409, dict(err=e)
-    pr = products_state()
-    if not pr['in_place']:
-        return 409, dict(err='产物已经是清空状态, 无需再清')
-    j = spawn(['scripts/products_state.py', '--off', '--yes'], 'products_off')
-    return 200, dict(ok=True, job=j, note='正在**删除**产物(不留备份, 不可恢复); 清完请点"启动服务"让页面呈现空状态')
-
-
-ACTIONS = dict(stop_services=act_stop, start_services=act_start, rebuild=act_rebuild,
-               products_off=act_products_off)
-
-
-def handle(method: str, path: str, body: bytes) -> tuple[int, str, bytes]:
-    """网关调用入口: → (http code, content-type, body bytes)。
-
-    路由:
-      `/ops`           运维控制台整页 (服务 + 重算 + 产物 + 动作进度)
-      `/ops/recalc`    **内嵌版**: 只保留 重算 + 产物 (+ 动作进度), 给门户菜单「数据重算」用
-                       (2026-09-16 用户令: 把 /ops 的重算与产物搬到门户菜单里)。
-                       走独立路由而不是 `?embed=…`: 网关转给本模块的是 `u.path` (查询串被丢掉),
-                       用查询串会变成"看起来支持、实际不生效"的静默坑。
-      `/ops/api/...`   运维动作 JSON API (GET state / POST 各动作)
-    """
-    if path in ('/ops', '/ops/'):
-        return 200, 'text/html; charset=utf-8', PAGE.encode('utf-8')
-    if path in ('/ops/recalc', '/ops/recalc/'):
-        import re as _re
-        html = _re.sub(r'<!--HIDE_IN_EMBED-->.*?<!--/HIDE_IN_EMBED-->', '', PAGE, flags=_re.S)
-        html = (html.replace('<title>观澜 · 运维控制台</title>', '<title>观澜 · 数据重算</title>')
-                    .replace('</style>', '.wrap{max-width:100%;padding:8px 10px 24px}'
-                                        'body{background:transparent}'
-                                        '.card{margin:0 0 12px}</style>', 1))
-        return 200, 'text/html; charset=utf-8', html.encode('utf-8')
-    if path == '/ops/api/state':
-        return 200, 'application/json; charset=utf-8', json.dumps(state(), ensure_ascii=False).encode('utf-8')
-    if path.startswith('/ops/api/'):
-        name = path[len('/ops/api/'):].strip('/')
-        if method != 'POST':
-            return 405, 'application/json; charset=utf-8', json.dumps(
-                dict(err='这些动作只接受 POST (避免链接被预取/刷新时误触发)'), ensure_ascii=False).encode('utf-8')
-        fn = ACTIONS.get(name)
-        if not fn:
-            return 404, 'application/json; charset=utf-8', json.dumps(dict(err=f'未知动作: {name}'), ensure_ascii=False).encode('utf-8')
-        try:
-            payload = json.loads(body.decode('utf-8')) if body else {}
-        except Exception:
-            payload = {}
-        try:
-            code, obj = fn(payload)
-        except Exception as e:
-            code, obj = 500, dict(err=f'{type(e).__name__}: {e}')
-        return code, 'application/json; charset=utf-8', json.dumps(obj, ensure_ascii=False).encode('utf-8')
-    return 404, 'application/json; charset=utf-8', json.dumps(dict(err='not found'), ensure_ascii=False).encode('utf-8')
-
-
-# ─────────────────────────────────────────────────────────── 页面
-PAGE = r"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<title>观澜 · 运维控制台</title><style>
-:root{--ink:#1B2430;--mut:#5B6B7B;--line:#DDE3E8;--bg:#F5F7F8;--pri:#1F6F8B;--ok:#1E8E5A;--warn:#B26A00;--bad:#C0392B}
-*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
-.wrap{max-width:1000px;margin:0 auto;padding:22px 20px 60px}
-h1{font-size:21px;margin:0 0 4px}.sub{color:var(--mut);font-size:13.5px;margin:0 0 18px}
-.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 14px}
-.card h2{font-size:15px;margin:0 0 10px;color:var(--pri)}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
-button{font:inherit;padding:9px 16px;border-radius:9px;border:1px solid var(--pri);background:var(--pri);color:#fff;cursor:pointer}
-button.ghost{background:#fff;color:var(--pri)}button.danger{border-color:var(--bad);background:var(--bad)}
-button:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.3)}
-table{width:100%;border-collapse:collapse;font-size:13.5px}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line)}
-th{color:var(--mut);font-weight:600}
-.pill{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12.5px;border:1px solid var(--line)}
-.up{background:#E8F6EF;color:var(--ok);border-color:#BFE6D3}.down{background:#FDECEA;color:var(--bad);border-color:#F5C6C0}
-.mut{color:var(--mut)}pre{background:#0F1720;color:#D7E2EA;padding:10px 12px;border-radius:8px;max-height:260px;overflow:auto;font-size:12.5px;margin:8px 0 0}
-.hint{font-size:13px;color:var(--mut);margin:8px 0 0}
-.busy{background:#FFF7E6;border:1px solid #F0D9A8;color:#7A5600;padding:8px 12px;border-radius:8px;margin:0 0 12px}
-label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
-</style></head><body><div class="wrap">
-<!--HIDE_IN_EMBED-->
-<h1>观澜 · 运维控制台</h1>
-<p class="sub">停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的动作后端也会拒绝。本页由网关(端口 <span id="gw"></span>)提供。</p>
-<!--/HIDE_IN_EMBED-->
-<div id="busy"></div>
-
-<!--HIDE_IN_EMBED-->
-<div class="card"><h2>服务</h2><div id="svc"></div>
-  <div class="row" style="margin-top:12px">
-    <button id="b_start" class="ghost">启动服务(并打开门户)</button>
-    <button id="b_stop" class="ghost">停止组件服务(保留控制台)</button>
-    <button id="b_restart" class="ghost">完整重启(含网关,页面会断开十几秒)</button>
-  </div>
-  <p class="hint" id="svc_hint"></p>
-</div>
-<!--/HIDE_IN_EMBED-->
-
-<div class="card"><h2>重算</h2>
-  <div class="row">
-    <label><input type="checkbox" id="f_scada"> 含 SCADA 侧 10 个构建器(逐台读 ~14 GB,约 15 分钟)</label>
-    <label><input type="checkbox" id="f_verify"> 末尾加台账等价验收</label>
-  </div>
-  <div class="row" style="margin-top:10px">
-    <button id="b_rebuild">执行重算(放数据→台账→月度件→补齐→本体→审计)</button>
-    <span class="mut" id="rebuild_hint"></span>
-  </div>
-  <p class="hint">默认跳过 SCADA(只换了台账类数据时的常用档)。重算会自己重启服务。</p>
-</div>
-
-<div class="card"><h2>产物</h2><div id="prod"></div>
-  <div class="row" style="margin-top:12px">
-    <button id="b_off" class="danger">清除产物(直接删除,不留备份)</button>
-  </div>
-  <p class="hint">按用户令(2026-09-16)**清除不留备份、不可恢复**;清完请点"启动服务"让页面呈现空状态。
-     要补回"包内没有生成端"的随包件:<code>python scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>(从交付包按需补齐)。</p>
-</div>
-
-<div class="card"><h2>最近一次动作</h2><div id="job">(无)</div><pre id="log"></pre></div>
-<p class="hint">命令行等价物: <code>guanlan.py stop/serve</code> · <code>scripts/rebuild_all.py</code> · <code>scripts/products_state.py --off --yes/--status</code>(手册 §0/§5b)。清除产物**不留备份**(用户令 2026-09-16);要补回缺失的随包件用 <code>scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>。</p>
-</div><script>
-const $ = s => document.querySelector(s);
-let S = null, busyTimer = null;
-async function api(path, body){
-  const r = await fetch(path, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)} : undefined);
-  return [r.status, await r.json().catch(()=>({err:'非 JSON 响应'}))];
-}
-function pill(up){ return `<span class="pill ${up?'up':'down'}">${up?'运行中':'未运行'}</span>`; }
-/* set: 元素可能不存在 —— 内嵌版 (/ops/recalc) 会去掉"服务"卡与页头, 直接 $() 取值再赋值会抛错,
-   而这里一抛整个 render() 就断, 页面看着像"卡住不动"(2026-09-16 加内嵌版时踩过)。 */
-function set(sel, fn){ const e = $(sel); if(e) fn(e); }
-function render(){
-  const s = S; if(!s) return;
-  set('#gw', e=>e.textContent = s.services.gateway ? s.services.gateway.port : '?');
-  const rows = Object.entries(s.services).map(([n,v]) =>
-     `<tr><td>${n}${v.is_gateway?' <span class="mut">(控制台本体)</span>':''}</td><td>${v.port}</td><td>${pill(v.up)}</td></tr>`).join('');
-  set('#svc', e=>e.innerHTML = `<table><tr><th>服务</th><th>端口</th><th>状态</th></tr>${rows}</table>`);
-  const p = s.products;
-  const st = Object.entries(p.stores||{}).map(([k,v])=>`${k} ${v}`).join(' · ') || '(空)';
-  set('#prod', e=>e.innerHTML = `<table>
-    <tr><th>产物仓</th><td>${p.store}</td></tr>
-    <tr><th>件数</th><td>${p.files} 件 ${p.in_place?'<span class="pill up">在位</span>':'<span class="pill down">已清空</span>'}</td></tr>
-    <tr><th>分布</th><td class="mut">${st}</td></tr>
-    <tr><th>来源台账</th><td class="mut">${p.provenance?`raw 重算 ${p.provenance.counts['raw-derived']} 件 · 随包补齐 ${p.provenance.counts.shipped} 件 (${p.provenance.at})`:'(无 _provenance.json)'}</td></tr>
-    <tr><th>备份</th><td class="mut">${p.no_backup?'**不留备份**(用户令 2026-09-16: 清除 = 直接删除)':''}${(p.legacy_backups&&p.legacy_backups.length)?` <b>← 旧设计残留 ${p.legacy_backups.join(' · ')} —— 确认不需要后手工删掉</b>`:''}</td></tr>
-    <tr><th>验收锚点</th><td class="mut">报警 ${s.anchors.alarms??'—'} 行 · 工单 ${s.anchors.workorders??'—'} · temp_monthly ${s.anchors.temp_monthly??'—'} · 本体 ${s.anchors.objects??'—'} 对象</td></tr>
-  </table>`);
-  const b = s.buttons, run = s.job && s.job.status==='running';
-  set('#b_start',   e=>e.disabled = !b.start_services);
-  set('#b_stop',    e=>e.disabled = !b.stop_services);
-  set('#b_restart', e=>e.disabled = !b.stop_services);
-  set('#b_rebuild', e=>e.disabled = !b.rebuild);
-  set('#b_off',     e=>e.disabled = !b.products_off);
-  set('#svc_hint', e=>e.textContent = b.start_services ? '有服务未运行 → 可启动。' : '全部组件服务已在运行 → 启动按钮已禁用。');
-  set('#rebuild_hint', e=>e.textContent = b.rebuild ? '' : '(有任务在跑, 重算按钮已禁用)');
-  set('#busy', e=>e.innerHTML = run ? `<div class="busy">正在执行: <b>${s.job.kind}</b>(起于 ${s.job.started})${s.job.note?' — '+s.job.note:''} · 完成后本页自动刷新</div>` : '');
-  const j = s.job;
-  set('#job', e=>e.innerHTML = j ? `<div>${j.kind} · <b>${j.status==='running'?'执行中':(j.rc===0?'完成 (退出码 0)':'结束 (退出码 '+j.rc+')')}</b> · ${j.started||''}${j.seconds?' · 耗时 '+j.seconds+'s':''}</div><div class="mut">${j.cmd||''}</div>${j.note?'<div class="mut">'+j.note+'</div>':''}` : '(无)');
-  set('#log', e=>e.textContent = (j && j.log_tail && j.log_tail.length) ? j.log_tail.join('\n') : '');
-}
-async function refresh(){ const [c,d] = await api('/ops/api/state'); if(c===200){ S=d; render(); } }
-async function fire(name, body){
-  const [c,d] = await api('/ops/api/'+name, body||{});
-  if(c!==200){ alert(`操作被拒绝 (HTTP ${c}): ${d.err||''}`); }
-  else { if(d.note) console.log(d.note); }
-  await refresh();
-}
-/* on: 与 set 同理 —— 内嵌版没有"服务"卡, 直接 $('#b_start').onclick=… 会抛 TypeError,
-   而**这一抛会让后面所有绑定与 refresh() 全不执行** (2026-09-16 用户实测: 门户 #recalc 页里
-   "执行重算 / 清除产物"点了没反应)。容错绑定 + 下面 try/catch 兜底 = 同类问题不再静默。 */
-function on(sel, fn){ const e = $(sel); if(e) e.onclick = fn; }
-try {
-  on('#b_start',   ()=>fire('start_services', {open_browser:true}));
-  on('#b_stop',    ()=>fire('stop_services', {include_gateway:false}));
-  on('#b_restart', ()=>{ if(confirm('完整重启会连网关一起停, 本页会断开约 15 秒后自动恢复。继续?')) fire('stop_services', {include_gateway:true}); });
-  on('#b_rebuild', ()=>{ if(confirm('开始重算? 期间服务会被重启, 页面可能短暂打不开。')) fire('rebuild', {skip_scada: !$('#f_scada').checked, with_verify: $('#f_verify').checked}); });
-  on('#b_off',     ()=>{ if(confirm('清除产物 = **直接删除, 不留备份, 不可恢复**。继续?')) fire('products_off', {}); });
-  refresh(); setInterval(refresh, 2000);
-} catch (err) {
-  /* 界面脚本自己坏了也要看得见 (否则表现就是"按钮点了没反应", 排查全靠猜) */
-  const b = document.getElementById('busy');
-  if (b) b.innerHTML = '<div class="busy">⚠ 本页脚本出错, 按钮可能不可用: ' + err + ' (请反馈该行)</div>';
-  else alert('本页脚本出错: ' + err);
-}
-</script></body></html>
-"""
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""观澜运维控制台的后端 (2026-09-12) —— 把"停服务 / 起服务 / 重算 / 清产物"做成一个可视化页面。
+
+## 它解决什么
+
+原来这四件事都得在黑窗口里敲命令 (顺序还不能错), 且没有任何"当前能不能做"的约束。
+本模块给网关加三个东西:
+  · `GET  /ops`                  一个自适应控制台页面(单文件, 无外部依赖);
+  · `GET  /ops/api/state`        真实状态: 各服务端口通不通 · 产物在不在 · 有没有任务在跑 · 上次结果;
+  · `POST /ops/api/<动作>`       停服务 · 起服务 · 重算 · 清产物。
+
+## 三条设计纪律
+
+1. **页面活着的服务不能被自己停掉。** 控制台由网关(28084)提供, 所以"停服务"默认**保留网关**,
+   否则按钮刚点完页面就没了、再也没法"启动服务"。要连网关一起停, 用 `include_gateway=True`,
+   这时用**分离进程**先停再起(页面会断开十几秒, 之后自动重连)。
+2. **一次只允许一个动作。** 各动作互相冲突(重算要重启服务、清产物要删产物), 所以有一把锁:
+   `run/ops_job.json` 里记着当前任务; 任务在跑时, 所有会冲突的按钮在后端**与前端都会被禁掉**
+   (后端拒绝 = 真生效, 前端禁用只是提示)。判断以后端为准。
+3. **日志与状态落盘。** 动作全部 `Popen` 到 `logs/ops_<动作>_<时间>.log`, 页面轮询 job 状态并把日志尾巴显示出来 ——
+   用户看得见"它在干什么", 而不是按钮转圈。
+
+## 按钮什么时候该灰 (后端 state 直接给出, 前端只管照用)
+
+    停服务    : 至少有一个组件服务在监听
+    启动服务  : 至少有一个组件服务没在监听
+    执行重算  : 没有任务在跑
+    清除产物  : 没有任务在跑 且 产物在位 (**直接删除, 不留备份** —— 2026-09-16 用户令; 无"恢复产物"按钮)
+"""
+from __future__ import annotations
+
+import datetime as dt
+import json
+import os
+import pathlib
+import socket
+import subprocess
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                        # noqa: E402
+from src import proc as _proc                                     # noqa: E402 无窗口子进程
+
+RUN = ROOT / 'run'
+LOGS = ROOT / 'logs'
+JOB = RUN / 'ops_job.json'
+OFF = ROOT / '_products_off'
+OFF_MANIFEST = OFF / 'manifest.json'
+PY = P.venv_python() if hasattr(P, 'venv_python') else sys.executable
+
+# 组件服务 (不含网关): 名字 → 端口。端口真源在 configs/serve.json, 这里只是网关不可用时的兜底。
+DEFAULT_PORTS = dict(detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292)
+GATEWAY_PORT = 28084
+KEEP_WHEN_STOPPING = 'gateway'      # 停服务默认保留网关(控制台自己在上面)
+
+
+def ports() -> dict:
+    p = P.SERVE_JSON                      # 配置唯一取用口
+    cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
+    out = dict(DEFAULT_PORTS)
+    out['gateway'] = int(cfg.get('gateway') or GATEWAY_PORT)
+    host = cfg.get('host') or '127.0.0.1'
+    for k in list(out):
+        if k in cfg:
+            out[k] = int(cfg[k])
+    return dict(host=host, **out)
+
+
+def listening(host: str, port: int, timeout=0.35) -> bool:
+    """端口有没有人在听。注意: 本机实测"连已关闭端口会一直等到超时"(不回 RST), 所以超时必须短。"""
+    try:
+        with socket.create_connection((host, port), timeout=timeout):
+            return True
+    except OSError:
+        return False
+
+
+def _job() -> dict:
+    try:
+        return json.loads(JOB.read_text(encoding='utf-8'))
+    except Exception:
+        return {}
+
+
+def job_running() -> tuple[bool, dict]:
+    """(是否在跑, 任务记录)。在跑 = 状态是 running 且执行器进程还活着。
+
+    注意: 退出码由执行器 scripts/_ops_run.py 写回 job (不在跑时才可信), 所以这里只判"活没活";
+    进程没了就把 running 收尾成 done(执行器正常收尾时会自己写, 这里兜的是被强杀的情况)。
+    """
+    j = _job()
+    if not j or j.get('status') != 'running':
+        return False, j
+    pid = j.get('pid')
+    # 心跳优先: 执行器每 15 s 更新 job 文件; 超过 STALE_S 没动静 = 被强杀(pid 可能被复用, 只看 pid 会误判)
+    STALE_S = 150
+    try:
+        age = time.time() - JOB.stat().st_mtime
+    except OSError:
+        age = 0
+    if age > STALE_S:
+        j.update(status='done', rc=None, finished=time.strftime('%Y-%m-%d %H:%M:%S'),
+                 note=(j.get('note') or '') + f' (心跳停了 {int(age)} s —— 任务多半被强杀, 未拿到退出码)')
+        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
+        return False, j
+    alive = False
+    if pid:
+        try:
+            if os.name == 'nt':
+                # errors='replace': tasklist 输出是控制台代码页(GBK), 而本进程可能是 PYTHONUTF8=1
+                # → 严格解码会失败并使 stdout 为 None (guanlan.py 的 alive() 踩过同一个坑)
+                # ★ 走 src.proc.run_text: 本模块跑在网关进程里 (无控制台), 裸 spawn tasklist 会让
+                #   Windows 新建可见控制台 —— 而 /ops 页面每 2 s 轮询一次 ⇒ **反复闪窗** (2026-09-16 实测)。
+                out = _proc.run_text(['tasklist', '/FI', f'PID eq {pid}']).stdout
+                alive = bool(out) and str(pid) in out
+            else:
+                os.kill(pid, 0); alive = True
+        except Exception:
+            alive = False
+    if not alive:
+        j.update(status=j.get('status') if j.get('rc') is not None else 'done',
+                 finished=j.get('finished') or time.strftime('%Y-%m-%d %H:%M:%S'),
+                 note=(j.get('note') or '') + (' (进程已结束但没写回退出码 —— 多半是被强杀)' if j.get('rc') is None else ''))
+        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
+        return False, j
+    return True, j
+
+
+def _log_tail(path, n=40) -> list:
+    if not path:
+        return []
+    f = pathlib.Path(path)
+    if not f.is_file():
+        return []
+    lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
+    return lines[-n:]
+
+
+def spawn(args: list, tag: str, detached=False) -> dict:
+    """起一个动作: 经 `_ops_launch.py` 二次启动 `_ops_run.py`。
+
+    两层是**必须的**(见 _ops_launch.py 注释): 直接 Popen 的话, 任务是网关的子进程, 而
+    `guanlan.py stop` 用 `taskkill /T` —— 停网关时会连带杀掉正在执行的任务(实测把自己杀了)。
+    执行器负责写回真实退出码; 日志落 `logs/ops/<tag>_<时间>.log`
+    (用户令 2 统一日志: 动作日志集中到 logs/ops/, 写前按保留策略清理, 见 src/logfile.py)。
+    """
+    from src import logfile as _lf
+    RUN.mkdir(exist_ok=True)
+    _lf.ensure_dirs()
+    _lf.prune_actions()                                   # 保留最近 20 份 / 30 天
+    log = _lf.action_log(f'ops_{tag}')
+    env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8', PYTHONUNBUFFERED='1')
+    # 走 src.proc.run_text: 网关进程无控制台, 裸 spawn 会让这次"拉启动器"也闪一下窗
+    r = _proc.run_text([PY, 'scripts/_ops_launch.py', '--tag', tag, '--log', str(log), '--'] + list(args),
+                       cwd=str(ROOT), env=env, timeout=60)
+    pid = None
+    for line in reversed((r.stdout or '').strip().splitlines()):
+        if line.strip().isdigit():
+            pid = int(line.strip()); break
+    if pid is None:
+        print('  启动器输出异常:', r.stdout, r.stderr)
+    j = dict(kind=tag, cmd=' '.join(args), pid=pid, log=str(log), status='running', rc=None,
+             started=time.strftime('%Y-%m-%d %H:%M:%S'), note='')
+    JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
+    return j
+
+
+# ─────────────────────────────────────────────────────────── 状态
+def products_state() -> dict:
+    store = P.store()                                     # outputs/<场>/windscada
+    fam = store.parent                                    # outputs/<场> —— 产物仓的根
+    n = sum(1 for x in fam.rglob('*') if x.is_file()) if fam.is_dir() else 0
+    stores = {}
+    if fam.is_dir():
+        for d in sorted(x for x in fam.iterdir() if x.is_dir()):
+            stores[d.name] = sum(1 for _ in d.rglob('*') if _.is_file())
+    prov = fam / '_provenance.json'
+    prov_d = json.loads(prov.read_text(encoding='utf-8')) if prov.is_file() else None
+    # ★2026-09-16 用户令"不要备份清除的产物": 清除 = 真删除, 没有暂存区可列。
+    #   这里只报"有没有旧设计留下的 _products_off*"(有就提示手工删掉), 不再提供"恢复产物"。
+    legacy = sorted(x.name for x in ROOT.glob('_products_off*') if x.is_dir())
+    return dict(
+        fam=str(fam.relative_to(ROOT)).replace('\\', '/'), store=str(store.relative_to(ROOT)).replace('\\', '/'),
+        files=n, in_place=n > 0, stores=stores,
+        cleared=n == 0,
+        no_backup=True,                                  # 现口径: 清除不留备份
+        legacy_backups=legacy,                           # 旧设计残留 (按用户令不自动删, 只提示)
+        provenance=(dict(counts=prov_d.get('counts'), at=prov_d.get('at')) if prov_d else None),
+    )
+
+def state() -> dict:
+    pt = ports(); host = pt['host']
+    svc = {}
+    for name, port in pt.items():
+        if name == 'host':
+            continue
+        svc[name] = dict(port=port, up=listening(host, port), is_gateway=(name == 'gateway'))
+    comps = {k: v for k, v in svc.items() if not v['is_gateway']}
+    gw_up = svc.get('gateway', {}).get('up', False)
+    running, j = job_running()
+    pr = products_state()
+    any_comp_up = any(v['up'] for v in comps.values())
+    any_comp_down = any(not v['up'] for v in comps.values())
+    return dict(
+        now=dt.datetime.now().isoformat(timespec='seconds'),
+        host=host, services=svc, gateway_up=gw_up,
+        products=pr,
+        job=(dict(kind=j.get('kind'), status=j.get('status'), started=j.get('started'), note=j.get('note'),
+                  cmd=j.get('cmd'), rc=j.get('rc'), seconds=j.get('seconds'), finished=j.get('finished'),
+                  log_tail=_log_tail(j.get('log'))) if j else None),
+        # 按钮可用性 —— 前端只照这个渲染, 后端还会再拒一次(见 check())
+        buttons=dict(
+            stop_services=any_comp_up and not running,
+            start_services=any_comp_down and not running,
+            rebuild=not running,
+            products_off=pr['in_place'] and not running,
+        ),
+        anchors=dict(alarms=_rows(f'{pr["fam"]}/windscada/alarms.parquet'),
+                     workorders=_rows(f'{pr["fam"]}/windscada/workorders.parquet'),
+                     temp_monthly=_rows(f'{pr["fam"]}/windscada/temp_monthly.parquet'),
+                     objects=_objects_count(f'{pr["fam"]}/ontology/objects.json')),
+    )
+
+
+def _rows(rel: str):
+    """某件 parquet 的行数 (路径是相对安装根的), 用于页面上的"验收锚点"自检。"""
+    f = ROOT / rel
+    if not f.is_file():
+        return None
+    try:
+        import pandas as pd
+        return int(len(pd.read_parquet(f)))
+    except Exception:
+        return None
+
+
+def _objects_count(rel: str):
+    f = ROOT / rel
+    try:
+        return len(json.loads(f.read_text(encoding='utf-8'))) if f.is_file() else None
+    except Exception:
+        return None
+
+
+# ─────────────────────────────────────────────────────────── 动作
+def _guard(what: str) -> str | None:
+    running, j = job_running()
+    if running:
+        return f'已有任务在跑 ({j.get("kind")}, 起于 {j.get("started")}) —— 等它结束再操作。'
+    return None
+
+
+def _svc_flags() -> tuple[bool, bool]:
+    """(有组件在跑, 有组件没跑) —— 供动作前置检查, 与 state() 里 buttons 用的是同一判据。"""
+    pt = ports(); host = pt['host']
+    ups = [listening(host, port) for name, port in pt.items() if name != 'host' and name != 'gateway']
+    return any(ups), any(not u for u in ups)
+
+
+def act_stop(body: dict) -> tuple[int, dict]:
+    pt = ports(); include_gw = bool(body.get('include_gateway'))
+    if (e := _guard('stop')):
+        return 409, dict(err=e)
+    any_up, _ = _svc_flags()
+    if not any_up:
+        # 按钮在前端就该是灰的, 但后端也必须拒 —— 否则直接打 API/重复提交就能做出无意义动作
+        return 409, dict(err='组件服务都已停止, 没有可停的 (按钮在页面上是禁用的)。')
+    if include_gw:
+        # 连网关一起停: 必须分离进程"先停 → 睡一会 → 再起", 否则控制台自己没了就再也起不回来。
+        # 现在经 _ops_launch 二次启动 → 这个任务不是网关的子孙, stop 里的 taskkill /T 杀不到它,
+        # 所以"起回来"那半一定执行得到(此前会让整个栈停在半路)。
+        j = spawn(['-c', 'import subprocess,sys,time;'
+                         f'subprocess.run([r"{PY}", "guanlan.py", "stop"]);'
+                         'time.sleep(2);'
+                         f'subprocess.run([r"{PY}", "guanlan.py", "serve"])'], 'restart_all')
+        j['note'] = '含网关的完整重启: 页面会断开约 15 秒, 之后自动重连'
+        JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
+        return 200, dict(ok=True, job=j, note=j['note'])
+    j = spawn(['scripts/_ops_stop_keep_gateway.py'], 'stop_keep_gw')
+    return 200, dict(ok=True, job=j, note='已停组件服务, 保留网关(控制台) —— 页面继续可用')
+
+
+def act_start(body: dict) -> tuple[int, dict]:
+    if (e := _guard('start')):
+        return 409, dict(err=e)
+    _, any_down = _svc_flags()
+    if not any_down:
+        return 409, dict(err='组件服务都在运行, 无需启动 (按钮在页面上是禁用的)。')
+    open_browser = body.get('open_browser', True)
+    j = spawn(['scripts/_ops_start_and_open.py'] + ([] if open_browser else ['--no-open']), 'start')
+    return 200, dict(ok=True, job=j,
+                     note='正在启动全部服务' + ('; 起来后自动打开 http://127.0.0.1:%d/' % ports()['gateway'] if open_browser else ''))
+
+
+def act_rebuild(body: dict) -> tuple[int, dict]:
+    if (e := _guard('rebuild')):
+        return 409, dict(err=e)
+    args = ['scripts/rebuild_all.py']
+    if body.get('skip_scada', True):
+        args.append('--skip-scada')
+    if body.get('with_verify'):
+        args.append('--with-verify')
+    src = (body.get('src') or '').strip()
+    if src:
+        args += ['--src', src]
+    j = spawn(args, 'rebuild')
+    return 200, dict(ok=True, job=j, note='重算已开始(含重启服务步骤); 进度看日志尾巴')
+
+
+def act_products_off(body: dict) -> tuple[int, dict]:
+    """清除产物 —— 2026-09-16 用户令: **直接删除, 不留备份**。
+
+    原实现是 `products_state.py --off`(移动到 `_products_off/`, 可 `--on` 还原)。现口径: 真删,
+    故这里传 `--yes`(脚本对不可恢复操作要求显式确认; 前端已 confirm 过一次)。
+    恢复缺失的**随包件**改用交付包补齐:
+    `python scripts/products_restore_missing.py --stash <交付包.zip>`。
+    """
+    if (e := _guard('products_off')):
+        return 409, dict(err=e)
+    pr = products_state()
+    if not pr['in_place']:
+        return 409, dict(err='产物已经是清空状态, 无需再清')
+    j = spawn(['scripts/products_state.py', '--off', '--yes'], 'products_off')
+    return 200, dict(ok=True, job=j, note='正在**删除**产物(不留备份, 不可恢复); 清完请点"启动服务"让页面呈现空状态')
+
+
+ACTIONS = dict(stop_services=act_stop, start_services=act_start, rebuild=act_rebuild,
+               products_off=act_products_off)
+
+
+def handle(method: str, path: str, body: bytes) -> tuple[int, str, bytes]:
+    """网关调用入口: → (http code, content-type, body bytes)。
+
+    路由:
+      `/ops`           运维控制台整页 (服务 + 重算 + 产物 + 动作进度)
+      `/ops/recalc`    **内嵌版**: 只保留 重算 + 产物 (+ 动作进度), 给门户菜单「数据重算」用
+                       (2026-09-16 用户令: 把 /ops 的重算与产物搬到门户菜单里)。
+                       走独立路由而不是 `?embed=…`: 网关转给本模块的是 `u.path` (查询串被丢掉),
+                       用查询串会变成"看起来支持、实际不生效"的静默坑。
+      `/ops/api/...`   运维动作 JSON API (GET state / POST 各动作)
+    """
+    if path in ('/ops', '/ops/'):
+        return 200, 'text/html; charset=utf-8', PAGE.encode('utf-8')
+    if path in ('/ops/recalc', '/ops/recalc/'):
+        import re as _re
+        html = _re.sub(r'<!--HIDE_IN_EMBED-->.*?<!--/HIDE_IN_EMBED-->', '', PAGE, flags=_re.S)
+        html = (html.replace('<title>观澜 · 运维控制台</title>', '<title>观澜 · 数据重算</title>')
+                    .replace('</style>', '.wrap{max-width:100%;padding:8px 10px 24px}'
+                                        'body{background:transparent}'
+                                        '.card{margin:0 0 12px}</style>', 1))
+        return 200, 'text/html; charset=utf-8', html.encode('utf-8')
+    if path == '/ops/api/state':
+        return 200, 'application/json; charset=utf-8', json.dumps(state(), ensure_ascii=False).encode('utf-8')
+    if path.startswith('/ops/api/'):
+        name = path[len('/ops/api/'):].strip('/')
+        if method != 'POST':
+            return 405, 'application/json; charset=utf-8', json.dumps(
+                dict(err='这些动作只接受 POST (避免链接被预取/刷新时误触发)'), ensure_ascii=False).encode('utf-8')
+        fn = ACTIONS.get(name)
+        if not fn:
+            return 404, 'application/json; charset=utf-8', json.dumps(dict(err=f'未知动作: {name}'), ensure_ascii=False).encode('utf-8')
+        try:
+            payload = json.loads(body.decode('utf-8')) if body else {}
+        except Exception:
+            payload = {}
+        try:
+            code, obj = fn(payload)
+        except Exception as e:
+            code, obj = 500, dict(err=f'{type(e).__name__}: {e}')
+        return code, 'application/json; charset=utf-8', json.dumps(obj, ensure_ascii=False).encode('utf-8')
+    return 404, 'application/json; charset=utf-8', json.dumps(dict(err='not found'), ensure_ascii=False).encode('utf-8')
+
+
+# ─────────────────────────────────────────────────────────── 页面
+PAGE = r"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>观澜 · 运维控制台</title><style>
+:root{--ink:#1B2430;--mut:#5B6B7B;--line:#DDE3E8;--bg:#F5F7F8;--pri:#1F6F8B;--ok:#1E8E5A;--warn:#B26A00;--bad:#C0392B}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
+.wrap{max-width:1000px;margin:0 auto;padding:22px 20px 60px}
+h1{font-size:21px;margin:0 0 4px}.sub{color:var(--mut);font-size:13.5px;margin:0 0 18px}
+.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 14px}
+.card h2{font-size:15px;margin:0 0 10px;color:var(--pri)}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+button{font:inherit;padding:9px 16px;border-radius:9px;border:1px solid var(--pri);background:var(--pri);color:#fff;cursor:pointer}
+button.ghost{background:#fff;color:var(--pri)}button.danger{border-color:var(--bad);background:var(--bad)}
+button:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.3)}
+table{width:100%;border-collapse:collapse;font-size:13.5px}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line)}
+th{color:var(--mut);font-weight:600}
+.pill{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12.5px;border:1px solid var(--line)}
+.up{background:#E8F6EF;color:var(--ok);border-color:#BFE6D3}.down{background:#FDECEA;color:var(--bad);border-color:#F5C6C0}
+.mut{color:var(--mut)}pre{background:#0F1720;color:#D7E2EA;padding:10px 12px;border-radius:8px;max-height:260px;overflow:auto;font-size:12.5px;margin:8px 0 0}
+.hint{font-size:13px;color:var(--mut);margin:8px 0 0}
+.busy{background:#FFF7E6;border:1px solid #F0D9A8;color:#7A5600;padding:8px 12px;border-radius:8px;margin:0 0 12px}
+label{font-size:13.5px;color:var(--mut);display:flex;gap:6px;align-items:center}
+</style></head><body><div class="wrap">
+<!--HIDE_IN_EMBED-->
+<h1>观澜 · 运维控制台</h1>
+<p class="sub">停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的动作后端也会拒绝。本页由网关(端口 <span id="gw"></span>)提供。</p>
+<!--/HIDE_IN_EMBED-->
+<div id="busy"></div>
+
+<!--HIDE_IN_EMBED-->
+<div class="card"><h2>服务</h2><div id="svc"></div>
+  <div class="row" style="margin-top:12px">
+    <button id="b_start" class="ghost">启动服务(并打开门户)</button>
+    <button id="b_stop" class="ghost">停止组件服务(保留控制台)</button>
+    <button id="b_restart" class="ghost">完整重启(含网关,页面会断开十几秒)</button>
+  </div>
+  <p class="hint" id="svc_hint"></p>
+</div>
+<!--/HIDE_IN_EMBED-->
+
+<div class="card"><h2>重算</h2>
+  <div class="row">
+    <label><input type="checkbox" id="f_scada"> 含 SCADA 侧 10 个构建器(逐台读 ~14 GB,约 15 分钟)</label>
+    <label><input type="checkbox" id="f_verify"> 末尾加台账等价验收</label>
+  </div>
+  <div class="row" style="margin-top:10px">
+    <button id="b_rebuild">执行重算(放数据→台账→月度件→补齐→本体→审计)</button>
+    <span class="mut" id="rebuild_hint"></span>
+  </div>
+  <p class="hint">默认跳过 SCADA(只换了台账类数据时的常用档)。重算会自己重启服务。</p>
+</div>
+
+<div class="card"><h2>产物</h2><div id="prod"></div>
+  <div class="row" style="margin-top:12px">
+    <button id="b_off" class="danger">清除产物(直接删除,不留备份)</button>
+  </div>
+  <p class="hint">按用户令(2026-09-16)**清除不留备份、不可恢复**;清完请点"启动服务"让页面呈现空状态。
+     要补回"包内没有生成端"的随包件:<code>python scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>(从交付包按需补齐)。</p>
+</div>
+
+<div class="card"><h2>最近一次动作</h2><div id="job">(无)</div><pre id="log"></pre></div>
+<p class="hint">命令行等价物: <code>guanlan.py stop/serve</code> · <code>scripts/rebuild_all.py</code> · <code>scripts/products_state.py --off --yes/--status</code>(手册 §0/§5b)。清除产物**不留备份**(用户令 2026-09-16);要补回缺失的随包件用 <code>scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code>。</p>
+</div><script>
+const $ = s => document.querySelector(s);
+let S = null, busyTimer = null;
+async function api(path, body){
+  const r = await fetch(path, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)} : undefined);
+  return [r.status, await r.json().catch(()=>({err:'非 JSON 响应'}))];
+}
+function pill(up){ return `<span class="pill ${up?'up':'down'}">${up?'运行中':'未运行'}</span>`; }
+/* set: 元素可能不存在 —— 内嵌版 (/ops/recalc) 会去掉"服务"卡与页头, 直接 $() 取值再赋值会抛错,
+   而这里一抛整个 render() 就断, 页面看着像"卡住不动"(2026-09-16 加内嵌版时踩过)。 */
+function set(sel, fn){ const e = $(sel); if(e) fn(e); }
+function render(){
+  const s = S; if(!s) return;
+  set('#gw', e=>e.textContent = s.services.gateway ? s.services.gateway.port : '?');
+  const rows = Object.entries(s.services).map(([n,v]) =>
+     `<tr><td>${n}${v.is_gateway?' <span class="mut">(控制台本体)</span>':''}</td><td>${v.port}</td><td>${pill(v.up)}</td></tr>`).join('');
+  set('#svc', e=>e.innerHTML = `<table><tr><th>服务</th><th>端口</th><th>状态</th></tr>${rows}</table>`);
+  const p = s.products;
+  const st = Object.entries(p.stores||{}).map(([k,v])=>`${k} ${v}`).join(' · ') || '(空)';
+  set('#prod', e=>e.innerHTML = `<table>
+    <tr><th>产物仓</th><td>${p.store}</td></tr>
+    <tr><th>件数</th><td>${p.files} 件 ${p.in_place?'<span class="pill up">在位</span>':'<span class="pill down">已清空</span>'}</td></tr>
+    <tr><th>分布</th><td class="mut">${st}</td></tr>
+    <tr><th>来源台账</th><td class="mut">${p.provenance?`raw 重算 ${p.provenance.counts['raw-derived']} 件 · 随包补齐 ${p.provenance.counts.shipped} 件 (${p.provenance.at})`:'(无 _provenance.json)'}</td></tr>
+    <tr><th>备份</th><td class="mut">${p.no_backup?'**不留备份**(用户令 2026-09-16: 清除 = 直接删除)':''}${(p.legacy_backups&&p.legacy_backups.length)?` <b>← 旧设计残留 ${p.legacy_backups.join(' · ')} —— 确认不需要后手工删掉</b>`:''}</td></tr>
+    <tr><th>验收锚点</th><td class="mut">报警 ${s.anchors.alarms??'—'} 行 · 工单 ${s.anchors.workorders??'—'} · temp_monthly ${s.anchors.temp_monthly??'—'} · 本体 ${s.anchors.objects??'—'} 对象</td></tr>
+  </table>`);
+  const b = s.buttons, run = s.job && s.job.status==='running';
+  set('#b_start',   e=>e.disabled = !b.start_services);
+  set('#b_stop',    e=>e.disabled = !b.stop_services);
+  set('#b_restart', e=>e.disabled = !b.stop_services);
+  set('#b_rebuild', e=>e.disabled = !b.rebuild);
+  set('#b_off',     e=>e.disabled = !b.products_off);
+  set('#svc_hint', e=>e.textContent = b.start_services ? '有服务未运行 → 可启动。' : '全部组件服务已在运行 → 启动按钮已禁用。');
+  set('#rebuild_hint', e=>e.textContent = b.rebuild ? '' : '(有任务在跑, 重算按钮已禁用)');
+  set('#busy', e=>e.innerHTML = run ? `<div class="busy">正在执行: <b>${s.job.kind}</b>(起于 ${s.job.started})${s.job.note?' — '+s.job.note:''} · 完成后本页自动刷新</div>` : '');
+  const j = s.job;
+  set('#job', e=>e.innerHTML = j ? `<div>${j.kind} · <b>${j.status==='running'?'执行中':(j.rc===0?'完成 (退出码 0)':'结束 (退出码 '+j.rc+')')}</b> · ${j.started||''}${j.seconds?' · 耗时 '+j.seconds+'s':''}</div><div class="mut">${j.cmd||''}</div>${j.note?'<div class="mut">'+j.note+'</div>':''}` : '(无)');
+  set('#log', e=>e.textContent = (j && j.log_tail && j.log_tail.length) ? j.log_tail.join('\n') : '');
+}
+async function refresh(){ const [c,d] = await api('/ops/api/state'); if(c===200){ S=d; render(); } }
+async function fire(name, body){
+  const [c,d] = await api('/ops/api/'+name, body||{});
+  if(c!==200){ alert(`操作被拒绝 (HTTP ${c}): ${d.err||''}`); }
+  else { if(d.note) console.log(d.note); }
+  await refresh();
+}
+/* on: 与 set 同理 —— 内嵌版没有"服务"卡, 直接 $('#b_start').onclick=… 会抛 TypeError,
+   而**这一抛会让后面所有绑定与 refresh() 全不执行** (2026-09-16 用户实测: 门户 #recalc 页里
+   "执行重算 / 清除产物"点了没反应)。容错绑定 + 下面 try/catch 兜底 = 同类问题不再静默。 */
+function on(sel, fn){ const e = $(sel); if(e) e.onclick = fn; }
+try {
+  on('#b_start',   ()=>fire('start_services', {open_browser:true}));
+  on('#b_stop',    ()=>fire('stop_services', {include_gateway:false}));
+  on('#b_restart', ()=>{ if(confirm('完整重启会连网关一起停, 本页会断开约 15 秒后自动恢复。继续?')) fire('stop_services', {include_gateway:true}); });
+  on('#b_rebuild', ()=>{ if(confirm('开始重算? 期间服务会被重启, 页面可能短暂打不开。')) fire('rebuild', {skip_scada: !$('#f_scada').checked, with_verify: $('#f_verify').checked}); });
+  on('#b_off',     ()=>{ if(confirm('清除产物 = **直接删除, 不留备份, 不可恢复**。继续?')) fire('products_off', {}); });
+  refresh(); setInterval(refresh, 2000);
+} catch (err) {
+  /* 界面脚本自己坏了也要看得见 (否则表现就是"按钮点了没反应", 排查全靠猜) */
+  const b = document.getElementById('busy');
+  if (b) b.innerHTML = '<div class="busy">⚠ 本页脚本出错, 按钮可能不可用: ' + err + ' (请反馈该行)</div>';
+  else alert('本页脚本出错: ' + err);
+}
+</script></body></html>
+"""

+ 176 - 176
scripts/guanlan_start_hidden.py

@@ -1,176 +1,176 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""**无窗口**启动观澜 (2026-09-16 用户令: "启动观澜系统, 弹出的命令窗口, 改为不弹出方式")。
-
-为什么单开一个脚本: `guanlan.py serve` 是**前台阻塞**命令 (它在控制台里打启动横幅、等 healthz),
-双击 start.bat 就会一直挂着一个黑窗; 而各组件服务其实早已是 `DETACHED_PROCESS` 起的 (不弹窗)。
-所以只需要一个"把 serve 藏到后台、等就绪、打开浏览器、自己退出"的入口。
-
-做法:
-  1. 网关已在 → 只打开浏览器 (重复点不重复起, 幂等);
-  2. 否则用 `CREATE_NO_WINDOW | DETACHED_PROCESS` 起 `guanlan.py serve`, stdout/stderr 落
-     `logs/serve.log` (有窗口才有地方看日志, 藏起来就必须落文件);
-  3. 轮询 `/healthz` 直到就绪 (最多 `--wait` 秒), 就绪后按 `--open/--no-open` 决定是否开浏览器;
-  4. 全过程写 `logs/start_hidden.log` (含退出码与失败原因) —— **不静默**:
-     失败时不但写日志, 还会弹一个消息框 (无窗口模式下唯一能让人看见的方式)。
-
-由 `pythonw.exe`(无控制台子系统的解释器) 调用 —— 2026-09-16 用户令"把 VBScript 替换掉"之后,
-不再经 `start_hidden.vbs` / Windows 脚本宿主: `start.bat` 与安装时生成的 `启动观澜.lnk` 都直接
-指向 `.venv\\Scripts\\pythonw.exe` + 本脚本, WSH 被禁用或未安装也照样无窗口启动。
-
-用法:
-    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py          # 无窗口启动 + 开浏览器
-    .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --no-open  # 无窗口启动, 不开浏览器
-    .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --wait 180 # 加长等待
-    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py --selftest # 只打印"有没有控制台"就退出
-"""
-from __future__ import annotations
-
-import argparse
-import datetime as dt
-import json
-import os
-import pathlib
-import socket
-import subprocess
-import sys
-import time
-import urllib.request
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import paths as P          # noqa: E402
-from src.console import soft        # noqa: E402
-
-soft()
-LOG = P.LOGS / 'start_hidden.log'
-
-
-def log(msg: str) -> None:
-    P.LOGS.mkdir(parents=True, exist_ok=True)
-    line = f'{dt.datetime.now():%Y-%m-%d %H:%M:%S} {msg}'
-    with open(LOG, 'a', encoding='utf-8') as f:
-        f.write(line + '\n')
-
-
-def port_up(host: str, port: int, t: float = 0.4) -> bool:
-    try:
-        with socket.create_connection((host, port), timeout=t):
-            return True
-    except OSError:
-        return False
-
-
-def healthz(url: str, timeout=6.0) -> bool:
-    try:
-        with urllib.request.urlopen(url, timeout=timeout) as r:
-            return r.status == 200
-    except Exception:
-        return False
-
-
-def notify(title: str, text: str) -> None:
-    """无窗口模式下唯一能让人看见失败的通道 (Windows 消息框; 非 Windows 走 stderr)。"""
-    if os.name != 'nt':
-        print(f'{title}: {text}', file=sys.stderr)
-        return
-    try:
-        import ctypes
-        ctypes.windll.user32.MessageBoxW(0, text, title, 0x30)      # MB_ICONWARNING
-    except Exception:
-        pass
-
-
-def console_hwnd() -> int:
-    """本进程有没有控制台窗口 (0 = 没有, 即"无窗口"成立)。非 Windows 返回 -1 (不适用)。"""
-    if os.name != 'nt':
-        return -1
-    try:
-        import ctypes
-        return int(ctypes.windll.kernel32.GetConsoleWindow())
-    except Exception:
-        return -2
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--host', default='127.0.0.1')
-    ap.add_argument('--gateway', type=int, default=None, help='网关端口 (默认读 configs/serve.json)')
-    ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
-    ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
-    ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
-    ap.add_argument('--selftest', action='store_true',
-                    help='"无窗口"自证: 打印本进程有没有控制台窗口就退出 (给验证用, 不启服务)')
-    a = ap.parse_args()
-
-    if a.selftest:
-        h = console_hwnd()
-        print(json.dumps(dict(console_hwnd=h, windowless=(h == 0),
-                              interpreter=sys.executable, argv0=sys.argv[0]),
-                         ensure_ascii=False))
-        return 0
-
-    gw = a.gateway
-    if gw is None:
-        try:
-            cfg = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
-            gw = int(cfg.get('gateway') or 28084)
-        except Exception:
-            gw = 28084
-    url = f'http://{a.host}:{gw}/'
-    _h = console_hwnd()
-    _w = {0: '无控制台窗口 (= 无窗口启动成立)', -1: '非 Windows (不适用)', -2: '取不到 (视为未知)'}.get(_h, f'有控制台窗口 hwnd={_h}')
-    log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s; 解释器 {sys.executable}) ---')
-    log(f'窗口状态: {_w}')
-
-    if port_up(a.host, gw):
-        log(f'网关 {gw} 已在运行 → 只打开页面')
-        if not a.no_open:
-            import webbrowser
-            webbrowser.open(url)
-        return 0
-
-    py = P.venv_python() or pathlib.Path(sys.executable)
-    cmd = [str(py), 'guanlan.py', 'serve']
-    flags = 0
-    if os.name == 'nt':
-        flags = (getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
-                 | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
-                 | getattr(subprocess, 'DETACHED_PROCESS', 0))
-    P.LOGS.mkdir(parents=True, exist_ok=True)
-    lf = open(P.LOGS / 'serve.log', 'ab')
-    kw = dict(cwd=str(ROOT), stdout=lf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
-    if os.name == 'nt':
-        kw['creationflags'] = flags
-    else:
-        kw['start_new_session'] = True
-    try:
-        pid = subprocess.Popen(cmd, **kw).pid
-    except Exception as e:
-        log(f'✘ 起 serve 失败: {type(e).__name__}: {e}')
-        if not a.quiet_fail:
-            notify('观澜启动失败', f'无法启动服务: {e}\n详见 logs\\start_hidden.log')
-        return 3
-    log(f'起 serve pid={pid} (无窗口; 日志 logs/serve.log)')
-
-    t0 = time.time()
-    while time.time() - t0 < a.wait:
-        if healthz(f'{url}healthz'):
-            log(f'✔ 就绪 ({time.time() - t0:.0f}s) → {url}')
-            if not a.no_open:
-                try:
-                    import webbrowser
-                    webbrowser.open(url)
-                except Exception as e:
-                    log(f'⚠ 打开浏览器失败: {e}')
-            return 0
-        time.sleep(1.5)
-    log(f'✘ {a.wait}s 内 healthz 未就绪 (serve pid={pid} 可能已退出)')
-    if not a.quiet_fail:
-        notify('观澜启动未就绪', f'{a.wait} 秒内网关未就绪, 请查看:\n'
-                                  f'logs\\serve.log\nlogs\\start_hidden.log')
-    return 2
-
-
-if __name__ == '__main__':
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""**无窗口**启动观澜 (2026-09-16 用户令: "启动观澜系统, 弹出的命令窗口, 改为不弹出方式")。
+
+为什么单开一个脚本: `guanlan.py serve` 是**前台阻塞**命令 (它在控制台里打启动横幅、等 healthz),
+双击 start.bat 就会一直挂着一个黑窗; 而各组件服务其实早已是 `DETACHED_PROCESS` 起的 (不弹窗)。
+所以只需要一个"把 serve 藏到后台、等就绪、打开浏览器、自己退出"的入口。
+
+做法:
+  1. 网关已在 → 只打开浏览器 (重复点不重复起, 幂等);
+  2. 否则用 `CREATE_NO_WINDOW | DETACHED_PROCESS` 起 `guanlan.py serve`, stdout/stderr 落
+     `logs/serve.log` (有窗口才有地方看日志, 藏起来就必须落文件);
+  3. 轮询 `/healthz` 直到就绪 (最多 `--wait` 秒), 就绪后按 `--open/--no-open` 决定是否开浏览器;
+  4. 全过程写 `logs/start_hidden.log` (含退出码与失败原因) —— **不静默**:
+     失败时不但写日志, 还会弹一个消息框 (无窗口模式下唯一能让人看见的方式)。
+
+由 `pythonw.exe`(无控制台子系统的解释器) 调用 —— 2026-09-16 用户令"把 VBScript 替换掉"之后,
+不再经 `start_hidden.vbs` / Windows 脚本宿主: `start.bat` 与安装时生成的 `启动观澜.lnk` 都直接
+指向 `.venv\\Scripts\\pythonw.exe` + 本脚本, WSH 被禁用或未安装也照样无窗口启动。
+
+用法:
+    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py          # 无窗口启动 + 开浏览器
+    .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --no-open  # 无窗口启动, 不开浏览器
+    .venv\\Scripts\\python.exe  scripts\\guanlan_start_hidden.py --wait 180 # 加长等待
+    .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py --selftest # 只打印"有没有控制台"就退出
+"""
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import pathlib
+import socket
+import subprocess
+import sys
+import time
+import urllib.request
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P          # noqa: E402
+from src.console import soft        # noqa: E402
+
+soft()
+LOG = P.LOGS / 'start_hidden.log'          # 启动器日志: 服务日志命名口径 logs/<组件>.log
+
+
+def log(msg: str) -> None:
+    P.LOGS.mkdir(parents=True, exist_ok=True)
+    line = f'{dt.datetime.now():%Y-%m-%d %H:%M:%S} {msg}'
+    with open(LOG, 'a', encoding='utf-8') as f:
+        f.write(line + '\n')
+
+
+def port_up(host: str, port: int, t: float = 0.4) -> bool:
+    try:
+        with socket.create_connection((host, port), timeout=t):
+            return True
+    except OSError:
+        return False
+
+
+def healthz(url: str, timeout=6.0) -> bool:
+    try:
+        with urllib.request.urlopen(url, timeout=timeout) as r:
+            return r.status == 200
+    except Exception:
+        return False
+
+
+def notify(title: str, text: str) -> None:
+    """无窗口模式下唯一能让人看见失败的通道 (Windows 消息框; 非 Windows 走 stderr)。"""
+    if os.name != 'nt':
+        print(f'{title}: {text}', file=sys.stderr)
+        return
+    try:
+        import ctypes
+        ctypes.windll.user32.MessageBoxW(0, text, title, 0x30)      # MB_ICONWARNING
+    except Exception:
+        pass
+
+
+def console_hwnd() -> int:
+    """本进程有没有控制台窗口 (0 = 没有, 即"无窗口"成立)。非 Windows 返回 -1 (不适用)。"""
+    if os.name != 'nt':
+        return -1
+    try:
+        import ctypes
+        return int(ctypes.windll.kernel32.GetConsoleWindow())
+    except Exception:
+        return -2
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--host', default='127.0.0.1')
+    ap.add_argument('--gateway', type=int, default=None, help='网关端口 (默认读 configs/serve.json)')
+    ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
+    ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
+    ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
+    ap.add_argument('--selftest', action='store_true',
+                    help='"无窗口"自证: 打印本进程有没有控制台窗口就退出 (给验证用, 不启服务)')
+    a = ap.parse_args()
+
+    if a.selftest:
+        h = console_hwnd()
+        print(json.dumps(dict(console_hwnd=h, windowless=(h == 0),
+                              interpreter=sys.executable, argv0=sys.argv[0]),
+                         ensure_ascii=False))
+        return 0
+
+    gw = a.gateway
+    if gw is None:
+        try:
+            cfg = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
+            gw = int(cfg.get('gateway') or 28084)
+        except Exception:
+            gw = 28084
+    url = f'http://{a.host}:{gw}/'
+    _h = console_hwnd()
+    _w = {0: '无控制台窗口 (= 无窗口启动成立)', -1: '非 Windows (不适用)', -2: '取不到 (视为未知)'}.get(_h, f'有控制台窗口 hwnd={_h}')
+    log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s; 解释器 {sys.executable}) ---')
+    log(f'窗口状态: {_w}')
+
+    if port_up(a.host, gw):
+        log(f'网关 {gw} 已在运行 → 只打开页面')
+        if not a.no_open:
+            import webbrowser
+            webbrowser.open(url)
+        return 0
+
+    py = P.venv_python() or pathlib.Path(sys.executable)
+    cmd = [str(py), 'guanlan.py', 'serve']
+    flags = 0
+    if os.name == 'nt':
+        flags = (getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
+                 | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
+                 | getattr(subprocess, 'DETACHED_PROCESS', 0))
+    P.LOGS.mkdir(parents=True, exist_ok=True)
+    lf = open(P.LOGS / 'serve.log', 'ab')
+    kw = dict(cwd=str(ROOT), stdout=lf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
+    if os.name == 'nt':
+        kw['creationflags'] = flags
+    else:
+        kw['start_new_session'] = True
+    try:
+        pid = subprocess.Popen(cmd, **kw).pid
+    except Exception as e:
+        log(f'✘ 起 serve 失败: {type(e).__name__}: {e}')
+        if not a.quiet_fail:
+            notify('观澜启动失败', f'无法启动服务: {e}\n详见 logs\\start_hidden.log')
+        return 3
+    log(f'起 serve pid={pid} (无窗口; 日志 logs/serve.log)')
+
+    t0 = time.time()
+    while time.time() - t0 < a.wait:
+        if healthz(f'{url}healthz'):
+            log(f'✔ 就绪 ({time.time() - t0:.0f}s) → {url}')
+            if not a.no_open:
+                try:
+                    import webbrowser
+                    webbrowser.open(url)
+                except Exception as e:
+                    log(f'⚠ 打开浏览器失败: {e}')
+            return 0
+        time.sleep(1.5)
+    log(f'✘ {a.wait}s 内 healthz 未就绪 (serve pid={pid} 可能已退出)')
+    if not a.quiet_fail:
+        notify('观澜启动未就绪', f'{a.wait} 秒内网关未就绪, 请查看:\n'
+                                  f'logs\\serve.log\nlogs\\start_hidden.log')
+    return 2
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 266 - 0
scripts/log_audit.py

@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""日志审计 —— 把"日志目录/命名/格式统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
+
+## 五条规则
+
+    L1 只在 logs/ 下    全仓的 `*.log` / `*.jsonl` 只许出现在 `logs/` (白名单: 产物里的
+                        `analyze_stdout.log` 属**产物附件**, 与运行日志不是一回事, 见登记理由)。
+    L2 命名              `logs/<组件>.log`(组件 = configs/serve.json 的服务键 + gateway/serve/start_hidden)、
+                        `logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log`、`logs/audit/<名字>.jsonl`。别的名字报出来。
+    L3 行格式            每一行必须匹配 `YYYY-MM-DD HH:MM:SS LEVEL 组件 消息` (src/logfile.py::LINE_RE)。
+                        **按文件末尾 200 行判**: 启用统一格式之前的旧行不算数 (这批已归档到 logs/legacy/)。
+    L4 行尾与颜色        logs/ 下的文本日志不许有 CRLF、不许有 ANSI 颜色码 (重定向到文件后颜色码是垃圾字节)。
+    L5 保留策略          `logs/ops/` 只留最近 20 份 / 30 天; 超出即报 (并可用 --prune 就地清理)。
+                        `logs/audit/*.jsonl` 每行必须是合法 JSON。
+
+## 退出码
+    0 全部通过 · 5 日志跑到 logs/ 外面 · 6 命名不合口径 · 7 行格式不合规 · 8 行尾/颜色问题 · 9 保留策略超限
+
+## 用法
+    python scripts/log_audit.py            # 检查
+    python scripts/log_audit.py --prune    # 检查并就地清理超限的动作日志
+    python scripts/log_audit.py --json
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import logfile as L                                          # noqa: E402
+from src import paths as P                                            # noqa: E402
+
+SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor', '.git-*'}
+# logs/ 之外的日志白名单: (正则, 理由)。两类:
+#   ① 产物附件: CMS 插件把"这次分析的 stdout"当产物附件留在产物目录;
+#   ② 第三方工具的缓存/配置库: npm 缓存、Puppeteer(Chromium) 的 QA profile —— 它们的 000003.log
+#      是 LevelDB 内部文件, 不是本系统的运行日志 (删不删是另一件事, 见 docs §11 的清理建议)。
+OUTSIDE_OK = [
+    (re.compile(r'^outputs/.*/windcms/.*analyze_stdout\.log$'),
+     'CMS 插件把"这次分析的 stdout"当作产物附件留在产物目录 (属产物, 不是运行日志)'),
+    (re.compile(r'^(?:.*/)?(?:\.npm-cache|\.qa-profile)/'),
+     '第三方工具 (npm 缓存 / Chromium QA profile) 自带的状态文件, 非本系统日志'),
+    (re.compile(r'^(?:.*/)?node_modules/'),
+     'node_modules 里的第三方包自带日志'),
+]
+TAIL_LINES = 200
+
+
+def scan_outside() -> list[pathlib.Path]:
+    found = []
+    for dp, dn, fns in os.walk(ROOT):
+        dn[:] = [d for d in dn if d not in SKIP_WALK and not d.startswith('.git')]
+        rel_dir = pathlib.Path(dp).relative_to(ROOT).as_posix()
+        if rel_dir == 'logs' or rel_dir.startswith('logs/'):
+            dn[:] = []
+            continue
+        for f in fns:
+            if f.endswith(('.log', '.jsonl')):
+                found.append(pathlib.Path(dp) / f)
+    return found
+
+
+def tail_lines(p: pathlib.Path, n: int = TAIL_LINES) -> list[str]:
+    try:
+        data = p.read_bytes()
+    except OSError:
+        return []
+    txt = data.decode('utf-8', 'replace')
+    lines = txt.replace('\r\n', '\n').split('\n')
+    return lines[-n:]
+
+
+def migrate_product_logs(dry: bool = True) -> list:
+    """把产物目录里的日志搬进 `logs/build/`, 并同步台账 (用户令 2: 日志不许留在产物里)。
+
+    为什么要搬: 交付包里 39 个 `.log` 躺在 `outputs/<场>/…` 下, 还被 `_provenance.json`
+    登记成 `shipped` 随包件 —— 产物台账里混着日志, 排障时也找不到。搬迁后:
+      · 文件去 `logs/build/<场>/<原相对路径>`;
+      · 台账里对应条目**删掉并重算计数**, 并写明"因日志归位而移出" (只搬文件不改台账 = 台账失真)。
+    返回 (moved, ledger_notes)。
+    """
+    moved, notes = [], []
+    out = P.ROOT / 'outputs'
+    if not out.is_dir():
+        return moved, notes
+    for f in sorted(out.rglob('*.log')):
+        rel = f.relative_to(out)                       # 如 rudong/windscada/temp_build.log
+        dst = L.LOGS / 'build' / rel
+        moved.append((f, dst))
+        if dry:
+            continue
+        dst.parent.mkdir(parents=True, exist_ok=True)
+        if dst.exists():
+            dst = dst.with_name(f'{dst.stem}_{int(dst.stat().st_mtime)}{dst.suffix}')
+        f.replace(dst)
+    if dry or not moved:
+        return moved, notes
+    for farm_dir in sorted(x for x in out.iterdir() if x.is_dir()):
+        prov_p = farm_dir / '_provenance.json'
+        if not prov_p.is_file():
+            continue
+        prov = json.loads(prov_p.read_text(encoding='utf-8'))
+        files = prov.get('files') or {}
+        drop = [k for k in files if str(k).endswith('.log')]
+        for k in drop:
+            files.pop(k, None)
+        if drop:
+            counts = {}
+            for v in files.values():
+                s = v.get('source') if isinstance(v, dict) else '?'
+                counts[s] = counts.get(s, 0) + 1
+            prov['counts'] = counts
+            prov['files'] = files
+            prov['note'] = (str(prov.get('note', '')) +
+                            f' | 2026-09-17 因「日志归位」(用户令 2) 移出 {len(drop)} 个 .log 条目: '
+                            f'那批是构建日志, 现位于 logs/build/{farm_dir.name}/ 下')
+            prov_p.write_text(json.dumps(prov, ensure_ascii=False, indent=1), encoding='utf-8')
+            notes.append(f'{farm_dir.name}/_provenance.json: 台账移出 {len(drop)} 个 .log 条目并重算计数')
+    return moved, notes
+
+
+def audit() -> tuple[int, list, dict]:
+    res: list[tuple[str, str, str, int]] = []
+    L.ensure_dirs()
+    n_svc = n_ops = n_audit = n_build = 0
+
+    # L1 只在 logs/ 下
+    for f in scan_outside():
+        rel = f.relative_to(ROOT).as_posix()
+        if any(r.match(rel) for r, _ in OUTSIDE_OK):
+            why = next(w for r, w in OUTSIDE_OK if r.match(rel))
+            res.append(('i', rel, f'白名单: {why}', 0))
+        else:
+            res.append(('X', rel, '日志跑到 logs/ 外面了 (用户令 2: 运行日志只在 logs/)', 5))
+
+    # L2/L3/L4/L5
+    for f in sorted(L.LOGS.rglob('*')):
+        if not f.is_file():
+            continue
+        rel = f.relative_to(ROOT).as_posix()
+        if 'legacy' in f.parts:
+            continue
+        if f.suffix == '.jsonl':
+            n_audit += 1
+            for i, ln in enumerate(tail_lines(f), 1):
+                if not ln.strip():
+                    continue
+                try:
+                    json.loads(ln)
+                except Exception:
+                    res.append(('X', rel, f'审计流水第 {i} 行不是合法 JSON (jsonl 一行一条)', 7))
+                    break
+            if f.parent != L.AUDIT_DIR:
+                res.append(('!', rel, '审计流水应放 logs/audit/ 下', 6))
+            continue
+        if f.suffix != '.log':
+            if f.name.endswith('.json'):
+                n_audit += 1
+                if f.parent != L.AUDIT_DIR:
+                    res.append(('!', rel, '审计/报告类 JSON 应放 logs/audit/ 下', 6))
+            continue
+        if f.parent == L.OPS_DIR:
+            n_ops += 1
+            if not re.match(r'^[\w\-]+_\d{8}-\d{6}\.log$', f.name):
+                res.append(('!', rel, '动作日志命名应为 logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log', 6))
+        elif L.BUILD_DIR in f.parents:
+            n_build += 1
+            # 构建日志: 允许按产物子路径归档 (logs/build/<场>/<原相对路径>.log)。
+            # 这些是**从产物目录归位过来的历史文件**, 由未随包的构建器写成, 无法追溯重排格式 ⇒ 只提示。
+            lines = [ln for ln in tail_lines(f) if ln.strip()]
+            bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
+            if bad:
+                res.append(('?', rel, f'历史构建日志 {len(bad)}/{len(lines)} 行不是统一格式 '
+                                       f'(写它的人不在本包; 新构建器请用 logfile.build_log() + logfile.line())', 0))
+            continue                      # 历史构建日志不再套"服务日志"那套严格检查 (没法追溯重排)
+        elif f.parent == L.LOGS:
+            n_svc += 1
+            if not re.match(r'^[\w\u4e00-\u9fff.\-]+\.log$', f.name):
+                res.append(('!', rel, '服务日志命名应为 logs/<组件>.log', 6))
+        else:
+            res.append(('!', rel, 'logs/ 下只允许 服务日志(顶层)、ops/、audit/、build/ 四处', 6))
+
+        raw = f.read_bytes()
+        if b'\r\n' in raw:
+            res.append(('?', rel, f'含 CRLF ({raw.count(bytes([13, 10]))} 处) —— 新写日志统一 LF', 0))
+        if L.ANSI_RE.search(raw.decode('utf-8', 'replace')):
+            res.append(('?', rel, '含 ANSI 颜色码 (历史行; 重定向到文件后是垃圾字节)', 0))
+        lines = [ln for ln in tail_lines(f) if ln.strip()]
+        if lines:
+            bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
+            if len(bad) == len(lines):
+                res.append(('X', rel, f'末尾 {len(lines)} 行都不符合统一格式 (应为 "时间戳 级别 组件 消息")', 7))
+            elif bad:
+                res.append(('?', rel, f'末尾 {len(lines)} 行里 {len(bad)} 行不符格式 (多为启用前的旧行)', 0))
+
+    # L5 保留策略
+    over = L.prune_actions(dry=True)
+    if over:
+        res.append(('X', f'{P.rel(L.OPS_DIR)}', f'{len(over)} 份动作日志超出保留策略 (最近 {L.ACTION_KEEP} 份 / {L.ACTION_DAYS} 天)', 9))
+
+    info = dict(service_logs=n_svc, ops_logs=n_ops, audit_files=n_audit, build_logs=n_build,
+                ops_over_quota=len(over), logs_dir=str(L.LOGS.relative_to(ROOT)))
+    rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
+    return (max(rc_map) if rc_map else 0), res, info
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--prune', action='store_true', help='就地清理超限的动作日志')
+    ap.add_argument('--migrate', action='store_true', help='把产物目录里的日志搬到 logs/build/ 并同步台账')
+    ap.add_argument('--yes', action='store_true', help='--migrate 时真正执行 (默认只预演)')
+    ap.add_argument('--json', action='store_true')
+    a = ap.parse_args()
+    rc, res, info = audit()
+    if a.migrate:
+        moved, notes = migrate_product_logs(dry=not a.yes)
+        print(f'{"预演" if not a.yes else "已执行"}日志归位: {len(moved)} 个文件 '
+              f'(产物目录 → logs/build/)')
+        for src, dst in moved[:5]:
+            print(f'   {src.relative_to(ROOT).as_posix()}  →  {dst.relative_to(ROOT).as_posix()}')
+        if len(moved) > 5:
+            print(f'   … 另有 {len(moved) - 5} 个')
+        for n in notes:
+            print(f'   台账: {n}')
+        if not a.yes:
+            print('   (要真搬请加 --yes)')
+        rc, res, info = audit()
+    if a.prune:
+        gone = L.prune_actions()
+        print(f'已清理动作日志 {len(gone)} 份: {[g.name for g in gone[:5]]}{" …" if len(gone) > 5 else ""}')
+        rc, res, info = audit()
+    if a.json:
+        print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
+                              info=info), ensure_ascii=False, indent=1))
+        return rc
+    print('== 日志口径 (src/logfile.py, 用户令 2) ==')
+    print(f'   服务日志 {info["service_logs"]} 份 (logs/<组件>.log) · 动作日志 {info["ops_logs"]} 份 '
+          f'(logs/ops/, 保留 {L.ACTION_KEEP} 份/{L.ACTION_DAYS} 天) · 审计流水 {info["audit_files"]} 份 (logs/audit/) · '
+          f'构建日志 {info["build_logs"]} 份 (logs/build/<场>/…)')
+    print(f'   行格式   {L.line("INFO", "示例", "一行长这样")}')
+    lvl = {}
+    for level, item, note, r in res:
+        lvl[level] = lvl.get(level, 0) + 1
+    print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · 白名单 {lvl.get("i", 0)} ==')
+    for level, item, note, r in res:
+        if level in ('X', '!'):
+            print(f'   [{level}] {item}: {note}')
+    seen = set()
+    for level, item, note, r in res:
+        if level == '?' and note not in seen:
+            seen.add(note)
+            print(f'   [?] {item}: {note}')
+    print(f'结论: {"全部符合口径" if rc == 0 else "见上"}; 退出码 {rc}')
+    return rc
+
+
+if __name__ == '__main__':
+    from src import console
+    console.soft()
+    sys.exit(main())

+ 2 - 1
scripts/page_fingerprint.py

@@ -28,6 +28,7 @@ import urllib.request
 
 
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 ROOT = pathlib.Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT))
 sys.path.insert(0, str(ROOT))
+from src import paths as P          # 配置唯一取用口
 
 
 # 覆盖六个服务与门户; 页面类要能代表"路径是否解析对"(取数面), 接口类代表"数据是否读得到"
 # 覆盖六个服务与门户; 页面类要能代表"路径是否解析对"(取数面), 接口类代表"数据是否读得到"
 ENDPOINTS = [
 ENDPOINTS = [
@@ -47,7 +48,7 @@ ENDPOINTS = [
 def ports() -> dict:
 def ports() -> dict:
     """端口只从 configs/serve.json 读 (相对路径按本文件位置解析, 不依赖 cwd); 缺项用启动器同款默认值。"""
     """端口只从 configs/serve.json 读 (相对路径按本文件位置解析, 不依赖 cwd); 缺项用启动器同款默认值。"""
     default = dict(gateway=28084, detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292, ollama=11434)
     default = dict(gateway=28084, detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292, ollama=11434)
-    p = ROOT / 'configs' / 'serve.json'
+    p = P.SERVE_JSON                      # 配置唯一取用口
     cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
     cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
     return {**default, **{k: v for k, v in cfg.items() if isinstance(v, int)}}
     return {**default, **{k: v for k, v in cfg.items() if isinstance(v, int)}}
 
 

+ 59 - 0
scripts/static_server.py

@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""静态目录服务 (viewer / sim 用) —— 替代 `python -m http.server`, 并接入统一日志格式。
+
+为什么要单开一个: `python -m http.server` 的请求日志是 `127.0.0.1 - - [date] "GET …" 200 -` (Common Log
+格式), 与其余 5 个服务的日志格式不一样, 也没法给它挂前缀 (那是 stdlib 的 main)。本器自己做三件事:
+
+  1. 每行都走 `src/logfile.py` 的统一格式: `YYYY-MM-DD HH:MM:SS INFO viewer GET /index.html 200 1234`;
+  2. 只服务**指定目录**(默认安装根下的相对目录), 不做目录列表以外的任何动态行为 (纯静态, 与原来等价);
+  3. `--comp` 指定组件名 (viewer / sim), 日志落到 `logs/<组件>.log`。
+
+用法:
+    python scripts/static_server.py --port 64292 --dir release/viewer --comp viewer
+    python scripts/static_server.py --port 18791 --dir resources/oem_envision_sc1_rudong2014 --comp sim
+"""
+from __future__ import annotations
+
+import argparse
+import functools
+import pathlib
+import sys
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import logfile as L                                          # noqa: E402
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--port', type=int, required=True)
+    ap.add_argument('--host', default='127.0.0.1')
+    ap.add_argument('--dir', required=True, help='要服务的目录 (相对安装根或绝对路径)')
+    ap.add_argument('--comp', default='static', help='日志组件名 (viewer / sim …)')
+    a = ap.parse_args()
+
+    L.prefix_stdout(a.comp)
+    d = pathlib.Path(a.dir)
+    d = d if d.is_absolute() else (ROOT / d)
+    if not d.is_dir():
+        L.write(a.comp, f'目录不存在: {d}', 'ERROR')
+        print(f'[X] 目录不存在: {d}')
+        return 2
+
+    class H(SimpleHTTPRequestHandler):
+        def log_message(self, fmt, *args):          # 统一格式: 一行一条, 带状态码与字节数
+            print(f'{self.command} {self.path} {fmt % args}', flush=True)
+
+        def log_error(self, fmt, *args):
+            print(f'ERROR {self.path} {fmt % args}', flush=True)
+
+    handler = functools.partial(H, directory=str(d))
+    print(f'静态服务 → http://{a.host}:{a.port}/  目录 {d}  (组件 {a.comp})', flush=True)
+    ThreadingHTTPServer((a.host, a.port), handler).serve_forever()
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 4 - 0
scripts/windscada_serve.py

@@ -5131,6 +5131,10 @@ class H(BaseHTTPRequestHandler):
             self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500)
             self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500)
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 统一日志口径 (用户令 2): 之后每一行都带 时间戳/级别/组件(详情见 src/logfile.py)
+    import pathlib as _pl, sys as _sys0
+    _sys0.path.insert(0, str(_pl.Path(__file__).resolve().parents[1]))
+    from src import logfile as _lf; _lf.prefix_stdout('detail')
     # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
     # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
     # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
     # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
     # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
     # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。

+ 206 - 0
src/logfile.py

@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""日志目录/命名/格式的唯一口径 (2026-09-17 用户令 2「统一日志输出目录及日志文件命名、内容格式」)。
+
+## 统一前是什么样 (实测, 不是推测)
+
+  · 目录: 运行日志散在 `logs/`, 但 CMS 插件把 `analyze_stdout.log` 写进了**产物目录**
+    (`outputs/<场>/windcms/...`), `_proc_reg.log` 这种自检残留也躺在 `logs/` 里;
+  · 命名: 服务日志是 `<组件>.log` (gateway/detail/cms/sim/sim_sys/viewer), 但对不上服务键的还有
+    `serve.log`、`start_hidden.log`; 运维动作是 `ops_<动作>_<YYYYmmdd_HHMMSS>.log` 直接堆在 `logs/` 顶层
+    —— 实测 34 个文件里 22 个是历史动作日志, 没有保留策略, 只会越堆越多;
+  · 内容: **没有时间戳、没有级别、编码与行尾都不统一** —— `detail.log` 首行是 `b'windscada serve :18033\r\n'`(CRLF!),
+    `cms.log` 首行甚至是一条历史 `SyntaxWarning`; 机器审计反而叫 `.jsonl` 混在 `.log` 里;
+  · 全仓 `import logging` 的文件数 = **0** —— 没有统一设施, 每个进程各 print 各的。
+
+## 统一后的口径 (机器可查, 见 scripts/log_audit.py)
+
+    logs/<组件>.log                长驻服务与启动器 (组件名 = configs/serve.json 的键 + gateway + serve/start_hidden)
+    logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log   运维动作日志 (保留最近 20 份 / 30 天, 超出的自动清理)
+    logs/audit/<名字>.jsonl        机器审计流水 (JSON Lines: 一行一条 JSON)
+
+    行格式 (每一行都要满足, 校验正则见 LINE_RE):
+        YYYY-MM-DD HH:MM:SS LEVEL 组件 消息
+    级别: DEBUG/INFO/WARN/ERROR;  UTF-8 无 BOM;  行尾 LF;  不含 ANSI 颜色码。
+
+服务侧怎么落地: 各服务不用改自己的 print —— 入口处调一次 `prefix_stdout(组件名)`,
+之后**每一行**都会自动带上时间戳/级别/组件 (实现见下面 _Prefixed 包装器)。这样"内容格式统一"
+不是靠自觉, 而是由设施保证。
+"""
+from __future__ import annotations
+
+import datetime as dt
+import json
+import os
+import pathlib
+import re
+import sys
+
+import pathlib as _p
+
+ROOT = _p.Path(__file__).resolve().parents[1]
+LOGS = _p.Path(os.environ.get('WINDSCADA_LOGS') or (ROOT / 'logs'))
+OPS_DIR = LOGS / 'ops'
+AUDIT_DIR = LOGS / 'audit'
+BUILD_DIR = LOGS / 'build'          # 构建/摄入类脚本的日志 (按产物子路径归档; 不许写在产物目录里)
+LEVELS = ('DEBUG', 'INFO', 'WARN', 'ERROR')
+
+# 一行日志的规范形式: 时间戳 + 级别 + 组件 + 消息 (组件名允许中文/点/下划线/连字符)
+LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:DEBUG|INFO|WARN|ERROR) [\w\u4e00-\u9fff.\-]+ ')
+ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
+
+ACTION_KEEP = 20          # 运维动作日志保留份数
+ACTION_DAYS = 30          # 运维动作日志保留天数
+
+
+def now() -> str:
+    return dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+
+def line(level: str, comp: str, msg: str, ts: str | None = None) -> str:
+    """拼一行规范日志 (纯函数, 便于测试)。多行消息会被逐行加前缀。"""
+    lv = (level or 'INFO').upper()
+    if lv not in LEVELS:
+        lv = 'INFO'
+    out = []
+    for i, part in enumerate(str(msg).replace('\r\n', '\n').replace('\r', '\n').split('\n')):
+        out.append(f'{ts or now()} {lv} {comp} {part}')
+    return '\n'.join(out)
+
+
+def component_log(comp: str) -> pathlib.Path:
+    """长驻服务/启动器的日志路径: logs/<组件>.log"""
+    return LOGS / f'{comp}.log'
+
+
+def action_log(tag: str, when: dt.datetime | None = None) -> pathlib.Path:
+    """运维动作日志路径: logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log (写入前会先按保留策略清理)"""
+    w = when or dt.datetime.now()
+    return OPS_DIR / f'{tag}_{w:%Y%m%d-%H%M%S}.log'
+
+
+def audit_log(name: str) -> pathlib.Path:
+    """机器审计流水路径: logs/audit/<名字>.jsonl"""
+    return AUDIT_DIR / (name if name.endswith(('.jsonl', '.json')) else f'{name}.jsonl')
+
+
+def ensure_dirs() -> None:
+    for d in (LOGS, OPS_DIR, AUDIT_DIR, BUILD_DIR):
+        d.mkdir(parents=True, exist_ok=True)
+
+
+def build_log(rel: str, farm: str | None = None) -> pathlib.Path:
+    """构建/摄入脚本的日志路径: `logs/build/<场>/<相对路径>.log`
+
+    ★ 2026-09-17 用户令 2 的由来: 交付包里 39 个构建日志原本**躺在产物目录里**
+    (`outputs/<场>/windscada/*.log` 等), 还被产物台账登记成 shipped 随包件 ——
+    "日志在产物里"正是这次要统一掉的不一致。新脚本用本函数落 `logs/build/`, 旧的已迁移过去。
+    """
+    p = pathlib.Path(rel)
+    parts = [farm] if farm else []
+    return BUILD_DIR.joinpath(*parts, *p.parts)
+
+
+def write(comp: str, msg: str, level: str = 'INFO', path: pathlib.Path | None = None) -> pathlib.Path:
+    """追加一行规范日志 (UTF-8, LF)。"""
+    ensure_dirs()
+    p = pathlib.Path(path) if path else component_log(comp)
+    p.parent.mkdir(parents=True, exist_ok=True)
+    with open(p, 'a', encoding='utf-8', newline='\n') as f:
+        f.write(line(level, comp, msg) + '\n')
+    return p
+
+
+def append_jsonl(name: str, rec: dict) -> pathlib.Path:
+    """机器审计流水: 一行一条 JSON (ensure_ascii=False, 时间戳字段 ts)。"""
+    ensure_dirs()
+    p = audit_log(name)
+    rec = dict(rec)
+    rec.setdefault('ts', now())
+    with open(p, 'a', encoding='utf-8', newline='\n') as f:
+        f.write(json.dumps(rec, ensure_ascii=False, default=str) + '\n')
+    return p
+
+
+def prune_actions(keep: int = ACTION_KEEP, days: int = ACTION_DAYS, dry: bool = False) -> list[pathlib.Path]:
+    """运维动作日志保留策略: 只留最近 keep 份、且不超过 days 天。→ 删掉的文件列表。"""
+    if not OPS_DIR.is_dir():
+        return []
+    files = sorted((f for f in OPS_DIR.glob('*.log') if f.is_file()), key=lambda f: f.stat().st_mtime, reverse=True)
+    cutoff = dt.datetime.now().timestamp() - days * 86400
+    gone = []
+    for i, f in enumerate(files):
+        if i < keep and f.stat().st_mtime >= cutoff:
+            continue
+        gone.append(f)
+        if not dry:
+            try:
+                f.unlink()
+            except OSError:
+                pass
+    return gone
+
+
+# ── 服务侧: 让 print 出来的每一行都符合格式 ─────────────────────────────────────────────
+class _Prefixed:
+    """把写到 stdout/stderr 的每一行加上 `时间戳 级别 组件` 前缀。
+
+    刻意做成**流包装**而不是要求 6 个服务各自改用 logging:
+    服务里已有大量 print (启动横幅/请求日志/进度), 逐个改既费事又会漏; 包一层之后
+    "内容格式统一"由设施保证。级别默认 INFO; 含 'error'/'traceback' 字样的行按 ERROR 记。
+    """
+
+    def __init__(self, stream, comp: str):
+        self._s = stream
+        self._comp = comp
+        self._buf = ''
+
+    def write(self, data):
+        if not isinstance(data, str):
+            data = str(data)
+        self._buf += data
+        while '\n' in self._buf:
+            ln, self._buf = self._buf.split('\n', 1)
+            self._emit(ln)
+        return len(data)
+
+    def _emit(self, ln: str):
+        clean = ANSI_RE.sub('', ln.rstrip('\r'))
+        lv = 'ERROR' if re.search(r'error|traceback|失败|异常', clean, re.I) else 'INFO'
+        try:
+            self._s.write(line(lv, self._comp, clean) + '\n')
+            self._s.flush()
+        except Exception:
+            pass
+
+    def flush(self):
+        if self._buf:
+            self._emit(self._buf)
+            self._buf = ''
+        try:
+            self._s.flush()
+        except Exception:
+            pass
+
+    def __getattr__(self, item):
+        return getattr(self._s, item)
+
+
+def prefix_stdout(comp: str) -> None:
+    """服务入口调一次: 之后 stdout/stderr 的每一行都符合统一格式 (幂等)。"""
+    if getattr(sys.stdout, '_guanlan_prefixed', False) or os.environ.get('GUANLAN_LOG_RAW'):
+        return
+    so, se = _Prefixed(sys.stdout, comp), _Prefixed(sys.stderr, comp)
+    so._guanlan_prefixed = se._guanlan_prefixed = True
+    sys.stdout, sys.stderr = so, se
+
+
+if __name__ == '__main__':      # 直接跑 = 打印口径 + 清洗一次动作日志
+    ensure_dirs()
+    print(f'logs 目录: {LOGS}')
+    print(f'  服务日志   logs/<组件>.log       (组件: configs/serve.json 的键 + gateway/serve/start_hidden)')
+    print(f'  动作日志   logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log   保留最近 {ACTION_KEEP} 份 / {ACTION_DAYS} 天')
+    print(f'  审计流水   logs/audit/<名字>.jsonl')
+    print(f'  行格式     {line("INFO", "示例", "一行长这样")}')
+    gone = prune_actions(dry=True)
+    print(f'  现有动作日志 {len(list(OPS_DIR.glob("*.log")))} 份, 按保留策略该清理 {len(gone)} 份')

+ 97 - 94
src/ontology/llm_gate.py

@@ -1,94 +1,97 @@
-# -*- coding: utf-8 -*-
-"""离线模型闸 (2026-09-07, 用户令 "离线模型也要把关, 要稳定和可靠"): 所有对本机 Ollama 的调用经此一层.
-保护: ① 确定性参数注入 (temperature=0, seed 固定; 同问同证据同答) ② 单飞 (同一时刻只一个生成请求, 防显存打架)
-③ 瞬断重试一次 (连接失败/超时/5xx; 4xx 不重试, 原样抛给调用方处理) ④ 熔断 (连续 3 次失败 → 60 s 内直接报"本机模型未就绪", 不再压请求; 探针恢复)
-⑤ 逐次审计留痕 logs/llm_audit.jsonl (模型/摘要/提示 sha/参数/耗时/输出 sha/错误) ⑥ 模型摘要钉死 (configs/models.json ↔ ollama /api/show digest)
-不做: 不改提示词, 不改校闸 (那些在 fast_agent), 不做任何联网."""
-from __future__ import annotations
-import hashlib, json, os, threading, time, urllib.error, urllib.request
-from pathlib import Path
-ROOT = Path(__file__).resolve().parents[2]
-CFG = ROOT / "configs/models.json"; AUDIT = ROOT / "logs/llm_audit.jsonl"
-ENDPOINT = os.environ.get("GUANLAN_OLLAMA", None)
-DETERMINISTIC = {"temperature": 0, "seed": 7}
-BREAK_N, BREAK_S = 3, 60
-_lock = threading.Semaphore(1); _state = {"fail": 0, "open_until": 0.0, "last_err": None, "n_calls": 0, "n_retry": 0, "n_break": 0}; _digest = {}
-
-
-def endpoint() -> str:
-    if ENDPOINT: return ENDPOINT.rstrip("/")
-    try: return json.loads(CFG.read_text(encoding="utf-8")).get("endpoint", "http://127.0.0.1:11434").rstrip("/")
-    except Exception: return "http://127.0.0.1:11434"
-
-
-def _sha(x) -> str: return hashlib.sha256(json.dumps(x, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()[:16]
-
-
-def digest(model: str) -> str | None:
-    """模型摘要 (缓存): /api/show 的 digest; 拿不到回 None (审计里如实记)."""
-    if model in _digest: return _digest[model]
-    try:   # /api/tags 每条带 digest (sha256:…); /api/show 不带 (09-07 实逮拿成 file_type "15")
-        with urllib.request.urlopen(endpoint() + "/api/tags", timeout=10) as r:
-            for m in json.load(r).get("models", []):
-                if m.get("name") in (model, model + ":latest") or m.get("model") in (model, model + ":latest"): _digest[model] = str(m.get("digest"))[:19]; break
-            else: _digest[model] = None
-    except Exception: _digest[model] = None
-    return _digest[model]
-
-
-def _audit(rec: dict):
-    try:
-        AUDIT.parent.mkdir(exist_ok=True)
-        with AUDIT.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
-    except Exception: pass
-
-
-class ModelDown(RuntimeError):
-    """熔断中或连续失败: 本机模型未就绪 (调用方应报 503/明示, 不得静默回落)."""
-
-
-def state() -> dict:
-    now = time.time(); return dict(open=now < _state["open_until"], open_for_s=max(0, round(_state["open_until"] - now)), fails=_state["fail"], last_err=_state["last_err"], n_calls=_state["n_calls"], n_retry=_state["n_retry"], n_break=_state["n_break"], endpoint=endpoint(), audit=str(AUDIT))
-
-
-def post(body: dict, timeout: int = 420, purpose: str = "chat") -> dict:
-    """向 Ollama /api/chat (或 body 含 'prompt' 时 /api/generate) 发一次请求. 返回解析后的 JSON. 4xx 原样抛 HTTPError."""
-    now = time.time()
-    if now < _state["open_until"]: raise ModelDown(f"本机模型熔断中 (连续 {BREAK_N} 次失败, {round(_state['open_until'] - now)} s 后重试): {_state['last_err']}")
-    body = dict(body); body["options"] = {**DETERMINISTIC, **(body.get("options") or {})}
-    path = "/api/generate" if "prompt" in body else "/api/chat"; model = body.get("model")
-    rec = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), purpose=purpose, model=model, digest=digest(model), prompt_sha16=_sha(body.get("messages") or body.get("prompt")), options=body["options"], think=body.get("think"), tools=bool(body.get("tools")))
-    with _lock:
-        _state["n_calls"] += 1; last = None
-        for attempt in (1, 2):
-            t0 = time.time()
-            try:
-                req = urllib.request.Request(endpoint() + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
-                with urllib.request.urlopen(req, timeout=timeout) as r: d = json.load(r)
-                out = (d.get("message") or {}).get("content") if "message" in d else d.get("response")
-                rec.update(attempt=attempt, latency_s=round(time.time() - t0, 2), status="ok", output_sha16=_sha(out), out_chars=len(out or "")); _audit(rec)
-                _state["fail"] = 0; return d
-            except urllib.error.HTTPError as e:
-                detail = e.read().decode("utf-8", "replace")[:300]; last = f"HTTP {e.code}: {detail[:120]}"
-                if e.code < 500:   # 4xx = 调用方问题 (如模型不支持 tools), 不重试, 原样抛 (保留 fast_agent 的退化逻辑)
-                    rec.update(attempt=attempt, latency_s=round(time.time() - t0, 2), status=f"http{e.code}", error=detail[:200]); _audit(rec)
-                    e2 = urllib.error.HTTPError(e.url, e.code, e.msg, e.hdrs, None); e2.read = lambda: detail.encode(); raise e2
-            except (urllib.error.URLError, TimeoutError, ConnectionError, OSError, json.JSONDecodeError) as e:
-                last = f"{e.__class__.__name__}: {str(e)[:120]}"
-            rec.update(attempt=attempt, latency_s=round(time.time() - t0, 2), status="fail", error=last); _audit(dict(rec))
-            if attempt == 1: _state["n_retry"] += 1; time.sleep(1.0)
-        _state["fail"] += 1; _state["last_err"] = last
-        if _state["fail"] >= BREAK_N: _state["open_until"] = time.time() + BREAK_S; _state["n_break"] += 1; _state["fail"] = 0
-        raise ModelDown(f"本机模型调用失败 (已重试 1 次): {last}")
-
-
-def probe(model: str = "qwen3:8b", timeout: int = 60) -> dict:
-    """小探针 (用 generate 非 tags: 大模型加载期 tags 仍 200). 成功即清熔断."""
-    t0 = time.time()
-    try:
-        post({"model": model, "prompt": "1", "stream": False, "options": {"num_predict": 1}}, timeout=timeout, purpose="probe")
-        _state["open_until"] = 0.0; return dict(ok=True, latency_s=round(time.time() - t0, 2), digest=digest(model))
-    except Exception as e: return dict(ok=False, err=str(e)[:160], latency_s=round(time.time() - t0, 2))
-
-
-def reset(): _state.update(fail=0, open_until=0.0, last_err=None); _digest.clear()
+# -*- coding: utf-8 -*-
+"""离线模型闸 (2026-09-07, 用户令 "离线模型也要把关, 要稳定和可靠"): 所有对本机 Ollama 的调用经此一层.
+保护: ① 确定性参数注入 (temperature=0, seed 固定; 同问同证据同答) ② 单飞 (同一时刻只一个生成请求, 防显存打架)
+③ 瞬断重试一次 (连接失败/超时/5xx; 4xx 不重试, 原样抛给调用方处理) ④ 熔断 (连续 3 次失败 → 60 s 内直接报"本机模型未就绪", 不再压请求; 探针恢复)
+⑤ 逐次审计留痕 logs/llm_audit.jsonl (模型/摘要/提示 sha/参数/耗时/输出 sha/错误) ⑥ 模型摘要钉死 (configs/models.json ↔ ollama /api/show digest)
+不做: 不改提示词, 不改校闸 (那些在 fast_agent), 不做任何联网."""
+from __future__ import annotations
+import hashlib, json, os, threading, time, urllib.error, urllib.request
+from pathlib import Path
+ROOT = Path(__file__).resolve().parents[2]
+import sys as _sys; _sys.path.insert(0, str(ROOT))
+from src import paths as P          # 配置/日志路径唯一取用口
+CFG = P.MODELS_JSON                      # 配置唯一取用口 (src/paths.py)
+AUDIT = P.LOGS / "audit" / "llm_audit.jsonl"   # 审计流水统一落点 logs/audit/ (命名/格式约定见 docs §11)
+ENDPOINT = os.environ.get("GUANLAN_OLLAMA", None)
+DETERMINISTIC = {"temperature": 0, "seed": 7}
+BREAK_N, BREAK_S = 3, 60
+_lock = threading.Semaphore(1); _state = {"fail": 0, "open_until": 0.0, "last_err": None, "n_calls": 0, "n_retry": 0, "n_break": 0}; _digest = {}
+
+
+def endpoint() -> str:
+    if ENDPOINT: return ENDPOINT.rstrip("/")
+    try: return json.loads(CFG.read_text(encoding="utf-8")).get("endpoint", "http://127.0.0.1:11434").rstrip("/")
+    except Exception: return "http://127.0.0.1:11434"
+
+
+def _sha(x) -> str: return hashlib.sha256(json.dumps(x, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()[:16]
+
+
+def digest(model: str) -> str | None:
+    """模型摘要 (缓存): /api/show 的 digest; 拿不到回 None (审计里如实记)."""
+    if model in _digest: return _digest[model]
+    try:   # /api/tags 每条带 digest (sha256:…); /api/show 不带 (09-07 实逮拿成 file_type "15")
+        with urllib.request.urlopen(endpoint() + "/api/tags", timeout=10) as r:
+            for m in json.load(r).get("models", []):
+                if m.get("name") in (model, model + ":latest") or m.get("model") in (model, model + ":latest"): _digest[model] = str(m.get("digest"))[:19]; break
+            else: _digest[model] = None
+    except Exception: _digest[model] = None
+    return _digest[model]
+
+
+def _audit(rec: dict):
+    try:
+        AUDIT.parent.mkdir(exist_ok=True)
+        with AUDIT.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
+    except Exception: pass
+
+
+class ModelDown(RuntimeError):
+    """熔断中或连续失败: 本机模型未就绪 (调用方应报 503/明示, 不得静默回落)."""
+
+
+def state() -> dict:
+    now = time.time(); return dict(open=now < _state["open_until"], open_for_s=max(0, round(_state["open_until"] - now)), fails=_state["fail"], last_err=_state["last_err"], n_calls=_state["n_calls"], n_retry=_state["n_retry"], n_break=_state["n_break"], endpoint=endpoint(), audit=str(AUDIT))
+
+
+def post(body: dict, timeout: int = 420, purpose: str = "chat") -> dict:
+    """向 Ollama /api/chat (或 body 含 'prompt' 时 /api/generate) 发一次请求. 返回解析后的 JSON. 4xx 原样抛 HTTPError."""
+    now = time.time()
+    if now < _state["open_until"]: raise ModelDown(f"本机模型熔断中 (连续 {BREAK_N} 次失败, {round(_state['open_until'] - now)} s 后重试): {_state['last_err']}")
+    body = dict(body); body["options"] = {**DETERMINISTIC, **(body.get("options") or {})}
+    path = "/api/generate" if "prompt" in body else "/api/chat"; model = body.get("model")
+    rec = dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), purpose=purpose, model=model, digest=digest(model), prompt_sha16=_sha(body.get("messages") or body.get("prompt")), options=body["options"], think=body.get("think"), tools=bool(body.get("tools")))
+    with _lock:
+        _state["n_calls"] += 1; last = None
+        for attempt in (1, 2):
+            t0 = time.time()
+            try:
+                req = urllib.request.Request(endpoint() + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
+                with urllib.request.urlopen(req, timeout=timeout) as r: d = json.load(r)
+                out = (d.get("message") or {}).get("content") if "message" in d else d.get("response")
+                rec.update(attempt=attempt, latency_s=round(time.time() - t0, 2), status="ok", output_sha16=_sha(out), out_chars=len(out or "")); _audit(rec)
+                _state["fail"] = 0; return d
+            except urllib.error.HTTPError as e:
+                detail = e.read().decode("utf-8", "replace")[:300]; last = f"HTTP {e.code}: {detail[:120]}"
+                if e.code < 500:   # 4xx = 调用方问题 (如模型不支持 tools), 不重试, 原样抛 (保留 fast_agent 的退化逻辑)
+                    rec.update(attempt=attempt, latency_s=round(time.time() - t0, 2), status=f"http{e.code}", error=detail[:200]); _audit(rec)
+                    e2 = urllib.error.HTTPError(e.url, e.code, e.msg, e.hdrs, None); e2.read = lambda: detail.encode(); raise e2
+            except (urllib.error.URLError, TimeoutError, ConnectionError, OSError, json.JSONDecodeError) as e:
+                last = f"{e.__class__.__name__}: {str(e)[:120]}"
+            rec.update(attempt=attempt, latency_s=round(time.time() - t0, 2), status="fail", error=last); _audit(dict(rec))
+            if attempt == 1: _state["n_retry"] += 1; time.sleep(1.0)
+        _state["fail"] += 1; _state["last_err"] = last
+        if _state["fail"] >= BREAK_N: _state["open_until"] = time.time() + BREAK_S; _state["n_break"] += 1; _state["fail"] = 0
+        raise ModelDown(f"本机模型调用失败 (已重试 1 次): {last}")
+
+
+def probe(model: str = "qwen3:8b", timeout: int = 60) -> dict:
+    """小探针 (用 generate 非 tags: 大模型加载期 tags 仍 200). 成功即清熔断."""
+    t0 = time.time()
+    try:
+        post({"model": model, "prompt": "1", "stream": False, "options": {"num_predict": 1}}, timeout=timeout, purpose="probe")
+        _state["open_until"] = 0.0; return dict(ok=True, latency_s=round(time.time() - t0, 2), digest=digest(model))
+    except Exception as e: return dict(ok=False, err=str(e)[:160], latency_s=round(time.time() - t0, 2))
+
+
+def reset(): _state.update(fail=0, open_until=0.0, last_err=None); _digest.clear()

+ 47 - 0
src/paths.py

@@ -51,6 +51,53 @@ DATA = ROOT / 'data'
 SERVE_JSON = CONFIGS / 'serve.json'
 SERVE_JSON = CONFIGS / 'serve.json'
 MODELS_JSON = CONFIGS / 'models.json'
 MODELS_JSON = CONFIGS / 'models.json'
 
 
+# ---- 配置目录约定与唯一取用口 (2026-09-17 用户令 2: 统一配置目录及配置文件) ----------------
+# 为什么要有这一节: 原来各模块自己拼 `ROOT / 'configs' / 'xxx.yaml'` (实测 9 个模块各拼各的),
+# 于是"配置放哪"随时间漂移: 顶层散着 serve.json/models.json/portal_pages.yaml, 还混进过一个
+# `serve.json.bak-bomfix`; `scripts/audit_chinese_terms.py` 甚至要**试三个位置**才找得到 terms 库。
+# 现在: 目录分工写死在下面, 代码**只从 `config()` / `config_dir()` 取路径**, 不许再手拼字符串;
+# `scripts/config_audit.py` 会按这套约定查实物与代码(见 docs §11)。
+#
+#   configs/<域>/<名字>.<yaml|json|csv>     域 = canonical | contracts | farms | terms | <新增>
+#   configs/serve.json                      运行期单件配置 (端口/路径真源)
+#   configs/models.json                     运行期单件配置 (本机模型档)
+#   configs/portal_pages.yaml               运行期单件配置 (门户页面归口登记表, 见 §10)
+TOP_LEVEL_CONFIGS = ('serve.json', 'models.json', 'portal_pages.yaml')
+CONFIG_DOMAINS = ('canonical', 'contracts', 'farms', 'terms')
+CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv')
+
+
+def config(*parts: str, must_exist: bool = False) -> pathlib.Path:
+    """配置文件的唯一取用口: `P.config('terms', 'display_map.yaml')` / `P.config('serve.json')`。
+
+    只做路径解析, 不读文件 (读法由调用方决定: yaml/json/csv 各不相同);
+    `must_exist=True` 时不存在就抛 FileNotFoundError —— 配置缺失应该在启动时报出来, 别静默用默认值。
+    """
+    p = CONFIGS.joinpath(*parts)
+    if must_exist and not p.is_file():
+        raise FileNotFoundError(f'缺配置文件 {p} (约定见 src/paths.py 配置一节 / docs §11)')
+    return p
+
+
+def config_dir(*parts: str) -> pathlib.Path:
+    """配置目录 (域) 的取用口: `P.config_dir('farms')`。"""
+    return CONFIGS.joinpath(*parts)
+
+
+def farm_config(name: str | None = None) -> pathlib.Path | None:
+    """场定义配置文件 —— **格式统一为 YAML**, 兼容历史 `.json` (有就优先用)。
+
+    2026-09-17 实测的坑: `configs/farms/` 下有 8 个场定义是 `.yaml`, 而 `available()` 只认 `*.json`
+    ⇒ 这些场**根本列不出来**(等于配置写了没人看见); 目录里那个模板还叫 `_模板.json.example`,
+    与实物格式相反。现在两边都认, 且约定"新的场定义写 yaml"。
+    """
+    f = farm(name)
+    for ext in ('.yaml', '.yml', '.json'):
+        p = FARMS / f'{f}{ext}'
+        if p.is_file():
+            return p
+    return None
+
 
 
 def farm(name: str | None = None) -> str:
 def farm(name: str | None = None) -> str:
     """当前场名: 显式 → env WINDSCADA_FARM → 'rudong'。
     """当前场名: 显式 → env WINDSCADA_FARM → 'rudong'。

+ 2 - 2
src/sop/analysis_lock.py

@@ -79,10 +79,10 @@ def _farm_matches(lock_farm, farm: str) -> bool:
 def find_lock(farm: str, root: Path | None = None):
 def find_lock(farm: str, root: Path | None = None):
     """→ (path, reason)。per 场锁优先; 默认锁**仅在 farm 字段对得上**时才认。"""
     """→ (path, reason)。per 场锁优先; 默认锁**仅在 farm 字段对得上**时才认。"""
     root = Path(root or _REPO)
     root = Path(root or _REPO)
-    per = root / "configs" / f"analysis_lock_{farm}.yaml"
+    per = P.config(f"analysis_lock_{farm}.yaml")
     if per.is_file():
     if per.is_file():
         return per, "per 场锁"
         return per, "per 场锁"
-    dflt = root / "configs" / "analysis_lock.yaml"
+    dflt = P.config("analysis_lock.yaml")
     if dflt.is_file():
     if dflt.is_file():
         import yaml
         import yaml
         try:
         try:

+ 1 - 1
src/sop/scenarios.py

@@ -12,7 +12,7 @@ from pathlib import Path
 import yaml
 import yaml
 
 
 REPO = Path(__file__).resolve().parents[2]
 REPO = Path(__file__).resolve().parents[2]
-REGISTRY = REPO / "configs" / "scenario_registry.yaml"
+REGISTRY = P.config("scenario_registry.yaml")        # 配置唯一取用口 (src/paths.py)
 
 
 # 交付九域 → 证据关键词 (扫 outputs/<farm>/sop/ 文件名判该域是否已产)。SOP §4.-1 crosswalk。
 # 交付九域 → 证据关键词 (扫 outputs/<farm>/sop/ 文件名判该域是否已产)。SOP §4.-1 crosswalk。
 DOMAIN_EVIDENCE = {
 DOMAIN_EVIDENCE = {

+ 1 - 1
src/sop/wrapup.py

@@ -182,7 +182,7 @@ if __name__ == '__main__':
         print(f'[wrapup] 柱2 无 cleaned parquet → day_costs 保持 None: {_why}', flush=True)
         print(f'[wrapup] 柱2 无 cleaned parquet → day_costs 保持 None: {_why}', flush=True)
     elif _why:
     elif _why:
         print(f'[wrapup] 柱2 cleaned parquet 选取说明: {_why}', flush=True)
         print(f'[wrapup] 柱2 cleaned parquet 选取说明: {_why}', flush=True)
-    vcfg = ROOT / 'configs' / a.farm / 'value_assumptions.yaml'
+    vcfg = P.config('value_assumptions', f'{a.farm}.yaml')   # 配置唯一取用口: configs/value_assumptions/<场>.yaml
     if pq is not None and pq.exists():
     if pq is not None and pq.exists():
         import pandas as pd
         import pandas as pd
         import yaml
         import yaml

+ 1 - 0
src/windcms/serve.py

@@ -399,5 +399,6 @@ def run(cfg, port=8030, model=None):
         def log_message(self, *a):
         def log_message(self, *a):
             pass
             pass
 
 
+    from src import logfile as _lf; _lf.prefix_stdout('cms')      # 统一日志格式 (用户令 2)
     print(f'windcms serve → http://127.0.0.1:{port}  (模型: {llm.pick(model) or "无, 规则调度回退"})', flush=True)
     print(f'windcms serve → http://127.0.0.1:{port}  (模型: {llm.pick(model) or "无, 规则调度回退"})', flush=True)
     ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever()
     ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever()

+ 68 - 10
src/windscada/config.py

@@ -23,9 +23,12 @@
 
 
 ## 二、场配置怎么来
 ## 二、场配置怎么来
 
 
-2026-08-28 多场化改造: 场定义 = 外部配置 (`configs/farms/<场名>.json`) + 内置默认 (rudong 是已跑通的正本,
-保证零配置也能起)。当前场由 `WINDSCADA_FARM` 或 set_current() 指定。
+2026-08-28 多场化改造: 场定义 = 外部配置 (`configs/farms/<场名>.yaml`, 历史 `.json` 兼容) + 内置默认
+(rudong 是已跑通的正本, 保证零配置也能起)。当前场由 `WINDSCADA_FARM` 或 set_current() 指定。
 **外场配置若显式给了 src_*, 以它为准**; 没给就按上面的扫描结果派生 (外场不必再抄一遍路径)。
 **外场配置若显式给了 src_*, 以它为准**; 没给就按上面的扫描结果派生 (外场不必再抄一遍路径)。
+★ 2026-09-17 (用户令 2 统一配置): 路径一律走 `src/paths.py` 的 `P.farm_config()` / `P.config_dir('farms')`,
+不再在本文件里手拼 `configs/farms/...`; 格式约定 = **YAML**(新场写 yaml), 且 yaml 与 json 都能被
+`available()` 列出 —— 此前只认 json, 目录里 8 个 yaml 场定义等于"配了看不见"。
 """
 """
 from pathlib import Path
 from pathlib import Path
 import json
 import json
@@ -165,15 +168,66 @@ def _expand(cfg, name):
     return c
     return c
 
 
 
 
+def farm_files():
+    """`configs/farms/` 下的候选场定义文件 (yaml/json), 不含模板/CSV。"""
+    if not FARM_DIR.is_dir():
+        return []
+    pats = ('*.yaml', '*.yml', '*.json')
+    out = []
+    for p in pats:
+        out += [f for f in FARM_DIR.glob(p) if not f.name.startswith('_')]
+    return sorted(out)
+
+
+def is_farm_def(f) -> bool:
+    """这个文件是不是一份**能加载的场定义**(必需键齐)。"""
+    try:
+        c = _load_farm_file(Path(f))
+    except Exception:
+        return False
+    return isinstance(c, dict) and all(c.get(k) for k in REQUIRED)
+
+
 def available():
 def available():
-    """列出可用场: 内置 + configs/farms/*.json。"""
+    """列出可用场: 内置 + 能加载的 `configs/farms/<场名>.{yaml,json}`。
+
+    ★ 2026-09-17 修两件事 (用户令 2 "统一配置"):
+      ① 原来只认 `*.json` ⇒ 目录里的 `.yaml` **配了看不见**; 现在 yaml/json 都认 (新场写 yaml);
+      ② 但这个目录里混着**另一种 schema** 的文件 (机型/场站物理约束 profile: `meta` + `physical_constraints`),
+         它们**不是**场定义, 列进"可用场"会在加载时炸。所以只列真能加载的, 其余的由
+         `foreign_farm_files()` 报出来, 由 `scripts/config_audit.py` 记账 (见 docs §11)。
+    """
     out = dict.fromkeys(_BUILTIN, '内置')
     out = dict.fromkeys(_BUILTIN, '内置')
-    if FARM_DIR.is_dir():
-        for f in sorted(FARM_DIR.glob('*.json')):
+    for f in farm_files():
+        if is_farm_def(f):
             out[f.stem] = str(f.relative_to(ROOT))
             out[f.stem] = str(f.relative_to(ROOT))
     return out
     return out
 
 
 
 
+def foreign_farm_files():
+    """`configs/farms/` 下"不是场定义"的文件 → [(文件, 依据)] (登记在册, 不参与场加载)。"""
+    bad = []
+    for f in farm_files():
+        if is_farm_def(f):
+            continue
+        try:
+            c = _load_farm_file(f)
+            keys = ','.join(sorted(c)[:4]) if isinstance(c, dict) else type(c).__name__
+        except Exception as e:
+            keys = f'解析失败: {type(e).__name__}'
+        bad.append((f, keys))
+    return bad
+
+
+def _load_farm_file(f: Path):
+    """读场定义 —— 按后缀选解析器 (yaml 优先/约定; json 兼容历史)。"""
+    text = f.read_text(encoding='utf-8-sig')
+    if f.suffix.lower() in ('.yaml', '.yml'):
+        import yaml
+        return yaml.safe_load(text) or {}
+    return json.loads(text)
+
+
 def set_current(name):
 def set_current(name):
     farm(name)          # 先验证能加载
     farm(name)          # 先验证能加载
     _CURRENT[0] = name
     _CURRENT[0] = name
@@ -192,11 +246,15 @@ def farm(name=None, refresh=False):
     if name in _BUILTIN:
     if name in _BUILTIN:
         _CACHE[name] = _expand(_BUILTIN[name], name)
         _CACHE[name] = _expand(_BUILTIN[name], name)
         return _CACHE[name]
         return _CACHE[name]
-    f = FARM_DIR / f'{name}.json'
-    if not f.exists():
-        raise SystemExit(f'未知场 {name}; 可用: {list(available())} '
-                         f'(新场请在 {FARM_DIR.relative_to(ROOT)}/ 放 <场名>.json)')
-    _CACHE[name] = _expand(json.loads(f.read_text(encoding='utf-8')), name)
+    f = P.farm_config(name)                      # yaml 优先, json 兼容 (路径真源在 src/paths.py)
+    if f is None or not is_farm_def(f):
+        extra = ''
+        if f is not None:
+            extra = (f' —— 该文件存在但**不是场定义**(缺必需键 {list(REQUIRED)}); '
+                     f'如果是机型/物理约束 profile, 它属于另一种 schema, 见 docs §11')
+        raise SystemExit(f'未知场 {name}; 可用: {list(available())}{extra} '
+                         f'(新场请在 {FARM_DIR.relative_to(ROOT)}/ 放 <场名>.yaml, 必需键见 config.REQUIRED)')
+    _CACHE[name] = _expand(_load_farm_file(f), name)
     return _CACHE[name]
     return _CACHE[name]
 
 
 
 

+ 31 - 29
src/windscada/terms.py

@@ -1,29 +1,31 @@
-# -*- coding: utf-8 -*-
-"""显示层术语映射 (configs/terms/display_map.yaml): 内部说法 → 现场说法。
-
-**只在渲染边界用**, 不改数据、不改任何比较/匹配逻辑 (那些内部词就在 handoff/本体数据里, 改数据会断匹配)。
-用法: from src.windscada.terms import humanize;  humanize('发电机(引用同族, 见族主台)') → '发电机(无本台独立证据, 参照同型机组)'
-前端同源: `pairs()` 出的表经语言包 `__display_zh` 注入 app.js 的 displayText。
-"""
-from __future__ import annotations
-import functools, pathlib
-ROOT = pathlib.Path(__file__).resolve().parents[2]
-MAP_FILE = ROOT / "configs/terms/display_map.yaml"
-
-
-@functools.lru_cache(maxsize=1)
-def pairs() -> tuple[tuple[str, str], ...]:
-    """(内部词, 现场词) 按内部词长度降序 —— 长词先替, 否则 '引用同族, 见族主台' 会被 '引用同族' 先吃掉半句."""
-    if not MAP_FILE.exists(): return ()
-    import yaml
-    d = yaml.safe_load(MAP_FILE.read_text(encoding="utf-8")) or {}
-    ps = [(str(i["from"]), str(i["to"])) for i in (d.get("items") or []) if i.get("from")]
-    return tuple(sorted(ps, key=lambda kv: -len(kv[0])))
-
-
-def humanize(s):
-    """把内部词换成现场说法; 非字符串原样返回 (调用点可以无脑包)."""
-    if not isinstance(s, str) or not s: return s
-    for a, b in pairs():
-        if a in s: s = s.replace(a, b)
-    return s
+# -*- coding: utf-8 -*-
+"""显示层术语映射 (configs/terms/display_map.yaml): 内部说法 → 现场说法。
+
+**只在渲染边界用**, 不改数据、不改任何比较/匹配逻辑 (那些内部词就在 handoff/本体数据里, 改数据会断匹配)。
+用法: from src.windscada.terms import humanize;  humanize('发电机(引用同族, 见族主台)') → '发电机(无本台独立证据, 参照同型机组)'
+前端同源: `pairs()` 出的表经语言包 `__display_zh` 注入 app.js 的 displayText。
+"""
+from __future__ import annotations
+import functools, pathlib, sys
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+from src import paths as P          # 配置唯一取用口
+MAP_FILE = P.config("terms", "display_map.yaml")     # 配置唯一取用口 (src/paths.py)
+
+
+@functools.lru_cache(maxsize=1)
+def pairs() -> tuple[tuple[str, str], ...]:
+    """(内部词, 现场词) 按内部词长度降序 —— 长词先替, 否则 '引用同族, 见族主台' 会被 '引用同族' 先吃掉半句."""
+    if not MAP_FILE.exists(): return ()
+    import yaml
+    d = yaml.safe_load(MAP_FILE.read_text(encoding="utf-8")) or {}
+    ps = [(str(i["from"]), str(i["to"])) for i in (d.get("items") or []) if i.get("from")]
+    return tuple(sorted(ps, key=lambda kv: -len(kv[0])))
+
+
+def humanize(s):
+    """把内部词换成现场说法; 非字符串原样返回 (调用点可以无脑包)."""
+    if not isinstance(s, str) or not s: return s
+    for a, b in pairs():
+        if a in s: s = s.replace(a, b)
+    return s