# -*- coding: utf-8 -*- """M6 导出与交付 E2E(《03-接口设计》§12.4 ~ §12.6)。 它验的不是"能不能打个包",而是**这条链路上四条硬规则**: 1. **铁律 10**:有不合格文件 → `40035` 且 `canExport=false`, 并且清单要**指名道姓**(谁的哪一项)—— 只说"存在不合格文件"等于没说。 2. **只装已通过终审的**:待审/被驳回的单子不许进包(交付物不能有半成品)。 3. **目录结构与内容**:班级/姓名/规范件名 + 汇总表;解压后能直接交给认定机构。 4. **中文文件名在 Windows 解压不乱码** —— 靠 ZIP 的 UTF-8 + EFS 标志位。 这一条在服务器上"看着都对",只在客户机器上出事,所以**必须在产物里断言**: 用 commons-compress 读回 entry 名,并检查 general purpose bit 11 被置位。 跑法(前提:后端 8080 在跑、`python scripts/dev-seed-audit.py` 已摆好演示数据): python scripts/e2e-m6-export.py """ from __future__ import annotations import argparse import importlib.util import io import json import os import sys import zipfile sys.stdout.reconfigure(encoding="utf-8") ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PASSED: list[str] = [] FAILED: list[tuple[str, str]] = [] def _load(module_name: str, filename: str): """复用既有 E2E 的工具(登录 / SQL / 打印口径),不复制第二份。""" path = os.path.join(ROOT, "scripts", filename) spec = importlib.util.spec_from_file_location(module_name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod R = _load("e2e_m2_roster", "e2e-m2-roster.py") BASE = "http://localhost:8080" # 演示任务(`scripts/dev-seed-audit.py` 建的)——按名字找,不写死 id DEMO_TASK_NAME = "【演示】审核台" TEACHER_USER = "demot1" TEACHER_PWD = "Demo1234" def reset_demo_state() -> int: """ 把演示数据摆回"刚提交完、还没审"的初始态。 为什么必须重摆而不是"接着上一轮跑": 本脚本要验的第一件事是**还有没审完的人时必须拦住导出**(铁律 10)。 而上一轮跑完,那三份已经全审过了 → 这条断言必然失败, 于是"第二次跑就红"——那种红看着像功能坏了,其实只是夹具状态不对。 (本脚本第一次跑就踩了这个:第二轮 canExport 直接是 true。) 直接复用开发用的种子脚本,避免在这里再写一份造数逻辑(两份必然分叉)。 """ seed = _load("dev_seed_audit", "dev-seed-audit.py") argv = sys.argv try: sys.argv = ["dev-seed-audit.py", "--reset"] return seed.main() finally: sys.argv = argv def ok(name: str) -> None: PASSED.append(name) print(f" [OK] {name}") def fail(name: str, detail: str) -> None: FAILED.append((name, detail)) print(f" [FAIL] {name}\n {detail}") def check(name: str, cond: bool, detail: str = "") -> bool: if cond: ok(name) else: fail(name, detail or "断言不成立") return cond def sql(stmt: str) -> str: okk, out = R.sql(stmt) return out def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None): _, b = R.call(method, f"{BASE}{path}", token, payload) return b def main() -> int: ap = argparse.ArgumentParser(description="M6 导出与交付 E2E") args = ap.parse_args() print("=" * 72) print(" M6 导出与交付 E2E") print("=" * 72) print("\n[准备] 把演示数据重摆到「刚提交、还没审」的初始态") if reset_demo_state() != 0: print(" ✗ 摆演示数据失败,先看 dev-seed-audit.py 的输出") return 2 _, adm = R.login(BASE, "admin", "Admin@123", "demo") if adm.get("code") != 0: print(f" ✗ 管理员登录失败:{adm}") return 2 at = adm["data"]["token"] _, tea = R.login(BASE, TEACHER_USER, TEACHER_PWD, "demo") if tea.get("code") != 0: print(f" ✗ 班主任登录失败:{tea}(先跑 scripts/dev-seed-audit.py)") return 2 tt = tea["data"]["token"] # 找演示任务 tasks = body_of("GET", "/api/v1/tasks?pageNum=1&pageSize=200", at) task = next((t for t in (tasks.get("data") or {}).get("list") or [] if t.get("taskName") == DEMO_TASK_NAME), None) if not task: print(f" ✗ 找不到演示任务「{DEMO_TASK_NAME}」,先跑 python scripts/dev-seed-audit.py") return 2 task_id = task["id"] print(f"\n[0] 演示任务 id={task_id}") # ── 1. 前置检查:有人没审完 → 必须拦住,并说清是谁 ── print("\n[1] 前置检查(还有没审完的人)") pre = body_of("GET", f"/api/v1/exports/precheck?taskId={task_id}", at) check("precheck 接口可用", pre.get("code") == 0, json.dumps(pre, ensure_ascii=False)[:200]) d = pre.get("data") or {} check("canExport=false(还有没审完的)", d.get("canExport") is False, f"{d.get('canExport')}") check("阻断里指出了 UNAPPROVED 这一类", any(b.get("type") == "UNAPPROVED" for b in (d.get("blocking") or [])), json.dumps((d.get("blocking") or [])[:3], ensure_ascii=False)) check("阻断项带姓名(能指名道姓)", all(b.get("realName") for b in (d.get("blocking") or []))) check("summary.unapproved 与阻断条数一致", (d.get("summary") or {}).get("unapproved") == len([b for b in (d.get("blocking") or []) if b.get("type") == "UNAPPROVED"]), json.dumps(d.get("summary"), ensure_ascii=False)) check("quickActions 给出下一步(APPROVE_PENDING)", "APPROVE_PENDING" in (d.get("quickActions") or []), json.dumps(d.get("quickActions"), ensure_ascii=False)) create = body_of("POST", "/api/v1/exports", at, { "taskId": task_id, "exportName": "不该成功", "scope": {"onlyApproved": False} }) check("这是前置检查不过时的 40035(不是别的错)", create.get("code") == 40035, f"code={create.get('code')} msg={create.get('msg')}") check("40035 的 data 里带着完整清单(前端能直接列出来)", isinstance(create.get("data"), dict) and "blocking" in (create.get("data") or {}), json.dumps(create.get("data"), ensure_ascii=False)[:200]) check("被拦时**没有**创建导出记录", sql(f"select count(*) from collect_export_task where task_id={task_id} " f"and status <> 3;").strip() == "0") # ── 2. 把三个学生审完(班主任初审 + 管理员终审)── print("\n[2] 把演示数据审到全部通过") todos = body_of("GET", "/api/v1/audit/todos?node=1&pageNum=1&pageSize=50", tt).get("data") or {} first_ids = [x["submissionId"] for x in (todos.get("list") or [])] check("班主任看到初审待办", len(first_ids) >= 2, f"todos={first_ids}") for sid in first_ids: b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", tt, {"actionSource": "BUTTON"}) if b.get("code") != 0: fail(f"初审通过 submission={sid}", json.dumps(b, ensure_ascii=False)[:200]) check("初审全部通过", True) final = body_of("GET", "/api/v1/audit/todos?node=2&pageNum=1&pageSize=50", at).get("data") or {} passes = [x["submissionId"] for x in (final.get("list") or [])] for sid in passes: b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at, {"actionSource": "BUTTON", "finalOpinion": "E2E 终审通过"}) if b.get("code") != 0: fail(f"终审通过 submission={sid}", json.dumps(b, ensure_ascii=False)[:200]) passed_cnt = sql(f"select count(*) from collect_submission where task_id={task_id} " f"and status=3 and deleted=0;").strip() check("三份填报单都是「已通过」", passed_cnt == "3", f"status=3 的份数={passed_cnt}") # ⚠️ 已通过的单子**不会再出现在待办里**(待办只列待处理的), # 所以后面要用到的 id 必须从库里取 —— 用 todos 的返回值会让脚本 # "第一次跑得过、第二次跑就空指针"(本脚本已经踩过一次)。 final_ids = [int(x) for x in sql( f"select id from collect_submission where task_id={task_id} and status=3 " f"and deleted=0 order by id limit 3;").split()] check("能从库里取到已通过的填报单 id", len(final_ids) == 3, f"{final_ids}") # ── 3. 前置检查应当通过 ── print("\n[3] 审完之后:前置检查应当放行") pre2 = body_of("GET", f"/api/v1/exports/precheck?taskId={task_id}", at).get("data") or {} check("canExport=true", pre2.get("canExport") is True, json.dumps(pre2.get("blocking"), ensure_ascii=False)[:300]) check("候选人数 = 3", pre2.get("candidateCount") == 3, f"{pre2.get('candidateCount')}") check("文件数 = 9(3 人 × 3 类材料)", pre2.get("fileCount") == 9, f"{pre2.get('fileCount')}") check("原始体积 > 0", (pre2.get("rawSizeBytes") or 0) > 0, f"{pre2.get('rawSizeBytes')}") # ── 4. 创建导出并等完成 ── print("\n[4] 创建导出并轮询到完成") created = body_of("POST", "/api/v1/exports", at, { "taskId": task_id, "exportName": "E2E导出-验收", "scope": {"onlyApproved": True}, "format": "ZIP", "generateSummaryExcel": True }) check("创建成功", created.get("code") == 0, json.dumps(created, ensure_ascii=False)[:250]) export_id = (created.get("data") or {}).get("exportId") check("返回 exportId", isinstance(export_id, int), f"{export_id}") detail = {} for _ in range(40): detail = body_of("GET", f"/api/v1/exports/{export_id}", at).get("data") or {} if detail.get("status") in (2, 3): break import time time.sleep(1) check("导出完成(status=2)", detail.get("status") == 2, f"status={detail.get('status')} step={detail.get('stepText')} err={detail.get('errorMsg')}") check("进度 100", detail.get("progress") == 100, f"{detail.get('progress')}") check("产物清单非空", bool(detail.get("resultFiles")), json.dumps(detail.get("resultFiles"))[:200]) rf = (detail.get("resultFiles") or [{}])[0] check("产物名带导出名", str(rf.get("name", "")).startswith("E2E导出-验收"), f"{rf.get('name')}") check("产物有体积", (rf.get("size") or 0) > 0, f"{rf.get('size')}") check("产物有 sha256 校验值", str(rf.get("checksum", "")).startswith("sha256:"), f"{rf.get('checksum')}") check("stats 里有压缩率", bool((detail.get("stats") or {}).get("compressRatio")), json.dumps(detail.get("stats"), ensure_ascii=False)) # ── 5. 下载并验内容 ── print("\n[5] 下载并检查包内容") dl = body_of("GET", f"/api/v1/exports/{export_id}/download", at).get("data") or {} check("下载信息给出地址与文件名", bool(dl.get("url")) and bool(dl.get("fileName")), json.dumps(dl, ensure_ascii=False)[:200]) check("下载有效期 > 0 秒", (dl.get("expireSeconds") or 0) > 0, f"{dl.get('expireSeconds')}") zip_path = os.path.join(ROOT, "scripts", "_e2e-export.zip") code = os.system(f'curl -s -o "{zip_path}" -H "Authorization: Bearer {at}" ' f'"{BASE}{dl.get("url")}"') check("下载接口返回 200 且落地成文件", os.path.exists(zip_path) and os.path.getsize(zip_path) > 1000, f"exit={code} size={os.path.getsize(zip_path) if os.path.exists(zip_path) else 'N/A'}") names: list[str] = [] with zipfile.ZipFile(zip_path) as z: for info in z.infolist(): names.append(info.filename) # ★ UTF-8 标志位(general purpose bit 11)—— Windows 解压看的就是它 if info.filename.endswith("身份证.jpg") or info.filename.endswith("汇总表.xlsx"): check(f"「{info.filename}」置了 UTF-8 标志位(bit 11)", bool(info.flag_bits & 0x800), f"flag_bits={info.flag_bits:#x}") # 汇总表内容。⚠️ 文件名按 PRD §5.9 的官方叫法,不是"汇总表.xlsx" xlsx = [n for n in names if n.endswith("(汇总).xlsx")] check("包里有汇总表(文件名用机构叫法)", bool(xlsx), f"{names[:5]}") if xlsx: from openpyxl import load_workbook with z.open(xlsx[0]) as fh: wb = load_workbook(io.BytesIO(fh.read())) ws = wb.active headers = [c.value for c in ws[1]] check("汇总表 21 列(对齐官方模板 A~U)", len(headers) == 21, f"{len(headers)}:{headers}") check("汇总表表头关键列正确", headers[:4] == ["序号", "姓名", "身份证号", "性别"] and headers[-1] == "备注", f"{headers[:4]} … {headers[-1]}") check("汇总表数据行 = 3", ws.max_row == 4, f"max_row={ws.max_row}") names_col = [ws.cell(row=r, column=2).value for r in range(2, ws.max_row + 1)] check("汇总表里三个学生都在", sorted(n for n in names_col if n) == sorted(["演示甲", "演示乙", "演示丙"]), f"{names_col}") idcard = str(ws.cell(row=2, column=3).value or "") check("汇总表里的证件号是脱敏的(不含 18 位明文)", "*" in idcard and len(idcard) <= 18, f"{idcard}") # ── 目录结构必须与 PRD §5.9 的规范一致(不是"看着合理"就行)── check("根目录是「{批次号}-{任务名称}」", any(n.startswith("202609-【演示】审核台/") for n in names), f"{sorted({n.split('/')[0] for n in names})}") check("第二层是班级", any(n.count("/") >= 2 and n.split("/")[1].startswith("E2E 24数控1班") for n in names), f"{sorted({n.split('/')[1] for n in names if n.count('/') >= 2})}") mats = sorted({n.split("/")[2] for n in names if n.count("/") >= 3}) # ⚠️ 用 set 比较,不用"排序后相等":中文的排序结果取决于码点, # 写死一个顺序会让断言在换一台机器/换一个 Python 版本时莫名其妙地红。 check("第三层是**材料类型**(一寸照片/身份证/学籍表)", set(mats) == {"一寸照片", "身份证", "学籍表"}, f"{mats}") check("第四层是文件(班级/材料/文件 三层目录 + 文件名)", all(n.count("/") == 3 for n in names if n.endswith(".jpg")), f"{[n for n in names if n.endswith('.jpg')][:3]}") check("汇总表在根目录(不进子目录)", all(n.count("/") == 1 for n in names if n.endswith("(汇总).xlsx")), f"{xlsx}") check("文件名是规范件名(带姓名与证件号)", all("演示" in n for n in names if n.endswith(".jpg")), f"{[n for n in names if n.endswith('.jpg')][:3]}") check("没有把草稿/原始件混进去(无 raw/ 前缀)", not any(n.startswith("raw/") or "_raw" in n for n in names), f"{names[:5]}") # 目录树预览接口:必须与产物一致(否则"预览"就是谎言) tree = body_of("GET", f"/api/v1/exports/preview?taskId={task_id}&onlyApproved=true", at).get("data") or {} check("目录树预览接口可用", tree.get("root") == "202609-【演示】审核台", f"root={tree.get('root')}") check("预览的班级节点与产物一致", {x.get("name") for x in (tree.get("children") or [])} == {n.split("/")[1] for n in names if n.count("/") >= 2}, f"{[x.get('name') for x in (tree.get('children') or [])]}") preview_mats = {m.get("name") for c in (tree.get("children") or []) for m in (c.get("children") or [])} check("预览的材料目录与产物一致", preview_mats == set(mats), f"{preview_mats}") check("预览标注了人数与文件数", tree.get("personCount") == 3 and tree.get("fileCount") == 9, f"person={tree.get('personCount')} file={tree.get('fileCount')}") os.remove(zip_path) # ── 6. 导出的连带效果 ── print("\n[6] 导出之后的连带约束") # # ⚠️ 这里**不能**断言"撤回返回 40033":`RevokePolicy` 在导出之后直接把 # `canRevoke` 置 false(理由写明"已导出打包,不可撤回"), # 前端因此拿不到 `auditRecordId` → 真去调接口会先撞"缺少要撤回的记录 id"(40001)。 # 也就是说 **40033 在界面上不会出现**,它只在"绕过前端直接调接口"时才是可达的。 # 所以这里断言的是**用户与前端真正看得见的东西**:policy 说不许撤,且理由点名"已导出"。 fdetail = body_of("GET", f"/api/v1/audit/submissions/{final_ids[0]}", at).get("data") or {} rp = fdetail.get("revokePolicy") or {} check("已导出之后 canRevoke=false", rp.get("canRevoke") is False, json.dumps(rp, ensure_ascii=False)) check("不能撤的理由点名了「已导出」", "导出" in str(rp.get("reason")), f"{rp.get('reason')}") check("前端拿不到可撤回的记录 id(所以不会误发请求)", rp.get("auditRecordId") is None, f"{rp.get('auditRecordId')}") revoke = body_of("POST", f"/api/v1/audit/submissions/{final_ids[0]}/revoke", at, {"reason": "E2E:导出后不该能撤"}) check("绕过前端直接调撤回:被拒(40033 或缺记录 id 都算拒)", revoke.get("code") in (40033, 40001), f"code={revoke.get('code')} msg={revoke.get('msg')}") still = sql(f"select status from collect_submission where id={final_ids[0]};").strip() check("那一单仍然是「已通过」(没被撤掉)", still == "3", f"status={still}") lst = body_of("GET", f"/api/v1/exports?taskId={task_id}&pageNum=1&pageSize=10", at).get("data") or {} check("导出列表里有这次记录", any(x.get("id") == export_id for x in (lst.get("list") or [])), json.dumps(lst, ensure_ascii=False)[:200]) check("列表里带任务名", all(x.get("taskName") for x in (lst.get("list") or []) if x.get("taskId")), json.dumps((lst.get("list") or [{}])[0], ensure_ascii=False)[:200]) # ── 7. 边界 ── print("\n[7] 边界与权限") b = body_of("POST", "/api/v1/exports", at, {"taskId": task_id, "exportName": "空数据", "scope": {"orgIds": [999999]}}) check("指定不存在的班 → 没有候选数据被拦住", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}") b = body_of("POST", "/api/v1/exports", at, {"exportName": "没任务"}) check("缺 taskId → 40001", b.get("code") == 40001, f"code={b.get('code')}") b = body_of("GET", f"/api/v1/exports/{export_id}", tt) check("班主任(无 export:download)看导出记录 → 40301 或 403", b.get("code") in (40301, 403) or b.get("_http") == 403, f"code={b.get('code')} http={b.get('_http')}") b = body_of("GET", "/api/v1/exports/99999999", at) check("不存在的导出 id → 明确报错而不是 500", b.get("code") == 40001, f"code={b.get('code')}") print("\n" + "=" * 72) print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") print("=" * 72) for n, det in FAILED: print(f" ✗ {n}\n {det}") return 1 if FAILED else 0 if __name__ == "__main__": raise SystemExit(main())