소스 검색

打包变更: 包里带**输入数据目录结构**(空目录), 不带任何文件(用户令 2026-09-19)

用户令: 「打包变更:包中 增加 输入数据目录结构,但不包含文件」。

背景: 交付包默认不含输入数据(现场原始件 30 GB 级), 可目标机解压后是一套**没有"放哪儿"的**程序 ——
人得翻文档才知道 data/raw/<场站>/{scada_10min,故障报警,…} 这个约定目录结构。

改动(scripts/pack_dist.py):
- 新增 data_skeleton(): 随包写 12 个**目录条目** —— data/ · data/raw/ · data/_incoming/ 与
  data/raw/<场站>/{scada_10min, scada_1min, 故障报警, 风机故障记录, 油样报告, windcms, m5_cms_tcm, scada_mdb};
  结构取自单一真源 src/windscada/config.py::STATION_SUBDIRS(改名等于换接口), 场站目录名取
  raw_station_dir() 解析出的那一个(现场是「如东」; 不再多发一份 farm key `rudong`, 免得目标机看着像多了个场)。
- 写成 zip 目录条目(ZipInfo 名以 `/` 结尾 + MS-DOS 目录位 + STORED), 解压即建空目录;
  空目录不会因为"里面没文件"而自动存在, 必须显式写条目。
- 文件通道不变: data/ 下的**文件**照旧不进包(keep() 的 EXCLUDE_GLOBS 仍拦着); --with-data 时整棵 data/ 进包,
  此时不再写空骨架(避免与真实目录重复)。
- dist-manifest.json 增 data_skeleton(12 条)+ data_skeleton_note; note() 里"不含输入数据"改成
  "不含 data/ 里的文件, 含目录结构"。
- --dry-run 逐条打印会写哪些空目录; build 打印"目录骨架 N 个空目录已写入"。
- --verify 增一道闸: 解压后**12 个目录都在位** 且 **目录里一个文件都没有** —— 两边都查(目录少了人不知道往哪放,
  混进文件就不是"只带结构"了)。

实测(本机):
- 打包: 880 MB · 条目 3480 件 + 目录 12 个; 包内 data/ 下的**文件条目 = 0**; manifest.data_skeleton = 12。
- 开箱验证 rc=0: 「[OK] 输入数据目录骨架: 12 个空目录全部在位, 且无任何文件」+
  「[OK] 引用全部在位, 编码守则通过」+ 页面可用 5/5 + 卸载核验通过(卸载探针同时报「数据 data/ 原样在位(0 件)」)。
zhouyang.xie 3 주 전
부모
커밋
2b1054fe5e
1개의 변경된 파일과 81개의 추가작업 그리고 7개의 파일을 삭제
  1. 81 7
      scripts/pack_dist.py

+ 81 - 7
scripts/pack_dist.py

@@ -18,14 +18,21 @@ r"""打一个"拷到别的电脑能装、能跑"的分发包 (2026-09-12)。
 **包含**: 程序(`src/` `scripts/` `guanlan.py`) · 配置(`configs/`) · 离线安装件(`wheels/win_amd64/` `vendor/python/`)
 · 页面与交付(`release/` `resources/` `reference/`) · 文档(`docs/` `README_先读我.MD` `测试须知.txt`)
 · 安装/起停/打包脚本(`install.bat` `install.sh` `install.ps1` `check.bat` `start.bat` `stop.bat` `pack.bat`
-`pack.sh`) · `requirements.txt`。
+`pack.sh`) · `requirements.txt`
+· ★**输入数据目录结构**(空目录, 2026-09-19 用户令「包中增加输入数据目录结构,但不包含文件」): `data/` `data/raw/`
+`data/_incoming/` 与 `data/raw/<场站>/{scada_10min, scada_1min, 故障报警, 风机故障记录, 油样报告, windcms,
+m5_cms_tcm, scada_mdb}` —— 共 12 个空目录, **里面一个文件都没有**。结构取自单一真源
+`src/windscada/config.py::STATION_SUBDIRS`, 场站目录名取解析出来的那一个(现场是「如东」)。
+目的: 目标机解压后开箱就能看见"每个约定位置在哪、该往哪个抽屉放原始件", 不必翻文档猜。
+开箱验证会同时查两件事: 12 个目录都在位 + 目录里没混进任何文件。
 
 **不含**修复记录/临时目录(`_修复记录_*` 之类): 本包已纳入 git 管理, 变更历史由版本库承载 (2026-09-12 用户令)。
 **不含 VBScript**: 2026-09-16 用户令"把 VBScript 替换掉" —— 无窗口启动由 `pythonw.exe` +
 `scripts/guanlan_start_hidden.py` 承担, 安装时另建 `启动观澜.lnk`; 包里已无 `.vbs`, 也不依赖 Windows 脚本宿主。
 
 **排除**(每条都写了理由; 前四类就是用户令点名的四类):
-    data/                            ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data
+    data/ 里的**文件**                  ① **输入数据**: 现场原始件(约定"原始件不随包分发"); 要一起交付用 --with-data。
+                                        ★目录结构随包(空目录, 见上); 用户令 2026-09-19
     outputs/                         ② **产物(输出数据)**: 页面取数靠它, 但按令不进包 —— 目标机放数据后
                                         `scripts/place_raw_data.py` + `scripts/rebuild_all.py` 重算, 或按需从
                                         无生成端的那批**运行期不从交付包补**(用户令 2026-09-17 #1): 由研发补生成端(docs §13.6)
@@ -129,6 +136,33 @@ def is_machine_artifact(p: pathlib.Path) -> bool:
             or any(fnmatch.fnmatch(p.name, pat) for pat in EXCLUDE_NAMES))
 
 
+def data_skeleton() -> list[str]:
+    r"""`data/` 下要随包发的**空目录骨架**(用户令 2026-09-19: 「包中增加输入数据目录结构,但不包含文件」)。
+
+    为什么要有它: 交付包默认不含输入数据(现场原始件 30 GB 级),可目标机解压后是一套**没有"放哪儿"的**
+    程序 —— 人得去翻文档才知道 `data/raw/<场站>/{scada_10min,故障报警,…}` 这个约定目录结构。
+    把**目录结构**随包发出去(**不带任何文件**),开箱就能看见每个约定位置在哪、该往哪个抽屉里放。
+
+    结构来源是**单一真源** `src/windscada/config.py::STATION_SUBDIRS`(摄入侧的约定子目录名 —— 改名等于
+    换接口),场站目录名取 `raw_station_dir()` 解析出来的那一个(现场是「如东」,不是 farm key「rudong」)。
+    另外补两个入口目录: `data/` 与 `data/raw/` 本身,以及 `data/_incoming/`(`place_raw_data.py` 的默认
+    投放口)。**不含任何文件**,也不含 `scada_mdb/<年>年/<月>月/` 那种数据生成出来的层级
+    (月目录是收资之后才有的,不该由包来规定)。
+    """
+    out = ['data', 'data/raw', 'data/_incoming']
+    try:
+        from src.windscada.config import STATION_SUBDIRS, raw_station_dir
+        # ★只发**解析出来的那一个场站目录名**(现场是「如东」) —— 不把 farm key(`rudong`) 也发一份:
+        #   两个空场站目录会让 `station_scan` 报"2 个场站目录", 目标机上看起来像"多了一个不知哪来的场"。
+        station = pathlib.Path(str(raw_station_dir()))
+        out.append(f'data/raw/{station.name}')
+        for sub in STATION_SUBDIRS:
+            out.append(f'data/raw/{station.name}/{sub}')
+    except Exception as e:                          # 配不出骨架时如实说, 不静默少发
+        print(f'   [!] 输入数据目录骨架没配出来 ({type(e).__name__}: {e}) —— 包里不会有 data/ 结构')
+    return out
+
+
 def _xfer():
     """复用移植检查器里的安装/起服务核验逻辑 (单一实现, 不抄第二份)。"""
     spec = importlib.util.spec_from_file_location('check_transferable', ROOT / 'scripts' / 'check_transferable.py')
@@ -184,7 +218,8 @@ def plan(with_data=False, with_products=False):
         inc.extend(keepable)
     for p in dropped:
         print(f'   [i] 不进包 (装机产物, 目标机安装时自建): {p.name}')
-    skipped = [('data/', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data')]
+    skipped = [('data/ 里的文件', '① 输入数据 (现场原始件): 用户令不进包; 要一起交付用 --with-data。'
+                                  '★目录结构随包 (空目录, 见"输入数据目录骨架"); 用户令 2026-09-19')]
     if (ROOT / PRODUCTS_DIR).is_dir():
         skipped += [('outputs/', '② 产物/输出数据: 用户令不进包 (目标机放数据后 place_raw_data.py + '
                                  'rebuild_all.py 重算; 无生成端的那批运行期不从交付包补(用户令 2026-09-17 #1), 由研发补生成端 docs §13.6)'
@@ -250,6 +285,7 @@ def size_of(p: pathlib.Path, with_data: bool = False, with_products: bool = Fals
 
 def build(out: pathlib.Path, with_data=False, progress=True, with_products=False) -> int:
     inc, skipped = plan(with_data, with_products)
+    skel = [] if with_data else data_skeleton()      # --with-data 时 data/ 整个进包, 不需要空骨架
     tot_n = tot_s = 0
     print('== 要打进包的内容 ==')
     print('   (默认口径: 不含 输入数据 / 产物 / 运行日志 / 临时文件 —— 用户令 2026-09-17)')
@@ -258,6 +294,8 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
         tot_n += n; tot_s += s
         print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
     print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB')
+    if skel:
+        print(f'   {"输入数据目录骨架":26s} {len(skel):6d} 个空目录  (用户令 2026-09-19: 带结构不带文件)')
     print('== 排除 ==')
     for name, why in skipped:
         print(f'   {name:26s} {why}')
@@ -266,10 +304,13 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
     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 + 1,   # +1 = dist-manifest.json 自身
-               bytes=tot_s,
+               bytes=tot_s, data_skeleton=skel or None,
+               data_skeleton_note=('用户令 2026-09-19: 包里带**输入数据目录结构**(空目录), 不带任何文件; '
+                                   '目标机解压后照这个结构往 data/raw/<场站>/ 各抽屉里放原始件'),
                no_data=not with_data, no_products=not with_products, no_logs=True, no_temp=True,
-               note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/) · '
-                    '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**。'
+               note='本包由 scripts/pack_dist.py 生成 (用户令 2026-09-17): **不含 输入数据(data/ 里的文件) · '
+                    '产物(outputs/) · 运行日志(logs/, run/, *.log, *.jsonl) · 临时文件(缓存/备份/系统索引)**; '
+                    '**含**输入数据目录结构(空目录, 用户令 2026-09-19)。'
                     '目标机解压后跑 install.bat(Windows) 或 sh install.sh(Linux/macOS), 再 check → start; '
                     '要让页面有数: 把现场包放好后跑 `scripts/place_raw_data.py --src <现场包目录> --scope full` '
                     '+ `scripts/rebuild_all.py` (无生成端的那批用 '
@@ -291,7 +332,18 @@ def build(out: pathlib.Path, with_data=False, progress=True, with_products=False
                     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')
+        # 输入数据目录骨架: 写成**目录条目**(名以 `/` 结尾 + MS-DOS 目录位), 解压即建出空目录。
+        # ★必须显式写条目: 空目录在 zip 里不会因为"里面没文件"而自动存在; 只写父目录也不够
+        #   (解压工具只按条目建目录)。
+        for rel in skel:
+            zi = zipfile.ZipInfo(rel.rstrip('/') + '/')
+            zi.external_attr = (0o40775 << 16) | 0x10
+            zi.compress_type = zipfile.ZIP_STORED
+            z.writestr(zi, b'')
+        if skel:
+            print(f'   … 目录骨架 {len(skel)} 个空目录已写入', flush=True)
+    print(f'\n完成: {out}  {out.stat().st_size/1e6:.0f} MB (源 {tot_s/1e6:.0f} MB), 条目 {done} 件'
+          + (f' + 目录 {len(skel)} 个' if skel else '') + f', 耗时 {time.time()-t0:.0f}s')
 
     # 入口引用闭合核对 (对着**包内条目**查; 2026-09-16: v0.4.0 第一版漏带 start_hidden.vbs, 目标机 start.bat 报错)
     # + 入口编码守则 (install.ps1 的 UTF-8 BOM 曾被编辑工具吃掉 → 目标机 install 1 秒即 ParserError)
@@ -344,6 +396,23 @@ def verify(zip_path: pathlib.Path, keep=False) -> int:
     if miss or enc:
         return 1
     print('   [OK] 引用全部在位, 编码守则通过 (install.ps1 = UTF-8 BOM + CRLF)')
+    # ★2026-09-19 用户令: 包里带"输入数据目录结构"(空目录)。解压后必须真的建出来了, 且**一个文件都没有**
+    #   —— 两边都查: 目录少了 ⇒ 人不知道往哪放; 目录里混进文件 ⇒ 包就不是"只带结构"了。
+    skel = data_skeleton()
+    miss_d = [r for r in skel if not (tmp / r).is_dir()]
+    extra_f = []
+    for r in skel:
+        d = tmp / r
+        if d.is_dir():
+            extra_f += [str(p.relative_to(tmp)) for p in d.rglob('*') if p.is_file()]
+    if miss_d:
+        print(f'   [X] 输入数据目录骨架缺 {len(miss_d)} 个: {miss_d[:5]}')
+    if extra_f:
+        print(f'   [X] 骨架目录里混进了 {len(extra_f)} 个文件 (应只有空目录): {extra_f[:5]}')
+    if miss_d or extra_f:
+        return 1
+    print(f'   [OK] 输入数据目录骨架: {len(skel)} 个空目录全部在位, 且无任何文件'
+          f' (用户令 2026-09-19: 带结构不带文件)')
     print('② 离线安装')
     xt = _xfer()
     py = xt.install_in(tmp)
@@ -423,6 +492,11 @@ def main() -> int:
             n, s = size_of(p, a.with_data, a.with_products); tot_n += n; tot_s += s
             print(f'   {p.relative_to(ROOT).as_posix():26s} {n:6d} 件  {s/1e6:9.1f} MB')
         print(f'   {"合计":26s} {tot_n:6d} 件  {tot_s/1e6:9.1f} MB  → {out}')
+        skel = [] if a.with_data else data_skeleton()
+        if skel:
+            print(f'   {"输入数据目录骨架":26s} {len(skel):6d} 个空目录 (不带文件; 用户令 2026-09-19):')
+            for r in skel:
+                print(f'      {r}/')
         print('== 会排除 ==')
         for name, why in skipped:
             print(f'   {name:26s} {why}')