Kaynağa Gözat

bug-2: 恢复 v0.2.0 漏发的 release/如东/ 交付件 (门户 #documents 坏链)

现象: http://127.0.0.1:28084/#documents 的「如东液压系统治理清单 · 网页版」iframe 显示
      {"err": "not found", "path": "/如东/如东治理清单_交付_20260901/02_治理清单/如东_液压系统治理清单_v1.2_2026-09-01.html"}

根因: v0.2.0 发行包里根本没有 release/如东/ —— 0.2.0 的 zip 中 release/ 下只有 viewer/,
      而门户 iframe 指向的正是 release/如东/.../如东_液压系统治理清单_v1.2_2026-09-01.html;
      网关 scripts/guanlan_gateway.py 的 _static() 在 release/ 下找不到文件即回
      {"err":"not found","path":…} 404 JSON, iframe 就把这段 JSON 当页面显示。
      门户里指向 release/如东/ 的引用全量扫过只有这一条 ⇒ 不是路径写错, 是交付件漏随包发出。

修复: 从本机 v0.1.0 全量包 F:\temp\guanlan-rudong-v2_0.1.0_all.zip (该交付件在里面是齐的)
      恢复整个 release/如东/ 到 v0.2.0 安装目录: 75 个文件 / 51.0 MB
        · 如东治理清单_交付_20260901/   33 件 11.4 MB (含 01_正式报告 / 02_治理清单 / 03_分册全套)
        · 如东取数单_2026-08-21/         18 件  0.2 MB
        · 观澜离线版.app/                10 件  1.1 MB
        · 根下散件                       14 件 38.3 MB
      逐件比对写出大小与 zip 元数据一致 (读到底顺带校验 CRC)。
      release/如东/ 按 .gitignore 既有约定不入库 (客户交付物只在受保护工作区), 故 git 侧无变更。

验收: · iframe URL 走网关 → [200] text/html; charset=utf-8, 103760 字节,
        标题「如东液压系统治理清单」, 文中 0 个外部相对引用 (自包含)
      · 同页另两个引用 127.0.0.1:18020 (CMS) 与 127.0.0.1:18033/v2 均 200
      · 全门户静态引用扫描 (含「如东」「/release」) → 坏链 0

脚本 (见 _修复记录_20260911/): restore_release_rudong.py / verify_bug2.py / scan_portal_links.py
zhouyang.xie 1 ay önce
ebeveyn
işleme
10323a3507

+ 23 - 1
_修复记录_20260911/README.md

@@ -56,7 +56,29 @@ start.bat.bak-healthz
 三个 `fix_*` 都是幂等的 (已改过会打印 "已修过")。`wheels\colorama*.whl` 与
 `release\scrub_rules.py` 属于新增文件, 没有对应的 `fix_*` 脚本。
 
-## 仍然存在 (不是代码问题)
+## bug-2 · `#documents` 页「如东液压系统治理清单 · 网页版」报 `{"err":"not found"}`
+
+**现象**: 门户 `http://127.0.0.1:28084/#documents` 里那个 iframe 显示
+`{"err": "not found", "path": "/如东/如东治理清单_交付_20260901/02_治理清单/如东_液压系统治理清单_v1.2_2026-09-01.html"}`。
+
+**根因**: v0.2.0 发行包里**没有 `release/如东/`** —— 0.2.0 的 zip 里 `release/` 下只有 `viewer/`,
+而门户那个 iframe 指的就是 `release/如东/.../_液压系统治理清单_v1.2_2026-09-01.html`;
+网关 `guanlan_gateway.py::_static()` 在 `release/` 里找不到文件就回那段 404 JSON
+(见 `_static()` 里 `return self._json(dict(err="not found", path=path), 404)`)。
+顺带核过: 门户里指向 `release/如东/` 的引用**只有这一条**, 所以不是路径写错, 是交付件没随 v0.2.0 发出来。
+
+**修复**: 从本机的 v0.1.0 全量包 `F:\temp\guanlan-rudong-v2_0.1.0_all.zip`(这份交付件在里面是齐的)
+把整个 `release/如东/` 恢复到 v0.2.0 安装目录 —— 75 个文件 / 51.0 MB
+(`如东治理清单_交付_20260901/` 33 件、`如东取数单_2026-08-21/` 18 件、`观澜离线版.app/` 10 件、根下散件 14 件)。
+`release/如东/` 按 `.gitignore` 的既有约定不入库(客户交付物只在受保护工作区), 所以 git 里看不到这次恢复。
+
+**验收**: iframe URL 走网关取 → **[200] text/html, 103760 字节**, 标题 `如东液压系统治理清单`,
+正文 0 个外部相对引用(自包含); 同页另外两个引用 (`127.0.0.1:18020` CMS、`127.0.0.1:18033/v2`) 均 200;
+全门户扫一遍含「如东 / release」的静态引用 → 坏链 0。
+
+脚本: `restore_release_rudong.py` (恢复) · `verify_bug2.py` (验收) · `scan_portal_links.py` (同类坏链全量扫描)
+
+
 
 - `/local-ai/` 仍是 DOWN: 本机 Ollama 没运行, 且 `%USERPROFILE%\.ollama` 下**没有任何模型**
   (`manifests` 都没有), 所以问答与本地审核页不可用。其余 6 个页面不受影响。

+ 67 - 0
_修复记录_20260911/restore_release_rudong.py

@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""bug-2 修复: 从 v0.1.0 发行包把缺失的 release/如东/ 交付件恢复到 v0.2.0 安装目录。
+
+根因: v0.2.0 包**根本没带** release/如东/ (0.2.0 zip 里 release/ 下只有 viewer/),
+而 release/portal.html 的 #documents 区块用 iframe 指向
+  http://127.0.0.1:28084/如东/如东治理清单_交付_20260901/02_治理清单/如东_液压系统治理清单_v1.2_2026-09-01.html
+该路径由网关 _static() 从 release/ 目录取文件, 文件不在 → 404 {"err":"not found", "path": …},
+iframe 里就直接显示那段 JSON。v0.1.0 包里这份交付件是齐的 (76 项), 直接恢复。
+
+(release/如东/ 按 .gitignore 约定不入库 —— 客户交付物只在受保护工作区。)
+"""
+from __future__ import annotations
+
+import pathlib
+import shutil
+import sys
+import zipfile
+
+ZIP = pathlib.Path(r"F:\temp\guanlan-rudong-v2_0.1.0_all.zip")
+REL = pathlib.Path(r"F:\temp\guanlan-rudong-v2_0.2.0\release")
+PREFIX = "release/如东/"
+
+
+def nm(info):
+    raw = info.filename
+    for enc in ("gbk", "utf-8"):
+        try:
+            return raw.encode("cp437").decode(enc)
+        except Exception:
+            continue
+    return raw
+
+
+def main() -> int:
+    if not ZIP.is_file():
+        print(f"  [X] 缺源包 {ZIP}")
+        return 1
+    print(f"源: {ZIP}")
+    print(f"目标: {REL}\\如东\\\n")
+    n_files = 0
+    n_bytes = 0
+    with zipfile.ZipFile(ZIP) as zf:
+        items = [(nm(i), i) for i in zf.infolist()]
+        rel = [(n.replace("\\", "/"), i) for n, i in items if n.replace("\\", "/").startswith(PREFIX)]
+        if not rel:
+            print("  [X] 源包里没有 release/如东/")
+            return 1
+        for name, info in rel:
+            target = REL / name[len("release/"):]
+            if info.is_dir():
+                target.mkdir(parents=True, exist_ok=True)
+                continue
+            target.parent.mkdir(parents=True, exist_ok=True)
+            with zf.open(info) as fsrc, open(target, "wb") as fdst:
+                shutil.copyfileobj(fsrc, fdst, 1024 * 1024)
+            if target.stat().st_size != info.file_size:
+                print(f"  [X] 大小不符: {target}")
+                return 1
+            n_files += 1
+            n_bytes += info.file_size
+    print(f"恢复 {n_files} 个文件, {n_bytes/1024/1024:.1f} MB")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 50 - 0
_修复记录_20260911/scan_portal_links.py

@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""全量核查 portal.html 里指向本机静态件的引用 (含相对写法), 找出同类"交付件没随包"的坏链。"""
+import pathlib
+import re
+import urllib.error
+import urllib.parse
+import urllib.request
+
+REL = pathlib.Path(r"F:\temp\guanlan-rudong-v2_0.2.0\release")
+html = (REL / "portal.html").read_text(encoding="utf-8", errors="replace")
+
+# 所有 href/src 里含 如东 或 release 的
+cand = set()
+for m in re.finditer(r'(?:href|src)="([^"]+)"', html):
+    u = m.group(1)
+    if ("如东" in u or "/release" in u or "release/" in u) and not u.startswith(("mailto:", "javascript:")):
+        cand.add(u)
+print(f"候选引用 {len(cand)} 个\n")
+
+
+def check(u: str):
+    if u.startswith("http"):
+        full = u
+    else:
+        base = "http://127.0.0.1:28084/" if u.startswith("/") else "http://127.0.0.1:28084/"
+        full = base + u.lstrip("/")
+    p = urllib.parse.urlsplit(full)
+    full = urllib.parse.urlunsplit((p.scheme, p.netloc, urllib.parse.quote(p.path), p.query, ""))
+    try:
+        with urllib.request.urlopen(full, timeout=30) as r:
+            r.read(1)
+            return r.status
+    except urllib.error.HTTPError as e:
+        return e.code
+    except Exception as e:
+        return f"{e.__class__.__name__}"
+
+
+bad = []
+for u in sorted(cand):
+    s = check(u)
+    tag = "ok  " if s == 200 else "BAD "
+    if s != 200:
+        bad.append(u)
+    print(f"  [{s}] {tag} {u}")
+
+print(f"\n坏链 {len(bad)} 个")
+for u in bad:
+    print("   - " + u)

+ 44 - 0
_修复记录_20260911/verify_bug2.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""bug-2 验收: 走网关取那个 iframe URL, 并把它自身的相对引用也逐条取一遍。"""
+import re
+import urllib.parse
+import urllib.request
+
+URL = ("http://127.0.0.1:28084/如东/如东治理清单_交付_20260901/"
+       "02_治理清单/如东_液压系统治理清单_v1.2_2026-09-01.html")
+
+
+def get(u):
+    req = urllib.request.Request(u, headers={'User-Agent': 'verify'})
+    try:
+        with urllib.request.urlopen(req, timeout=30) as r:
+            return r.status, r.headers.get('Content-Type'), r.read()
+    except urllib.error.HTTPError as e:
+        return e.code, e.headers.get('Content-Type'), e.read()
+
+
+def q(u):
+    """URL 里中文需编码, 网关会 unquote。"""
+    p = urllib.parse.urlsplit(u)
+    return urllib.parse.urlunsplit((p.scheme, p.netloc,
+                                    urllib.parse.quote(p.path), p.query, p.fragment))
+
+
+st, ct, body = get(q(URL))
+print(f"iframe URL : [{st}] {ct} {len(body)} 字节")
+txt = body.decode('utf-8', 'replace')
+print(f"首行       : {txt.splitlines()[0][:120] if txt.strip() else '(空)'}")
+print(f"含 not found: {'not found' in txt[:400] and st != 200}")
+
+refs = sorted({m for m in re.findall(r'(?:href|src)="([^"]+)"', txt)
+               if not m.startswith(('http', '#', 'data:', 'mailto:', 'javascript:'))})
+print(f"\n文中相对引用 {len(refs)} 个, 逐条取:")
+bad = 0
+for r in refs:
+    u = urllib.parse.urljoin(URL, r)
+    s, c, b = get(q(u))
+    if s != 200:
+        bad += 1
+    print(f"  [{s}] {len(b):8d} 字节  {r}")
+print(f"\n结论: {'全部 200' if not bad else f'{bad} 个引用取不到'}")