Browse Source

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

zhouyang.xie 3 tuần trước cách đây
mục cha
commit
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 兼容件
 │    ├─ guanlan\facts_contract_v0.json  事实契约 (问答引文的依据)
 │    └─ sop\                         SOP 中间件/评审/台账 (含少量历史装配脚本)
-├─ configs\                          端口/模型/场配置/机型契约
+├─ configs\                          端口/模型/场配置/机型契约 (目录约定见 系统设计说明 §11)
 │    ├─ serve.json                   端口、发布目录、Python 路径 (install 脚本写)
 │    ├─ 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\                   机型契约 (变桨/偏航/发电机…判据参数)
 │    ├─ canonical\ terms\            术语与词典 (canonical 决策/字典/手册; terms 显示映射)
 ├─ 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 | 打包后**对着 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 | **用户令 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 占位面板 |
 
 <!-- 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
 from pathlib import Path
 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"
 
 
@@ -38,7 +40,7 @@ def jload(p: Path):
 
 
 def cfg():
-    c = dict(DEFAULT); p = ROOT / "configs/serve.json"
+    c = dict(DEFAULT); p = P.SERVE_JSON          # 端口/路径真源 (配置取用口 = src/paths.py)
     if p.exists():
         try: c.update(jload(p))
         except Exception as ex:  # 配置写坏不该让整个产品起不来: 退回内置默认端口, 但要说清楚
@@ -77,9 +79,11 @@ def services(c):
     return [
         ("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"])]),
-        ("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", 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"])]),
     ]
 
@@ -198,6 +202,25 @@ def cmd_check(c):
         row("页面归口: 页面是否随数据变 / 是否陈旧 (configs/portal_pages.yaml)", _rc == 0, _note)
     except Exception as _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"):
         try: __import__(m); row(f"依赖 {m}", True)
         except Exception as ex: row(f"依赖 {m}", False, f"未安装: {ex.__class__.__name__} (运行 install 脚本)")
@@ -225,7 +248,7 @@ def cmd_check(c):
     # 模型闸: 探针 (generate 非 tags) + 档位模型是否都在本机 + 摘要记录; 无模型时问答不可用, 其余页面不受影响
     sys.path.insert(0, str(ROOT))
     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 拉模型)")
         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", [])}
@@ -238,6 +261,7 @@ def cmd_check(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)
     for name, port, cmd in services(c):
         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():
+    # 统一日志口径 (用户令 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()
     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

+ 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]
 sys.path.insert(0, str(ROOT))
+from src import paths as P          # 配置唯一取用口
 
 # 覆盖六个服务与门户; 页面类要能代表"路径是否解析对"(取数面), 接口类代表"数据是否读得到"
 ENDPOINTS = [
@@ -47,7 +48,7 @@ ENDPOINTS = [
 def ports() -> dict:
     """端口只从 configs/serve.json 读 (相对路径按本文件位置解析, 不依赖 cwd); 缺项用启动器同款默认值。"""
     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 {}
     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)
 
 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): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
     # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
     # 不用 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'
 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:
     """当前场名: 显式 → 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):
     """→ (path, reason)。per 场锁优先; 默认锁**仅在 farm 字段对得上**时才认。"""
     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():
         return per, "per 场锁"
-    dflt = root / "configs" / "analysis_lock.yaml"
+    dflt = P.config("analysis_lock.yaml")
     if dflt.is_file():
         import yaml
         try:

+ 1 - 1
src/sop/scenarios.py

@@ -12,7 +12,7 @@ from pathlib import Path
 import yaml
 
 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。
 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)
     elif _why:
         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():
         import pandas as pd
         import yaml

+ 1 - 0
src/windcms/serve.py

@@ -399,5 +399,6 @@ def run(cfg, port=8030, model=None):
         def log_message(self, *a):
             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)
     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_*, 以它为准**; 没给就按上面的扫描结果派生 (外场不必再抄一遍路径)。
+★ 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
 import json
@@ -165,15 +168,66 @@ def _expand(cfg, name):
     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():
-    """列出可用场: 内置 + 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, '内置')
-    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))
     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):
     farm(name)          # 先验证能加载
     _CURRENT[0] = name
@@ -192,11 +246,15 @@ def farm(name=None, refresh=False):
     if name in _BUILTIN:
         _CACHE[name] = _expand(_BUILTIN[name], 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]
 
 

+ 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