Ver Fonte

修交付包致命漏件: start_hidden.vbs 未打进包 (目标机 start.bat 报'无法找到脚本文件'); 新增 src/entry_refs.py 入口引用闭合检查(打包自动补入+打包后复核+开箱验证+装机自检)

zhouyang.xie há 3 semanas atrás
pai
commit
9fe4800f50
4 ficheiros alterados com 218 adições e 11 exclusões
  1. 18 3
      docs/系统设计说明.md
  2. 11 0
      guanlan.py
  3. 53 8
      scripts/pack_dist.py
  4. 136 0
      src/entry_refs.py

+ 18 - 3
docs/系统设计说明.md

@@ -263,6 +263,7 @@
 | 2026-09-16 | 振动侧接入(详见 `docs/振动数据接入_v0.1.md`): `data/raw/<场>/{windcms,m5_cms_tcm}` 两类源件、`rudong_tcm_index.py`/`rudong_tcm_spectra.py`/`vib_raw_build.py`/`vib_reports_build.py`、窗 `w0316` |
 | 2026-09-16 | 用户令"清除产物不留备份": `products_state.py --off --yes` 改为**真删除**(不再产生 `_products_off*/`)、`--on` 与门户「恢复产物」按钮移除;随包件的唯一来源改为**交付包 zip**(`products_restore_missing.py --stash <交付包.zip>`);`derived_manifest.prune()` 清掉陈旧自登记(`raw-derived` 台账 3444 → 1740 件,回到真实) |
 | 2026-09-16 | 用户令"打包不含 输入数据/产物/日志" → 交付包 **v0.4.0**(见 §9): `pack_dist.py` 增 `--no-products`、`VERSION='0.4.0'`、`dist-manifest.json` 记 `no_data/no_products/no_logs` 与逐条排除理由;`guanlan.py check` 读该清单,产物缺失显示 `[--] 待重算` 而非 FAIL |
+| 2026-09-16 | **现场实炸一次**: 目标机 `D:\产品\app` 双击 `start.bat` 报"无法找到脚本文件 `start_hidden.vbs`" —— 打包器 `INCLUDE_FILES` 是**手工清单**,新加的根目录文件(无窗口启动的 `.vbs`)忘了写进去。修法不是"再补一行",而是**从引用反推**:新增 `src/entry_refs.py`,凡入口脚本(`.bat/.vbs/.ps1/.sh`)引用的、本机真实存在的文件,打包时**自动补入**、打包后**对着包内条目复核**(缺一个就删掉这个包并 rc=1)、开箱验证(`--verify` 第①b 步)与装机自检(`guanlan.py check` 一行)各再查一遍。同时修正 `size_of()` 把 `__pycache__/*.pyc` 也数进"预演件数"的虚报(src 少报 129 件) |
 
 ---
 
@@ -272,12 +273,12 @@
 
 | 进包 | 内容 |
 |---|---|
-| `src/` `scripts/` `guanlan.py` | 程序与全部构建/运维脚本 |
+| `src/` `scripts/` `guanlan.py` | 程序与全部构建/运维脚本(含 `src/entry_refs.py` 入口引用闭合检查) |
 | `configs/` `resources/` `reference/` | 配置(含 `serve.json` 端口真源、场站/通道台账) |
 | `release/` | 门户、仿真页、三维资产 `viewer/`、治理清单交付件 `release/如东/`(客户交付物,勿外传) |
 | `docs/` `README_先读我.txt` `测试须知.txt` | 交付文档与说明书 |
 | `wheels/win_amd64/`(42 件)、`vendor/python/`(3 平台便携运行时) | 离线安装件:Windows 完全离线可装;Linux/macOS 走联网安装(要离线就把轮子放进 `wheels/linux_x86_64` / `wheels/macos_arm64`) |
-| `install.bat/.ps1/.sh`、`check/start/stop.bat`、`requirements.txt` | 安装与起停入口 |
+| `install.bat/.ps1/.sh`、`check/start/stop.bat`、**`start_hidden.vbs`**、`requirements.txt` | 安装与起停入口(`start.bat` 默认调 `start_hidden.vbs` 无窗口启动,缺它则双击即报错) |
 
 | 不进包 | 理由(同时写进包内 `dist-manifest.json` 的 `excluded`) |
 |---|---|
@@ -289,6 +290,20 @@
 | `__pycache__/` `*.pyc`、顶层 `*.zip` | 解释器缓存、旧的交付压缩包(避免包中包) |
 
 开箱验证(一条命令给出"能不能装、能不能跑"的证据):`python scripts/pack_dist.py --verify <zip>` —— 解压到
-临时目录 → 离线安装 → 起服务核验 `/`·`/detail/`·`/cms/`·`/ops`·`/healthz` → 收尾清理,要求 ≥4 个页面可用。
+临时目录 → ①b **入口引用闭合核对** → 离线安装 → 起服务核验 `/`·`/detail/`·`/cms/`·`/ops`·`/healthz` → 收尾清理,
+要求 ≥4 个页面可用。
 ★ 核验要求组件端口(18033/18020/18791/18792/64292)空闲,**验证前先 `guanlan.py stop`**,否则整段核验被跳过(打印 `[!]`)。
 
+### 9.1 入口引用闭合(`src/entry_refs.py`,2026-09-16 实炸后加)
+
+现场反馈:目标机 `D:\产品\app` 双击 `start.bat` → `无法找到脚本文件 "D:\产品\app\start_hidden.vbs"`。
+根因是打包器的 `INCLUDE_FILES` 是**手工清单**,新加根目录文件时漏了一项 —— 这类清单必然漏,所以改成**从引用反推**:
+
+```
+凡是入口脚本 (.bat/.vbs/.ps1/.sh) 里出现的一个路径, 且该路径在本机源码树里真实存在 → 它必须在包里
+(本机不存在的 `.venv\Scripts\pythonw.exe` / 系统 exe / `%~dp0` 前缀残片, 按"装机后才有的东西"忽略)
+```
+
+四处落地:打包时**自动补入**(`plan()` 打印 `[+] 自动补入 (被入口脚本引用)`)→ 打包后**对着 zip 条目复核**(缺一个就删包 + rc=1,不留"看起来能用"的坏包)→ `--verify` 第①b 步在解压目录再查 → `guanlan.py check` 输出一行 `入口脚本引用闭合 (23 条: start.bat→start_hidden.vbs 等)`,装机后 `check.bat` 也能看出来。
+
+

+ 11 - 0
guanlan.py

@@ -163,6 +163,17 @@ def cmd_check(c):
             f"flags={hex(_proc.flags())}")
     except Exception as _e:
         row("子进程口径自检", False, f"{type(_e).__name__}: {_e}")
+    # 入口引用闭合 (2026-09-16 现场实炸后加): 目标机 (D:\产品\app) 双击 start.bat 报
+    # "无法找到脚本文件 start_hidden.vbs" —— 包少带了一个被引用的文件。这条守卫在**装机后**复查一遍:
+    # start.bat/check.bat/stop.bat/start_hidden.vbs/install.* 引用的每个文件都必须在位, 缺一个就 FAIL。
+    try:
+        from src.entry_refs import missing_refs, referenced_tree_files
+        _miss = missing_refs(ROOT)
+        _nref = sum(len(v) for v in referenced_tree_files(ROOT).values())
+        row(f"入口脚本引用闭合 ({_nref} 条: start.bat→start_hidden.vbs 等)", not _miss,
+            "; ".join(f"{e} 缺 {r}" for e, r in _miss[:3]) or "每个入口引用的文件都在位")
+    except Exception as _e:
+        row("入口脚本引用闭合", 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 脚本)")

+ 53 - 8
scripts/pack_dist.py

@@ -64,6 +64,9 @@ PRODUCTS_DIR = 'outputs'          # 产物仓: 默认打进包 (页面开箱有
 #   `docs\振动数据接入_v0.1.md` 与 `docs\数据目录结构与落位约定_v0.2.md`, 目录本身按用户令删除)。
 VERSION = '0.4.0'                 # 包版本 (2026-09-16): 写进 dist-manifest.json 与默认文件名
 INCLUDE_FILES = ['guanlan.py', 'install.bat', 'install.ps1', 'install.sh', 'check.bat', 'start.bat', 'stop.bat',
+                 'start_hidden.vbs',      # ★ 无窗口启动: start.bat 会调它。v0.4.0 第一版漏了它 → 目标机 start.bat 报
+                                          #   "无法找到脚本文件 start_hidden.vbs" (2026-09-16 现场反馈)。
+                                          #   现在除了补进本清单, 还由 src/entry_refs.py **从引用反推**兜底(漏不掉)。
                  'requirements.txt', 'README_先读我.txt', '测试须知.txt']
 EXCLUDE_DIRS = {'.git', '.venv', '.github', 'logs', 'run', '__pycache__'}
 # ★ node_modules **不排除**: `release/viewer/node_modules` 里是 esbuild(构建工具)+three(三维库源码), 共 52.5 MB;
@@ -107,6 +110,18 @@ def plan(with_data=False, no_products=False):
         p = ROOT / f
         if p.is_file():
             inc.append(p)
+        else:
+            print(f'   [!] 清单里的 {f} 不在源码树里 —— 交付包会少一个入口文件, 请查')
+    # ★ 引用闭合兜底 (2026-09-16 现场实炸后加): 凡是入口脚本 (.bat/.vbs/.ps1/.sh) 引用到的、本机真实存在的
+    #   根目录文件, 一律自动补进包。手工清单 `INCLUDE_FILES` 漏一个文件, 代价是目标机双击 start.bat 直接报错;
+    #   这条兜底让"漏"变得不可能 —— 上面那个 [X] 才是靠人盯的。
+    from src.entry_refs import auto_include, referenced_tree_files
+    got = {p.relative_to(ROOT).as_posix().lower() for p in inc}
+    extra = auto_include(ROOT, got)
+    if extra:
+        for p in extra:
+            print(f'   [+] 自动补入 (被入口脚本引用): {p.name}')
+        inc.extend(extra)
     skipped = [('.git/', '版本库'), ('.venv/', '虚拟环境(换机必失效, 目标机安装时重建)')]
     _glob_why = {
         '_products_off*': '历史遗留的"清除产物"暂存档 (2026-09-16 起清除=真删, 不再产生; 老机器上若有可手工删)',
@@ -120,12 +135,25 @@ def plan(with_data=False, no_products=False):
     return inc, skipped
 
 
+def keep(f: pathlib.Path) -> bool:
+    """这个文件真会被写进包吗 —— 打包循环与"预演件数"共用同一条判据。
+
+    ★ 2026-09-16 修正: 原实现里 `size_of()` 数的是**目录下所有文件**(连 `__pycache__/*.pyc` 一起数),
+    而实际写入时又跳过它们 ⇒ 预演报的"件数/体积"比包里真的多 (src 少报 100+ 件)。
+    现在两处都走 `keep()`: 预演数字 = 包里条目数, 对不上就是 bug。
+    """
+    parts = f.relative_to(ROOT).parts
+    if any(x in EXCLUDE_DIRS for x in parts) or f.suffix == '.pyc':
+        return False
+    return not any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1])
+
+
 def size_of(p: pathlib.Path):
     if p.is_file():
         return 1, p.stat().st_size
     n = s = 0
     for f in p.rglob('*'):
-        if f.is_file():
+        if f.is_file() and keep(f):
             n += 1; s += f.stat().st_size
     return n, s
 
@@ -146,7 +174,8 @@ def build(out: pathlib.Path, with_data=False, progress=True, no_products=False)
     out.parent.mkdir(parents=True, exist_ok=True)
     man = dict(version=VERSION, built=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(ROOT),
                includes=[p.relative_to(ROOT).as_posix() for p in inc],
-               excluded={n: w for n, w in skipped}, files=tot_n, bytes=tot_s,
+               excluded={n: w for n, w in skipped}, files=tot_n + 1,   # +1 = dist-manifest.json 自身
+               bytes=tot_s,
                no_data=not with_data, no_products=bool(no_products), no_logs=True,
                note='本包由 scripts/pack_dist.py 生成; **不含 输入数据(data/) · 产物(outputs/) · 日志(logs/, run/)** '
                     '(2026-09-16 用户令)。目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), '
@@ -163,18 +192,26 @@ def build(out: pathlib.Path, with_data=False, progress=True, no_products=False)
                 done += 1
             else:
                 for f in p.rglob('*'):
-                    if not f.is_file():
-                        continue
-                    parts = f.relative_to(ROOT).parts
-                    if any(x in EXCLUDE_DIRS for x in parts) or f.suffix == '.pyc':
-                        continue
-                    if any(x.startswith(tuple(g.rstrip('*') for g in EXCLUDE_GLOBS)) for x in parts[:1]):
+                    if not f.is_file() or not keep(f):
                         continue
                     z.write(f, f.relative_to(ROOT).as_posix())
                     done += 1
             if progress and done % 500 < 1:
                 print(f'   … 已写入 {done} 件', flush=True)
     print(f'\n完成: {out}  {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件, 耗时 {time.time()-t0:.0f}s')
+
+    # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
+    from src.entry_refs import missing_refs
+    with zipfile.ZipFile(out) as z:
+        names = z.namelist()
+    miss = missing_refs(ROOT, names)
+    if miss:
+        for e, rel in miss:
+            print(f'   [X] 包内 {e} 引用了 {rel}, 但包里没有 —— 这个包不能交付')
+        out.unlink()
+        print(f'   [X] 已删除不合格的包 {out.name} (留着会被人当成能用的包发出去)')
+        return 1
+    print(f'   [OK] 入口引用闭合: 包内每个入口脚本引用的文件都在包里 (条目 {len(names)} 件)')
     print('目标机用法: 解压到无空格无中文路径 → install.bat / sh install.sh → check → start(自动开门户)')
     print(f'开箱验证(推荐): python scripts/pack_dist.py --verify "{out}"')
     return 0
@@ -191,6 +228,14 @@ def verify(zip_path: pathlib.Path, keep=False) -> int:
         z.extractall(tmp)
     n = sum(1 for _ in tmp.rglob('*') if _.is_file())
     print(f'   {n} 件, 耗时 {time.time()-t0:.0f}s')
+    print('①b 入口引用闭合核对 (包内 start.bat → start_hidden.vbs 之类)')
+    from src.entry_refs import missing_refs
+    miss = missing_refs(tmp)
+    if miss:
+        for e, rel in miss:
+            print(f'   [X] {e} 引用了 {rel}, 但解压目录里没有 —— 目标机会双击即报错')
+        return 1
+    print('   [OK] 全部在位')
     print('② 离线安装')
     xt = _xfer()
     py = xt.install_in(tmp)

+ 136 - 0
src/entry_refs.py

@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+r"""入口脚本的"引用闭合"检查 —— 防止"包内少带一个被引用的文件"(2026-09-16 实炸)。
+
+## 为什么要这个模块
+
+交付包 v0.4.0 第一次打出来时, 目标机 (D:\产品\app) 双击 `start.bat` 报:
+
+    无法找到脚本文件 "D:\产品\app\start_hidden.vbs"
+
+原因不是 start.bat 写错, 而是打包器的 `INCLUDE_FILES` 是**手工清单**: 新加根目录文件
+`start_hidden.vbs`(无窗口启动) 时忘了把它写进去, 于是包里 `start.bat` 指向一个不存在的文件。
+手工清单这种东西**必然会漏** —— 所以这里改成**从被引用的文件反推**:
+
+    凡是包内入口脚本 (.bat/.vbs/.ps1/.sh) 里出现的一个路径, 且该路径在**本机源码树里真实存在**,
+    它就**必须**出现在包里(或被打包器自动补进去)。
+
+· 本机不存在的路径 (`.venv\Scripts\pythonw.exe`, `python.exe`, `vendor\ollama\OllamaSetup.exe` 之类)
+  按"目标机安装后才有的东西"忽略 —— 它们本来就不该在包里;
+· `%~dp0start_hidden.vbs` 这类批处理展开写法会自动去掉 `%~dp0` 前缀再判断;
+· 只做**存在性**判断(不做内容比对): 这份检查的目的只有一个 —— 包内不留"指向空气"的入口。
+
+单一实现, 三处共用: `scripts/pack_dist.py`(打包时 + 开箱验证)、`guanlan.py check`(装机后自检)。
+"""
+from __future__ import annotations
+
+import pathlib
+import re
+
+# 包内入口脚本 (相对安装根)。这些是"用户会直接双击 / 直接敲"的东西。
+ENTRY_FILES = ('start.bat', 'check.bat', 'stop.bat', 'start_hidden.vbs',
+               'install.bat', 'install.ps1', 'install.sh')
+
+# 引用形式: 允许中文名、路径分隔符、- 与 _ ; 只要"看起来是个文件"就抓(后缀限定, 免得把 URL/域名当文件)
+_REF = re.compile(r'[A-Za-z0-9_.\u4e00-\u9fff-]+'
+                  r'(?:[\\/][A-Za-z0-9_.\u4e00-\u9fff-]+)*'
+                  r'\.(?:bat|vbs|ps1|sh|py|txt|json|yaml|yml|md|html|zip|csv|parquet)')
+
+_STRIP = ('dp0', 'DP0')          # %~dp0 / %dp0 展开后的残前缀
+
+
+def _candidates(tok: str):
+    """一个 token 可能对应的"安装根相对路径"候选 (从最具体到最宽松)。"""
+    t = tok.replace('\\', '/').lstrip('./')
+    cands = [t]
+    for p in _STRIP:
+        if t.startswith(p) and len(t) > len(p):
+            cands.append(t[len(p):])
+    parts = t.split('/')
+    cands += ['/'.join(parts[i:]) for i in range(1, len(parts))]
+    return [c for c in dict.fromkeys(cands) if c]
+
+
+def _norm(name: str) -> str:
+    return name.replace('\\', '/').lstrip('./').lower()
+
+
+def entry_refs(path: pathlib.Path) -> set[str]:
+    """一个入口脚本里出现的全部"像文件"的路径 token。"""
+    try:
+        txt = path.read_text(encoding='utf-8-sig', errors='replace')
+    except OSError:
+        return set()
+    return {m.group(0) for m in _REF.finditer(txt)}
+
+
+def referenced_tree_files(root: pathlib.Path) -> dict[str, set[str]]:
+    """→ {入口脚本: {安装根相对路径…}} —— 只留**本机源码树里真实存在**的那些。
+
+    本机不存在的 token 直接丢掉: 它们是"目标机安装后才有的"(venv/系统 exe)或纯噪声(`sys.exe`)。
+    """
+    root = pathlib.Path(root)
+    out: dict[str, set[str]] = {}
+    for name in ENTRY_FILES:
+        p = root / name
+        if not p.is_file():
+            continue
+        found: set[str] = set()
+        for tok in entry_refs(p):
+            for cand in _candidates(tok):
+                if (root / cand).is_file():
+                    found.add(_norm(cand))
+                    break
+        if found:
+            out[name] = found
+    return out
+
+
+def missing_refs(root: pathlib.Path, provided=None) -> list[tuple[str, str]]:
+    """→ [(入口脚本, 缺失的安装根相对路径)] —— provided=None 时按 root 下的真实文件判断。
+
+    provided 给一组"包内已有条目名"(zip 的 namelist 或解压后的相对路径), 用于核对**包**而不是磁盘。
+    """
+    have = None if provided is None else {_norm(x.rstrip('/')) for x in provided}
+    bad: list[tuple[str, str]] = []
+    root = pathlib.Path(root)
+    for entry, refs in sorted(referenced_tree_files(root).items()):
+        for rel in sorted(refs):
+            ok = (rel in have) if have is not None else (root / rel).is_file()
+            if not ok:
+                bad.append((entry, rel))
+    return bad
+
+
+def auto_include(root: pathlib.Path, already: set[str]) -> list[pathlib.Path]:
+    """被入口脚本引用、且本机存在、但不在 already 里的**根目录**文件 —— 打包器自动补进去。
+
+    只自动补**根目录**文件 (如 `start_hidden.vbs`); 子目录里的引用交给 INCLUDE_DIRS 管,
+    缺了会由 `missing_refs` 报出来, 但不会静默漏掉任何入口。
+    """
+    got = {_norm(x) for x in already}
+    root = pathlib.Path(root)
+    add: list[pathlib.Path] = []
+    for _, refs in referenced_tree_files(root).items():
+        for rel in refs:
+            if '/' in rel or rel in got:
+                continue
+            p = root / rel
+            if p.is_file():
+                add.append(p)
+                got.add(rel)
+    return sorted(set(add), key=lambda p: p.name)
+
+
+if __name__ == '__main__':      # 直接跑 = 对当前安装目录做一次闭合检查
+    import sys
+    r = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
+    refs = referenced_tree_files(r)
+    miss = missing_refs(r)
+    for e in sorted(refs):
+        print(f'   {e:18s} → ' + ', '.join(sorted(refs[e])))
+    for e, rel in miss:
+        print(f'[X] {e} 引用了 {rel}, 但 {r} 下没有')
+    if not miss:
+        print(f'[OK] 入口脚本引用闭合 ({len(refs)} 个入口, '
+              f'{sum(len(v) for v in refs.values())} 条引用全部在位)')
+    sys.exit(1 if miss else 0)