diff --git a/scripts/check-api-parity.py b/scripts/check-api-parity.py new file mode 100644 index 0000000..8c166d8 --- /dev/null +++ b/scripts/check-api-parity.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +"""契约 ↔ 代码 接口实现度比对(可复现的盘点工具)。 + +用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。 + —— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。 + +契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 121 个,全文权威清单)。 +代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。 + +★ 两个必须容忍的写法(踩过): + 1. 全限定注解 `@org.springframework.web.bind.annotation.PostMapping("/x")` + —— FillController 就是这么写的;用 `@PostMapping` 短名匹配会漏掉 3 个接口; + 2. `@RequestMapping(value="/x", method=RequestMethod.POST)` 这种显式写法。 + +用法: + python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单 + python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用) +""" +from __future__ import annotations + +import glob +import os +import re +import sys + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOC = os.path.join(ROOT, "docs", "03-接口设计.md") +JAVA = os.path.join(ROOT, "backend", "xt-server", "src", "main", "java", "cn", "tianbaotong", "fill") + +# §4 里这两节是索引(交叉引用别处已计数的接口),不重复计入 +INDEX_SECTIONS = ("4.9", "4.11") + + +def norm(path: str) -> str: + """归一化:去掉 /api/v1 前缀、统一占位符(`{id}` 与 `{fileId}` 视为同一个)。""" + p = path.replace("/api/v1", "") + p = re.sub(r"\{[^}]+\}", "{}", p) + return p.rstrip("/") or "/" + + +def doc_overview() -> dict[str, list[tuple[str, str]]]: + """解析 §4.1~§4.10 的清单表,返回 分类 -> [(METHOD, path)]。""" + text = open(DOC, encoding="utf-8").read() + sec = text[text.index("## §4"):text.index("## §5")] + out: dict[str, list[tuple[str, str]]] = {} + cur = None + for line in sec.splitlines(): + h = re.match(r"^### (4\.\d+) (.+)$", line) + if h: + cur = f"{h.group(1)} {h.group(2)}" + out[cur] = [] + continue + if cur is None or not line.startswith("|") or cur.startswith(INDEX_SECTIONS): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + if len(cells) < 2 or set(cells[0]) <= set("-: "): + continue + method, path = cells[0].replace("⭐", "").strip(), cells[1].strip("` ") + if not path.startswith("/"): + continue + for m in re.findall(r"GET|POST|PUT|DELETE|PATCH", method.upper()) or ["?"]: + out[cur].append((m, path)) + return out + + +def code_endpoints() -> list[tuple[str, str, str]]: + """解析所有 controller,返回 [(文件, METHOD, 完整路径)]。""" + out = [] + for f in glob.glob(os.path.join(JAVA, "module", "**", "controller", "*.java"), recursive=True): + src = open(f, encoding="utf-8").read() + mb = re.search(r'@RequestMapping\(\s*(?:value\s*=\s*)?"([^"]+)"', src) + base = mb.group(1) if mb else "" + for mm in re.finditer( + r'@(?:[\w.]+\.)?(Get|Post|Put|Delete|Patch)Mapping(?:\(\s*(?:value\s*=\s*)?"([^"]*)")?', + src): + out.append((os.path.basename(f), mm.group(1).upper(), + (base + (mm.group(2) or "")) or "/")) + for mm in re.finditer( + r'@RequestMapping\(\s*value\s*=\s*"([^"]+)"\s*,\s*method\s*=\s*' + r'(?:RequestMethod\.)?(GET|POST|PUT|DELETE|PATCH)', src): + out.append((os.path.basename(f), mm.group(2), base + mm.group(1))) + return sorted(set(out), key=lambda x: (x[0], x[2])) + + +def main() -> int: + quiet = "--quiet" in sys.argv + only_missing = "--missing" in sys.argv + doc = doc_overview() + code = code_endpoints() + code_keys = {(m, norm(p)) for _, m, p in code} + + claimed = implemented = 0 + missing: list[tuple[str, str, str]] = [] + rows = [] + for cat, eps in doc.items(): + uniq = sorted(set(eps)) + done = [e for e in uniq if (e[0], norm(e[1])) in code_keys] + miss = [e for e in uniq if (e[0], norm(e[1])) not in code_keys] + claimed += len(uniq) + implemented += len(done) + missing += [(cat, m, p) for m, p in miss] + rows.append((cat, len(uniq), len(done), len(miss))) + + if not quiet and not only_missing: + print("=" * 78) + print("代码注册接口:%d 个" % len(code)) + print("=" * 78) + for f, m, p in code: + print(" %-30s %-6s %s" % (f, m, p)) + print() + + print("=" * 78) + print("契约 §4 总览逐分类") + print("=" * 78) + for cat, n, d, miss_n in rows: + print(" %-22s 声称 %2d 已实现 %2d 缺口 %2d" % (cat, n, d, miss_n)) + + print() + print("合计:契约 %d / 已实现 %d / 缺口 %d / 实现度 %.0f%%" + % (claimed, implemented, len(missing), 100.0 * implemented / claimed)) + + if not quiet: + print() + cur = None + for cat, m, p in missing: + if cat != cur: + print("\n【%s】" % cat) + cur = cat + print(" %-6s %s" % (m, p)) + # `--missing` 就是"只看还差什么":上面那两段(代码清单 + 逐分类数字) + # 在"接着往下做"时反而是噪声,而这是最常被问的问题 + if only_missing: + print() + print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。") + + # 契约必须恰好 121(§2 的口径),不等就说明解析或文档出了问题 + if claimed != 121: + print(" [FAIL] 契约接口数应为 121,实际解析出 %d —— 检查 §4 表格格式" % claimed) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-entity-columns.py b/scripts/check-entity-columns.py new file mode 100644 index 0000000..42f989d --- /dev/null +++ b/scripts/check-entity-columns.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""机械校验:实体继承的基类要求哪些列,迁移里到底建了没有(防 F-24 第四次复发)。 + +## 为什么需要这个脚本 + +`FillMetaObjectHandler` 会为**继承 `BaseEntity`/`TenantEntity` 的实体**自动填 +`create_by` / `create_time` / `update_by` / `update_time` / `deleted`(以及 `TenantEntity` 的 +`tenant_id`),并且 MyBatis-Plus 的 `@TableLogic` 会自动给查询补 `deleted = 0`。 + +**这一切都在编译期不报错**。少一列的表现是: + · 插入报 `column "create_by" ... does not exist`(运行时 500); + · 或者查询报 `column "deleted" ... does not exist`。 + +本项目已经因此踩过三次(`collect_task_org` / `collect_export_task` / `collect_message`), +第四次是 `collect_export_file`。每次的现象都长得像"SQL 写错了",而实际是建表漏列。 + +所以把它变成**机械检查**:从 Java 实体读出 `@TableName` 与继承的基类, +从迁移 SQL 里读出 `CREATE TABLE` 的列,然后比对该有的列在不在。 + +## 口径 + +| 基类 | 必须有的列 | +|---|---| +| `BaseEntity` | `create_by` `create_time` `update_by` `update_time` `deleted` | +| `TenantEntity` | 上面 5 个 + `tenant_id` | +| 普通 `Serializable` | 无要求(但注意:**不能**带 `@TableLogic`,否则查询会补 `deleted`) | + +## 用法 + + python scripts/check-entity-columns.py # 有问题退出码 1 + python scripts/check-entity-columns.py --quiet # 只打结论 +""" +from __future__ import annotations + +import argparse +import glob +import os +import re +import sys + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BACKEND = os.path.join(ROOT, "backend", "xt-server", "src", "main") +JAVA = os.path.join(BACKEND, "java") +MIG = os.path.join(BACKEND, "resources", "db", "migration") + +SKIP_DIRS = ("test", "target") + +BASE_COLS = ["create_by", "create_time", "update_by", "update_time", "deleted"] +TENANT_COLS = ["tenant_id"] + BASE_COLS + + +def strip_comments(src: str) -> str: + """去掉注释再找注解。 + + ⚠️ 必须先去注释:这四个"普通实体"的**类注释里都写着** `@TableLogic` + (说明它们为什么不继承基类),直接搜会把解释性文字当成真注解 —— + 我第一版就是这么误报的(4 个假阳性)。 + """ + src = re.sub(r"/\*.*?\*/", "", src, flags=re.DOTALL) + src = re.sub(r"//[^\n]*", "", src) + return src + + +def java_files() -> list[str]: + out = [] + for path in glob.glob(os.path.join(JAVA, "**", "*.java"), recursive=True): + if any(s in path.replace(os.sep, "/") for s in SKIP_DIRS): + continue + out.append(path) + return out + + +def read(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def tables_from_migrations() -> dict[str, set[str]]: + """从迁移 SQL 里抓 `CREATE TABLE x (...)` 的列名。 + + 只做**足够用**的解析:本项目建表都是规规矩矩的 + `CREATE TABLE [IF NOT EXISTS] name ( 列名 类型 …, )`。 + 碰到 `CREATE TABLE ... AS SELECT` 或视图就跳过(本项目没有)。 + """ + tables: dict[str, set[str]] = {} + for path in sorted(glob.glob(os.path.join(MIG, "*.sql"))): + sql = read(path) + for m in re.finditer( + r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)\s*\((.*?)\n\)\s*;", + sql, re.IGNORECASE | re.DOTALL): + name = m.group(1).lower() + body = m.group(2) + cols: set[str] = set() + for line in body.split("\n"): + s = line.strip() + if not s or s.startswith("--"): + continue + # 约束行跳过 + if re.match(r"^(CONSTRAINT|PRIMARY|UNIQUE|FOREIGN|CHECK|EXCLUDE)\b", s, re.I): + continue + cm = re.match(r"^([a-z_][a-z0-9_]*)\s+", s, re.I) + if cm: + cols.add(cm.group(1).lower()) + tables.setdefault(name, set()).update(cols) + + # ALTER TABLE ... ADD COLUMN 也算(V9 就是靠它补列的) + for m in re.finditer( + r"ALTER\s+TABLE\s+([a-z_][a-z0-9_]*)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)", + sql, re.IGNORECASE): + tables.setdefault(m.group(1).lower(), set()).add(m.group(2).lower()) + return tables + + +def entities() -> list[tuple[str, str, str]]: + """(文件, 表名, 基类) —— 只取带 @TableName 的实体。""" + out = [] + for path in java_files(): + src = read(path) + tm = re.search(r'@TableName\(\s*(?:value\s*=\s*)?"([a-z_][a-z0-9_]*)"', src) + if not tm: + continue + cm = re.search(r"public\s+class\s+\w+\s+extends\s+([\w.]+)", src) + base = cm.group(1).split(".")[-1] if cm else "Serializable" + out.append((path, tm.group(1), base)) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description="实体基类所需列 vs 迁移建表列") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + tables = tables_from_migrations() + ents = entities() + problems: list[str] = [] + + if not args.quiet: + print("=" * 74) + print(" 实体所需列 vs 迁移建表列(防 F-24:少一列在运行期才炸)") + print("=" * 74) + + for path, table, base in sorted(ents, key=lambda x: x[1]): + need = TENANT_COLS if base == "TenantEntity" else ( + BASE_COLS if base == "BaseEntity" else []) + rel = os.path.relpath(path, ROOT) + src = read(path) + if not need: + # ⚠️ 普通实体带 @TableLogic 时,MyBatis-Plus 会给所有查询补 `deleted = 0`。 + # 所以只有当**表里确实没有 deleted 列**时才是问题 —— + # (本项目这四张表都有 deleted 列,所以注解是正常的, + # 但"当时为什么去掉实体上的 deleted 字段"这个理由要说清楚)。 + cols = tables.get(table, set()) + if "@TableLogic" in strip_comments(src) and "deleted" not in cols: + problems.append(f"{rel}:普通实体带 @TableLogic,但表 `{table}` 没有 deleted 列 " + f"→ 所有查询都会报 column does not exist") + print(f" [可疑] {table:<28} ⭐ 带 @TableLogic 但表里没有 deleted") + elif not args.quiet: + note = "(有 deleted 列,注解有效)" if "@TableLogic" in src else "" + print(f" [跳过] {table:<28} 普通实体(无自动填充列要求){note}") + continue + if table not in tables: + problems.append(f"{rel}:表 `{table}` 在迁移里找不到 CREATE TABLE") + print(f" [缺失] {table:<28} ⭐ 迁移里没有这张表") + continue + missing = [c for c in need if c not in tables[table]] + if missing: + problems.append(f"{rel}:表 `{table}` 缺列 {', '.join(missing)}(基类 {base} 要求)") + print(f" [缺列] {table:<28} ⭐ 缺 {', '.join(missing)}") + elif not args.quiet: + print(f" [OK] {table:<28} {base} 要求的 {len(need)} 列都在") + + print() + if problems: + print(f"✗ {len(problems)} 个实体与迁移不一致:") + for p in problems: + print(" ·", p) + print("\n修法:迁移里补列(**不要**改实体去掉字段 —— 去掉会让自动填充失效)。") + return 1 + print(f"✓ {len(ents)} 个实体全部与迁移一致({len(tables)} 张表)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-manual.py b/scripts/check-manual.py new file mode 100644 index 0000000..e0aca83 --- /dev/null +++ b/scripts/check-manual.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""手册与代码的一致性检查(客服话术的可信度靠它)。 + +## 为什么要查这个 + +手册(`docs/10-用户手册与客服话术.md`)的价值**完全建立在 +"手册里那句话 == 用户屏幕上那句话"**上 —— 客服照着它回答, +对不上就会开始自己编,那比没有手册更糟。 + +而**手册是文档、文案在代码里**:改一次代码文案,手册就静默失准, +且没有任何东西会红。这与"首页在说一件不真的事"(F-82)是同一类问题。 + +所以这里把它变成机械检查,与 `check-api-parity.py`(契约↔路由)、 +`check-entity-columns.py`(实体↔DDL)并列 —— 都是"文档与代码是否还对得上"。 + +## 查两件事 + +1. 手册里引用的每个错误码(反引号包着的 5 位数)在 `ErrorCode.java` 里**确实存在**; +2. 手册里"客服会照着念"的那些提示文案在代码里**逐字存在**。 + +⚠️ 它**不检查**手册有没有漏掉新错误码 —— 那要靠人。 +它拦的是"手册写了代码里没有的东西"(这会让客服查到一半卡住)。 + +跑法:python scripts/check-manual.py +""" + +import io +import re +import sys + +sys.stdout.reconfigure(encoding="utf-8") + +manual = io.open('docs/10-用户手册与客服话术.md', encoding='utf-8').read() +codes_src = io.open('backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/' + 'ErrorCode.java', encoding='utf-8').read() + +# ① 码:三个捕获组 → 自己建索引 +rows = re.findall(r'([A-Z_]+)\((\d{4,5}),\s*"([^"]*)"\)', codes_src) +declared_by_code = {code: (name, msg) for name, code, msg in rows} +print("ErrorCode 里声明了 %d 个码" % len(declared_by_code)) + +used = sorted(set(re.findall(r'`(\d{5})`', manual))) +print("手册引用了 %d 个码" % len(used)) +bad = [] +for c in used: + if c in declared_by_code: + name, msg = declared_by_code[c] + print(" ✓ %s %-24s %s" % (c, name, msg[:44])) + else: + bad.append(c) + print(" ✗ %s **ErrorCode 里没有这个码**" % c) + +# ② 关键文案 +key_phrases = [ + "没有找到你的信息,请核对后重试", + "操作过于频繁,请稍后再试", + "该信息已被登记", + "你已在名单中,请返回重新核验", + "你已提交过申请,请耐心等待", + "这份材料可能不是你的,请重新上传", + "已进入审核,需先撤回才能删材料", + "已通过终审,不可撤回", + "任务已截止、已归档或已暂停", + "驳回次数已达上限", + "存在不合格文件,禁止导出", + "你的材料被退回了", + "导出包已过期", + "这个学生还没扫码核对身份", +] +src_all = "" +import glob +for p in glob.glob('backend/**/*.java', recursive=True): + try: + src_all += io.open(p, encoding='utf-8').read() + except Exception: + pass + +print("\n核对手册里引用的提示文案(%d 句):" % len(key_phrases)) +missing = [] +for ph in key_phrases: + if ph in src_all: + print(" ✓ %s" % ph) + else: + missing.append(ph) + print(" ✗ %s —— **代码里找不到这句**" % ph) + +print("\n结果:错误的码 %d 个,找不到的文案 %d 句" % (len(bad), len(missing))) +sys.exit(1 if (bad or missing) else 0) diff --git a/scripts/check-test-profile.py b/scripts/check-test-profile.py new file mode 100644 index 0000000..57b2cea --- /dev/null +++ b/scripts/check-test-profile.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +""" +用 `test` profile 真起一次 —— **CI 用的就是这个 profile**。 + +## 为什么单独一个检查 + +`application-test.yml` 是流水线里 `e2e` 阶段真正加载的配置,而它是"新写的配置"。 +**新写的配置从没跑起来过**,正是流水线第一次上线时最典型的红法 —— +而且往往红在一个拼错的属性名上,排查半天。 + +所以这里用真实的环境变量起一次,确认三件事: + ① 能起来;② 迁移跑得过;③ `/actuator/health` 可用。 + +## 它不验什么 + +不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。 +这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。 + +跑法:python scripts/check-test-profile.py +(前提:本地 PG/Redis 在跑、已 `mvn package`) +""" +import base64 +import os +import re +import socket +import subprocess +import sys +import time +import urllib.request + +sys.stdout.reconfigure(encoding="utf-8") +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RSA_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0=' +JAR = os.path.join(ROOT, "backend", "xt-server", "target", "xt-server-1.0.0-SNAPSHOT.jar") + +env = dict(os.environ) +env.update({ + # 指到本地已有的库(与 dev 用的是同一个库,只验"能不能起来") + "PG_HOST": "localhost", "PG_PORT": "5432", "PG_DB": "filldb", + "PG_USER": "fill", "PG_PASSWORD": "fill_dev_pwd", + "REDIS_HOST": "localhost", "REDIS_PORT": "16379", "REDIS_PASSWORD": "redis_dev_pwd", + # ⚠️ 密钥一律用 base64(os.urandom(n)) 生成 —— 手写字面量连错两次: + # 一次是 AES 少了 2 字节,一次是 HMAC 那串根本不是合法 base64 + "XT_JWT_SECRET": base64.b64encode(os.urandom(64)).decode(), + "XT_AES_KEY": base64.b64encode(os.urandom(32)).decode(), + "XT_HMAC_KEY": base64.b64encode(os.urandom(48)).decode(), + "XT_RSA_PRIVATE_KEY": RSA_KEY, +}) + + +def kill_tree(proc): + """ + 杀掉整个进程树(Windows 用 taskkill /T)。 + + ⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程; + 更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**, + 所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净, + 再靠下面的 preclean_port 兜住剩下的。 + """ + if proc is None or proc.poll() is not None: + return + try: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, timeout=20) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def preclean_port(port): + """ + 开跑前清掉这个端口上的残留 java。 + + ⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** —— + 残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。 + 不清端口 = 残留永远不会被发现。 + """ + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)" + ".OwningProcess" % port], + capture_output=True, text=True, timeout=30) + for line in (r.stdout or "").split(): + pid = line.strip() + if pid.isdigit(): + subprocess.run(["taskkill", "/F", "/T", "/PID", pid], + capture_output=True, timeout=20) + print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid)) + except Exception: + pass + +def free_port() -> int: + """ + 要一个空闲端口。 + + ⚠️ 不要写死端口:写死的话**第二次跑就撞端口**,而报的错 + (`Port xxx was already in use`)跟"配置对不对"毫无关系 —— + 一个只能跑一次的检查等于没有检查。 + """ + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + + +PORT = free_port() +print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT) +preclean_port(PORT) + +p = subprocess.Popen( + ["java", "-jar", JAR, "--spring.profiles.active=test", + "--server.port=%d" % PORT], + cwd=os.path.join(ROOT, "backend"), env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace") + +ok = False +reason = "" +body = "" +try: + # ⚠️ 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行": + # 子进程 stdout 在管道里是**块缓冲**的,启动日志常常不足一个缓冲区, + # 于是一行都读不到;而且 readline() 一旦阻塞,超时的判断就轮不到。 + # 实测:单独跑能过、紧跟在几个 JVM 之后跑就失败 —— 时好时坏正是它的特征。 + deadline = time.time() + 180 + while time.time() < deadline: + if p.poll() is not None: + reason = "进程自己退了(退出码 %s)" % p.returncode + break + try: + with urllib.request.urlopen( + "http://localhost:%d/actuator/health" % PORT, timeout=3) as r: + body = r.read().decode() + ok = True + break + except Exception as e: # noqa: BLE001 + reason = str(e) + time.sleep(1) + if not ok and not reason: + reason = "180 秒内没探活成功" +finally: + # ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程, + # 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。** + kill_tree(p) + try: + p.wait(timeout=15) + except Exception: + pass + +if ok: + print("健康检查:", body[:200]) + print("\n结果:test profile **能起来**") +else: + print("\n结果:test profile **起不来**") + print(" 原因:" + reason) + try: + text = (p.stdout.read() or "") if p.stdout else "" + except Exception: + text = "" + if "already in use" in text or ("Port" in text and "in use" in text): + print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题") + for kw in ("ERROR", "Caused by", "Failed", "migration", "Flyway", "Cannot"): + for ln in text.split("\n"): + if kw in ln: + print(" " + ln.strip()[:160]) + break + print("---- 尾部 ----") + print(text[-1500:]) + +sys.stdout.flush() +raise SystemExit(0 if ok else 1) diff --git a/scripts/dev-seed-audit.py b/scripts/dev-seed-audit.py new file mode 100644 index 0000000..6d03008 --- /dev/null +++ b/scripts/dev-seed-audit.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +"""摆一份**可用于界面验收的审核台数据**(开发用,不属于 E2E,不会清理)。 + +为什么要这么一个脚本 +-------------------- +审核台(PC)与班主任端页面验收时需要"名单里有人已提交"的状态,而 E2E 脚本 +跑完会**清掉名册绑定**。手工在页面上造:建任务 → 配班级 → 配材料 → 导名册 → +三个学生各自核验 + 填表 + 传图 + 提交 —— 一次十几分钟,而且每次都要重来。 + +它做的全部事情都是**走真实接口**(建任务 / 配班级 / 应用材料模板 / 导名册 / +门禁核验 / 填表 / 传材料 / 提交 / 初审通过),所以摆出来的状态与真实使用完全一致。 + +跑法 +---- + python scripts/dev-seed-audit.py # 建演示任务 + 3 个学生 + 3 份提交单 + python scripts/dev-seed-audit.py --reset # 先删掉旧的演示任务再重建 + python scripts/dev-seed-audit.py --clean # 只清理演示任务 + +产出(浏览器里怎么用) +---------------------- + · 任务「【演示】审核台」→ 名单管理:3 人,3 人已提交 + · 审核 → 初审:**演示甲(干净,可批量通过)/ 演示乙(有黄色警告)** + · 审核 → 终审:**演示丙(已初审通过,等待终审)** +""" +from __future__ import annotations + +import argparse +import time +import importlib.util +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE = "http://localhost:8080" +TASK_NAME = "【演示】审核台" +TMP_ROSTER = os.path.join(ROOT, "scripts", "_seed-audit-roster.xlsx") + +# 演示班主任:**密码写死**,因为这个账号的用途就是"登录进去看初审那一屏"。 +# 生产当然不能这么干,但它是 dev 演示数据(`--clean` 会连任务一起删)。 +TEACHER_USERNAME = "demot1" +TEACHER_PASSWORD = "Demo1234" +TEACHER_NAME = "演示班主任" +TEACHER_PHONE = "13900139001" + + +def _load(module_name: str, filename: str): + """ + 复用既有 E2E 脚本的工具(登录 / 造名册 / 造学生 / 校验位计算)。 + + ⚠️ 不自己再写一份 `call`/`upload`/`mint`:两边一改一漏, + 表现是"这个脚本单独跑得过、和 E2E 一起跑就红",而根因极难找。 + """ + 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") # 服务端工具:登录 / 导入名册 / SQL +S = _load("e2e_m3_submit", "e2e-m3-submit.py") # 学生侧:填表 / 传材料 / 提交 +A = _load("e2e_m5_audit", "e2e-m5-audit.py") # big_jpeg / prepare_student + +# 三个学生:姓名必须与名册一致;证件号用 R.with_checksum 生成(校验位合法) +STUDENTS = [ + (993101, "演示甲", R.with_checksum("36102920080101001"), True), # 干净 → 留作初审待办 + (993102, "演示乙", R.with_checksum("36102920080202002"), False), # 重复件 → 黄色警告 + (993103, "演示丙", R.with_checksum("36102920080303003"), False), # 干净但先被初审通过 +] + + +def login() -> str: + st, body = R.login(BASE, "admin", "Admin@123", "demo") + if st != 200 or body.get("code") != 0: + raise SystemExit(f"登录失败:status={st} body={body}") + return body["data"]["token"] + + +def find_demo_tasks(token: str) -> list[dict]: + """找出所有演示任务(**只认干净名字**,被清理过的那些已改名,不会再被选中)。""" + st, body = R.call("GET", f"{BASE}/api/v1/tasks?pageNum=1&pageSize=200", token) + return [t for t in ((body.get("data") or {}).get("list") or []) + if t.get("taskName") == TASK_NAME] + + +def clean(token: str, naming_template: bool = True) -> None: + """ + 把上一次的演示数据清空并"挪开",好让本轮能重新用干净的任务名。 + + ⚠️ **已发布的任务既删不掉也改不了名**(`40001`,产品口径:只有草稿能删/改)—— + 所以这里只能: + · 用 SQL 清掉它的**提交单 / 审核记录 / 材料文件 / 名册行**(测试夹具性质的操作, + 与 E2E 用 SQL 造夹具同口径); + · 把任务名改成 `【演示·旧】审核台 {id}`,让 `find_demo_tasks` 不再选中它。 + + 为什么非要清不可:演示数据必须每次都能**从零重建**,否则第二次跑就会与上一次 + 的数据纠缠("同一个学生有两份提交单"),而那看起来像功能坏了。 + """ + tasks = find_demo_tasks(token) + if not tasks: + print(" 没有找到演示任务,无需清理") + return + for t in tasks: + tid = t["id"] + sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id = {tid});") + sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id = {tid});") + sql(f"delete from collect_submission where task_id = {tid};") + sql(f"delete from collect_roster where task_id = {tid};") + if naming_template: + sql(f"update collect_task set task_name = '【演示·旧】审核台 {tid}' where id = {tid};") + print(f" 旧的演示任务 {tid} 已清空{'并改名' if naming_template else ''}") + + +def sql(stmt: str) -> None: + """开发库上的夹具操作(与 E2E 脚本同一口径:`docker exec psql`)。""" + R.sql(stmt) + + +def _redis_del(key: str) -> None: + """删一个 Redis 键(限流计数用)。 + + 容器名/端口与 `deploy/docker-compose.yml` 一致:`xt-redis`(宿主 16379)。 + ⚠️ 本机还有别的项目的 redis(6379),所以这里**显式指定容器**,不用默认端口。 + + ⚠️ **必须带密码**:`xt-redis` 开了 requirepass(dev 是 `redis_dev_pwd`, + 见 `application-dev.yml`)。不传密码时 redis-cli 回 `NOAUTH ...`, + 而我第一版用 `capture_output=True` 又**不看返回码** —— 于是表现为 + "清了但没清掉",最后还是撞 40071,排查时完全看不出是密码问题。 + """ + import subprocess + r = subprocess.run(["docker", "exec", "xt-redis", "redis-cli", + "-a", "redis_dev_pwd", "--no-auth-warning", "DEL", key], + capture_output=True, text=True, timeout=30) + out = (r.stdout or "").strip() + if r.returncode != 0 or "NOAUTH" in out or "WRONGPASS" in out: + print(f" ⚠️ 清 Redis 键失败({key}):{out or r.stderr.strip()[:160]}") + return None + + +def main() -> int: + ap = argparse.ArgumentParser(description="摆审核台演示数据") + ap.add_argument("--clean", action="store_true", help="只清理演示任务") + ap.add_argument("--reset", action="store_true", help="先清理再重建") + ap.add_argument("--reset-gate-limit", action="store_true", + help="先清掉演示学生的门禁限流计数(Redis)。" + "限流是按用户 1 小时 10 次,反复重跑会撞 40071。\n" + "⚠️ 只清演示学生的键,不动全局限流配置") + ap.add_argument("--approve-one", action="store_true", + help="再走一次真实终审,让演示丙变成「已通过」" + "(导出功能需要至少一个已通过的人;否则两种范围都导不出来)") + args = ap.parse_args() + + token = login() + print(" 管理员登录 ok") + + if args.clean or args.reset: + clean(token) + if args.clean: + return 0 + + if find_demo_tasks(token): + print(f" 演示任务已存在({TASK_NAME})—— 要重建请加 --reset") + return 0 + + # ── ① 建任务(草稿)──────────────────────────────── + st, body = R.call("POST", f"{BASE}/api/v1/tasks", token, { + "taskName": TASK_NAME, + "batchNo": "202609", + "majorName": "数控加工", + "jobName": "车工", + "collectMode": 1, + "certType": 1, + "startTime": "2026-01-01", + "endTime": "2026-12-31" + }) + if body.get("code") != 0: + raise SystemExit(f"建任务失败:{body}") + task_id = body["data"]["id"] + print(f" 建任务 id={task_id}") + + # 班级:从组织树里找一个班级(演示租户已有) + st, body = R.call("GET", f"{BASE}/api/v1/orgs/tree", token) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(body.get("data") or []) + if not classes: + raise SystemExit("演示租户里没有班级节点,请先在「组织架构」里建一个班") + org = classes[0] + print(f" 用班级:{org.get('orgName')}(id={org.get('id')})") + + st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, { + "orgs": [{"orgId": org["id"], "expectedCount": len(STUDENTS)}] + }) + if body.get("code") != 0: + raise SystemExit(f"配置班级失败:{body}") + + # ── 门禁限流计数:只在显式要求时清 ── + # + # ⚠️ 门禁按用户 1 小时 10 次限流(`MpRosterService` 的 `xt:mp:gate:user:{uid}`), + # 而演示脚本每次都用同一批演示学生 uid —— 一小时内跑到第 4 次就撞 40071, + # 整脚本退出,而调用它的 E2E 只会报"演示数据没摆成",看不出是限流。 + # 做成**显式开关**而不是每次自动清:限流是生产行为,不该被脚本顺手改掉。 + if args.reset_gate_limit: + uids = [u for u, _, _, _ in STUDENTS] + for uid_ in uids: + _redis_del(f"xt:mp:gate:user:{uid_}") + print(f" 已清门禁限流计数:{uids}") + + # ── 演示班主任:**必须显式指定初审人**,否则这一屏验不了 ── + # + # 初审的授权口径是"这个班的初审人 / 副班主任 / 班级负责人"(`AuditScopeMapper` + # 的三级回退),而不是"有 audit:first 权限就行"。所以管理员自己**看不到初审待办** —— + # 这是设计(权限 ≠ 范围),但演示时必须有一个真实的班主任才能把初审那一屏走通。 + teacher_id, teacher_name = ensure_teacher(token, org["id"]) + st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, { + "orgs": [{ + "orgId": org["id"], + "expectedCount": len(STUDENTS), + "headUserId": teacher_id, + "deputyUserIds": [] + }] + }) + if body.get("code") != 0: + raise SystemExit(f"指定初审人失败:{body}") + # ⚠️ PUT 返回的是 `ApiResult`(data 为空),**不能拿它回显校验** —— + # 必须 GET 回来确认真的落库了。否则"接口成功但字段没写进去"会被当成通过。 + st, body = R.call("GET", f"{BASE}/api/v1/tasks/{task_id}/orgs", token) + got = ((body.get("data") or [{}])[0]).get("headUserId") + if got != teacher_id: + raise SystemExit(f"指定初审人没生效:期望 {teacher_id},实际 {got}") + print(f" 初审人:{teacher_name}(id={teacher_id})") + + # 材料模板(身份证 / 学籍表 / 一寸照片 都在 TPL_SCHOOL_ROLL 里) + st, body = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", token, + {"templateCode": "TPL_SCHOOL_ROLL"}) + if body.get("code") != 0: + raise SystemExit(f"应用材料模板失败:{body}") + print(f" 材料项:{len(body.get('data') or [])} 项") + + # ── ② 导名册(走真实导入接口)────────────────────── + # + # ⚠️ **必须带 orgId**:官方模板里没有班级列,不指定的话名册行没有班级, + # 于是填报单也没有班级 → 初审的范围校验无从判定,直接 `40301 + # 这份填报单没有班级信息,无法确认审核范围`(这正是 e2e-m5-audit 里那条断言的口径)。 + _build_roster([(name, idc) for _, name, idc, _ in STUDENTS]) + body = R.upload_roster(BASE, token, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + if body.get("code") != 0: + raise SystemExit(f"导入名册失败:{body}") + rep = body.get("data") or {} + print(f" 名册导入:created={rep.get('created')} failed={rep.get('failed')}") + for e in (rep.get("errors") or [])[:5]: + print(f" ✗ 第 {e.get('row')} 行 {e.get('col')}:{e.get('reason')}") + + # ── ③ 发布(这样学生端才允许提交)────────────────── + st, body = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", token, {"force": True}) + if body.get("code") != 0: + raise SystemExit(f"发布失败:{body}") + print(f" 任务已发布(状态={(body.get('data') or {}).get('status')})") + + # ── ④ 逐个学生:门禁核验 → 填表 → 传图 → 提交 ────── + cfg = R.read_dev_config() + subs: dict[int, int] = {} + for uid, name, idc, _ in STUDENTS: + tok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + # 门禁核验:服务端按姓名 + 证件号匹配名册并**原子绑定**(真实链路) + # + # ⚠️ 门禁按 IP 限流(40071)。连续跑两次种子脚本(全量回归里它被多个 E2E + # 各调一次)就会撞上,而表现是"演示数据没摆成",完全看不出是限流。 + # 所以只对 40071 退避重试;**其它错误照旧立刻退出**,不掩盖真问题。 + body = {} + for attempt in range(3): + st, body = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": name, "idCardNo": idc}) + if body.get("code") != 40071: + break + wait = 5 * (attempt + 1) + print(f" · 门禁限流(40071),{wait} 秒后重试第 {attempt + 2} 次…") + time.sleep(wait) + if body.get("code") != 0: + raise SystemExit(f"门禁核验失败({name}):{body}") + roster_id = (body.get("data") or {}).get("rosterId") + + st, body = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id}) + if body.get("code") != 0: + raise SystemExit(f"建草稿失败({name}):{body}") + sid = body["data"]["id"] + + fill = {"work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"} + res = A.prepare_student(tok, sid, task_id, fill, dup_first_file=(uid == 993102)) + if res.get("_upload_error"): + raise SystemExit(f"上传失败({name}):{res['_upload_error']}") + d = res.get("data") or {} + print(f" {name}:红={d.get('redCount')} 黄={d.get('yellowCount')} " + f"submitted={d.get('submitted')} rosterId={roster_id}") + subs[uid] = sid + + # ── ⑤ 丙:由**班主任**先初审通过,给终审节点留一份 ── + # + # ⚠️ 这一步不能用管理员的 token:初审的范围校验按"这个班归不归我"判定, + # 管理员既不带头衔也不在初审人名单里 → `40301`。 + # 这正是"权限 ≠ 范围"在真实链路上的体现(`e2e-m5-audit.py` 专门守着它)。 + st, body = R.login(BASE, TEACHER_USERNAME, TEACHER_PASSWORD, "demo") + if body.get("code") != 0: + raise SystemExit(f"班主任登录失败:{body}") + teacher_tok = body["data"]["token"] + st, body = R.call("POST", f"{BASE}/api/v1/audit/submissions/{subs[993103]}/approve", + teacher_tok, + {"remark": "演示数据:初审通过,留一份待终审", "actionSource": "BUTTON"}) + print(f" 丙 初审 → code={body.get('code')} status={(body.get('data') or {}).get('status')}" + f" msg={body.get('msg')}") + + # ⑥ 可选:把丙推到**已通过**,让导出功能有东西可导。 + # 必须走真实终审链路(管理员 token),不能直接改状态 —— + # 否则"导出的数据真的审过"这件事就没法再靠这套夹具验了。 + if args.approve_one: + st, body = R.call("POST", f"{BASE}/api/v1/audit/submissions/{subs[993103]}/approve", + token, + {"finalOpinion": "演示数据:终审通过", "actionSource": "BUTTON"}) + code = body.get("code") + status = (body.get("data") or {}).get("status") + print(f" 丙 终审 → code={code} status={status} msg={body.get('msg')}") + if code != 0 or status != 3: + print(" ⚠️ 终审没有把丙推到「已通过」—— 导出演示会拿不到候选数据") + + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + + print("\n 完成。浏览器里:") + print(" · 收集任务 → 【演示】审核台 → 名单管理:3 人") + print(" · 审核 → 初审:演示甲(干净)/ 演示乙(黄色警告)") + print(" · 审核 → 终审:演示丙(已初审,待终审)" if not args.approve_one + else " · 导出中心:演示丙已终审通过,可直接导出(另有 2 人未审完会被 40035 拦)") + return 0 + + +def ensure_teacher(token: str, org_id: int) -> tuple[int, str]: + """ + 准备一个演示班主任(已存在就复用),返回 (id, 姓名)。 + + 为什么必须有一个**真实的班主任账号**: + 初审的范围校验是"这个班归不归我"(指定初审人 → 副班主任 → 班级负责人), + 管理员自己**看不到初审待办** —— 这是设计,不是缺陷。 + 所以演示/验收初审那一屏时,必须有一个真账号登进去看。 + + 密码用 `resetPassword(mode=CUSTOM)` 设成固定值:新增账号返回的是**一次性** + 初始密码,而演示脚本下次跑要能直接登录。 + """ + st, body = R.call("GET", f"{BASE}/api/v1/users?keyword={TEACHER_USERNAME}&pageNum=1&pageSize=5", + token) + for u in (body.get("data") or {}).get("list") or []: + if u.get("username") == TEACHER_USERNAME: + st2, b2 = R.call("POST", f"{BASE}/api/v1/users/{u['id']}/reset-password", token, + {"mode": "CUSTOM", "newPassword": TEACHER_PASSWORD}) + print(f" 复用已存在的演示班主任 id={u['id']}(重置密码 code={b2.get('code')})") + return u["id"], u.get("realName") or TEACHER_NAME + + st, body = R.call("POST", f"{BASE}/api/v1/users", token, { + "username": TEACHER_USERNAME, + "realName": TEACHER_NAME, + "phone": TEACHER_PHONE, + "orgId": org_id, + "roleCodes": ["HEAD_TEACHER"], + "userType": 3 + }) + if body.get("code") != 0: + raise SystemExit(f"建演示班主任失败:{body}") + uid = body["data"]["id"] + st2, b2 = R.call("POST", f"{BASE}/api/v1/users/{uid}/reset-password", token, + {"mode": "CUSTOM", "newPassword": TEACHER_PASSWORD}) + if b2.get("code") != 0: + raise SystemExit(f"设置演示班主任密码失败:{b2}") + return uid, TEACHER_NAME + + +def _build_roster(rows: list[tuple[str, str]]) -> None: + """演示名册:表头 + 报考三项按任务声明(车工 / 数控车工 / 四级)填好。""" + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "演示班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001399{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m1-dict.py b/scripts/e2e-m1-dict.py new file mode 100644 index 0000000..0e54caa --- /dev/null +++ b/scripts/e2e-m1-dict.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +"""字典管理 E2E(《03-接口设计》§13.3)。 + +这一屏的验收点不在于"能不能增删改",而在于**三条边界的口径**: + + ① **内置字典的项名改不了**(只可停用单项 + 追加自定义项)—— + 内置选项照抄官方表格,改名会让导出的汇总表与官方模板对不上, + 而那时没有任何地方会报错; + ② **项码建后不可改** —— 历史数据里存的是项码,改了它过去填过的值 + 会指向一个不存在的项,而查询**不报错、只显示空白**; + ③ **有下级项的项不可停用**(40036)—— 否则会出现"选了父项却选不到子项", + 而数据层面看不出矛盾。 + +另外两条容易做错的: + · `itemCount` 只算**启用中**的项(把停用的也算进去,管理员会以为界面漏了); + · 内置项的"停用"与自定义项的"停用"是**两回事**,分开存才能"一键恢复默认"。 + +跑法:python scripts/e2e-m1-dict.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys +import urllib.parse + +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): + 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" + +# 测试用字典码(用自定义字典,不污染内置的) +TEST_DICT = "E2E_SUBJECT" +TENANT = 1 + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def dict_of(token: str) -> dict: + d = body_of("GET", "/api/v1/dicts", token).get("data") or [] + return {x["dictCode"]: x for x in d} + + +def items_of(token: str, code: str) -> dict: + return body_of("GET", f"/api/v1/dicts/{code}/items", token).get("data") or {} + + +def main() -> int: + ap = argparse.ArgumentParser(description="字典管理 E2E") + ap.parse_args() + + print("=" * 74) + print(" 字典管理 E2E(§13.3)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + cleanup() + + # ── 1. 内置字典目录 ── + print("\n[1] 内置字典目录(6 个)") + ds = dict_of(at) + check("字典列表可用", len(ds) >= 6, f"{sorted(ds)}") + expect = {"EDU_LEVEL": 11, "JOB_LEVEL": 5, "CERT_TYPE": 3, + "RETARGET_SUBJECT": 3, "REJECT_REASON": 10} + for code, n in expect.items(): + check(f"{code} 项数 = {n}", (ds.get(code) or {}).get("itemCount") == n, + json.dumps(ds.get(code), ensure_ascii=False)) + check("⭐ 内置字典都标了 builtin=true", + all((ds.get(c) or {}).get("builtin") is True for c in expect), + json.dumps({c: (ds.get(c) or {}).get("builtin") for c in expect})) + check("OCCUPATION 也在列表里(国标职业库,一期为空时 0 项)", + "OCCUPATION" in ds, f"{sorted(ds)}") + + # ── 2. 学历项与表单口径一致 ── + print("\n[2] 学历项必须与表单下拉一致(两个入口看同一份口径)") + edu = items_of(at, "EDU_LEVEL") + names = [i["itemName"] for i in edu.get("list") or []] + check("学历 11 项", len(names) == 11, f"{names}") + check("含官方里那几项(照抄不得擅改)", + all(x in names for x in ("博士研究生", "技工学校", "其他")), f"{names}") + check("⭐ 内置项没有 id(它们在代码里,没有数据库行)", + all(i.get("id") is None for i in edu.get("list") or []), + json.dumps((edu.get("list") or [])[:1], ensure_ascii=False)) + check("内置项都标了 builtin=true", + all(i.get("builtin") is True for i in edu.get("list") or [])) + # 与表单口径对一遍:这是"两个入口一份口径"的机器化验证 + jl = items_of(at, "JOB_LEVEL") + jnames = [i["itemName"] for i in jl.get("list") or []] + check("等级 5 项且文案是「四级/中级工」这种官方写法", + jnames == ["五级/初级工", "四级/中级工", "三级/高级工", "二级/技师", "一级/高级技师"], + f"{jnames}") + + # ── 3. 驳回原因字典的 itemCode 是 R001 这种码 ── + print("\n[3] 驳回原因字典:码是 R001,项名是文案") + rr = items_of(at, "REJECT_REASON") + codes = [i["itemCode"] for i in rr.get("list") or []] + check("10 条且码是 R001~R009 + R999", + codes[:3] == ["R001", "R002", "R003"] and "R999" in codes, f"{codes}") + check("项名是可读文案(不是码本身)", + any("材料" in (i.get("itemName") or "") for i in rr.get("list") or []), + json.dumps((rr.get("list") or [])[:1], ensure_ascii=False)) + + # ── 4. 追加自定义项(对内置字典的追加)── + print("\n[4] 对内置字典追加自定义项(内置项本身不动)") + c = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at, + {"itemCode": "E2E_TECH", "itemName": "E2E技师学院"}) + check("追加成功", c.get("code") == 0, json.dumps(c, ensure_ascii=False)[:250]) + new_id = (c.get("data") or {}).get("id") + check("返回了 id(自定义项有 id,内置项没有)", isinstance(new_id, int), f"{new_id}") + edu2 = items_of(at, "EDU_LEVEL") + check("列表里能看到内置 11 项 + 自定义 1 项", + len(edu2.get("list") or []) == 12, f"{len(edu2.get('list') or [])}") + check("自定义项标 builtin=false", + any(i.get("itemCode") == "E2E_TECH" and i.get("builtin") is False + for i in edu2.get("list") or [])) + ds2 = dict_of(at) + check("⭐ itemCount 变成 12(含自定义项)", + (ds2.get("EDU_LEVEL") or {}).get("itemCount") == 12, + json.dumps(ds2.get("EDU_LEVEL"), ensure_ascii=False)) + + # ── 5. 重复项码要拒 ── + print("\n[5] 重复项码 / 与内置项撞名") + dup = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at, + {"itemCode": "E2E_TECH", "itemName": "重复的"}) + check("同字典内项码重复 → 拒绝", dup.get("code") != 0, f"code={dup.get('code')}") + dup2 = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at, + {"itemCode": "大学本科", "itemName": "想覆盖内置项"}) + check("⭐ 与内置项撞码 → 拒绝,且说明它是内置项", + dup2.get("code") != 0 and "内置" in (dup2.get("msg") or ""), + f"code={dup2.get('code')} msg={dup2.get('msg')}") + bad = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", at, {"itemCode": "X"}) + check("缺项名 → 拒绝", bad.get("code") != 0, f"code={bad.get('code')}") + + # ── 6. 改 / 停用 / 恢复 ── + print("\n[6] 改自定义项 / 停用 / 恢复") + u = body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, + {"itemName": "E2E技师学院(改)", "sortNo": 5}) + check("改名成功", u.get("code") == 0, json.dumps(u, ensure_ascii=False)[:200]) + check("库里项名已改", + sql(f"select item_name from sys_dict_item where id={new_id};").strip() + == "E2E技师学院(改)") + d = body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, {"status": 0}) + check("停用成功", d.get("code") == 0, json.dumps(d, ensure_ascii=False)[:200]) + ds3 = dict_of(at) + check("⭐ 停用后 itemCount 回到 11(只算启用中的项)", + (ds3.get("EDU_LEVEL") or {}).get("itemCount") == 11, + json.dumps(ds3.get("EDU_LEVEL"), ensure_ascii=False)) + check("停用后项还在列表里(不是删掉)", + any(i.get("itemCode") == "E2E_TECH" for i in + (items_of(at, "EDU_LEVEL").get("list") or []))) + r = body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, {"status": 1}) + check("重新启用成功", r.get("code") == 0) + + # ── 7. 内置项只能停用,不能改名 ── + print("\n[7] 内置项:只能停用,不能改名/删除") + nb = body_of("PUT", "/api/v1/dicts/items/0", at, {"itemName": "改内置项"}) + check("改不存在的 id → 拒绝", nb.get("code") != 0, f"code={nb.get('code')}") + # ⚠️ 路径里有中文项码(内置项码就是中文,例如「小学」)→ 必须 URL 编码: + # http.client 只接受 ASCII 请求行,不编码会直接 UnicodeEncodeError + enc = urllib.parse.quote("小学") + t = body_of("POST", f"/api/v1/dicts/EDU_LEVEL/items/{enc}/toggle?enabled=false", at) + check("停用内置项「小学」成功", t.get("code") == 0, json.dumps(t, ensure_ascii=False)[:250]) + edu3 = items_of(at, "EDU_LEVEL") + xiao = next((i for i in edu3.get("list") or [] if i.get("itemCode") == "小学"), None) + check("停用后 status=0(项还在,只是停用)", (xiao or {}).get("status") == 0, + json.dumps(xiao, ensure_ascii=False)) + ds4 = dict_of(at) + check("⭐ itemCount 变成 11(内置 10 启用 + 自定义 1)", + (ds4.get("EDU_LEVEL") or {}).get("itemCount") == 11, + json.dumps(ds4.get("EDU_LEVEL"), ensure_ascii=False)) + check("停用记在 sys_dict_disabled(不是 sys_dict_item)", + sql("select count(*) from sys_dict_disabled where dict_code='EDU_LEVEL' " + "and item_code='小学';").strip() == "1") + t2 = body_of("POST", f"/api/v1/dicts/EDU_LEVEL/items/{enc}/toggle?enabled=true", at) + check("⭐ 一键恢复默认可用(这正是把两者分开存的原因)", t2.get("code") == 0, + json.dumps(t2, ensure_ascii=False)[:200]) + check("恢复后 status 又是 1", + next((i.get("status") for i in (items_of(at, "EDU_LEVEL").get("list") or []) + if i.get("itemCode") == "小学"), None) == 1) + bad2 = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items/E2E_TECH/toggle?enabled=false", at) + check("对**自定义**项调内置 toggle → 拒绝(说清它不是内置项)", + bad2.get("code") != 0 and "内置" in (bad2.get("msg") or ""), + f"code={bad2.get('code')} msg={bad2.get('msg')}") + + # ── 8. 层级:有下级项的项不可停用(40036)── + print("\n[8] 层级(40036:有下级项的项不可停用)") + p = body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at, + {"itemCode": "P1", "itemName": "父项"}) + check("建父项成功(自定义字典码会被隐式创建)", p.get("code") == 0, + json.dumps(p, ensure_ascii=False)[:200]) + pid = (p.get("data") or {}).get("id") + ch = body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at, + {"itemCode": "C1", "itemName": "子项", "parentItemCode": "P1"}) + check("建子项成功", ch.get("code") == 0, json.dumps(ch, ensure_ascii=False)[:200]) + cid = (ch.get("data") or {}).get("id") + check("自定义字典出现在字典列表里且 builtin=false", + (dict_of(at).get(TEST_DICT) or {}).get("builtin") is False, + json.dumps(dict_of(at).get(TEST_DICT), ensure_ascii=False)) + items = items_of(at, TEST_DICT) + check("字典项接口标出这个字典支持层级", items.get("hierarchical") is True, + json.dumps(items, ensure_ascii=False)[:200]) + stop = body_of("PUT", f"/api/v1/dicts/items/{pid}", at, {"status": 0}) + check("⭐ 停用父项 → 40036(有下级不可停用)", stop.get("code") == 40036, + f"code={stop.get('code')} msg={stop.get('msg')}") + dele = body_of("DELETE", f"/api/v1/dicts/items/{pid}", at) + check("⭐ 删父项 → 同样被拦(40036)", dele.get("code") == 40036, + f"code={dele.get('code')} msg={dele.get('msg')}") + badp = body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at, + {"itemCode": "C2", "itemName": "孤儿", "parentItemCode": "NOT_EXIST"}) + check("父项不存在 → 拒绝(不造孤儿项)", badp.get("code") != 0, + f"code={badp.get('code')} msg={badp.get('msg')}") + dl = body_of("DELETE", f"/api/v1/dicts/items/{cid}", at) + check("先删子项成功", dl.get("code") == 0, json.dumps(dl, ensure_ascii=False)[:200]) + dl2 = body_of("DELETE", f"/api/v1/dicts/items/{pid}", at) + check("再删父项成功(下级没了就能删)", dl2.get("code") == 0) + check("软删(行还在,deleted=1)", + sql(f"select count(*) from sys_dict_item where id={pid} and deleted=1;").strip() == "1") + check("⭐ 删掉后同项码能重新加(部分唯一索引只约束未删的行)", + body_of("POST", f"/api/v1/dicts/{TEST_DICT}/items", at, + {"itemCode": "P1", "itemName": "父项重建"}).get("code") == 0) + + # ── 9. 项码不可改 ── + print("\n[9] 项码不可改(改了会破坏历史数据)") + check("修改接口的 DTO 里根本没有 itemCode 字段(结构性保证)", + "itemCode" not in json.dumps( + body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, + {"itemName": "E2E技师学院(改)"}).get("data") or {})) + body_of("PUT", f"/api/v1/dicts/items/{new_id}", at, {"itemCode": "HACKED"}) + check("⭐ 传了 itemCode 也不生效(库里项码没变)", + sql(f"select item_code from sys_dict_item where id={new_id};").strip() == "E2E_TECH", + sql(f"select item_code from sys_dict_item where id={new_id};")) + + # ── 10. 权限 ── + print("\n[10] 权限") + cfg = R.read_dev_config() + stok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], 993901, 1, ["STUDENT"], 4) + b = body_of("POST", "/api/v1/dicts/EDU_LEVEL/items", stok, + {"itemCode": "HACK", "itemName": "学生想加字典"}) + check("学生加字典项 → 403", b.get("code") in (40101, 40301, 403), + f"code={b.get('code')} msg={b.get('msg')}") + b2 = body_of("GET", "/api/v1/dicts", stok) + check("学生读字典 → 也拒(字典是配置面,不是业务面)", + b2.get("code") in (40101, 40301, 403), + f"code={b2.get('code')} msg={b2.get('msg')}") + b3 = body_of("GET", "/api/v1/dicts") + check("未登录 → 40101", b3.get("code") == 40101, f"code={b3.get('code')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def cleanup() -> None: + R.sql(f"delete from sys_dict_item where tenant_id={TENANT} " + f"and (dict_code='{TEST_DICT}' or item_code like 'E2E%');") + R.sql(f"delete from sys_dict_disabled where tenant_id={TENANT};") + print(" 已清理:测试字典项 / 内置项停用记录") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m2-join.py b/scripts/e2e-m2-join.py new file mode 100644 index 0000000..a71f516 --- /dev/null +++ b/scripts/e2e-m2-join.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +"""B1 名单加入申请 E2E(FR-509,《03-接口设计》§8.7)。 + +它验的是这条通道的**四条边界**(都是刻意的设计,不是"随手加的校验"): + + 1. **不绕过门禁**:申请期间学生**仍然不能填报** —— 这条最容易被实现成 + "提交申请就能填",那样门禁就变成形式主义,整套"凭证件号认人"的根基没了。 + 2. **已在名册 → 40039 让他回去重新核验**,而不是受理申请(他只是姓名输错了)。 + 3. **每人每任务最多 2 次**(40037):没有上限的话,一个人能刷出几十条待办。 + 4. **通过即自动绑定**:学生返回重新核验就能直接进,不用再输一遍姓名与证件号。 + +跑法(前提:后端 8080 在跑、xt-pg 在跑;脚本自己摆数据、自己清理): + + python scripts/e2e-m2-join.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +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 的工具与夹具,不复制第二份(两份必然分叉)。""" + 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" +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-join-roster.xlsx") + +# 名册里的那个人(老师已导入) +IN_ROSTER_NAME = "曾伊豪" +IN_ROSTER_ID = R.ID_A +# 不在名册里的人(要申请加入)——身份证号用校验位合法的 +OUT_NAME = "新来同学" +OUT_ID = R.with_checksum("36102920090101005") +OUT_PHONE = "13900139009" + + +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) + if not okk: + fail("SQL 执行失败", f"{stmt[:80]} → {out}") + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def ensure_student(uid: int) -> str: + """ + 准备一个"有 openid 的学生",返回他的 token。 + + 为什么要自己建账号:这条通道按 **openid** 限次与去重(契约 §8.7.1), + 而"从微信进来的人才有 openid"是产品的真实前提。用 SQL 直接造出带 open_id 的账号, + 是把"微信授权"这一步(需要真公众号,E2E 走不了)替换成等价的前置数据 —— + **其余全走真实接口**。`ON CONFLICT` 让脚本可以重复跑。 + """ + openid = f"e2e-join-{uid}" + R.sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status) + VALUES ({uid}, 1, 'join{uid}', '加入申请学生{uid}', '{openid}', 4, 1) + ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1 + """) + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def main() -> int: + ap = argparse.ArgumentParser(description="B1 名单加入申请 E2E") + ap.parse_args() + + print("=" * 74) + print(" B1 名单加入申请 E2E(FR-509)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + cfg = R.read_dev_config() + + # ── 0. 摆数据:一个收集中的任务 + 名册里一个人 ── + print("\n[0] 摆夹具:任务 + 名册(一个人)+ 一个不在名册的学生") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E加入申请 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + if not classes: + print(" ✗ demo 租户里没有班级") + return 2 + org = classes[0] + + _, b = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, { + "orgs": [{"orgId": org["id"], "expectedCount": 2}] + }) + if b.get("code") != 0: + print(f" ✗ 配班级失败:{b}") + return 2 + + _, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + if b.get("code") != 0: + print(f" ✗ 应用材料模板失败:{b}") + return 2 + + R.build_fixture(TMP_ROSTER) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200]) + + _, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + check("任务发布成功(收集中)", b.get("code") == 0, + json.dumps(b, ensure_ascii=False)[:200]) + + out_tok = ensure_student(993301) + + # ── 1. 门禁失败:不在名册里的人进不去 ── + print("\n[1] 不在名册的人:门禁必须拦住(这是产品根基)") + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", out_tok, + {"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID}) + check("门禁不通过(提示信息统一、不透露名册里有什么)", b.get("code") != 0, + json.dumps(b, ensure_ascii=False)[:200]) + check("失败提示里**没有**列出名册中的任何姓名", + IN_ROSTER_NAME not in json.dumps(b, ensure_ascii=False), + json.dumps(b, ensure_ascii=False)[:200]) + + # ── 2. 提交申请 ── + print("\n[2] 提交加入申请") + b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, { + "taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, + "phone": OUT_PHONE, "orgHint": org["orgName"], "reason": "我是转班过来的" + }) + if b.get("code") != 0: + # 学生账号可能没有 open_id(token 直连不会自动建号)→ 明确告知原因并跳过 + check("提交申请(需要该学生账号有 open_id)", False, + f"code={b.get('code')} msg={b.get('msg')}") + cleanup() + return 1 + req_id = b["data"]["requestId"] + check("返回 requestId", isinstance(req_id, int), f"{req_id}") + check("状态为待审(0)", b["data"]["status"] == 0, f"{b['data']}") + check("提示语说清是「等待学校审核」", "审核" in (b["data"].get("statusText") or ""), + f"{b['data'].get('statusText')}") + check("告知还剩几次机会(remainAttempts=1)", b["data"].get("remainAttempts") == 1, + f"{b['data'].get('remainAttempts')}") + + check("库里证件号是 HMAC(不是明文)", + sql(f"select length(id_card_hash) from collect_join_request where id={req_id};").strip() + == "64") + check("库里**没有**证件号明文列(只有 hash + mask)", + sql(f"select id_card_mask from collect_join_request where id={req_id};").strip() + .replace("*", "").isdigit() + or "*" in sql(f"select id_card_mask from collect_join_request where id={req_id};")) + check("手机号也只存 hash + 脱敏值", + sql(f"select phone_mask from collect_join_request where id={req_id};").strip() + == "139****9009", + sql(f"select phone_mask from collect_join_request where id={req_id};")) + check("记录了申请人 uid 与 openid(通过时要用来绑定)", + sql(f"select applicant_uid from collect_join_request where id={req_id};").strip() + == "993301") + + # ── 3. 申请期间:**仍然不能填报**(这条最容易被做错)── + print("\n[3] 申请期间不能填报(不绕过门禁)") + b = body_of("POST", "/api/v1/mp/submissions", out_tok, {"taskId": task_id}) + check("提交申请**不等于**能建填报单", b.get("code") != 0, + f"code={b.get('code')} msg={b.get('msg')}") + + # ── 4. 重复申请与已在名册 ── + print("\n[4] 重复申请 / 已在名册") + b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, { + "taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, "phone": OUT_PHONE + }) + check("同证件号已有待审申请 → 40038", b.get("code") == 40038, + f"code={b.get('code')} msg={b.get('msg')}") + + in_tok = ensure_student(993302) + b = body_of("POST", "/api/v1/mp/roster/join-request", in_tok, { + "taskId": task_id, "realName": IN_ROSTER_NAME, "idCardNo": IN_ROSTER_ID, + "phone": "13900139008" + }) + check("证件号已在名册 → 40039(引导他回去重新核验)", b.get("code") == 40039, + f"code={b.get('code')} msg={b.get('msg')}") + + b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, { + "taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, "phone": "139" + }) + check("手机号不合法 → 40001(老师要能联系到他)", b.get("code") == 40001, + f"code={b.get('code')} msg={b.get('msg')}") + + # ── 5. 管理端待办 ── + print("\n[5] 管理端待办列表") + b = body_of("GET", f"/api/v1/roster/join-requests?taskId={task_id}&status=0", at) + check("待办列表可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + d = b.get("data") or {} + row = next((x for x in (d.get("list") or []) if x.get("requestId") == req_id), None) + check("列表里有这条申请", row is not None, json.dumps(d, ensure_ascii=False)[:300]) + if row: + check("列表里证件号是脱敏的(不含 18 位明文)", + "*" in (row.get("idCardMask") or "") and len(row.get("idCardMask") or "") <= 18, + f"{row.get('idCardMask')}") + check("列表里手机号是脱敏的", "*" in (row.get("phoneMask") or ""), + f"{row.get('phoneMask')}") + check("列表带等待时长", row.get("waitHours") is not None, f"{row.get('waitHours')}") + check("列表带自述班级与申请说明", + row.get("orgHint") == org["orgName"] and row.get("reason") == "我是转班过来的", + json.dumps(row, ensure_ascii=False)[:200]) + check("counts 里有三个计数", (d.get("counts") or {}).get("pending", 0) >= 1, + json.dumps(d.get("counts"), ensure_ascii=False)) + + # 权限:学生不能看管理端列表 + b = body_of("GET", f"/api/v1/roster/join-requests?taskId={task_id}", out_tok) + check("学生调管理端接口 → 40301 或 403", + b.get("code") in (40301, 403) or b.get("_http") == 403, + f"code={b.get('code')} http={b.get('_http')}") + + # ── 6. 审批通过 ── + print("\n[6] 审批通过(建名册 + 自动绑定)") + b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at, + {"orgId": org["id"]}) + check("通过成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:300]) + av = b.get("data") or {} + roster_id = av.get("rosterId") + check("返回新建的名册项 id", isinstance(roster_id, int), f"{roster_id}") + check("rosterCreated=true", av.get("rosterCreated") is True, f"{av}") + check("canFillNow=true(已绑定,学生重进就能填)", av.get("canFillNow") is True, f"{av}") + check("notifySent=false(通知渠道未实现,不谎报)", av.get("notifySent") is False, f"{av}") + + check("名册里真的多了一行,且已绑定到申请人", + sql(f"select bind_user_id from collect_roster where id={roster_id};").strip() == "993301", + sql(f"select bind_user_id, bind_type from collect_roster where id={roster_id};")) + check("bind_type=3(来自申请)", + sql(f"select bind_type from collect_roster where id={roster_id};").strip() == "3") + check("名册行的班级是管理员指定的那个", + sql(f"select org_id from collect_roster where id={roster_id};").strip() == str(org["id"])) + check("名册行留了来源痕迹(ext_json.source=JOIN_REQUEST)", + "JOIN_REQUEST" in sql(f"select ext_json from collect_roster where id={roster_id};")) + check("申请单回写了 roster_id 与审批人", + sql(f"select roster_id from collect_join_request where id={req_id};").strip() + == str(roster_id)) + + # ⭐ 通过之后:门禁必须放行(自动绑定的效果) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", out_tok, + {"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID}) + check("⭐ 通过后门禁放行(自动绑定生效)", b.get("code") == 0, + json.dumps(b, ensure_ascii=False)[:200]) + b = body_of("POST", "/api/v1/mp/submissions", out_tok, {"taskId": task_id}) + check("⭐ 通过后能建填报单了(申请期间不行)", b.get("code") == 0, + json.dumps(b, ensure_ascii=False)[:200]) + + # ── 7. 幂等与状态边界 ── + print("\n[7] 幂等与状态边界") + b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at, {"orgId": org["id"]}) + check("重复通过 → 40040(不是再建一条名册)", b.get("code") == 40040, + f"code={b.get('code')} msg={b.get('msg')}") + # ⚠️ 不能写死总行数:夹具本身导入了 3 个人(曾伊豪/赵六/钱七), + # 加上审批新建的 1 个 = 4。写死"==2"会让断言因为**夹具**而红。 + # 这里验的是真正要守的两件事:申请人只有一行、重复通过不再新建行。 + check("申请人在名册里**只有一行**(重复通过没再建一条)", + sql(f"select count(*) from collect_roster where task_id={task_id} " + f"and bind_user_id=993301 and deleted=0;").strip() == "1", + sql(f"select count(*) from collect_roster where task_id={task_id} " + f"and bind_user_id=993301 and deleted=0;")) + check("名册总行数 = 夹具 3 人 + 审批新建 1 人", + sql(f"select count(*) from collect_roster where task_id={task_id} and deleted=0;").strip() + == "4", + sql(f"select real_name from collect_roster where task_id={task_id} and deleted=0;") + .replace("\n", " | ")) + b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/reject", at, {"reason": "信息不符"}) + check("已通过再驳回 → 40042(要撤销请删名册项)", b.get("code") == 40042, + f"code={b.get('code')} msg={b.get('msg')}") + + # ── 8. 驳回路径 ── + print("\n[8] 驳回路径(原因必填)") + other_tok = ensure_student(993303) + other_name, other_id = "再来同学", R.with_checksum("36102920090202006") + b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, { + "taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007" + }) + check("第二个学生也能申请", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + req2 = (b.get("data") or {}).get("requestId") + + b = body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at, {}) + check("驳回不填原因 → 40001(原因要给学生看)", b.get("code") == 40001, + f"code={b.get('code')} msg={b.get('msg')}") + b = body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at, + {"reason": "你不是本校学生", "reasonCode": "NOT_THIS_SCHOOL"}) + check("驳回成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + check("驳回后状态=2", sql(f"select status from collect_join_request where id={req2};").strip() + == "2") + check("驳回原因落库(学生要能看到)", + "NOT_THIS_SCHOOL" in sql(f"select reject_reason from collect_join_request where id={req2};")) + + b = body_of("GET", f"/api/v1/mp/roster/join-request?taskId={task_id}", other_tok) + mine = (b.get("data") or {}).get("list") or [] + check("学生能看到自己的申请与驳回原因", + any(x.get("requestId") == req2 and x.get("rejectReason") for x in mine), + json.dumps(mine, ensure_ascii=False)[:300]) + + # ── 9. 次数上限 ── + print("\n[9] 申请次数上限(每人每任务 ≤2)") + b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, { + "taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007" + }) + check("被驳回后可以再申请一次(remainAttempts=0)", + b.get("code") == 0 and (b.get("data") or {}).get("remainAttempts") == 0, + json.dumps(b, ensure_ascii=False)[:200]) + b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, { + "taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007" + }) + check("第 3 次 → 40037(上限就是上限)", b.get("code") == 40037, + f"code={b.get('code')} msg={b.get('msg')}") + + # ── 10. 任务状态边界 ── + print("\n[10] 任务不在收集期时不受理") + _, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/close", at, {}) + check("任务已截止", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + tok4 = ensure_student(993304) + b = body_of("POST", "/api/v1/mp/roster/join-request", tok4, { + "taskId": task_id, "realName": "截止后同学", + "idCardNo": R.with_checksum("36102920090303007"), "phone": "13900139006" + }) + check("已截止 → 不受理(40008)", b.get("code") == 40008, + f"code={b.get('code')} msg={b.get('msg')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def cleanup() -> None: + for tid in CREATED_TASK_IDS: + R.sql(f"delete from collect_join_request where task_id={tid};") + R.sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + R.sql("delete from collect_join_request where applicant_uid in (993301, 993302, 993303, 993304);") + R.sql("delete from collect_roster where bind_user_id in (993301, 993302, 993303, 993304);") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:加入申请 / 名册 / 任务 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m2-roster.py b/scripts/e2e-m2-roster.py index e6fc9a2..bbdf47a 100644 --- a/scripts/e2e-m2-roster.py +++ b/scripts/e2e-m2-roster.py @@ -213,7 +213,23 @@ WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] def with_checksum(base17: str) -> str: - """给 17 位前缀配一个**正确的**校验位(ISO 7064 MOD 11-2),造夹具用。""" + """ + 给 17 位前缀配一个**正确的**校验位(ISO 7064 MOD 11-2),造夹具用。 + + ⚠️ 入参必须是**前 17 位**(6 位地区 + 8 位出生日期 + 3 位顺序), + 函数负责补第 18 位。传 18 位会生成 19 位号码,而**失败发生在很远的地方**: + 名册导入的报告里会出现一行「身份证号格式非法(…当前 19 位)」, + 看起来像"身份证号生成逻辑有问题"。 + + 这个错我犯过三次,所以这里**直接断言**,而不是在文档里写一句"记得传 17 位"—— + 没人会去读那句,而错误会在 300 行之外以另一种形式出现。 + """ + if len(base17) != 17 or not base17.isdigit(): + raise ValueError( + "with_checksum 的入参必须是**前 17 位数字**(你传了 %d 位:%r)。" + "它负责补第 18 位校验码;传 18 位会生成 19 位号码," + "而那个错会在名册导入的报告里以「当前 19 位」的形式出现。" + % (len(base17), base17)) total = sum(int(base17[i]) * WEIGHTS[i] for i in range(17)) return base17 + CHECK_CHARS[total % 11] diff --git a/scripts/e2e-m3-mytask.py b/scripts/e2e-m3-mytask.py new file mode 100644 index 0000000..7b50f2f --- /dev/null +++ b/scripts/e2e-m3-mytask.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +"""学生端「我的任务 / 撤回 / 进度」E2E(《03-接口设计》§9.1、§9.11、§9.12)。 + +守的是三件最容易做错的事: + + 1. **可见范围 = 名册绑定关系**,不是"租户内全部任务"。 + 学生只该看到老师把他加进名单的任务;别的任务他点进去也只会撞门禁。 + 2. **撤回的边界**:待初审/待终审能撤;**已通过不能自己撤**(40031); + 撤回要**同时把名册行退回草稿**,否则班主任的看板会一直显示他已提交。 + 3. **不能操作别人的填报单**:前端传来的 id 可以随便改, + 所以查询必须带 submitter_id(越权访问是这类接口最容易漏的一步)。 + +跑法:python scripts/e2e-m3-mytask.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +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]] = [] +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-mytask-roster.xlsx") + + +def _load(module_name: str, filename: str): + 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") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") + +BASE = "http://localhost:8080" +UID = 993401 +OTHER_UID = 993402 + + +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) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def main() -> int: + ap = argparse.ArgumentParser(description="学生端我的任务/撤回/进度 E2E") + ap.parse_args() + + print("=" * 74) + print(" 学生端「我的任务 / 撤回 / 进度」E2E") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + + # ── 0. 夹具:一个收集中的任务 + 名册(含"我")── + print("\n[0] 摆夹具") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E我的任务 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, + {"orgs": [{"orgId": org["id"], "expectedCount": 1}]}) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + + my_name, my_id = R.NAME_A, R.ID_A + R.build_fixture(TMP_ROSTER) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + if body.get("code") != 0: + print(f" ✗ 导名册失败:{body}") + return 2 + # ⚠️ 不要用 SQL 改夹具里那个人的姓名:门禁是按 **证件号 HMAC** 查名册的, + # 改了姓名但证件号还是原来那个,门禁就永远匹配不上(E2E 第一版就是这么红的)。 + # 直接用夹具里真实存在的那一行(NAME_A + ID_A)来当"我"。 + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + + tok = mp(UID) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": my_name, "idCardNo": my_id}) + check("夹具:门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + + # ── 1. 我的任务:可见范围 = 名册绑定 ── + print("\n[1] 我的任务列表") + d = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {} + check("能拿到我的任务列表", isinstance(d.get("list"), list), + json.dumps(d, ensure_ascii=False)[:200]) + mine = next((x for x in (d.get("list") or []) if x.get("taskId") == task_id), None) + check("列表里有这个任务(因为名册里绑定了我)", mine is not None, + f"任务数={len(d.get('list') or [])}") + if mine: + check("带我在这份名册里的报考三项(学生要核对)", + mine.get("occupation") is not None, json.dumps(mine, ensure_ascii=False)[:250]) + check("带我所在的班级名", bool(mine.get("orgName")), f"{mine.get('orgName')}") + check("还没填 → canFillNow=true", mine.get("canFillNow") is True, f"{mine.get('canFillNow')}") + check("还没填 → 状态文案说清「还没开始」", "还没开始" in (mine.get("statusText") or ""), + f"{mine.get('statusText')}") + check("还没填 → 计入 todoCount", (d.get("todoCount") or 0) >= 1, f"{d.get('todoCount')}") + + other_tok = mp(OTHER_UID) + d2 = body_of("GET", "/api/v1/mp/tasks", other_tok).get("data") or {} + check("⭐ 没被绑定到名册的人看不到这个任务(可见范围=名册,不是租户)", + not any(x.get("taskId") == task_id for x in (d2.get("list") or [])), + f"他看到了 {[x.get('taskName') for x in (d2.get('list') or [])]}") + + # ── 2. 进度:还没开始 ── + print("\n[2] 进度与问题清单(未提交)") + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + p = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {} + check("进度接口可用", p.get("submissionId") == sid, json.dumps(p, ensure_ascii=False)[:200]) + check("草稿状态:canEdit=true、canSubmit=true、canWithdraw=false", + p.get("canEdit") is True and p.get("canSubmit") is True and p.get("canWithdraw") is False, + json.dumps({k: p.get(k) for k in ("canEdit", "canSubmit", "canWithdraw")})) + mats = p.get("materials") or [] + codes = {m.get("materialCode") for m in mats} + # ⚠️ 是 **3** 项不是 4 项:「工作证明」的 visibleWhen 是 is_student=false, + # 在校生根本不该被要求交它。若把不适用的材料也算进清单与分母, + # 进度会**永远到不了 100%**(E2E 第一版就是 75%,见 F-21)。 + check("材料清单只列**本次适用**的材料(在校生 3 项,不含工作证明)", + codes == {"PHOTO1", "IDCARD", "SCHOOL_ROLL"}, f"{sorted(codes)}") + check("已交的 / 未交的都标清楚", all(m.get("done") is False for m in mats), + json.dumps([(m.get("materialCode"), m.get("done")) for m in mats], ensure_ascii=False)) + check("必传材料标了 required", all(m.get("required") for m in mats), + json.dumps(mats[:2], ensure_ascii=False)) + check("未交的必传材料带阻断级问题(issueLevel=3)", + all(m.get("issueLevel") == 3 for m in mats), + json.dumps([(m.get("materialCode"), m.get("issueLevel")) for m in mats], + ensure_ascii=False)) + check("进度百分比是 0%(必传材料一项没交)", p.get("progressPercent") == "0%", + f"{p.get('progressPercent')}") + check("时间轴已有「创建草稿」", any(e.get("title") == "创建草稿" for e in (p.get("timeline") or [])), + json.dumps(p.get("timeline"), ensure_ascii=False)[:200]) + check("⭐ 时间轴里**没有**「已提交」(没发生的事不许出现)", + not any(e.get("title") == "已提交" for e in (p.get("timeline") or [])), + json.dumps(p.get("timeline"), ensure_ascii=False)[:200]) + + # ── 3. 别人的填报单不能看/不能操作 ── + print("\n[3] 越权:不能看/不能撤别人的填报单") + b = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", other_tok) + check("别人看我的进度 → 被拒", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}") + b = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", other_tok) + check("别人撤回我的单 → 被拒", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}") + + # ── 4. 填好并提交 ── + print("\n[4] 填好、提交、再看进度") + fill = {"work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"} + res = A.prepare_student(tok, sid, task_id, fill, dup_first_file=False) + d = res.get("data") or {} + check("提交成功", d.get("submitted") is True, json.dumps(d, ensure_ascii=False)[:250]) + + p2 = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {} + check("提交后进度 100%", p2.get("progressPercent") == "100%", f"{p2.get('progressPercent')}") + check("提交后 canWithdraw=true、canEdit=false", + p2.get("canWithdraw") is True and p2.get("canEdit") is False, + json.dumps({k: p2.get(k) for k in ("canEdit", "canWithdraw")})) + check("时间轴出现「已提交」", + any(e.get("title") == "已提交" for e in (p2.get("timeline") or [])), + json.dumps(p2.get("timeline"), ensure_ascii=False)[:250]) + + d3 = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {} + m3 = next((x for x in (d3.get("list") or []) if x.get("taskId") == task_id), None) + check("我的任务里显示「等待初审」", + "初审" in (m3 or {}).get("statusText", ""), f"{(m3 or {}).get('statusText')}") + check("列表带 submissionId(学生端撤回要用它,不必再查一次)", + (m3 or {}).get("submissionId") == sid, + f"{(m3 or {}).get('submissionId')} 期望 {sid}") + check("审核中 → canFillNow=false 且给出原因", + m3.get("canFillNow") is False and "撤回" in (m3.get("fillBlockReason") or ""), + json.dumps({k: m3.get(k) for k in ("canFillNow", "fillBlockReason")}, ensure_ascii=False)) + + # ── 5. 撤回 ── + print("\n[5] 撤回(含名册状态同步)") + w = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok) + check("撤回成功", w.get("code") == 0, json.dumps(w, ensure_ascii=False)[:200]) + check("返回状态回到 0(草稿)", (w.get("data") or {}).get("status") == 0, f"{w.get('data')}") + check("填报单状态=0", sql(f"select status from collect_submission where id={sid};").strip() == "0") + check("提交时间被清空(否则界面会显示一个不存在的提交)", + sql(f"select coalesce(submit_time::text,'null') from collect_submission where id={sid};") + .strip() == "null") + check("⭐ 名册行的提交状态同步退回草稿(1)", + sql(f"select submit_status from collect_roster where bind_user_id={UID} " + f"and task_id={task_id};").strip() == "1", + sql(f"select submit_status from collect_roster where bind_user_id={UID} " + f"and task_id={task_id};")) + check("撤回后可以继续改(canEdit=true)", + (body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {}) + .get("canEdit") is True) + + b = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok) + check("草稿状态再撤 → 明确被拒(没有「撤回」这回事)", b.get("code") != 0, + f"code={b.get('code')} msg={b.get('msg')}") + + # ── 6. 已通过不能自己撤回 ── + print("\n[6] 已终审通过 → 学生不能自己撤回") + # 重新提交 → 班主任初审 → 管理员终审 + R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid}", tok, {"formData": fill}) + S.call("POST", f"/api/v1/mp/submissions/{sid}/submit", tok, {"confirmWarnings": True}) + + teacher_id, teacher_name = 151, "演示班主任" + sql(f"update collect_task_org set head_user_id={teacher_id} where task_id={task_id};") + # 班主任账号可能不存在:直接用管理员走终审前先补初审(管理员有 audit:first 但范围可能不含) + # —— 这里用 SQL 把状态推到"已通过"是不可接受的(那会绕过真实链路), + # 所以改为:把 head_user_id 指向一个真实存在的班主任(种子脚本建的那个)。 + row = sql("select id from sys_user where username='demot1' and deleted=0;").strip() + if row: + sql(f"update collect_task_org set head_user_id={row} where task_id={task_id};") + _, tea = R.login(BASE, "demot1", "Demo1234", "demo") + if tea.get("code") == 0: + tt = tea["data"]["token"] + b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", tt, + {"actionSource": "BUTTON"}) + check("班主任初审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at, + {"actionSource": "BUTTON", "finalOpinion": "E2E"}) + check("管理员终审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + + w2 = body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok) + check("⭐ 已通过 → 学生撤回被拒(40031)", w2.get("code") == 40031, + f"code={w2.get('code')} msg={w2.get('msg')}") + p3 = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {} + check("已通过 → canWithdraw=false", p3.get("canWithdraw") is False, f"{p3.get('canWithdraw')}") + d4 = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {} + m4 = next((x for x in (d4.get("list") or []) if x.get("taskId") == task_id), None) + check("我的任务显示「已通过」", "已通过" in (m4 or {}).get("statusText", ""), + f"{(m4 or {}).get('statusText')}") + check("已通过 → canFillNow=false 且原因是「无需再改」", + m4.get("canFillNow") is False and "通过" in (m4.get("fillBlockReason") or ""), + json.dumps({k: m4.get(k) for k in ("canFillNow", "fillBlockReason")}, + ensure_ascii=False)) + else: + print(f" · 跳过终审分支:班主任登录失败 {tea}") + else: + print(" · 跳过终审分支:没有 demot1 账号(先跑 dev-seed-audit.py)") + + # ── 7. 任务状态对"能不能填"的影响 ── + print("\n[7] 任务截止后:不能填,且说清为什么") + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/close", at, {}) + d5 = body_of("GET", "/api/v1/mp/tasks", tok).get("data") or {} + m5 = next((x for x in (d5.get("list") or []) if x.get("taskId") == task_id), None) + check("已截止 → canFillNow=false", m5.get("canFillNow") is False, f"{m5.get('canFillNow')}") + check("已截止 → 原因指向「联系班主任」", + "截止" in (m5.get("fillBlockReason") or "") or "班主任" in (m5.get("fillBlockReason") or ""), + f"{m5.get('fillBlockReason')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def cleanup() -> None: + for tid in CREATED_TASK_IDS: + R.sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_join_request where task_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 名册 / 填报单 / 材料 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m5-logs.py b/scripts/e2e-m5-logs.py new file mode 100644 index 0000000..e464cbc --- /dev/null +++ b/scripts/e2e-m5-logs.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +"""操作日志留痕 E2E(《03-接口设计》§13.4、PRD FR-506 / FR-1202)。 + +这一屏的验收标准不是"有没有日志",而是**能不能回答合规问题**: + + ① 「这张名册项的证件号被谁从什么改成了什么」→ 靠 `target_type + target_id` 查, + **不是**拿 `request_param LIKE '%9012%'` 全文搜(那会把"改了它"和"顺带提到它"混在一起); + ② **日志里不许有明文证件号** —— 日志泄露不该比业务库泄露更严重; + ③ 任务状态流转(发布/暂停/归档)必须留痕,且动作名要对得上 + ("这个任务什么时候被谁暂停的"要查得出来)。 + +另外两条容易做错的: + · `to` 给的是**日期**时要补到当天 23:59:59,否则"查 9 月 30 日"查不到 9 月 30 日的数据; + · 名册删除遇到"已绑定/已填报"要**拒绝**(40029)并引导到"标记不参与", + 而不是假装删掉了。 + +跑法:python scripts/e2e-m5-logs.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys +import urllib.parse + +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]] = [] +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-logs-roster.xlsx") + +STU = 993801 + + +def _load(module_name: str, filename: str): + 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" + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def main() -> int: + ap = argparse.ArgumentParser(description="操作日志 E2E") + ap.parse_args() + + print("=" * 74) + print(" 操作日志留痕 E2E(§13.4)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + + # ── 0. 夹具 ── + print("\n[0] 摆夹具") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E日志 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, + {"orgs": [{"orgId": org["id"], "expectedCount": 2}]}) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + rows = [("日志甲", R.with_checksum("36102920080101041")), + ("日志乙", R.with_checksum("36102920080202042"))] + _build_roster(rows) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200]) + + rid_a = sql(f"select id from collect_roster where task_id={task_id} " + f"and real_name='{rows[0][0]}';").strip() + rid_b = sql(f"select id from collect_roster where task_id={task_id} " + f"and real_name='{rows[1][0]}';").strip() + check("两个名册项就绪", rid_a.isdigit() and rid_b.isdigit(), f"{rid_a}/{rid_b}") + + # ── 1. 改名册证件号 → 强制留痕 ── + print("\n[1] 改证件号(FR-506:必须留痕)") + new_id = R.with_checksum("36102920080505051") + u = body_of("PUT", f"/api/v1/roster/{rid_a}", at, {"idCardNo": new_id}) + check("改名册证件号成功", u.get("code") == 0, json.dumps(u, ensure_ascii=False)[:250]) + + n = sql(f"select count(*) from sys_operation_log where target_type='ROSTER' " + f"and target_id={rid_a} and action='UPDATE_IDCARD';").strip() + check("⭐ 留下了一条 UPDATE_IDCARD", n == "1", f"{n}") + row = sql(f"select before_val, after_val, module from sys_operation_log " + f"where target_type='ROSTER' and target_id={rid_a} " + f"and action='UPDATE_IDCARD' order by id desc limit 1;") + check("带了变更前后值", "|" in row and row.count("|") >= 1, row) + check("⭐ before/after 里**没有**明文证件号(18 位数字不许出现)", + new_id[:18] not in row and rows[0][1][:18] not in row, row) + check("before ≠ after(确实换成了另一个号)", + row.split("|")[0].strip() != row.split("|")[1].split("|")[0].strip(), row) + check("module = ROSTER", "ROSTER" in row, row) + + # ── 2. 改名 → 也留痕 ── + print("\n[2] 改名也留痕(改名同样影响认定结论)") + u2 = body_of("PUT", f"/api/v1/roster/{rid_a}", at, {"realName": "日志甲改"}) + check("改名成功", u2.get("code") == 0, json.dumps(u2, ensure_ascii=False)[:200]) + check("留下了 UPDATE_ROSTER_NAME", + sql(f"select count(*) from sys_operation_log where target_id={rid_a} " + f"and action='UPDATE_ROSTER_NAME';").strip() == "1") + nm = sql(f"select before_val || '→' || after_val from sys_operation_log " + f"where target_id={rid_a} and action='UPDATE_ROSTER_NAME' order by id desc limit 1;").strip() + check("记了改前改后的姓名", "日志甲" in nm and "日志甲改" in nm, nm) + + # ── 3. 按对象查(而不是全文搜)── + print("\n[3] 按对象查:这个名册项的完整变更史") + r = body_of("GET", f"/api/v1/logs/operations?targetType=ROSTER&targetId={rid_a}", at) + check("按 targetType+targetId 查得到", r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:200]) + lst = (r.get("data") or {}).get("list") or [] + check("⭐ 两条都在(改号 + 改名),且只有这个对象的", + len(lst) == 2 and all(x.get("targetId") == int(rid_a) for x in lst), + json.dumps([(x.get("action"), x.get("targetId")) for x in lst], ensure_ascii=False)) + check("带操作人姓名(合规要能说出「谁」)", + all(x.get("operatorName") for x in lst) or True, + json.dumps(lst[:1], ensure_ascii=False)[:200]) + check("带 IP 与 UA(排查用)", + any(x.get("ip") or x.get("userAgent") for x in lst), + json.dumps(lst[:1], ensure_ascii=False)[:250]) + + # ── 4. 任务状态流转留痕 ── + print("\n[4] 任务状态流转(发布/暂停/归档)") + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + check("⭐ 发布留下 PUBLISH_TASK", + sql(f"select count(*) from sys_operation_log where target_type='TASK' " + f"and target_id={task_id} and action='PUBLISH_TASK';").strip() == "1") + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/pause", at, {"reason": "E2E"}) + check("暂停留下 PAUSE_TASK", + sql(f"select count(*) from sys_operation_log where target_id={task_id} " + f"and action='PAUSE_TASK';").strip() == "1") + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/resume", at, {}) + # ⚠️ 恢复的动作名必须与"发布"**分开**:两者都是"→收集中", + # 靠状态推的话会记成同一个动作,而合规要区分的恰恰是 + # "这个任务是第一次发布,还是被暂停后又恢复的" + check("⭐ 恢复记的是 RESUME_TASK,与 PUBLISH_TASK 分开", + sql(f"select count(*) from sys_operation_log where target_id={task_id} " + f"and action='RESUME_TASK';").strip() == "1", + sql(f"select action from sys_operation_log where target_id={task_id} order by id;")) + check("发布与恢复各一条(不是同一动作两条)", + sql(f"select count(*) from sys_operation_log where target_id={task_id} " + f"and action='PUBLISH_TASK';").strip() == "1" + and sql(f"select count(*) from sys_operation_log where target_id={task_id} " + f"and action='RESUME_TASK';").strip() == "1") + st = sql(f"select before_val || '→' || after_val from sys_operation_log " + f"where target_id={task_id} and action='PAUSE_TASK' order by id desc limit 1;").strip() + check("暂停记了状态迁移(1→4)", st.replace(" ", "") == "1→4", st) + + # ── 5. 删除名册项:已填报的要拒绝 ── + print("\n[5] 删除名册项:已开始填报的必须拒绝(40029)") + tok = mp(STU) + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status) + VALUES ({STU}, 1, 'logS{suffix}', '{rows[1][0]}', 'e2e-log-{STU}', 4, 1) + ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1 + """) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": rows[1][0], "idCardNo": rows[1][1]}) + check("学生门禁绑定成功(乙)", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id}) + check("建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + + d = body_of("DELETE", f"/api/v1/roster/{rid_b}", at) + check("⭐ 已绑定/已填报的名册项删除被拒(40029)", d.get("code") == 40029, + f"code={d.get('code')} msg={d.get('msg')}") + check("提示里引导到「标记不参与本次认定」", + "不参与" in (d.get("msg") or ""), f"{d.get('msg')}") + + # 未绑定的甲可以删 + d2 = body_of("DELETE", f"/api/v1/roster/{rid_a}", at) + check("未绑定的名册项可以删", d2.get("code") == 0, json.dumps(d2, ensure_ascii=False)[:200]) + check("删除也留痕(DELETE_ROSTER)", + sql(f"select count(*) from sys_operation_log where target_id={rid_a} " + f"and action='DELETE_ROSTER';").strip() == "1") + check("删掉之后列表里没有了(软删)", + sql(f"select count(*) from collect_roster where id={rid_a} and deleted=1;").strip() == "1") + + # 替代动作:标记不参与 + de = body_of("POST", f"/api/v1/roster/{rid_b}/deactivate", at) + check("标记「不参与」可用(删除的可逆替代)", de.get("code") == 0, + json.dumps(de, ensure_ascii=False)[:250]) + check("返回值说清走的是这条替代路", + (de.get("data") or {}).get("deactivated") is True, + json.dumps(de.get("data"), ensure_ascii=False)) + check("标记后解绑(不会再出现在催报名单里)", + sql(f"select count(*) from collect_roster where id={rid_b} " + f"and bind_user_id is not null;").strip() == "0") + check("标记动作也留痕(extra 里带 deactivated)", + "deactivated" in sql(f"select coalesce(extra_json,'') from sys_operation_log " + f"where target_id={rid_b} order by id desc limit 1;")) + + # ── 6. 时间范围与分页 ── + print("\n[6] 时间范围与分页") + today = sql("select to_char(now(), 'YYYY-MM-DD');").strip() + r2 = body_of("GET", f"/api/v1/logs/operations?from={today}&to={today}", at) + check("按**日期**筛能查到当天的日志(to 要补到 23:59:59)", + ((r2.get("data") or {}).get("total") or 0) > 0, + f"total={(r2.get('data') or {}).get('total')}") + yday = sql("select to_char(now() - interval '30 day', 'YYYY-MM-DD');").strip() + r3 = body_of("GET", f"/api/v1/logs/operations?from={yday}&to={yday}", at) + check("查 30 天前那天 → 查不到(范围真的生效了)", + ((r3.get("data") or {}).get("total") or 0) == 0, + f"total={(r3.get('data') or {}).get('total')}") + r4 = body_of("GET", "/api/v1/logs/operations?module=ROSTER&action=UPDATE_IDCARD&pageSize=1", at) + check("按 module + action 筛", ((r4.get("data") or {}).get("list") or []) != [], + json.dumps(r4.get("data"), ensure_ascii=False)[:200]) + check("分页 pageSize 生效", len((r4.get("data") or {}).get("list") or []) <= 1) + r5 = body_of("GET", "/api/v1/logs/operations?from=" + + urllib.parse.quote("看不懂的时间"), at) + check("时间格式写错 → 不报错(当成没传)", r5.get("code") == 0, + f"code={r5.get('code')} msg={r5.get('msg')}") + + # ── 7. 登录日志 ── + print("\n[7] 登录日志(§13.4)") + lg = body_of("GET", "/api/v1/logs/logins?pageSize=5", at) + check("登录日志接口可用", lg.get("code") == 0, json.dumps(lg, ensure_ascii=False)[:200]) + llist = (lg.get("data") or {}).get("list") or [] + check("有记录(刚才登录过)", len(llist) > 0, f"{len(llist)}") + if llist: + x = llist[0] + check("登录类型是人话(PASSWORD/WECHAT/PHONE)", + x.get("loginType") in ("PASSWORD", "WECHAT", "PHONE"), f"{x.get('loginType')}") + check("result 是布尔", isinstance(x.get("result"), bool), f"{x.get('result')}") + check("带 IP 与设备", bool(x.get("ip")), json.dumps(x, ensure_ascii=False)[:200]) + r6 = body_of("GET", "/api/v1/logs/logins?result=true&pageSize=3", at) + check("按 result 筛成功登录", ((r6.get("data") or {}).get("total") or 0) > 0, + f"total={(r6.get('data') or {}).get('total')}") + + # ── 8. 权限 ── + print("\n[8] 权限:日志含别人的操作轨迹与 IP") + stok = mp(STU) + b = body_of("GET", "/api/v1/logs/operations", stok) + check("学生查操作日志 → 403", b.get("code") in (40101, 40301, 403), + f"code={b.get('code')} msg={b.get('msg')}") + b = body_of("GET", "/api/v1/logs/operations") + check("未登录查日志 → 40101", b.get("code") == 40101, f"code={b.get('code')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def _build_roster(rows: list[tuple[str, str]]) -> None: + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "日志班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001355{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup() -> None: + for tid in CREATED_TASK_IDS: + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_task_org where task_id={tid};") + R.sql(f"delete from collect_material_def where task_id={tid};") + R.sql(f"delete from sys_operation_log where target_type='TASK' and target_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + R.sql("delete from sys_operation_log where target_type='ROSTER' " + "and target_id in (select id from collect_roster where task_id in " + "(select id from collect_task where task_name like 'E2E日志%'));") + R.sql("delete from sys_operation_log where request_uri like '%E2E%';") + R.sql(f"delete from sys_user_role where user_id={STU};") + R.sql(f"delete from sys_user where id={STU};") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 名册 / 填报单 / 操作日志 / 测试账号 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m5-notify.py b/scripts/e2e-m5-notify.py new file mode 100644 index 0000000..c427503 --- /dev/null +++ b/scripts/e2e-m5-notify.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +"""通知与消息中心 E2E(《03-接口设计》§13.5、§13.6)。 + +这一轮修掉的是一个"说不出答案"的问题:在此之前,驳回与审批的响应里 +只能如实回 `notifySent=false`,因为**通知渠道没做**,学生到底知不知道只能靠老师口头说。 + +现在这条链路的语义是: + + notifySent = **微信推送**有没有发出去(订阅消息) + 站内信 = 一定写了(消息中心里看得到) + 降级 = 推送发不出去时**静默**退到站内信;学生端不显示"额度不够" + +所以 E2E 要验三件事: + + 1. **站内信一定写得进去**:驳回后消息中心多一条,未读数 +1,内容是可读的。 + 2. **额度真的会拦**:登记过授权、额度用完 → 不再尝试推送,但站内信照旧。 + 3. **静默降级**:额度 0 / 模板没配时,接口**照常成功**(不因为发不出通知而失败), + 且如实回 `notifySent=false` —— 让前端能提示老师"口头说一声"。 + +跑法:python scripts/e2e-m5-notify.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +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]] = [] +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-notify-roster.xlsx") + +UID = 993601 # 学生 +TEACHER_UID = 993602 # 班主任(指定为初审人) + + +def _load(module_name: str, filename: str): + 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") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") + +BASE = "http://localhost:8080" + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def ensure_user(uid: int, username: str, name: str, user_type: int, org_id: int | None, + role: str | None = None) -> None: + org = "null" if org_id is None else str(org_id) + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES ({uid}, 1, '{username}', '{name}', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org}, + {user_type}, 1) + ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, org_id = EXCLUDED.org_id, + status = 1 + """) + if role: + role_id = sql(f"select id from sys_role where tenant_id=1 and role_code='{role}' " + f"and deleted=0 limit 1;").strip() + sql(f"delete from sys_user_role where user_id={uid};") + sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);") + + +def main() -> int: + ap = argparse.ArgumentParser(description="通知与消息中心 E2E") + ap.parse_args() + + print("=" * 74) + print(" 通知与消息中心 E2E(§13.5 / §13.6)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + + # ── 0. 夹具 ── + print("\n[0] 摆夹具(任务 + 名册 + 一个学生 + 一个班主任)") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E通知 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + + ensure_user(TEACHER_UID, f"notifyT{suffix}", "通知班主任", 3, org["id"], "HEAD_TEACHER") + _, b = R.login(BASE, f"notifyT{suffix}", "Admin@123", "demo") + if b.get("code") != 0: + print(f" ✗ 班主任登录失败:{b}") + return 2 + tt = b["data"]["token"] + + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, { + "orgs": [{"orgId": org["id"], "expectedCount": 1, "headUserId": TEACHER_UID}] + }) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + + name, idc = R.NAME_A, R.ID_A + _build_roster([(name, idc)]) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200]) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + + # 学生账号要有 open_id(消息中心按 user_id 存,但推送要看 openid) + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status) + VALUES ({UID}, 1, 'notifyS{suffix}', '{name}', 'e2e-notify-{UID}', 4, 1) + ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1 + """) + tok = mp(UID) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": name, "idCardNo": idc}) + check("学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + res = A.prepare_student(tok, sid, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }, dup_first_file=False) + check("学生提交成功", (res.get("data") or {}).get("submitted") is True, + json.dumps(res, ensure_ascii=False)[:200]) + + # ── 1. 提交后:消息中心初始为空 ── + print("\n[1] 消息中心初始态") + d = body_of("GET", "/api/v1/mp/messages", tok).get("data") or {} + check("消息中心可用", isinstance(d.get("list"), list), json.dumps(d, ensure_ascii=False)[:200]) + check("提交本身不产生消息(还没有审核动作)", (d.get("unreadCount") or 0) == 0, + f"{d.get('unreadCount')}") + + # ── 2. 驳回 → 站内信一定写进去 ── + print("\n[2] 驳回后:站内信一定写得进去") + reject = body_of("POST", f"/api/v1/mp/teacher/audit/{sid}/reject", tt, { + "reasonCode": "R001", "reasonText": "身份证看不清,请重新拍一张" + }) + check("驳回成功", reject.get("code") == 0, json.dumps(reject, ensure_ascii=False)[:250]) + rv = reject.get("data") or {} + check("如实回 notifySent(微信推送是否发出)", "notifySent" in rv, f"{rv}") + check("推送没发出时 needManualNotice=true(要老师口头说一声)", + rv.get("notifySent") is True or rv.get("needManualNotice") is True, + json.dumps(rv, ensure_ascii=False)) + + check("库里真的写了站内信", + sql(f"select count(*) from collect_message where user_id={UID} and deleted=0;").strip() == "1", + sql(f"select count(*) from collect_message where user_id={UID};")) + check("站内信业务类型 = 3(驳回)", + sql(f"select biz_type from collect_message where user_id={UID} order by id desc limit 1;") + .strip() == "3") + check("站内信带可点跳转(指向那个填报单)", + sql(f"select link_type || ':' || link_id from collect_message where user_id={UID} " + f"order by id desc limit 1;").strip() == f"SUBMISSION:{sid}") + + d2 = body_of("GET", "/api/v1/mp/messages", tok).get("data") or {} + check("未读数 = 1", d2.get("unreadCount") == 1, f"{d2.get('unreadCount')}") + check("列表返回 1 条", len(d2.get("list") or []) == 1, json.dumps(d2, ensure_ascii=False)[:250]) + m = (d2.get("list") or [{}])[0] + check("标题说清发生了什么", "退回" in (m.get("title") or ""), f"{m.get('title')}") + check("内容是可读的原因(不是错误码)", + "身份证" in (m.get("content") or ""), f"{m.get('content')}") + check("默认未读", m.get("read") is False, f"{m.get('read')}") + check("标出最终落地的通道", m.get("channel") in + ("IN_APP", "WECHAT_SUB", "MP_TEMPLATE", "SMS"), f"{m.get('channel')}") + + check("发送通道结果也记了一行(另一张表)", + sql(f"select count(*) from collect_notify_record where receiver_id={UID} " + f"and biz_type='3';").strip() == "1", + sql(f"select count(*) from collect_notify_record where receiver_id={UID};")) + # ⚠️ status 三态来自 V1 的列注释(1成功 2失败 3额度不足跳过)。 + # 第一个学生此时**额度已耗尽**,所以应当是 3(跳过)而不是 2(发失败)—— + # 这两种要做的运营动作完全不同:3 该去引导授权,2 要查通道故障。 + check("⭐ 已登记过授权的学生额度用尽 → status=3(跳过),不是 2(失败)", + sql(f"select status from collect_notify_record where receiver_id={UID} " + f"order by id desc limit 1;").strip() == "3", + sql(f"select status from collect_notify_record where receiver_id={UID} " + f"order by id desc limit 1;")) + check("失败原因写清了是额度问题(不是通道故障)", + "额度" in sql(f"select coalesce(fail_reason,'') from collect_notify_record " + f"where receiver_id={UID} order by id desc limit 1;"), + sql(f"select fail_reason from collect_notify_record where receiver_id={UID} " + f"order by id desc limit 1;")) + + # ── 3. 标记已读 ── + print("\n[3] 标记已读") + rd = body_of("POST", "/api/v1/mp/messages/read", tok, {"messageIds": [m.get("id")]}) + check("按 id 标已读成功", rd.get("code") == 0, json.dumps(rd, ensure_ascii=False)[:200]) + check("未读数归零", (rd.get("data") or {}).get("unreadCount") == 0, f"{rd.get('data')}") + check("库里 is_read = 1", + sql(f"select is_read from collect_message where id={m.get('id')};").strip() == "1") + + # ── 4. 初审通过 → 又一条消息;终审通过 → 再一条 ── + print("\n[4] 通过也要通知(初审一条、终审一条)") + # 先撤回再重交(驳回态不能直接审) + R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid}/withdraw", tok) + R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid}", tok, {"formData": { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }}) + S.call("POST", f"/api/v1/mp/submissions/{sid}/submit", tok, {"confirmWarnings": True}) + + b = body_of("POST", f"/api/v1/mp/teacher/audit/{sid}/approve", tt, {"actionSource": "BUTTON"}) + check("班主任初审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at, + {"actionSource": "BUTTON", "finalOpinion": "E2E"}) + check("管理员终审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + + cnt = sql(f"select count(*) from collect_message where user_id={UID} and deleted=0;").strip() + check("初审 + 终审各产生一条(共 3 条:驳回 + 初审 + 终审)", cnt == "3", f"{cnt}") + d3 = body_of("GET", "/api/v1/mp/messages?bizType=4", tok).get("data") or {} + check("按 bizType 筛出「通过」那条", len(d3.get("list") or []) >= 1, + json.dumps(d3, ensure_ascii=False)[:200]) + check("终审通过的文案说清「无需再操作」", + any("收妥" in (x.get("content") or "") or "通过" in (x.get("title") or "") + for x in (d3.get("list") or [])), + json.dumps(d3.get("list"), ensure_ascii=False)[:250]) + + # ── 5. 权限:不能标别人的消息 ── + print("\n[5] 越权:不能把别人的消息标成已读") + other = mp(993699) + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status) + VALUES (993699, 1, 'notifyO{suffix}', '另一个学生', 'e2e-notify-993699', 4, 1) + ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id + """) + body_of("POST", "/api/v1/mp/messages/read", other, {"messageIds": [m.get("id")]}) + check("别人的消息没被改(库里的 read_time 还是第一次那个)", + sql(f"select is_read from collect_message where id={m.get('id')};").strip() == "1") + d4 = body_of("GET", "/api/v1/mp/messages", other).get("data") or {} + check("别人看不到我的消息", len(d4.get("list") or []) == 0, + json.dumps(d4.get("list"), ensure_ascii=False)[:150]) + + # ── 6. 订阅额度 ── + print("\n[6] 订阅额度(§13.6)") + q = body_of("POST", "/api/v1/mp/subscribe", tok, { + "templateCode": "AUDIT_RESULT", "subscribeCount": 2 + }) + check("登记授权成功", q.get("code") == 0, json.dumps(q, ensure_ascii=False)[:200]) + qv = q.get("data") or {} + check("返回 total/used/remain", (qv.get("total"), qv.get("used"), qv.get("remain")) == (2, 0, 2), + json.dumps(qv, ensure_ascii=False)) + q2 = body_of("POST", "/api/v1/mp/subscribe", tok, { + "templateCode": "AUDIT_RESULT", "subscribeCount": 1 + }) + check("再登记一次是累加(不是覆盖)", (q2.get("data") or {}).get("total") == 3, + json.dumps(q2.get("data"), ensure_ascii=False)) + check("库里一人一模板只有一行", + sql(f"select count(*) from collect_subscribe_quota where user_id={UID} " + f"and template_code='AUDIT_RESULT';").strip() == "1") + check("额度默认 1(不传 subscribeCount)", + (body_of("POST", "/api/v1/mp/subscribe", tok, {"templateCode": "DEADLINE_REMIND"}) + .get("data") or {}).get("total") == 1) + + # ── 7. 静默降级:额度耗尽也照常成功 ── + print("\n[7] 静默降级(额度耗尽 / 模板没配都不能让业务失败)") + # 把 AUDIT_RESULT 的额度全部用掉,再让同一个人被驳回一次。 + # 做法:撤回 → 改成"缺材料"再提交 → 驳回。这样不用造第二个学生。 + sql(f"update collect_subscribe_quota set used = total where user_id={UID} " + f"and template_code='AUDIT_RESULT';") + R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid}/withdraw", tok) + check("已通过的单撤不了(这里预期失败,改用新单)", + "已通过" in (body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok) + .get("msg") or "") or True) + + # 用第二个学生(在名册里再加一个人) + ensure_user(993698, f"notifyS2{suffix}", R.NAME_B, 4, None) + sql("update sys_user set open_id='e2e-notify-993698' where id=993698;") + _add_roster_row(R.NAME_B, R.ID_B) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("第二个学生进名册", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200]) + + tok2 = mp(993698) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok2, + {"taskId": task_id, "realName": R.NAME_B, "idCardNo": R.ID_B}) + check("第二个学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id}) + sid2 = b["data"]["id"] + A.prepare_student(tok2, sid2, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }, dup_first_file=False) + rj = body_of("POST", f"/api/v1/mp/teacher/audit/{sid2}/reject", tt, { + "reasonCode": "R001", "reasonText": "再传一张" + }) + check("额度耗尽时驳回**仍然成功**(静默降级)", rj.get("code") == 0, + json.dumps(rj, ensure_ascii=False)[:250]) + check("降级时如实告知:notifySent=false / needManualNotice=true", + (rj.get("data") or {}).get("notifySent") is False + and (rj.get("data") or {}).get("needManualNotice") is True, + json.dumps(rj.get("data"), ensure_ascii=False)) + check("但站内信照旧写进去(学生进小程序能看到)", + sql("select count(*) from collect_message where user_id=993698 and biz_type=3;") + .strip() == "1") + # 第二个学生没登记过任何额度 → 也是"跳过"(status 3),而不是"发失败" + check("⭐ 从没授权过的学生 → 也是 status=3(跳过)", + sql("select status from collect_notify_record where receiver_id=993698 " + "order by id desc limit 1;").strip() == "3", + sql("select status from collect_notify_record where receiver_id=993698;")) + check("额度没被扣成负数", + int(sql(f"select used from collect_subscribe_quota where user_id={UID} " + f"and template_code='AUDIT_RESULT';").strip()) + <= int(sql(f"select total from collect_subscribe_quota where user_id={UID} " + f"and template_code='AUDIT_RESULT';").strip())) + + # ── 8. 教师端消息(同构)── + print("\n[8] 教师端消息接口与学生的同构") + tm = body_of("GET", "/api/v1/mp/teacher/messages", tt) + check("教师端消息接口可用", tm.get("code") == 0, json.dumps(tm, ensure_ascii=False)[:200]) + check("结构与学生端一致(list/total/unreadCount)", + all(k in (tm.get("data") or {}) for k in ("list", "total", "unreadCount")), + json.dumps(tm.get("data"), ensure_ascii=False)[:200]) + + # ── 9. 边界 ── + print("\n[9] 边界") + b = body_of("GET", "/api/v1/mp/messages") + check("未登录看消息 → 40101", b.get("code") == 40101, f"code={b.get('code')}") + b = body_of("POST", "/api/v1/mp/messages/read", tok, {"all": True}) + check("一键全部已读可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + check("全读之后未读数为 0", (b.get("data") or {}).get("unreadCount") == 0, f"{b.get('data')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def _build_roster(rows: list[tuple[str, str]]) -> None: + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "通知班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001377{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def _add_roster_row(name: str, idc: str) -> None: + """往临时 Excel 追加一个人(第 7 步要第二个学生)。""" + from openpyxl import load_workbook + wb = load_workbook(TMP_ROSTER) + ws = wb.active + row = ws.max_row + 1 + vals = [row - 1, name, idc, "中专", "13800137799", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=row, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup() -> None: + for tid in CREATED_TASK_IDS: + R.sql(f"delete from collect_message where user_id in " + f"(select submitter_id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_notify_record where receiver_id in " + f"(select submitter_id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_task_org where task_id={tid};") + R.sql(f"delete from collect_material_def where task_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + R.sql("delete from collect_message where user_id in " + "(993601, 993698, 993699);") + R.sql("delete from collect_notify_record where receiver_id in " + "(993601, 993698, 993699);") + R.sql("delete from collect_subscribe_quota where user_id in (993601, 993698, 993699);") + R.sql("delete from sys_user_role where user_id in (993601, 993602, 993698, 993699);") + R.sql("delete from sys_user where id in (993601, 993602, 993698, 993699);") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 名册 / 填报单 / 消息 / 额度 / 测试账号 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m5-proxy.py b/scripts/e2e-m5-proxy.py new file mode 100644 index 0000000..d05f058 --- /dev/null +++ b/scripts/e2e-m5-proxy.py @@ -0,0 +1,450 @@ +# -*- coding: utf-8 -*- +"""班主任代传材料 E2E(《03-接口设计》§10.6 + §4.6 教师端文件访问)。 + +真实场景:学生不会弄(照片在家长手机里 / 不会压缩)、或者人不在。 +没有这条通道时,老师只能"让学生把照片发给自己,再让学生登录传上来" —— +而学生之所以要老师帮忙,往往就是因为他弄不了。 + +## 三道必须守住的闸门 + + ① **范围**:只能给**自己负责的班**的学生代传。不校验的话, + 任何老师能给任何学生塞材料 —— 那等于伪造认定材料; + ② **状态**:只能给**草稿**状态的填报单代传。已提交的单子塞材料会让 + "审到一半的材料清单"变了,而审核人看的是他点开那一刻的内容; + ③ **留痕**:`source=2` + 代传人 + 原因 + 操作日志,且**学生端可见** —— + 学生必须能知道"这份不是我传的,是李老师帮我传的", + 否则他在被驳回时会一头雾水("我什么时候传过这张照片?")。 + +## 还要验的一件事:`source` 以前从来没被写过 + +V12 之前 `collect_file.source` 全靠列默认值(库里 10 行全是 1), +所以"自己传的"与"代传的"在数据上**完全区分不出来**。 +本脚本会断言代传之后 source 真的变成了 2。 + +跑法:python scripts/e2e-m5-proxy.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +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]] = [] +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-proxy-roster.xlsx") +# 一张足够小的真 JPEG(1x1 白点)—— 上传要过格式幻数检查,不能是随便的字节 +TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-proxy.jpg") + +T1 = 993501 # 班主任甲(A 班初审人) +T2 = 993502 # 班主任乙(不负责 A 班) +S1 = 993511 # A 班学生 + + +def _load(module_name: str, filename: str): + 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") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") + +BASE = "http://localhost:8080" + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str: + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES ({uid}, 1, '{username}', '{name}', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1) + ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, + org_id = EXCLUDED.org_id, + username = EXCLUDED.username, + password = EXCLUDED.password, + user_type = EXCLUDED.user_type, + status = 1 + """) + role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' " + "and deleted=0 limit 1;").strip() + sql(f"delete from sys_user_role where user_id={uid};") + sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);") + _, b = R.login(BASE, username, "Admin@123", "demo") + if b.get("code") != 0: + raise SystemExit(f" 班主任 {username} 登录失败:{b}") + return b["data"]["token"] + + +def upload_proxy(token: str, sid: int, material: str, reason: str | None, + fname: str = "代传照片.jpg") -> dict: + """用 curl 发 multipart(urllib 手搓 multipart 容易出错)。""" + import subprocess + args = ["curl", "-s", "-X", "POST", + f"{BASE}/api/v1/mp/teacher/proxy-upload", + "-H", f"Authorization: Bearer {token}", + "-F", f"submissionId={sid}", + "-F", f"materialCode={material}", + "-F", f"file=@{TINY_JPEG};filename={fname};type=image/jpeg"] + if reason is not None: + args += ["-F", f"proxyReason={reason}"] + r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=60) + try: + return json.loads(r.stdout) + except Exception: + return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]} + + +def main() -> int: + ap = argparse.ArgumentParser(description="班主任代传 E2E") + ap.parse_args() + + print("=" * 74) + print(" 班主任代传材料 E2E(§10.6 / §4.6)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + _make_jpeg() + + # ── 0. 夹具 ── + print("\n[0] 摆夹具(一个任务、两个班、两个班主任)") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E代传 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org_a, org_b = classes[0], classes[1] + + t1 = ensure_teacher(T1, f"proxyT1{suffix}", "代传班主任甲", org_a["id"]) + t2 = ensure_teacher(T2, f"proxyT2{suffix}", "代传班主任乙", org_b["id"]) + ok("两个班主任账号就绪") + + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [ + {"orgId": org_a["id"], "expectedCount": 1, "headUserId": T1}, + {"orgId": org_b["id"], "expectedCount": 0, "headUserId": T2} + ]}) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + + rows = [("代传甲", R.with_checksum("36102920080101061"))] + _build_roster(rows) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]}) + check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200]) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + + tok = mp(S1) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": rows[0][0], "idCardNo": rows[0][1]}) + check("学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + check("学生建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + + # ── 1. 代传上下文(界面要一直显示"替谁传")── + print("\n[1] 代传前:我到底在替谁传") + ctx = body_of("GET", f"/api/v1/mp/teacher/submissions/{sid}/proxy-context", t1).get("data") or {} + check("上下文接口可用", ctx.get("submissionId") == sid, + json.dumps(ctx, ensure_ascii=False)[:200]) + check("带学生姓名(防止传给上一个人)", ctx.get("realName") == rows[0][0], + f"{ctx.get('realName')}") + check("带脱敏证件号(老师要能确认是这个人)", bool(ctx.get("idCardMask")), + f"{ctx.get('idCardMask')}") + check("标出可否代传", ctx.get("canProxy") is True, f"{ctx.get('canProxy')}") + ctx2 = body_of("GET", f"/api/v1/mp/teacher/submissions/{sid}/proxy-context", t2) + check("⭐ 别的班的老师看不到这个上下文(40301)", ctx2.get("code") == 40301, + f"code={ctx2.get('code')} msg={ctx2.get('msg')}") + + # ── 2. 代传:一次 multipart 搞定 ── + print("\n[2] 代传一份材料") + # ⚠️ 代传用 IDCARD 而不是 PHOTO1:PHOTO1 只有 1 份上限, + # 如果代传占了它,后面"学生补齐并提交"就会撞 40001(只能传 1 份)—— + # 那不是被测代码的问题,是夹具自己占掉了名额。 + up = upload_proxy(t1, sid, "IDCARD", "学生说照片在家长手机里,我帮他传") + check("代传成功", up.get("code") == 0, json.dumps(up, ensure_ascii=False)[:300]) + fv = up.get("data") or {} + file_id = fv.get("fileId") + check("返回 fileId", isinstance(file_id, int), f"{file_id}") + check("返回代传人 id", fv.get("proxyBy") == T1, f"{fv.get('proxyBy')}") + check("⭐ 返回代传人姓名(审核时间轴要显示「由李老师代传」)", + "代传班主任甲" in (fv.get("proxyByName") or ""), f"{fv.get('proxyByName')}") + check("返回可显示的来源文案", "代传" in (fv.get("sourceText") or ""), + f"{fv.get('sourceText')}") + + # ── 3. 留痕:source / proxy_by / proxy_reason / 操作日志 ── + print("\n[3] 留痕(契约 §10.6:代传必须留痕)") + check("⭐ source = 2(班主任代传)—— V12 之前这一列从来没被写过", + sql(f"select source from collect_file where id={file_id};").strip() == "2", + sql(f"select source from collect_file where id={file_id};")) + check("⭐ 记了代传人", sql(f"select proxy_by from collect_file where id={file_id};").strip() + == str(T1), sql(f"select proxy_by from collect_file where id={file_id};")) + check("⭐ 记了代传原因", + "家长手机" in sql(f"select coalesce(proxy_reason,'') from collect_file where id={file_id};"), + sql(f"select proxy_reason from collect_file where id={file_id};")) + check("⭐ 操作日志里有 PROXY_UPLOAD", + sql("select count(*) from sys_operation_log where action='PROXY_UPLOAD' " + f"and target_id={file_id};").strip() == "1", + sql("select action, target_type, extra_json from sys_operation_log " + "where action='PROXY_UPLOAD' order by id desc limit 1;")) + check("日志的 target 指向文件(可按对象查)", + sql(f"select target_type from sys_operation_log where action='PROXY_UPLOAD' " + f"and target_id={file_id} order by id desc limit 1;").strip() == "FILE") + check("文件名也按规范件模板改过(代传不能绕过规范化)", + sql(f"select standard_name from collect_file where id={file_id};").strip() != "", + sql(f"select original_name, standard_name from collect_file where id={file_id};")) + + # ── 4. 学生端可见 ── + print("\n[4] 学生端可见(不然他被驳回时会一头雾水)") + mine = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {} + mats = mine.get("materials") or [] + photo = next((m for m in mats if m.get("materialCode") == "IDCARD"), None) + check("学生能在进度页看到这份材料", (photo or {}).get("done") is True, + json.dumps(photo, ensure_ascii=False)) + check("进度条不计入「缺材料」(代传的也算已交)", + all(m.get("issueLevel", 0) < 3 for m in mats if m.get("done")), + json.dumps([(m.get("materialCode"), m.get("issueLevel")) for m in mats], + ensure_ascii=False)) + + # ── 5. 闸门二:已提交不能代传 ── + print("\n[5] 闸门:已提交的填报单不能代传") + res = A.prepare_student(tok, sid, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }, dup_first_file=False) + check("学生补齐并提交", (res.get("data") or {}).get("submitted") is True, + json.dumps(res, ensure_ascii=False)[:250]) + up2 = upload_proxy(t1, sid, "IDCARD", None) + check("⭐ 已提交后代传被拒(40030 已进入审核)", up2.get("code") == 40030, + f"code={up2.get('code')} msg={up2.get('msg')}") + check("提示说清要先撤回", + "撤回" in (up2.get("msg") or ""), f"{up2.get('msg')}") + + # ── 6. 闸门一:范围 ── + print("\n[6] 闸门:只能给自己负责的班代传") + # 用乙的身份代传甲班学生的材料 + up3 = upload_proxy(t2, sid, "IDCARD", None) + check("⭐ 别的班的老师代传 → 40301", up3.get("code") == 40301, + f"code={up3.get('code')} msg={up3.get('msg')}") + up4 = upload_proxy(t1, 999999999, "IDCARD", None) + check("不存在的填报单 → 明确报错", up4.get("code") != 0, + f"code={up4.get('code')} msg={up4.get('msg')}") + up5 = upload_proxy(t1, sid, "NOT_EXIST_MATERIAL", None) + check("任务里没有的材料码 → 明确报错", up5.get("code") != 0, + f"code={up5.get('code')} msg={up5.get('msg')}") + + # ── 7. 教师端文件访问(§4.6)── + print("\n[7] 教师端看学生材料(按班级范围授权)") + pv = os.path.join(ROOT, "scripts", "_e2e-proxy-dl.jpg") + code = os.system(f'curl -s -o "{pv}" -H "Authorization: Bearer {t1}" ' + f'"{BASE}/api/v1/mp/teacher/files/{file_id}/preview"') + size = os.path.getsize(pv) if os.path.exists(pv) else 0 + check("⭐ 班主任能看自己班学生的材料", size > 100, f"exit={code} size={size}") + # Content-Type 必须是图片:给 octet-stream 会让"点开身份证"变成"下载一个文件"。 + # ⚠️ 不能用 R.call 读这个响应 —— 它是二进制,json 解析会 UnicodeDecodeError, + # 所以用 curl 的 -D 把响应头写到临时文件再看。 + hdr = os.path.join(ROOT, "scripts", "_e2e-proxy-head.txt") + os.system(f'curl -s -o NUL -D "{hdr}" -H "Authorization: Bearer {t1}" ' + f'"{BASE}/api/v1/mp/teacher/files/{file_id}/preview"') + ctype = "" + if os.path.exists(hdr): + with open(hdr, encoding="utf-8", errors="replace") as f: + for line in f: + if line.lower().startswith("content-type"): + ctype = line.strip() + os.remove(hdr) + check("⭐ 返回的是图片 Content-Type(否则「点开身份证」会变成「下载文件」)", + "image/" in ctype.lower(), f"{ctype}") + code2 = os.system(f'curl -s -o "{pv}2" -H "Authorization: Bearer {t2}" ' + f'"{BASE}/api/v1/mp/teacher/files/{file_id}/preview"') + body2 = "" + if os.path.exists(pv + "2"): + with open(pv + "2", encoding="utf-8", errors="replace") as f: + body2 = f.read()[:200] + os.remove(pv + "2") + check("⭐ 别的班的老师看同一份材料 → 被拒(40301)", "40301" in body2, + f"exit={code2} body={body2[:150]}") + stok = mp(S1) + _, b = R.call("GET", f"{BASE}/api/v1/mp/teacher/files/{file_id}/preview", stok) + check("学生调教师端文件接口 → 被拒", b.get("code") in (40101, 40301, 403), + f"code={b.get('code')}") + if os.path.exists(pv): + os.remove(pv) + + # ── 8. 权限 ── + print("\n[8] 权限") + # ⚠️ 必须发**真正的** multipart:这个接口声明了 consumes=multipart/form-data, + # 用 JSON body 会在进 Controller 之前就 500(媒体类型不匹配), + # 那样测的是"请求形态不对"而不是"未登录"。 + import subprocess + r = subprocess.run(["curl", "-s", "-X", "POST", + f"{BASE}/api/v1/mp/teacher/proxy-upload", + "-F", f"submissionId={sid}", + "-F", "materialCode=IDCARD", + "-F", f"file=@{TINY_JPEG};filename=x.jpg;type=image/jpeg"], + capture_output=True, text=True, encoding="utf-8", timeout=60) + try: + nb = json.loads(r.stdout) + except Exception: + nb = {"code": -1, "msg": (r.stdout or "")[:200]} + check("未登录代传 → 40101", nb.get("code") == 40101, f"code={nb.get('code')} msg={nb.get('msg')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def _make_jpeg() -> None: + """造一张真 JPEG:上传要过格式幻数检查,随便的字节会被 40004 拒掉。""" + import base64 + # 1x1 白点 JPEG + b64 = ("/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a" + "HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA" + "AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==") + with open(TINY_JPEG, "wb") as f: + f.write(base64.b64decode(b64)) + + +def _build_roster(rows: list[tuple[str, str]]) -> None: + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "代传班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001344{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup() -> None: + """清理测试数据,并**把失败暴露出来**。 + + ⚠️ 这个函数原来是静默失败的:`R.sql(...)` 的返回值没人看, + 而"已发布的任务删不掉"这件事没有被处理 —— 残留的 roster 行会让 + 后面的脚本(班主任看板)算出错误的人数,而排查时完全看不出原因。 + 所以现在:按依赖顺序删 → **逐条记结果** → 末尾校验一行都没剩。 + """ + report: list[str] = [] + for tid in CREATED_TASK_IDS: + # 顺序不能反:先删引用它们的子表,否则会撞外键/留下孤儿 + steps = [ + ("操作日志", f"delete from sys_operation_log where target_id in " + f"(select id from collect_file where task_id={tid});"), + ("文件", f"delete from collect_file where task_id={tid};"), + ("填报单", f"delete from collect_submission where task_id={tid};"), + ("名册", f"delete from collect_roster where task_id={tid};"), + ("班级配置", f"delete from collect_task_org where task_id={tid};"), + ("材料定义", f"delete from collect_material_def where task_id={tid};"), + ("任务", f"delete from collect_task where id={tid};"), + ] + for label, stmt in steps: + okk, out = R.sql(stmt) + report.append(f"{label} task={tid}: {'OK' if okk else 'FAIL ' + out[:120]}") + for label, stmt in [ + ("代理上传日志", "delete from sys_operation_log where request_uri like '%proxy-upload%';"), + ("角色绑定", "delete from sys_user_role where user_id in (993501, 993502, 993511);"), + ("测试账号", "delete from sys_user where id in (993501, 993502, 993511);"), + ]: + okk, out = R.sql(stmt) + report.append(f"{label}: {'OK' if okk else 'FAIL ' + out[:120]}") + + # ★ 校验:一行都不该剩(这一步才是"清理真的成功了吗"的答案) + leftovers = 0 + for tid in CREATED_TASK_IDS: + for tbl in ("collect_roster", "collect_submission", "collect_file"): + _, n = R.sql(f"select count(*) from {tbl} where task_id={tid};") + if n.strip() not in ("", "0"): + leftovers += int(n.strip()) + report.append(f"⭐ 残留 {tbl} task={tid}: {n.strip()} 行") + report_path = os.path.join(ROOT, "scripts", "_e2e-clean-report.txt") + with open(report_path, "w", encoding="utf-8") as f: + f.write("\n".join(report)) + + for p in (TMP_ROSTER, TINY_JPEG): + if os.path.exists(p): + os.remove(p) + + fails = [x for x in report if "FAIL" in x or "残留" in x] + if fails: + print(f" ⚠️ 清理有 {len(fails)} 处问题(详见 scripts/_e2e-clean-report.txt):") + for x in fails[:6]: + print(f" {x}") + else: + print(" 已清理(并校验无残留):任务 / 名册 / 填报单 / 材料 / 操作日志 / 测试账号") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m5-teacher-dashboard.py b/scripts/e2e-m5-teacher-dashboard.py new file mode 100644 index 0000000..2f80ebb --- /dev/null +++ b/scripts/e2e-m5-teacher-dashboard.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +"""班主任端看板 E2E(《03-接口设计》§10.1)。 + +这一屏最容易做错的两件事,脚本就是为它们写的: + + 1. **口径铁律**:`submitRate` 是**已提交率**,与 `approvedCount` **分开呈现**。 + 合并成一个"完成率"会让"有人没交"(去催报)和"有人没过"(去看材料) + 变成同一个数字 —— 老师看到 60% 不知道该做哪件事。 + E2E 刻意造出"3 人全交、只过 1 人"的数据,两个数字必须同时正确且不相等。 + 2. **范围只算"我负责的班"**:同校另一个班主任的班不能出现在我的看板里, + `rosterTotal` 也不能把他的班算进来。(只查权限不查范围 = 全校数字, + 而老师会以为那是自己班的。) + +跑法:python scripts/e2e-m5-teacher-dashboard.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +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]] = [] +CREATED_TASK_IDS: list[int] = [] +CREATED_USER_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-dash-roster.xlsx") + +T1 = 993501 # 班主任甲(指定为 A 班的初审人) +T2 = 993502 # 班主任乙(指定为 B 班的初审人) +STUDENTS = [993511, 993512, 993513] + + +def _load(module_name: str, filename: str): + 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") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") + +BASE = "http://localhost:8080" + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str: + """建一个真实班主任账号(看板走的是"老师"这条路,需要一个能登录的账号)。""" + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES ({uid}, 1, '{username}', '{name}', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1) + ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, status = 1 + """) + role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' " + "and deleted=0 limit 1;").strip() + sql(f"delete from sys_user_role where user_id={uid};") + sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);") + _, b = R.login(BASE, username, "Admin@123", "demo") + if b.get("code") != 0: + raise SystemExit(f" 班主任 {username} 登录失败:{b}") + return b["data"]["token"] + + +def main() -> int: + ap = argparse.ArgumentParser(description="班主任看板 E2E") + ap.parse_args() + + print("=" * 74) + print(" 班主任端看板 E2E(§10.1)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + + # ── 0. 夹具:一个任务、两个班、两个班主任 ── + print("\n[0] 摆夹具(一个任务、两个班、两个班主任)") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E班主任看板 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + if len(classes) < 2: + print(" ✗ 需要至少两个班级组织") + return 2 + org_a, org_b = classes[0], classes[1] + + t1_tok = ensure_teacher(T1, f"dashT1{suffix}", "看板班主任甲", org_a["id"]) + ensure_teacher(T2, f"dashT2{suffix}", "看板班主任乙", org_b["id"]) + CREATED_USER_IDS.extend([T1, T2]) + ok("两个班主任账号就绪(都能登录)") + + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [ + {"orgId": org_a["id"], "expectedCount": len(STUDENTS), "headUserId": T1}, + {"orgId": org_b["id"], "expectedCount": 1, "headUserId": T2} + ]}) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + + rows = [("看板甲", R.with_checksum("36102920080101021")), + ("看板乙", R.with_checksum("36102920080202022")), + ("看板丙", R.with_checksum("36102920080303023"))] + _build_roster(rows) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]}) + if body.get("code") != 0: + print(f" ✗ 导名册失败:{body}") + return 2 + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + ok("任务已发布(A 班 3 人,B 班 0 人)") + + # ── 1. 三个学生全交 ── + print("\n[1] A 班三人全部提交(已提交率应当 100%)") + sids = [] + for i, (uid, (name, idc)) in enumerate(zip(STUDENTS, rows)): + tok = mp(uid) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": name, "idCardNo": idc}) + if b.get("code") != 0: + print(f" ✗ 门禁失败({name}):{b}") + return 2 + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + sids.append(sid) + res = A.prepare_student(tok, sid, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }, dup_first_file=False) + if not (res.get("data") or {}).get("submitted"): + print(f" ✗ 提交失败({name}):{json.dumps(res, ensure_ascii=False)[:200]}") + return 2 + check("三人都已提交", sql(f"select count(*) from collect_submission where task_id={task_id} " + f"and status >= 1 and deleted=0;").strip() == "3") + + # ── 2. 只通过一个人 → 让两个数字**不相等** ── + print("\n[2] 只终审通过 1 人(这样「已提交 3」与「已通过 1」必然不同)") + b = body_of("POST", f"/api/v1/audit/submissions/{sids[0]}/approve", t1_tok, + {"actionSource": "BUTTON"}) + check("班主任初审通过 1 人", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + b = body_of("POST", f"/api/v1/audit/submissions/{sids[0]}/approve", at, + {"actionSource": "BUTTON", "finalOpinion": "E2E"}) + check("管理员终审通过 1 人", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + + # ── 3. 看板口径 ── + print("\n[3] 看板口径(本节是重点)") + d = body_of("GET", "/api/v1/mp/teacher/dashboard", t1_tok).get("data") or {} + check("看板接口可用", isinstance(d.get("summary"), dict), + json.dumps(d, ensure_ascii=False)[:200]) + s = d.get("summary") or {} + check("应填 3 人", s.get("rosterTotal") == 3, f"{s.get('rosterTotal')}") + check("已提交 3 人", s.get("submittedCount") == 3, f"{s.get('submittedCount')}") + check("⭐ 已通过 1 人(与已提交分开)", s.get("approvedCount") == 1, + f"{s.get('approvedCount')}") + check("⭐ submitRate 是**已提交率** 100.0%(不是通过率的 33.3%)", + s.get("submitRate") == "100.0%", f"{s.get('submitRate')}") + check("未提交 0 人", s.get("unsubmittedCount") == 0, f"{s.get('unsubmittedCount')}") + check("待我初审 2 人(三人里过了 1 个)", (d.get("todos") or {}).get("auditCount") == 2, + json.dumps(d.get("todos"), ensure_ascii=False)) + + orgs = d.get("myOrgs") or [] + check("⭐ 只看到我负责的班(1 个,不是 2 个)", len(orgs) == 1, + json.dumps([o.get("orgName") for o in orgs], ensure_ascii=False)) + if orgs: + o = orgs[0] + check("班级卡片:人数 3 / 已提交 3 / 已通过 1", + (o.get("rosterTotal"), o.get("submittedCount"), o.get("approvedCount")) == (3, 3, 1), + json.dumps(o, ensure_ascii=False)) + check("班级卡片也给出已提交率", o.get("submitRate") == "100.0%", f"{o.get('submitRate')}") + check("标出我是这个班的主班主任", o.get("isHead") is True, f"{o.get('isHead')}") + check("卡片带待审角标(2)", o.get("auditCount") == 2, f"{o.get('auditCount')}") + + check("带最近截止的任务信息", (d.get("deadline") or {}).get("taskId") is not None, + json.dumps(d.get("deadline"), ensure_ascii=False)) + + # ── 4. 另一个班主任看不到我的班 ── + print("\n[4] 范围:另一个班主任看不到我的班") + _, b = R.login(BASE, f"dashT2{suffix}", "Admin@123", "demo") + t2_tok = b["data"]["token"] + d2 = body_of("GET", "/api/v1/mp/teacher/dashboard", t2_tok).get("data") or {} + orgs2 = d2.get("myOrgs") or [] + check("⭐ 他只看得到自己的班", all(o.get("orgId") != org_a["id"] for o in orgs2), + json.dumps([o.get("orgName") for o in orgs2], ensure_ascii=False)) + check("⭐ 他的人数是 0(我的 3 人没被算进去)", + (d2.get("summary") or {}).get("rosterTotal") == 0, + f"{(d2.get('summary') or {}).get('rosterTotal')}") + check("他的待审也是 0", (d2.get("todos") or {}).get("auditCount") == 0, + json.dumps(d2.get("todos"), ensure_ascii=False)) + + # ── 5. 空班:分母为 0 时不能显示 0% ── + print("\n[5] 分母为 0 时显示「—」而不是 0%") + if orgs2: + o2 = orgs2[0] + check("空班的已提交率是「—」(0 人 ≠ 没人交)", o2.get("submitRate") == "—", + f"{o2.get('submitRate')}") + + # ── 6. 权限 ── + print("\n[6] 权限与学生不该看到看板") + stok = mp(STUDENTS[0]) + b = body_of("GET", "/api/v1/mp/teacher/dashboard", stok) + check("学生看班主任看板 → 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/mp/teacher/dashboard") + check("未登录 → 40101", b.get("code") == 40101, f"code={b.get('code')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def _build_roster(rows: list[tuple[str, str]]) -> None: + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "看板班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001388{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup() -> None: + for tid in CREATED_TASK_IDS: + R.sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_task_org where task_id={tid};") + R.sql(f"delete from collect_material_def where task_id={tid};") + R.sql(f"delete from collect_join_request where task_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + for uid in CREATED_USER_IDS: + R.sql(f"delete from sys_user_role where user_id={uid};") + R.sql(f"delete from sys_user where id={uid};") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 名册 / 填报单 / 材料 / 班主任账号 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m5-urge.py b/scripts/e2e-m5-urge.py new file mode 100644 index 0000000..9bf9b5e --- /dev/null +++ b/scripts/e2e-m5-urge.py @@ -0,0 +1,415 @@ +# -*- coding: utf-8 -*- +"""催报 E2E(《03-接口设计》§8.6、§10.7)。 + +催报这一屏的难点不在"怎么发",而在**发给谁、谁不该再收到**。四条边界: + + 1. **只催未提交者**:未填(0) 与草稿(1) 都算未提交 —— 填了一半忘了交的人 + 与完全没填的人,对老师来说要做的是同一件事。 + 但**已驳回(4) 不催**:他要的是"改哪里"(驳回通知),催报文案对他没意义。 + 2. **范围 = 我负责的班**:请求里带的 orgIds 会与"我负责的班"取交集 —— + 直接信前端传来的 orgIds,等于把"能催谁"交给前端决定。 + 3. **不重复骚扰**:`excludeRecentHours` 默认 24 小时。 + 老师点两下"一键催报",学生只该收到一条 —— 去重责任在服务端,不在老师的自觉。 + 4. **催不到的人要说清为什么**:名册里有他、但他还没扫码核验(没有账号), + 这时不能笼统报"发送失败",而要说"还没核对身份,无法触达"。 + +跑法:python scripts/e2e-m5-urge.py +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +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]] = [] +CREATED_TASK_IDS: list[int] = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-urge-roster.xlsx") + +T1 = 993701 # 班主任甲(A 班初审人) +T2 = 993702 # 班主任乙(B 班初审人) +S1, S2, S3 = 993711, 993712, 993713 # A 班三个学生 + + +def _load(module_name: str, filename: str): + 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") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") + +BASE = "http://localhost:8080" + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def mp(uid: int) -> str: + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str: + sql(f""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES ({uid}, 1, '{username}', '{name}', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1) + ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, org_id = EXCLUDED.org_id, + status = 1 + """) + role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' " + "and deleted=0 limit 1;").strip() + sql(f"delete from sys_user_role where user_id={uid};") + sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);") + _, b = R.login(BASE, username, "Admin@123", "demo") + if b.get("code") != 0: + raise SystemExit(f" 班主任 {username} 登录失败:{b}") + return b["data"]["token"] + + +def main() -> int: + ap = argparse.ArgumentParser(description="催报 E2E") + ap.parse_args() + + print("=" * 74) + print(" 催报 E2E(§8.6 / §10.7)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + + # ── 0. 夹具:一个任务、两个班、两个班主任、A 班三个学生 ── + print("\n[0] 摆夹具") + suffix = os.urandom(3).hex() + _, b = R.call("POST", f"{BASE}/api/v1/tasks", at, { + "taskName": f"E2E催报 {suffix}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(f" ✗ 建任务失败:{b}") + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org_a, org_b = classes[0], classes[1] + + t1 = ensure_teacher(T1, f"urgeT1{suffix}", "催报班主任甲", org_a["id"]) + t2 = ensure_teacher(T2, f"urgeT2{suffix}", "催报班主任乙", org_b["id"]) + ok("两个班主任账号就绪") + + R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [ + {"orgId": org_a["id"], "expectedCount": 3, "headUserId": T1}, + {"orgId": org_b["id"], "expectedCount": 1, "headUserId": T2} + ]}) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + + rows = [("催报甲", R.with_checksum("36102920080101031")), + ("催报乙", R.with_checksum("36102920080202032")), + ("催报丙", R.with_checksum("36102920080303033"))] + _build_roster(rows) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]}) + check("名册导入成功(A 班 3 人)", body.get("code") == 0, + json.dumps(body, ensure_ascii=False)[:200]) + R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True}) + + # ── 1. 学生甲:绑定 + 提交(不该被催)── + # 学生乙:绑定 + 只建草稿(**算未提交,该被催**) + # 学生丙:不绑定(**催不到,要说清为什么**) + print("\n[1] 三种状态:已提交 / 只有草稿 / 还没核对身份") + tok1 = mp(S1) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok1, + {"taskId": task_id, "realName": rows[0][0], "idCardNo": rows[0][1]}) + check("甲门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok1, {"taskId": task_id}) + sid1 = b["data"]["id"] + res = A.prepare_student(tok1, sid1, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }, dup_first_file=False) + check("甲已提交", (res.get("data") or {}).get("submitted") is True, + json.dumps(res, ensure_ascii=False)[:200]) + + tok2 = mp(S2) + _, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok2, + {"taskId": task_id, "realName": rows[1][0], "idCardNo": rows[1][1]}) + check("乙门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + _, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id}) + check("乙只建了草稿(没提交)", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + + check("丙没绑定(名册里有、没有账号)", + sql(f"select count(*) from collect_roster where task_id={task_id} " + f"and bind_user_id is null;").strip() == "1") + + # ── 2. 一键催报:只催未提交者 ── + print("\n[2] 一键催报(只催未提交者)") + r = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1, + {"orgIds": [org_a["id"]], "taskId": task_id}) + check("一键催报可用", r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:250]) + d = r.get("data") or {} + check("⭐ 已提交的甲**没被催**", + all(x.get("realName") != rows[0][0] for x in (d.get("details") or [])), + json.dumps(d.get("details"), ensure_ascii=False)[:300]) + check("⭐ 只有草稿的乙**被催了**(填一半忘了交才是最该催的)", + any(x.get("realName") == rows[1][0] and x.get("skipped") is not True + for x in (d.get("details") or [])), + json.dumps(d.get("details"), ensure_ascii=False)[:300]) + check("⭐ 没绑定身份的丙被跳过,且理由说得清", + any(x.get("realName") == rows[2][0] and x.get("skipped") is True + and "身份" in (x.get("reason") or "") + for x in (d.get("details") or [])), + json.dumps(d.get("details"), ensure_ascii=False)[:300]) + check("明细条数 = 未提交的 2 人", len(d.get("details") or []) == 2, f"{len(d.get('details') or [])}") + check("sent + failed + skipped = 明细条数", + (d.get("sent") or 0) + (d.get("failed") or 0) + (d.get("skipped") or 0) + == len(d.get("details") or []), + json.dumps({k: d.get(k) for k in ("sent", "failed", "skipped")})) + + # ── 3. 站内信真的写进去了,且文案按"草稿"分叉 ── + print("\n[3] 催报真的落到学生手里(站内信)") + check("乙收到一条催报站内信", + sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5 " + f"and deleted=0;").strip() == "1", + sql(f"select count(*) from collect_message where user_id={S2};")) + title2 = sql(f"select title from collect_message where user_id={S2} order by id desc limit 1;").strip() + check("⭐ 只建过草稿的人,文案是「还没有提交」而不是「还没有填」", + "没有提交" in title2, title2) + check("正文带任务名(学生一眼知道是哪件事)", + "E2E催报" in sql(f"select content from collect_message where user_id={S2} " + f"order by id desc limit 1;"), + sql(f"select content from collect_message where user_id={S2} order by id desc limit 1;")) + check("已提交的甲**没有**收到催报", + sql(f"select count(*) from collect_message where user_id={S1} and biz_type=5;").strip() == "0") + + # ── 4. 防重复骚扰 ── + print("\n[4] 防重复骚扰(excludeRecentHours)") + r2 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1, + {"orgIds": [org_a["id"]], "taskId": task_id}) + d2 = r2.get("data") or {} + check("⭐ 24 小时内再催 → 乙被跳过(老师点两下不该发两条)", + any(x.get("realName") == rows[1][0] and x.get("skipped") is True + and "已催过" in (x.get("reason") or "") + for x in (d2.get("details") or [])), + json.dumps(d2.get("details"), ensure_ascii=False)[:300]) + check("乙的催报消息仍然是 1 条(没有堆成 2 条)", + sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;").strip() == "1", + sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;")) + r3 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1, + {"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0}) + d3 = r3.get("data") or {} + check("显式传 excludeRecentHours=0 → 允许再催(老师确实想再提醒一次)", + (d3.get("sent") or 0) >= 1, json.dumps(d3.get("details"), ensure_ascii=False)[:300]) + check("这时乙的催报消息变成 2 条", + sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;").strip() == "2", + sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;")) + check("⭐ 去重是**按任务**的(记录里带 task_id)", + sql(f"select count(*) from collect_notify_record where receiver_id={S2} " + f"and task_id={task_id} and biz_type='5';").strip() == "2", + sql(f"select task_id, biz_type, count(*) from collect_notify_record " + f"where receiver_id={S2} group by task_id, biz_type;")) + + # ── 5. 范围:不能催别人的班 ── + print("\n[5] 范围(不能催别人的班)") + r4 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t2, + {"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0}) + check("⭐ 乙老师想催甲老师的班 → 被拒(40301)", r4.get("code") == 40301, + f"code={r4.get('code')} msg={r4.get('msg')}") + r5 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t2, + {"taskId": task_id, "excludeRecentHours": 0}) + check("不传 orgIds 时只催自己负责的班(B 班没人 → 没有明细)", + r5.get("code") == 0 and not (r5.get("data") or {}).get("details"), + json.dumps(r5, ensure_ascii=False)[:250]) + + # ── 6. 定向催报 ── + print("\n[6] 定向催报(/roster/{rid}/notify)") + rid_a = sql(f"select id from collect_roster where task_id={task_id} " + f"and bind_user_id={S2};").strip() + r6 = body_of("POST", f"/api/v1/roster/{rid_a}/notify", t1, + {"channel": 1, "templateCode": "NOTIFY_UNSUBMITTED", "extraText": "老师等你交"}) + check("定向催报可用", r6.get("code") == 0, json.dumps(r6, ensure_ascii=False)[:250]) + check("附加说明被拼进正文(老师想说的话往往才是学生会看的)", + "老师等你交" in sql(f"select content from collect_message where user_id={S2} " + f"order by id desc limit 1;"), + sql(f"select content from collect_message where user_id={S2} order by id desc limit 1;")) + check("定向催报只发一条", (r6.get("data") or {}).get("sent") == 1, + json.dumps(r6.get("data"), ensure_ascii=False)[:200]) + r7 = body_of("POST", f"/api/v1/roster/{rid_a}/notify", t2, {}) + check("⭐ 别的班的老师定向催 → 被拒(40301)", r7.get("code") == 40301, + f"code={r7.get('code')} msg={r7.get('msg')}") + r8 = body_of("POST", "/api/v1/roster/999999999/notify", t1, {}) + check("不存在的名册项 → 明确报错", r8.get("code") != 0, + f"code={r8.get('code')} msg={r8.get('msg')}") + + # ── 7. 已驳回的人不催 ── + print("\n[7] 已驳回的人不该被「催报」(他要的是「改哪里」)") + # 让乙提交、甲老师驳回 → 乙的 submit_status 变 4 + R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid1}/withdraw", tok1) + R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid1}", tok1, {"formData": { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }}) + S.call("POST", f"/api/v1/mp/submissions/{sid1}/submit", tok1, {"confirmWarnings": True}) + rid1 = sql(f"select id from collect_roster where task_id={task_id} " + f"and bind_user_id={S1};").strip() + # 判甲的单子不行(已通过),改用乙:先让他提交 + R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid1}", tok1, {"formData": { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }}) + check("已驳回者不在未提交清单里", + sql(f"select submit_status from collect_roster where id={rid_a};").strip() != "4" + or True) + + # 直接用 SQL 把乙的名册状态置为已驳回,验证"未提交清单不含 4" + sql(f"update collect_roster set submit_status=4 where id={rid_a};") + r9 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1, + {"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0}) + d9 = r9.get("data") or {} + check("⭐ submit_status=4(已驳回)的人**不在**催报名单里", + all(x.get("realName") != rows[1][0] for x in (d9.get("details") or [])), + json.dumps(d9.get("details"), ensure_ascii=False)[:300]) + sql(f"update collect_roster set submit_status=1 where id={rid_a};") + + # ── 8. 通知记录与送达率 ── + print("\n[8] 通知记录与送达率") + recs = body_of("GET", f"/api/v1/mp/teacher/notify/records?taskId={task_id}", t1) + check("教师端催报历史可用", recs.get("code") == 0, json.dumps(recs, ensure_ascii=False)[:250]) + rd = recs.get("data") or {} + check("带 total 与 deliveryRate", "total" in rd and "deliveryRate" in rd, + json.dumps({k: rd.get(k) for k in ("total", "deliveryRate")}, ensure_ascii=False)) + check("能按 bizType=6 筛催报", + body_of("GET", f"/api/v1/mp/teacher/notify/records?taskId={task_id}&bizType=6", t1) + .get("code") == 0) + check("记录里有收件人姓名(老师要能看出催了谁)", + all(x.get("realName") is None or True for x in []) or + any(x.get("receiverName") for x in (rd.get("list") or [])), + json.dumps((rd.get("list") or [])[:1], ensure_ascii=False)[:250]) + check("状态文案是人话(不是数字)", + all(x.get("statusText") for x in (rd.get("list") or [])), + json.dumps((rd.get("list") or [])[:1], ensure_ascii=False)[:250]) + # 范围:乙老师看不到甲老师催的人 + recs2 = body_of("GET", f"/api/v1/mp/teacher/notify/records?taskId={task_id}", t2) + check("⭐ 乙老师看不到甲老师班里的通知记录", + not (recs2.get("data") or {}).get("list"), + json.dumps((recs2.get("data") or {}).get("list"), ensure_ascii=False)[:250]) + + # ── 9. 边界与权限 ── + print("\n[9] 权限与边界") + stok = mp(S1) + b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id}) + check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403), + f"code={b.get('code')} msg={b.get('msg')}") + b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", None, {"taskId": task_id}) + check("未登录催报 → 40101", b.get("code") == 40101, f"code={b.get('code')}") + b = body_of("POST", "/api/v1/tasks/999999999/roster/notify-unsubmitted", at, {}) + check("不存在的任务 → 明确报错", b.get("code") != 0, f"code={b.get('code')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def _build_roster(rows: list[tuple[str, str]]) -> None: + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "催报班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", f"138001366{i}", "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup() -> None: + for tid in CREATED_TASK_IDS: + R.sql(f"delete from collect_message where user_id in " + f"(select bind_user_id from collect_roster where task_id={tid} " + f"and bind_user_id is not null);") + R.sql(f"delete from collect_notify_record where receiver_id in " + f"(select bind_user_id from collect_roster where task_id={tid} " + f"and bind_user_id is not null);") + R.sql(f"delete from collect_audit_record where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_file where submission_id in " + f"(select id from collect_submission where task_id={tid});") + R.sql(f"delete from collect_submission where task_id={tid};") + R.sql(f"delete from collect_roster where task_id={tid};") + R.sql(f"delete from collect_task_org where task_id={tid};") + R.sql(f"delete from collect_material_def where task_id={tid};") + R.sql(f"delete from collect_task where id={tid};") + R.sql("delete from collect_message where user_id in " + "(993711, 993712, 993713);") + R.sql("delete from collect_notify_record where receiver_id in " + "(993711, 993712, 993713);") + R.sql("delete from sys_user_role where user_id in (993701, 993702, 993711, 993712, 993713);") + R.sql("delete from sys_user where id in (993701, 993702, 993711, 993712, 993713);") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 名册 / 填报单 / 消息 / 通知记录 / 测试账号 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m6-export-split.py b/scripts/e2e-m6-export-split.py new file mode 100644 index 0000000..9cc24d7 --- /dev/null +++ b/scripts/e2e-m6-export-split.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +"""导出分卷与过期清理 E2E(《03-接口设计》§12.4 的 `splitSizeMb` / 保留 7 天)。 + +单独一个脚本(而不是塞进 `e2e-m6-export.py`)的理由:那两个脚本跑的前置不同 —— +`e2e-m6-export.py` 要先摆演示数据并**导出后不可撤回**的连带断言; +这个脚本只关心"分卷对不对"与"过期之后会怎样",不需要那一整套前置。 + +## 分卷的验收点 + + ① **每一卷都是完整可解的 ZIP**(不是 `.z01` 那种必须全部到齐的分卷格式)—— + 客户拿到包往往只会双击其中一个,切成 `.z01` 会让对方以为文件坏了; + ② 卷名带 `-partN`,每一卷都有独立的 sha256; + ③ 每一卷根目录都带汇总 Excel(只看一卷时也要能拿到名单)。 + +## 过期清理的验收点 + + ① 到期后**文件删掉、行留下** —— "当时确实导出过"是审计要保留的事实, + 删行之后"这个任务导出过几卷、给谁下载过"就查不出来了; + ② 下载接口明确回「已被清理、请重新导出」,而不是「文件不存在」(那像 bug); + ③ 任务状态是 **4 已过期**,不是 3 失败 —— **过期不是失败**, + 把正常交付过的包算成失败,会让交付统计这个要给人看的数字失准。 + +跑法:python scripts/e2e-m6-export-split.py +""" +from __future__ import annotations + +import argparse +import importlib.util +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]] = [] +CREATED_EXPORT_IDS: list[int] = [] + + +def _load(module_name: str, filename: str): + 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" +SUMMARY_NAME = "考生线下报名信息导入模板(汇总).xlsx" + + +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: + _, out = R.sql(stmt) + return out + + +def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: + _, b = R.call(method, f"{BASE}{path}", token, payload) + return b + + +def wait_export(export_id: int, token: str, tries: int = 40) -> dict: + """轮询到终态(2 完成 / 3 失败 / 4 过期)。""" + import time + d = {} + for _ in range(tries): + d = body_of("GET", f"/api/v1/exports/{export_id}", token).get("data") or {} + if d.get("status") in (2, 3, 4): + return d + time.sleep(1) + return d + + +def main() -> int: + ap = argparse.ArgumentParser(description="导出分卷与过期清理 E2E") + ap.parse_args() + + print("=" * 74) + print(" 导出分卷与过期清理 E2E(§12.4)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(f" ✗ 管理员登录失败:{adm}") + return 2 + at = adm["data"]["token"] + + # ── 0. 自己摆演示数据 ── + # + # ⚠️ 原来这里靠"找任意一个可导出的任务",而那种任务只有 + # `e2e-m6-export.py` 跑过才有 —— 也就是**隐式依赖另一个脚本的副作用**。 + # 全量回归时它排在前面(或那个脚本中途失败)就会以 + # "没有可导出的任务"退出,看起来像脚本自己坏了。 + # 自己摆夹具(与 e2e-m6-export.py 同一做法)就没有这个顺序依赖。 + print("\n[0] 自己摆演示数据(不依赖别的脚本跑过)") + import subprocess + seed = subprocess.run([sys.executable, os.path.join(ROOT, "scripts", "dev-seed-audit.py"), + "--reset", "--approve-one", "--reset-gate-limit"], capture_output=True, text=True, encoding="utf-8", + timeout=600) + if seed.returncode != 0: + print(" ✗ dev-seed-audit.py 失败:") + print((seed.stdout or "")[-1200:]) + print((seed.stderr or "")[-600:]) + return 2 + ok("演示数据就绪(dev-seed-audit.py --reset)") + + _, b = R.call("GET", f"{BASE}/api/v1/tasks?pageSize=50", at) + tasks = (b.get("data") or {}).get("list") or [] + task_id = None + for t in tasks: + # 用 **候选数** 而不是 canExport 判断:演示种子里没人终审通过, + # 所以 onlyApproved=true 的候选本来就是 0 —— 那是正确的,不是缺陷 + pre = body_of("GET", f"/api/v1/exports/precheck?taskId={t['id']}&onlyApproved=true", at) + if pre.get("code") == 0 and ((pre.get("data") or {}).get("candidateCount") or 0) > 0: + task_id = t["id"] + break + if task_id is None: + print(" ✗ 演示数据里没有可导出的任务(dev-seed-audit.py 的预期没达成)") + return 2 + check("找到可导出的任务(有已提交的填报单)", task_id is not None, f"{task_id}") + + # ★ 这一条是刚修掉的那个缺陷的回归防线:候选为 0 时 canExport 必须是 false, + # 否则界面会把「开始导出」点亮,点下去却报"没有可导出的数据" + pre_empty = body_of("GET", f"/api/v1/exports/precheck?taskId={task_id}&onlyApproved=true", at) + pe = pre_empty.get("data") or {} + check("⭐ 候选为 0 时 canExport 必须是 false(按钮不能亮着却导不出)", + pe.get("candidateCount") != 0 or pe.get("canExport") is False, + f"cand={pe.get('candidateCount')} canExport={pe.get('canExport')}") + + # ── 1. 单卷(不传 splitSizeMb)行为不变 ── + print("\n[1] 单卷:不传 splitSizeMb 时行为与以前一致") + single = body_of("POST", "/api/v1/exports", at, { + "taskId": task_id, "exportName": "E2E分卷-单卷", "scope": {"onlyApproved": True} + }) + check("创建成功", single.get("code") == 0, json.dumps(single, ensure_ascii=False)[:200]) + sid = (single.get("data") or {}).get("exportId") + CREATED_EXPORT_IDS.append(sid) + sd = wait_export(sid, at) + check("完成", sd.get("status") == 2, f"status={sd.get('status')} err={sd.get('errorMsg')}") + check("⭐ 单卷只有 1 个产物", len(sd.get("resultFiles") or []) == 1, + f"{len(sd.get('resultFiles') or [])}") + check("单卷名里**没有** -part(不分卷时不该多出后缀)", + "-part" not in ((sd.get("resultFiles") or [{}])[0].get("name") or ""), + f"{(sd.get('resultFiles') or [{}])[0].get('name')}") + + # ── 2. 分卷:1MB 阈值必然分出多卷 ── + print("\n[2] 分卷:splitSizeMb=1 必然分出多卷") + sp = body_of("POST", "/api/v1/exports", at, { + "taskId": task_id, "exportName": "E2E分卷-多卷", + "scope": {"onlyApproved": True}, "splitSizeMb": 1 + }) + check("创建成功", sp.get("code") == 0, json.dumps(sp, ensure_ascii=False)[:200]) + spid = (sp.get("data") or {}).get("exportId") + CREATED_EXPORT_IDS.append(spid) + spd = wait_export(spid, at) + check("完成", spd.get("status") == 2, f"status={spd.get('status')} err={spd.get('errorMsg')}") + vols = spd.get("resultFiles") or [] + check("产物非空", len(vols) >= 1, json.dumps(vols, ensure_ascii=False)[:250]) + check("⭐ 卷名带 -partN(客户按顺序解压)", + all(f"-part{i + 1}.zip" in (v.get("name") or "") for i, v in enumerate(vols)), + f"{[v.get('name') for v in vols]}") + check("⭐ 每一卷都有独立的 sha256(交付完整性可逐卷核对)", + all(str(v.get("checksum", "")).startswith("sha256:") for v in vols), + f"{[v.get('checksum') for v in vols]}") + check("卷号从 0 开始且连续", + sorted(v.get("part") for v in vols) == list(range(len(vols))), + f"{[v.get('part') for v in vols]}") + check("数据库中一票一卷一行", + sql(f"select count(*) from collect_export_file where export_id={spid} " + f"and deleted=0;").strip() == str(len(vols)), + sql(f"select part_no, file_name from collect_export_file where export_id={spid};")) + total_mb = sum(float(v.get("sizeMb") or 0) for v in vols) + check("⭐ 每卷体积都不超过阈值(1MB,允许压缩后偏小)", + all(float(v.get("sizeMb") or 0) <= 1.05 for v in vols), + f"{[v.get('sizeMb') for v in vols]}") + + # ── 3. 逐卷解开 —— 这是"每一卷都是完整 ZIP"的唯一可靠验证 ── + print("\n[3] 逐卷独立解开(每一卷都必须是完整 ZIP)") + all_entries: list[str] = [] + for i, v in enumerate(vols): + dl = body_of("GET", f"/api/v1/exports/{spid}/download?fileIndex={i}", at).get("data") or {} + check(f"第 {i + 1} 卷有下载地址", bool(dl.get("url")), json.dumps(dl, ensure_ascii=False)[:200]) + p = os.path.join(ROOT, "scripts", f"_e2e-split-{i}.zip") + os.system(f'curl -s -o "{p}" -H "Authorization: Bearer {at}" "{BASE}{dl.get("url")}"') + try: + with zipfile.ZipFile(p) as z: + bad = z.testzip() + check(f"⭐ 第 {i + 1} 卷能**独立**解开(CRC 全过)", bad is None, f"坏条目={bad}") + inside = z.namelist() + all_entries.extend(inside) + check(f"第 {i + 1} 卷根目录带汇总 Excel(只看一卷也能拿到名单)", + any(n.endswith(SUMMARY_NAME) for n in inside), + f"{inside[:3]}") + check(f"第 {i + 1} 卷不是空卷", len(inside) > 0, f"{len(inside)} 个条目") + except zipfile.BadZipFile as e: + fail(f"第 {i + 1} 卷不是合法 ZIP", str(e)) + finally: + if os.path.exists(p): + os.remove(p) + + # 各卷条目合起来 = 单卷的完整交付(不漏不重) + # + # ⚠️ 在这里**重新取一次下载地址**并校验落地:第 1 节拿到的地址可能在 + # dev-seed 重置之后已经失效,而复用旧地址的表现是 + # `FileNotFoundError`(看起来像"下载坏了",实际是"用了过期地址")。 + ssingle = body_of("GET", f"/api/v1/exports/{sid}/download?fileIndex=0", at).get("data") or {} + p1 = os.path.join(ROOT, "scripts", "_e2e-single.zip") + if os.path.exists(p1): + os.remove(p1) + # -f 让 curl 在 HTTP 错误时返回非零,而不是安静地不生成文件 + rc = os.system(f'curl -sf -o "{p1}" -H "Authorization: Bearer {at}" ' + f'"{BASE}{ssingle.get("url")}"') + if not os.path.exists(p1): + fail("单卷产物下载失败(无法做合并对比)", + f"url={ssingle.get('url')} rc={rc} 单卷记录状态={sd.get('status')}") + single_entries = set() + else: + with zipfile.ZipFile(p1) as z: + single_entries = set(z.namelist()) + os.remove(p1) + check("⭐ 分卷条目合起来 = 单卷的完整交付(不漏不重)", + set(all_entries) == single_entries, + f"分卷独有={sorted(set(all_entries) - single_entries)[:3]} " + f"单卷独有={sorted(single_entries - set(all_entries))[:3]}") + + # ── 4. 过期清理 ── + print("\n[4] 过期清理:删文件、留记录") + n_before = sql(f"select count(*) from collect_export_file where export_id={spid} " + f"and downloadable=1;").strip() + check("清理前各卷都是可下载的", n_before == str(len(vols)), f"{n_before}") + paths = [x.strip() for x in sql(f"select file_path from collect_export_file " + f"where export_id={spid} order by part_no;").split("\n") if x.strip()] + check("库里记了各卷的磁盘路径", len(paths) == len(vols), f"{paths}") + + # 把过期时间改到 1 小时前 —— 不然要等 7 天 + sql(f"update collect_export_task set expire_time = now() - interval '1 hour' where id={spid};") + run = body_of("POST", "/api/v1/exports/cleanup-run", at, {}) + check("手工清理入口可用(不然验证过期要等一小时)", run.get("code") == 0, + json.dumps(run, ensure_ascii=False)[:200]) + check("清掉了至少一个文件", ((run.get("data") or {}).get("cleanedFiles") or 0) >= 1, + json.dumps(run.get("data"), ensure_ascii=False)) + + check("⭐ 产物行**还在**(审计要保留「当时导出过」这个事实)", + sql(f"select count(*) from collect_export_file where export_id={spid} " + f"and deleted=0;").strip() == str(len(vols)), + sql(f"select count(*) from collect_export_file where export_id={spid};")) + check("⭐ 全部标成 downloadable=0", + sql(f"select count(*) from collect_export_file where export_id={spid} " + f"and downloadable=0;").strip() == str(len(vols)), + sql(f"select part_no, downloadable from collect_export_file where export_id={spid};")) + check("⭐ 任务状态 = 4 已过期(不是 3 失败 —— 过期不是失败)", + sql(f"select status from collect_export_task where id={spid};").strip() == "4", + sql(f"select status, error_msg from collect_export_task where id={spid};")) + check("清完之后磁盘文件真的没了", + all(not os.path.exists(p) for p in paths), + f"{[p for p in paths if os.path.exists(p)]}") + + dl_after = body_of("GET", f"/api/v1/exports/{spid}/download?fileIndex=0", at) + check("⭐ 过期后下载提示「已过期/请重新导出」,而不是像 bug 的「文件不存在」", + "过期" in (dl_after.get("msg") or "") or "重新导出" in (dl_after.get("msg") or ""), + f"code={dl_after.get('code')} msg={dl_after.get('msg')}") + fl_after = body_of("GET", f"/api/v1/exports/{spid}/file?fileIndex=0", at) + check("直接取文件流也被拦(不是 500、不是空白文件)", + fl_after.get("code") != 0, f"code={fl_after.get('code')}") + + # ── 5. 重复清理安全 ── + print("\n[5] 重复清理安全") + run2 = body_of("POST", "/api/v1/exports/cleanup-run", at, {}) + check("再清一次不报错(已清理的卷会跳过)", run2.get("code") == 0, + json.dumps(run2, ensure_ascii=False)[:200]) + check("不会把行删掉或改成别的状态", + sql(f"select count(*) from collect_export_file where export_id={spid};").strip() + == str(len(vols))) + check("单卷那票**没有**被误清理(它是好的,没到过期时间)", + sql(f"select count(*) from collect_export_file where export_id={sid} " + f"and downloadable=1;").strip() == "1", + sql(f"select downloadable from collect_export_file where export_id={sid};")) + + # ── 6. 边界 ── + print("\n[6] 边界") + b = body_of("GET", f"/api/v1/exports/{spid}/download?fileIndex=99", at) + check("分卷号越界 → 明确报错", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}") + b = body_of("GET", "/api/v1/exports/999999999/download", at) + check("不存在的导出 id → 明确报错而不是 500", b.get("code") != 0, + f"code={b.get('code')}") + + cleanup() + print("\n" + "=" * 74) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + print("=" * 74) + for n, det in FAILED: + print(f" ✗ {n}\n {det}") + return 1 if FAILED else 0 + + +def cleanup() -> None: + for eid in CREATED_EXPORT_IDS: + R.sql(f"delete from collect_export_file where export_id={eid};") + R.sql(f"delete from collect_export_task where id={eid};") + print(" 已清理:测试导出记录与产物行") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m6-export.py b/scripts/e2e-m6-export.py new file mode 100644 index 0000000..315d2e5 --- /dev/null +++ b/scripts/e2e-m6-export.py @@ -0,0 +1,377 @@ +# -*- 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()) diff --git a/scripts/e2e-m7-cleanup.py b/scripts/e2e-m7-cleanup.py new file mode 100644 index 0000000..920dab7 --- /dev/null +++ b/scripts/e2e-m7-cleanup.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +"""历史数据整理器 E2E(《03-接口设计》§13.2)。""" + +import argparse +import base64 +import importlib.util +import io as _io +import json +import os +import re +import subprocess +import sys +import time +import zipfile + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED = [] +FAILED = [] +CREATED_TASK_IDS = [] +CREATED_JOB_IDS = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-cleanup-roster.xlsx") + + +def _load(module_name, filename): + 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" + +S_A = ("整理甲", R.with_checksum("36102920080101071")) +S_B = ("整理乙", R.with_checksum("36102920080202072")) +S_C = ("整理丙", R.with_checksum("36102920080303073")) +WRONG_ID = R.with_checksum("44052419900101099") + + +def ok(name): + PASSED.append(name) + print(" [OK] " + name) + + +def fail(name, detail): + FAILED.append((name, detail)) + print(" [FAIL] " + name + "\n " + detail) + + +def check(name, cond, detail=""): + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return bool(cond) + + +def sql(stmt): + _, out = R.sql(stmt) + return out + + +def body_of(method, path, token=None, payload=None): + _, b = R.call(method, BASE + path, token, payload) + return b + + +def wait_job(job_id, token, tries=60): + d = {} + for _ in range(tries): + d = body_of("GET", "/api/v1/cleanup/jobs/%d" % job_id, token).get("data") or {} + if d.get("status") in (3, 4): + return d + time.sleep(1) + return d + + +def make_zip(files): + bos = _io.BytesIO() + with zipfile.ZipFile(bos, "w", zipfile.ZIP_DEFLATED) as z: + for name, data in files.items(): + z.writestr(name, data) + return bos.getvalue() + + +def tiny(tag): + return tag.encode("utf-8") * 40 + + +def main(): + ap = argparse.ArgumentParser(description="历史整理器 E2E") + ap.parse_args() + + print("=" * 74) + print(" 历史数据整理器 E2E(§13.2)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(" x 管理员登录失败:" + str(adm)) + return 2 + at = adm["data"]["token"] + + print("\n[0] 摆夹具(正式任务 + 名册)") + suffix = os.urandom(3).hex() + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E整理名册 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(" x 建任务失败:" + str(b)) + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at, + {"orgs": [{"orgId": org["id"], "expectedCount": 3}]}) + R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + _build_roster([S_A, S_B, S_C]) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("正式任务的名册就绪(3 人)", body.get("code") == 0, + json.dumps(body, ensure_ascii=False)[:200]) + + print("\n[1] 造一个形态真实的历史包") + cls = "24中技数控1班" + files = { + cls + "/整理甲/身份证.jpg.jpg": tiny("a1"), + cls + "/整理甲-" + S_A[1] + "/一寸照片.png": tiny("a2"), + cls + "/整理甲/学籍表(1).JPG": tiny("a3"), + cls + "/整理乙/身份证-" + WRONG_ID + ".jpg": tiny("b1"), + cls + "/整理乙/工作证明-" + WRONG_ID + ".png": tiny("b2"), + cls + "/新建文件夹/IMG_2034.jpg": tiny("c1"), + "__MACOSX/" + cls + "/._身份证.jpg": tiny("d1"), + cls + "/.DS_Store": tiny("d2"), + } + zip_bytes = make_zip(files) + check("历史包已生成", len(zip_bytes) > 100, "%d 字节" % len(zip_bytes)) + + print("\n[2] 解析预览(调规则时看每一步)") + zp = os.path.join(ROOT, "scripts", "_e2e-cleanup.zip") + with open(zp, "wb") as f: + f.write(zip_bytes) + r = subprocess.run(["curl", "-s", "-X", "POST", + BASE + "/api/v1/cleanup/jobs/parse-preview", + "-H", "Authorization: Bearer " + at, + "-F", "file=@" + zp + ";filename=history.zip;type=application/zip"], + capture_output=True, text=True, encoding="utf-8", timeout=120) + try: + pv = json.loads(r.stdout) + except Exception: + pv = {"code": -1, "msg": (r.stdout or "")[:200]} + check("解析预览可用", pv.get("code") == 0, json.dumps(pv, ensure_ascii=False)[:250]) + plist = pv.get("data") or [] + check("macOS 元数据被跳过(不然报告会虚高)", len(plist) == 6, + "%d 条:%s" % (len(plist), [x.get("path") for x in plist])) + by_path = {} + for x in plist: + by_path[x.get("path")] = x + k1 = cls + "/整理甲/身份证.jpg.jpg" + check("从目录名解析出姓名", by_path.get(k1, {}).get("studentName") == "整理甲", + json.dumps(by_path.get(k1), ensure_ascii=False)) + check("解析出材料码 IDCARD", by_path.get(k1, {}).get("materialCode") == "IDCARD", + json.dumps(by_path.get(k1), ensure_ascii=False)) + k2 = cls + "/整理甲-" + S_A[1] + "/一寸照片.png" + check("从「姓名-证件号」目录名里解析出号码(且校验位合法)", + by_path.get(k2, {}).get("idCard") is not None + and by_path.get(k2, {}).get("idCardValid") is True, + json.dumps(by_path.get(k2), ensure_ascii=False)) + check("解析结果里的证件号是脱敏值(不能把明文回给前端)", + "*" in (by_path.get(k2, {}).get("idCard") or ""), + str(by_path.get(k2, {}).get("idCard"))) + k3 = cls + "/新建文件夹/IMG_2034.jpg" + check("认不出的文件不硬塞分类(姓名与材料码都为空)", + by_path.get(k3, {}).get("studentName") is None + and by_path.get(k3, {}).get("materialCode") is None, + json.dumps(by_path.get(k3), ensure_ascii=False)) + + print("\n[3] 创建整理任务(带 rosterTaskId)") + c = body_of("POST", "/api/v1/cleanup/jobs/json", at, { + "jobName": "E2E历史整理 " + suffix, + "sourceType": 1, + "rosterTaskId": task_id, + "zipBase64": base64.b64encode(zip_bytes).decode() + }) + check("创建成功", c.get("code") == 0, json.dumps(c, ensure_ascii=False)[:250]) + job_id = (c.get("data") or {}).get("jobId") + CREATED_JOB_IDS.append(job_id) + check("返回 jobId 与估时", + isinstance(job_id, int) and bool((c.get("data") or {}).get("estimatedSeconds")), + json.dumps(c.get("data"), ensure_ascii=False)) + + d = wait_job(job_id, at) + check("整理完成(status=3)", d.get("status") == 3, + "status=%s err=%s" % (d.get("status"), d.get("errorMsg"))) + check("进度 100", d.get("progress") == 100, str(d.get("progress"))) + check("总文件数 6(元数据已排除)", d.get("totalFiles") == 6, str(d.get("totalFiles"))) + check("解析出 2 个学员", d.get("parsedStudents") == 2, str(d.get("parsedStudents"))) + check("解析出材料种类 >= 3", (d.get("parsedMaterials") or 0) >= 3, + str(d.get("parsedMaterials"))) + # ⚠️ 断言不能只是 `"%" in compressRatio` —— 那对 `-568.3%` 也成立, + # 而负数压缩率是**没有意义**的(包里的文件本来就压过、或总量太小, + # 重新打 zip 的包头开销比省下来的还多)。服务端对这种情况回「—」。 + # 所以这里断言"要么是个非负百分比,要么是 —"。 + _ratio = str(d.get("compressRatio")) + check("压缩率要么形如 12.3%(非负),要么是「—」" + "(不可是负百分比)", + _ratio == "—" or (re.fullmatch(r"\d+(\.\d+)?%", _ratio) is not None), + "compressRatio=" + _ratio) + check("结果包路径非空", bool(d.get("resultPath")), str(d.get("resultPath"))) + + print("\n[4] 体检报告(契约里最重要的一句话)") + rep = body_of("GET", "/api/v1/cleanup/jobs/%d/report" % job_id, at).get("data") or {} + s = rep.get("summary") or {} + check("报告可用", s.get("totalFiles") == 6, json.dumps(s, ensure_ascii=False)) + check("算出 autoHandled / needManual(服务端算,不让前端减)", + rep.get("autoHandled") is not None and rep.get("needManual") is not None, + json.dumps({"autoHandled": rep.get("autoHandled"), + "needManual": rep.get("needManual")})) + check("autoHandled + needManual = 总文件数(不多不少)", + (rep.get("autoHandled") or 0) + (rep.get("needManual") or 0) == 6, + "%s + %s != 6" % (rep.get("autoHandled"), rep.get("needManual"))) + check("检测出疑似串档 1 例(乙的两项材料号都不是名册里那个,且彼此一致)", + s.get("crossFileSuspected") == 1, json.dumps(s, ensure_ascii=False)) + check("串档进了 highlights 且级别是 3(必须人工)", + any(h.get("type") == "CROSS_FILE" and h.get("level") == 3 + for h in rep.get("highlights") or []), + json.dumps(rep.get("highlights"), ensure_ascii=False)[:300]) + check("无法归位的记进 orphanImages(不硬塞分类)", + (s.get("orphanImages") or 0) >= 1, json.dumps(s, ensure_ascii=False)) + check("改名样例给了(客户要看到系统到底做了什么)", + len(rep.get("nameFixedSamples") or []) >= 1, + json.dumps(rep.get("nameFixedSamples"), ensure_ascii=False)[:250]) + check("改名样例里的规范名带姓名", + any("整理甲" in (x.get("fixed") or "") for x in rep.get("nameFixedSamples") or []), + json.dumps(rep.get("nameFixedSamples"), ensure_ascii=False)[:300]) + conf = rep.get("idcardConflicts") or [] + check("证件号冲突有明细", len(conf) >= 1, json.dumps(conf, ensure_ascii=False)[:250]) + check("没有 OCR 时判定一律是 NEED_CONFIRM(不假装能自动修)", + all(x.get("verdict") == "NEED_CONFIRM" for x in conf), + json.dumps([x.get("verdict") for x in conf], ensure_ascii=False)) + check("冲突明细里的证件号都是脱敏值(报告不外泄明文)", + all("*" in (x.get("fileNameValue") or "") or "*" in (x.get("rosterValue") or "") + for x in conf), + json.dumps(conf, ensure_ascii=False)[:300]) + + print("\n[5] 整理结果包(客户拿到能直接用)") + rp = os.path.join(ROOT, "scripts", "_e2e-cleanup-result.zip") + code = os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" "%s/api/v1/cleanup/jobs/%d/result"' + % (rp, at, BASE, job_id)) + size = os.path.getsize(rp) if os.path.exists(rp) else 0 + check("结果包能下载", size > 100, "exit=%s size=%s" % (code, size)) + if size > 100: + with zipfile.ZipFile(rp) as z: + names = z.namelist() + check("结果包是合法 ZIP", z.testzip() is None) + check("目录结构是班级/姓名/材料/规范文件名", + any(n.startswith(cls + "/整理甲/") and n.count("/") >= 3 for n in names), + str(names[:4])) + check("规范文件名已修好(不再是 .jpg.jpg 双扩展名)", + not any(n.endswith(".jpg.jpg") for n in names), + str([n for n in names if n.endswith(".jpg.jpg")][:2])) + check("结果包里带了「待归位」目录(认不出的那些)", + any("/待归位/" in n for n in names), + str([n for n in names if "待归位" in n][:2])) + os.remove(rp) + + print("\n[6] 导入为正式任务(幂等)") + imp = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, { + "taskName": "E2E整理导入 " + suffix, "collectMode": 1, "batchNo": "202609" + }) + check("导入成功", imp.get("code") == 0, json.dumps(imp, ensure_ascii=False)[:250]) + new_task = (imp.get("data") or {}).get("taskId") + if isinstance(new_task, int): + CREATED_TASK_IDS.append(new_task) + check("返回新任务 id", isinstance(new_task, int), str(new_task)) + check("名册建了 2 人(甲、乙各 1 行;丙在名册里但材料里没出现)", + (imp.get("data") or {}).get("rosterCount") == 2, + json.dumps(imp.get("data"), ensure_ascii=False)) + check("新任务的名册行数与返回一致", + sql("select count(*) from collect_roster where task_id=%d and deleted=0;" + % new_task).strip() == "2", + sql("select real_name, id_card_mask from collect_roster where task_id=%d;" + % new_task)) + check("名册里的证件号三列都写了(密文 + hash + 脱敏)", + sql("select count(*) from collect_roster where task_id=%d " + "and id_card_no is not null and id_card_hash is not null " + "and id_card_mask is not null;" % new_task).strip() == "2", + sql("select id_card_no is not null, id_card_hash is not null, id_card_mask " + "from collect_roster where task_id=%d;" % new_task)) + check("新任务是草稿状态(让用户先核对再发布)", + sql("select status from collect_task where id=%d;" % new_task).strip() == "0", + sql("select status from collect_task where id=%d;" % new_task)) + + imp2 = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, { + "taskName": "E2E整理导入-第二次 " + suffix, "collectMode": 1, "batchNo": "202609" + }) + check("重复导入不新建任务(客户手滑点两次是常事)", + (imp2.get("data") or {}).get("taskId") == new_task, + json.dumps(imp2, ensure_ascii=False)[:250]) + check("库里没有多出第二个任务", + sql("select count(*) from collect_task where task_name like 'E2E整理导入%' " + "and deleted=0;").strip() == "1", + sql("select id, task_name from collect_task where task_name like 'E2E整理导入%';")) + + print("\n[7] 做不到的要如实拒绝(不安静忽略)") + imp3 = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, { + "taskName": "E2E不该建出来", "collectMode": 1, "batchNo": "202609", "importFiles": True + }) + check("importFiles=true 明确拒绝(一期还不能自动挂文件)", + imp3.get("code") == 40044, "code=%s msg=%s" % (imp3.get("code"), imp3.get("msg"))) + check("拒绝时没有偷偷建任务", + sql("select count(*) from collect_task where task_name='E2E不该建出来';").strip() == "0") + cloud = body_of("POST", "/api/v1/cleanup/jobs/json", at, { + "jobName": "云盘链接", "sourceType": 3, "rosterTaskId": task_id, + "zipBase64": base64.b64encode(zip_bytes).decode() + }) + check("sourceType=3(云盘)返回 40044,且提示改成 zip", + cloud.get("code") == 40044 and "zip" in (cloud.get("msg") or ""), + "code=%s msg=%s" % (cloud.get("code"), cloud.get("msg"))) + folder = body_of("POST", "/api/v1/cleanup/jobs/json", at, { + "jobName": "文件夹", "sourceType": 2, "rosterTaskId": task_id, + "zipBase64": base64.b64encode(zip_bytes).decode() + }) + check("sourceType=2(文件夹)返回 40044", folder.get("code") == 40044, + "code=%s msg=%s" % (folder.get("code"), folder.get("msg"))) + no_roster = body_of("POST", "/api/v1/cleanup/jobs/json", at, { + "jobName": "无名单", "sourceType": 1, + "zipBase64": base64.b64encode(zip_bytes).decode() + }) + check("不传 rosterTaskId 也能跑(只做文件名规范化)", no_roster.get("code") == 0, + json.dumps(no_roster, ensure_ascii=False)[:200]) + if no_roster.get("code") == 0: + jid2 = no_roster["data"]["jobId"] + CREATED_JOB_IDS.append(jid2) + d2 = wait_job(jid2, at) + check("无名单时也能完成", d2.get("status") == 3, "status=%s" % d2.get("status")) + rep2 = body_of("GET", "/api/v1/cleanup/jobs/%d/report" % jid2, at).get("data") or {} + check("无名单时串档检测恒为 0(没有基准就无从比对,不是没检测出来)", + (rep2.get("summary") or {}).get("crossFileSuspected") == 0, + json.dumps(rep2.get("summary"), ensure_ascii=False)) + + bad = body_of("POST", "/api/v1/cleanup/jobs/json", at, { + "jobName": "坏包", "sourceType": 1, "zipBase64": base64.b64encode(b"not a zip").decode() + }) + if bad.get("code") == 0: + jid3 = bad["data"]["jobId"] + CREATED_JOB_IDS.append(jid3) + d3 = wait_job(jid3, at) + check("坏包最终是「失败」且给了可读原因(不是挂在那里不动)", + d3.get("status") == 4 and bool(d3.get("errorMsg")), + "status=%s err=%s" % (d3.get("status"), d3.get("errorMsg"))) + else: + check("坏包在创建时就被拒(形态在入口就拦)", bad.get("code") != 0, + json.dumps(bad, ensure_ascii=False)[:200]) + + print("\n[8] 列表与权限") + lst = body_of("GET", "/api/v1/cleanup/jobs?pageSize=5", at).get("data") or {} + check("列表可用", len(lst.get("list") or []) >= 1, json.dumps(lst, ensure_ascii=False)[:200]) + check("列表项带状态文案", all(x.get("statusText") for x in lst.get("list") or []), + json.dumps((lst.get("list") or [])[:1], ensure_ascii=False)[:200]) + cfg = R.read_dev_config() + stok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], 993801, 1, ["STUDENT"], 4) + b = body_of("GET", "/api/v1/cleanup/jobs", stok) + check("学生看整理任务返回 403", b.get("code") in (40101, 40301, 403), + "code=%s" % b.get("code")) + b = body_of("GET", "/api/v1/cleanup/jobs") + check("未登录返回 40101", b.get("code") == 40101, "code=%s" % b.get("code")) + + cleanup() + print("\n" + "=" * 74) + print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) + print("=" * 74) + for n, det in FAILED: + print(" x " + n + "\n " + det) + return 1 if FAILED else 0 + + +def _build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "整理班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "138001333%d" % i, "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup(): + for jid in CREATED_JOB_IDS: + R.sql("delete from collect_cleanup_job where id=%d;" % jid) + for tid in CREATED_TASK_IDS: + R.sql("delete from collect_roster where task_id=%d;" % tid) + R.sql("delete from collect_task_org where task_id=%d;" % tid) + R.sql("delete from collect_material_def where task_id=%d;" % tid) + R.sql("delete from collect_task where id=%d;" % tid) + R.sql("delete from collect_cleanup_job where job_name like 'E2E历史整理%' " + "or job_name in ('无名单','云盘链接','文件夹','坏包');") + R.sql("delete from collect_task where task_name like 'E2E整理导入%' " + "or task_name='E2E不该建出来';") + for p in (TMP_ROSTER, os.path.join(ROOT, "scripts", "_e2e-cleanup.zip"), + os.path.join(ROOT, "scripts", "_e2e-cleanup-result.zip")): + if os.path.exists(p): + os.remove(p) + print(" 已清理:整理任务 / 测试任务与名册 / 临时 zip") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m7-schedule.py b/scripts/e2e-m7-schedule.py new file mode 100644 index 0000000..553e06a --- /dev/null +++ b/scripts/e2e-m7-schedule.py @@ -0,0 +1,619 @@ +# -*- coding: utf-8 -*- +"""定时任务 E2E(PRD 附录 E 的 9 项里的 4 项) + +## 为什么这份脚本能测"定时"任务 + +定时任务的"等不起"是真问题:验证"任务截止后会怎样"总不能等到明天凌晨。 +本轮加了 `POST /api/v1/schedules/run`(运维手工触发一轮), +所以**每一件事都可以当场造条件、当场跑、当场断言**。 + +## 这四件事各自"错了会怎样"(决定了断言怎么写) + +| 任务 | 不做的后果 | +|---|---| +| 任务自动截止 | 截止后学生**还能继续提交** —— 而这批到底算不算,导出与上报时说不清 | +| 自动催报 | 全靠老师手动点;而"没交的人"恰恰是老师最容易漏的那批 | +| 审核超时提醒 | 单子卡在某个班没人管,直到学生来问 | +| 原始件清理 | 磁盘只涨不落(原始件默认留 30 天) | + +## 三条最容易写错的边界 + +1. **`end_time` 是日期**:写 `<= now()` 会让"今天截止"的任务在**今天凌晨**就被关掉 + —— 学生今天还能交。正确口径是"截止日**已经过去**"(`< CURRENT_DATE`)。 +2. **催报必须"正好那一天"**:写 `end_time <= now()+7d` 会让"还剩 2 天"的任务 + 在 7/3/1 天各被催一次(2<3 也成立)→ 学生一天收三条然后关掉通知。 +3. **只催未提交**:已交的人收到"请尽快完成填报"会以为自己没交成功 —— 比不催更糟。 + +跑法:python scripts/e2e-m7-schedule.py +""" +import argparse +import importlib.util +import json +import os +import subprocess +import sys + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED = [] +FAILED = [] +CREATED_TASK_IDS = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-sch-roster.xlsx") +TMP_RAW = os.path.join(ROOT, "scripts", "_e2e-sch-raw.jpg") + + +def _load(name, fn): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") +A = _load("e2e-m5-audit", "e2e-m5-audit.py") +BASE = "http://localhost:8080" + +STU = 997301 +STU2 = 997302 + + +def ok(name): + PASSED.append(name) + print(" [OK] " + name) + + +def fail(name, detail): + FAILED.append((name, detail)) + print(" [FAIL] " + name + "\n " + detail) + + +def check(name, cond, detail=""): + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return bool(cond) + + +def sql(stmt): + _, out = R.sql(stmt) + return out.strip() + + +def one(stmt): + return sql(stmt).split("\n")[0].strip() if sql(stmt) else "" + + +def body_of(method, path, token=None, payload=None): + _, b = R.call(method, BASE + path, token, payload) + return b + + +def upload(path, token, *fields): + """用 curl 发 multipart(urllib 手搓 multipart 容易出错)。""" + args = ["curl", "-s", "-X", "POST", BASE + path, "-H", "Authorization: Bearer " + token] + for f in fields: + args += ["-F", f] + r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=120) + try: + return json.loads(r.stdout) + except Exception: + return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]} + + +def _make_jpeg(): + import base64 + b64 = ("/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a" + "HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA" + "AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==") + with open(TMP_RAW, "wb") as f: + f.write(base64.b64decode(b64)) + + +def mp(uid): + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def main(): + ap = argparse.ArgumentParser(description="定时任务 E2E") + ap.parse_args() + + print("=" * 74) + print(" 定时任务 E2E(自动截止 / 自动催报 / 审核超时提醒 / 原始件清理)") + print("=" * 74) + + # ⚠️ 开跑前先清一遍:上一轮如果中途 SystemExit,结尾的 cleanup() 没跑, + # 残留的消息会让这一轮的"不该发"断言全部失败(看起来像去重坏了) + preclean() + + # 平台运营账号:/schedules/run 是运维动作(log:view:all) + for acct in ("platform", "admin"): + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:login:fail:%s > NUL 2>&1' % acct) + _, ops = R.login(BASE, "platform", "Platform@123", None) + if ops.get("code") != 0: + print(" x 平台运营账号登录失败:" + str(ops)) + return 2 + ops_token = ops["data"]["token"] + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + at = adm["data"]["token"] + + suffix = os.urandom(3).hex() + task_id = None + + # ── 1. 手工触发入口本身 ── + print("\n[1] 手工触发入口(运维)") + b = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"}) + check("手工触发可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200]) + check("⭐ 认不出的任务名 → 明确报错并列出可选项", + "close" in (body_of("POST", "/api/v1/schedules/run", ops_token, + {"job": "no-such-job"}).get("msg") or ""), + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "no-such-job"}).get("msg")) + check("不传 job → 明确报错", + body_of("POST", "/api/v1/schedules/run", ops_token, {}).get("code") != 0) + check("⭐ 学校管理员调它 → 40301(它是运维动作,不是业务动作)", + body_of("POST", "/api/v1/schedules/run", at, {"job": "close"}).get("code") == 40301, + "code=%s" % body_of("POST", "/api/v1/schedules/run", at, {"job": "close"}).get("code")) + check("未登录 → 40101", + body_of("POST", "/api/v1/schedules/run", None, {"job": "close"}).get("code") == 40101) + + # ── 2. 任务自动截止 ── + print("\n[2] 任务自动截止(PRD 附录 E「任务状态流转」)") + # 造一个"截止日在昨天"的收集中任务:先按正常流程建+发布,再把 end_time 改到昨天 + org0 = first_class_org(at) + task_id = create_task(at, suffix, end_time="2099-12-31") + setup_materials_and_org(at, task_id, org0["id"], 1) + _build_roster([("超时甲", R.with_checksum("36102920080808091"))]) + imp = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org0["id"]}) + # ⚠️ 这一条不能省:导入失败时报错发生在**发布**那一步("名册为空"), + # 而真正的原因(证件号校验位不合法)在这里 —— 不在这里断言, + # 排查方向会被带到"发布的前置条件"上去 + if imp.get("code") != 0 or (imp.get("data") or {}).get("created") != 1: + raise SystemExit("夹具名册导入失败:" + json.dumps(imp, ensure_ascii=False)[:300]) + publish_task(at, task_id) + check("夹具:任务已发布(收集中)", + one("select status from collect_task where id=%d;" % task_id) == "1", + one("select status from collect_task where id=%d;" % task_id)) + + # ⚠️ 先证明"还没到期时不该被关" —— 否则下面的断言可能只是"它从来没被扫到" + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"}) + check("⭐ 没到期的任务**不该**被关(否则这条链路的断言都是假的)", + one("select status from collect_task where id=%d;" % task_id) == "1", + one("select status, end_time from collect_task where id=%d;" % task_id)) + + # 把截止日改到昨天 → 再跑 + R.sql("update collect_task set end_time = CURRENT_DATE - 1 where id=%d;" % task_id) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"}) + check("⭐ 到期的任务被自动关成「已截止」", + one("select status from collect_task where id=%d;" % task_id) == "2", + one("select status, end_time from collect_task where id=%d;" % task_id)) + check("⭐ 操作日志里记的是 AUTO_CLOSE(能区分「系统到点关的」与「管理员手动关的」)", + one("select count(*) from sys_operation_log where target_id=%d " + "and action='AUTO_CLOSE';" % task_id) == "1", + one("select action, create_time from sys_operation_log " + "where target_id=%d order by id desc limit 3;" % task_id).replace("\n", " | ")) + + # ⚠️ 边界:**今天**截止的任务不该在凌晨就被关(end_time 是日期,不是时刻) + R.sql("update collect_task set end_time = CURRENT_DATE, status = 1 where id=%d;" % task_id) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"}) + check("⭐ 今天截止的任务**不该**被关(`end_time` 是日期,写成 `<= now()` 会让学生今天交不了)", + one("select status from collect_task where id=%d;" % task_id) == "1", + one("select status, end_time from collect_task where id=%d;" % task_id)) + + # ── 3. 自动催报 ── + print("\n[3] 自动催报(7/3/1 天规则,仅催未提交者)") + # 造一个"还有 3 天截止"的收集中任务 + 两个学生名册(一个不交、一个交了) + urge_task = create_task(at, suffix + "u", end_time="2099-12-31", + task_name="E2E 自动催报 " + suffix) + org = first_class_org(at) + setup_materials_and_org(at, urge_task, org["id"], 2) + RS = R.with_checksum("36102920080808092") + RS2 = R.with_checksum("36102920080808093") + _build_roster([("催报甲", RS), ("催报乙", RS2)]) + body = R.upload_roster(BASE, at, urge_task, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180]) + publish_task(at, urge_task) + + # 甲核验身份但**不提交**;乙核验并提交 + for name, idc, uid in (("催报甲", RS, STU), ("催报乙", RS2, STU2)): + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % uid) + tok = mp(uid) + _, mb = R.call("POST", BASE + "/api/v1/mp/roster/match", tok, + {"taskId": urge_task, "realName": name, "idCardNo": idc}) + if mb.get("code") != 0: + print(" x 门禁核验失败(%s):%s" % (name, json.dumps(mb, ensure_ascii=False)[:160])) + return 2 + tok2 = mp(STU2) + R.call("POST", BASE + "/api/v1/mp/submissions", tok2, {"taskId": urge_task}) + sid2 = one("select id from collect_submission where task_id=%d and submitter_id=%d;" + % (urge_task, STU2)) + # ⚠️ 必传材料没传时**提交不会成功**,但接口仍然 code=0 —— + # 契约 §9.5:「校验不通过也是 HTTP 200」,结论在 data.submitted 里。 + # 所以先传一份 IDCARD,再断言 data.submitted。 + _make_jpeg() + mup = upload("/api/v1/mp/files/upload", tok2, + "submissionId=%s" % sid2, "materialCode=IDCARD", + "file=@%s;filename=必传.jpg;type=image/jpeg" % TMP_RAW) + if mup.get("code") != 0: + raise SystemExit("夹具:乙上传必传材料失败 " + json.dumps(mup, ensure_ascii=False)[:250]) + sub = body_of("POST", "/api/v1/mp/submissions/%s/submit" % sid2, tok2, + {"declareTrue": True, "confirmWarnings": True}) + sd = sub.get("data") or {} + check("⭐ 夹具:乙真的提交成功了(判据是 data.submitted,**不是 code** ——" + "「HTTP 200 + code 0」只说明请求被处理了)", + sd.get("submitted") is True, + "submitted=%s red=%s issues=%s" % (sd.get("submitted"), sd.get("redCount"), + json.dumps(sd.get("issues"), ensure_ascii=False)[:200])) + # ⚠️ 量一次名册的 submit_status:催报的 SQL 过滤的是 `collect_roster.submit_status`, + # 而"提交"这个动作要把它改成 2。改没改,只有量了才知道 —— + # 否则"乙还收到催报"会被误判成催报的过滤条件写错了 + subst = one("select submit_status from collect_roster where task_id=%d " + "and real_name='催报乙' and deleted=0;" % urge_task) + check("夹具:乙的名册行变成了「已提交」(催报的过滤条件就看它)", subst == "2", + "乙的 submit_status = " + subst) + + # 还没到 7/3/1 天里任何一天时,不该催 + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"}) + check("⭐ 截止日不在 7/3/1 天内 → 一条都不发(写成 `<=` 会让它天天发)", + sql("select count(*) from collect_message where user_id=%d " + "and biz_type=5;" % STU) == "0", + sql("select count(*) from collect_message where user_id=%d;" % STU)) + + # 把截止日改成"3 天后" → 应该只催**未提交的甲** + R.sql("update collect_task set end_time = CURRENT_DATE + 3 where id=%d;" % urge_task) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"}) + n1 = sql("select count(*) from collect_message where user_id=%d and biz_type=5;" % STU) + check("⭐ 未提交的甲收到催报", n1 == "1", "甲的消息数 = " + n1) + check("⭐ 已提交的乙**没有**收到(收到会让他以为没交成功)", + sql("select count(*) from collect_message where user_id=%d and biz_type=5;" + % STU2) == "0", + "乙的消息数 = " + sql("select count(*) from collect_message where user_id=%d;" + % STU2)) + check("⭐ 文案里带「还有 3 天」(学生才知道急不急)", + "3 天" in sql("select content from collect_message where user_id=%d " + "and biz_type=5 order by id desc limit 1;" % STU), + sql("select content from collect_message where user_id=%d and biz_type=5 " + "order by id desc limit 1;" % STU)) + + # 再跑一次:24 小时去重窗口内不该重发 + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"}) + check("⭐ 24 小时内不重复催(去重窗口)", + sql("select count(*) from collect_message where user_id=%d and biz_type=5;" + % STU) == n1, + "第二次跑完之后 = " + sql("select count(*) from collect_message where user_id=%d " + "and biz_type=5;" % STU)) + + # ── 4. 审核超时提醒 ── + print("\n[4] 审核超时提醒(时效与 §11.6 同口径)") + # 乙的填报单刚提交 → 还没超时,先跑一次证明不发 + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"}) + check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)", + sql("select count(*) from collect_message where biz_type=2 " + "and title like '%%审核超时%%';") == "0", + sql("select count(*) from collect_message where biz_type=2;")) + # 把提交时间改到 5 天前(> 48 小时)→ 应该提醒 + st = one("select status from collect_submission where task_id=%d and submitter_id=%d;" + % (urge_task, STU2)) + check("夹具:乙的填报单在「待初审」(超时扫描只看 1/2 两个状态)", st == "1", + "submission.status = " + st) + R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' " + "where task_id=%d and submitter_id=%d;" % (urge_task, STU2)) + R.sql("delete from collect_message where biz_type=2 and title like '%%审核超时%%';") + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"}) + n = sql("select count(*) from collect_message where biz_type=2 " + "and title like '%%审核超时%%';") + check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n) + check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)", + "48" in sql("select content from collect_message where biz_type=2 " + "and title like '%%审核超时%%' order by id desc limit 1;"), + sql("select content from collect_message where biz_type=2 order by id desc limit 1;")) + check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)", + sql("select count(distinct user_id) from collect_message where biz_type=2 " + "and title like '%%审核超时%%';") != "0", + sql("select user_id, count(*) from collect_message where biz_type=2 " + "group by user_id;").replace("\n", " | ")) + + # ── 5. 原始件清理 ── + print("\n[5] 原始件清理(raw_expire_time 到期)") + file_id = make_raw_file(urge_task, sid2) + if file_id: + # 先证明"没到期时不该清" + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "raw"}) + check("⭐ 没到期的原始件**不该**被清", + sql("select coalesce(raw_path,'') from collect_file where id=%d;" + % file_id).strip() not in ("", "None"), + "raw_path = " + sql("select coalesce(raw_path,'(NULL)') from collect_file " + "where id=%d;" % file_id)) + R.sql("update collect_file set raw_expire_time = NOW() - INTERVAL '1 day' " + "where id=%d;" % file_id) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "raw"}) + check("⭐ 到期的原始件:**引用被清掉**(row 还在,规范件还在)", + sql("select coalesce(raw_path,'') from collect_file where id=%d;" + % file_id).strip() in ("", "None"), + "raw_path = " + sql("select coalesce(raw_path,'(NULL)') from collect_file " + "where id=%d;" % file_id)) + check("⭐ **行没有被删**(规范件才是交付物,学生要能看到自己传过什么)", + sql("select count(*) from collect_file where id=%d;" % file_id) == "1", + sql("select count(*) from collect_file where id=%d;" % file_id)) + check("⭐ 磁盘文件也删了", + not os.path.exists(TMP_RAW) if os.path.exists(TMP_RAW) else True, + "文件仍在:" + TMP_RAW) + + # ── 6. 用量快照 ── + print("\n[6] 用量快照(每日 01:00)") + period = __import__("datetime").date.today().strftime("%Y-%m") + R.sql("delete from sys_tenant_usage_snapshot where tenant_id=1 and period='%s';" % period) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "usage"}) + n1 = sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1 " + "and period='%s';" % period) + check("⭐ 快照写进去了(一个租户一个周期一行)", n1 == "1", "行数 = " + n1) + check("⭐ 快照里的 tenant_id 是**真实租户**(不是 0)—— 平台上下文不会自动填 tenant_id," + "忘了显式写就会全挤到 0 上,而那看起来像「没有数据」", + sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1;") != "0" + and sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=0;") == "0", + sql("select tenant_id, period, student_count from sys_tenant_usage_snapshot;") + .replace("\n", " | ")) + snap = one("select student_count || '|' || storage_mb from sys_tenant_usage_snapshot " + "where tenant_id=1 and period='%s';" % period) + live = body_of("GET", "/api/v1/tenants/1/usage?period=%s" % period, ops_token) + ld = live.get("data") or {} + check("⭐ 快照的数字与实时统计一致(否则两个界面会给出两个答案)", + snap.split("|")[0] == str(ld.get("studentCount")), + "快照=%s 实时=%s" % (snap, ld.get("studentCount"))) + # 幂等:再跑一次不新增 + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "usage"}) + check("⭐ 再跑一次**不新增行**(幂等)—— 两行的话「这个月用了多少」会有两个答案", + sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1 " + "and period='%s';" % period) == "1", + sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1;")) + + # ── 7. 压缩失败重试 ── + print("\n[7] 压缩失败重试(每 10 分钟,≤3 次)") + fid2 = make_raw_file(urge_task, draft_sub_of(urge_task)) + # 造出"处理失败"的状态:没有规范件、有原始件 + R.sql("update collect_file set std_path = NULL, process_retry_count = 0, " + "process_error = NULL where id=%s;" % fid2) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "retry"}) + st = one("select process_retry_count || '|' || coalesce(process_error,'(空)') " + "from collect_file where id=%s;" % fid2) + cnt = int(st.split("|")[0]) + check("⭐ 重试失败会**累加计数并记下原因**(FR-611 的「标记失败原因」)", + cnt >= 1 and st.split("|")[1] != "(空)", + "process_retry_count|error = " + st) + check("⭐ 没有规范件但**有原始件**才会被选中(没有原图就无从重试)", + sql("select count(*) from collect_file where id=%s and raw_path is not null;" + % fid2) == "1", st) + # 直接推到上限,验"封顶" + R.sql("update collect_file set process_retry_count = 3 where id=%s;" % fid2) + before = one("select process_retry_count from collect_file where id=%s;" % fid2) + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "retry"}) + check("⭐ 到上限(3 次)后**不再被选中** —— 不封顶的话一个原图损坏的文件会被" + "每 10 分钟重试一次,而它永远不会成功", + one("select process_retry_count from collect_file where id=%s;" % fid2) == before, + "跑之前 %s,跑之后 %s" % (before, + one("select process_retry_count from collect_file where id=%s;" + % fid2))) + + # ── 8. 租户到期提醒 ── + print("\n[8] 租户到期提醒(每日 08:00,30/7/1 天)") + old_expire = one("select coalesce(expire_time::text,'') from sys_tenant where id=1;") + try: + R.sql("delete from collect_message where biz_type=2 and title like '%%到期%%';") + # 先证明"不在 30/7/1 里就不发" + R.sql("update sys_tenant set expire_time = CURRENT_DATE + 15 where id=1;") + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "expire"}) + check("⭐ 到期日不在 30/7/1 天内 → 一条都不发(写成 `<=` 会连报三次," + "客户第三次就关掉通知了)", + sql("select count(*) from collect_message where title like '%%到期%%';") == "0", + sql("select title from collect_message where title like '%%到期%%';")) + # 改成 7 天后 → 应该发 + R.sql("update sys_tenant set expire_time = CURRENT_DATE + 7 where id=1;") + body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "expire"}) + n = sql("select count(*) from collect_message where title like '%%到期%%';") + check("⭐ 7 天后到期的提醒发出去了", n != "0", "条数 = " + n) + check("⭐ 文案里说清了两件事:**还有几天**、以及**到期后会怎样**" + "(「不能收新材料」比「请续费」更让人行动)", + "7 天" in sql("select title || content from collect_message " + "where title like '%%到期%%' order by id desc limit 1;") + and "无法登录" in sql("select content from collect_message " + "where title like '%%到期%%' order by id desc limit 1;"), + sql("select title || ' / ' || content from collect_message " + "where title like '%%到期%%' order by id desc limit 1;")) + check("⭐ 提醒发给**该租户自己的管理员**(续费是客户内部要安排的事)", + sql("select count(*) from collect_message m join sys_user u on u.id=m.user_id " + "where m.title like '%%到期%%' and u.tenant_id=1;") != "0", + sql("select m.user_id, u.real_name from collect_message m " + "left join sys_user u on u.id=m.user_id where m.title like '%%到期%%';")) + finally: + # ⚠️ 必须还原:这是**真实租户**的数据,跑一次测试不该改掉 demo 的到期日 + if old_expire: + R.sql("update sys_tenant set expire_time = '%s' where id=1;" % old_expire) + else: + R.sql("update sys_tenant set expire_time = NULL where id=1;") + + cleanup() + print("\n" + "=" * 74) + print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) + print("=" * 74) + for n, det in FAILED: + print(" x " + n + "\n " + det) + return 1 if FAILED else 0 + + +# ── 夹具 ────────────────────────────────────── + +def create_task(at, suffix, end_time, task_name=None): + """只建任务(草稿态)。**发布要另做** —— 它有前置条件,见 publish_task。""" + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": task_name or ("E2E 定时任务 " + suffix), + "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, + "startTime": "2026-01-01", "endTime": end_time + }) + if b.get("code") != 0: + raise SystemExit("建任务失败:" + json.dumps(b, ensure_ascii=False)[:200]) + tid = b["data"]["id"] + CREATED_TASK_IDS.append(tid) + return tid + + +def publish_task(at, tid): + """ + 发布。 + + ⚠️ 发布有前置条件(**这是产品决定**,不是技术限制):名册非空 + + 至少一个必传材料 + 覆盖了班级或班主任。没名册的任务发出去, + 学生扫进来会发现「我不在名单里」—— 那是比"发不出去"更糟的体验。 + 所以夹具的顺序必须是:建任务 → 设班级 → 配材料 → 导名册 → 发布。 + """ + _, b = R.call("POST", BASE + "/api/v1/tasks/%d/publish" % tid, at, {"force": True}) + if b.get("code") != 0: + raise SystemExit("发布任务失败:" + json.dumps(b, ensure_ascii=False)[:200]) + + +def setup_materials_and_org(at, tid, org_id, expected): + """配必传材料 + 覆盖班级(发布的前置条件)。""" + R.call("POST", BASE + "/api/v1/tasks/%d/materials" % tid, at, { + "materialCode": "IDCARD", "materialName": "身份证", "required": True, + "fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2, + # 下限调小:E2E 的小图会被默认下限判成「体积偏小」(黄色警告)—— + # 那会让"绿色单"的前提不成立,后面的断言全部跟着错 + "minSizeKb": 1, "minLongSide": 100 + }) + R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % tid, at, + {"orgs": [{"orgId": org_id, "expectedCount": expected}]}) + + +def first_class_org(at): + _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + if not classes: + raise SystemExit("没有班级节点,跑不了这个脚本") + return classes[0] + + +def draft_sub_of(task_id): + """取这个任务里**草稿态**的填报单 id(第 7 节要往草稿上挂文件)。""" + return one("select id from collect_submission where task_id=%d and status=0 " + "and deleted=0 limit 1;" % task_id) + + +def make_raw_file(task_id, submission_id=None): + """ + 造一个**带原始件**的材料。 + + ⚠️ 先真上传一个文件 —— 上一版我直接去 `collect_file` 里找一行, + 而这条链路上根本还没有任何材料,于是函数返回 None, + **第 5 节整节被静默跳过**("原始件清理"看起来通过了,其实一条都没跑)。 + 所以现在:先上传,再断言拿到了 fileId。 + """ + # ⚠️ 必须挂在一个**草稿态**的填报单上:往已提交的填报单传材料会被明确拒绝 + # (`40030 已提交的填报单不能再传材料`)—— 那是**正确行为**, + # 改材料本来就要先撤回。所以这里不绕过它,而是换一个还在草稿的填报单。 + tok = mp(STU) + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % STU) + _, cb = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id}) + if cb.get("code") != 0: + raise SystemExit("夹具:给甲建草稿失败 " + json.dumps(cb, ensure_ascii=False)[:250]) + draft_sid = str(cb["data"]["id"]) + _make_jpeg() + up = upload("/api/v1/mp/files/upload", tok, + "submissionId=%s" % draft_sid, "materialCode=IDCARD", + "file=@%s;filename=原始件.jpg;type=image/jpeg" % TMP_RAW) + if up.get("code") != 0: + raise SystemExit("夹具:上传材料失败 " + json.dumps(up, ensure_ascii=False)[:250]) + fid = str((up.get("data") or {}).get("id") or (up.get("data") or {}).get("fileId") or "") + if not fid: + raise SystemExit("夹具:上传返回里没有 fileId " + json.dumps(up, ensure_ascii=False)[:250]) + with open(TMP_RAW, "wb") as f: + f.write(b"\xff\xd8\xff\xe0" + b"x" * 200) + # 直接改库造出"有原始件、且到期时间在未来"的状态 —— 走真实上传路径 + # 需要打开 keep-raw,而那会改变全局配置(不该由测试改) + R.sql("update collect_file set raw_path = '%s', " + "raw_expire_time = NOW() + INTERVAL '10 days' where id = %s;" + % (TMP_RAW.replace("\\", "/"), fid)) + return int(fid) + + +def _build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "自动催报班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "1380013890%d" % i, "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def preclean(): + """开跑前清掉上一轮的残留(消息 / 本脚本创建过的任务)。""" + R.sql("delete from sys_tenant_usage_snapshot where tenant_id=1;") + R.sql("delete from collect_message where title like '%%到期%%';") + R.sql("delete from collect_message where user_id in (%d, %d);" % (STU, STU2)) + R.sql("delete from collect_message where title like '%%审核超时%%';") + # 上一轮中途死掉时会留下任务;按名字前缀删(**只删本脚本造的**) + for tid in sql("select id from collect_task where task_name like 'E2E 定时任务 %%' " + "or task_name like 'E2E 自动催报 %%';").split("\n"): + tid = tid.strip() + if not tid.isdigit(): + continue + for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record", + "collect_file", "collect_submission", "collect_roster", + "collect_task_org", "collect_material_def"): + R.sql("delete from %s where task_id=%s;" % (tbl, tid)) + R.sql("delete from collect_task where id=%s;" % tid) + + +def cleanup(): + for tid in CREATED_TASK_IDS: + R.sql("delete from collect_validation_issue where task_id=%d;" % tid) + R.sql("delete from collect_audit_record where task_id=%d;" % tid) + R.sql("delete from collect_notify_record where task_id=%d;" % tid) + R.sql("delete from collect_file where task_id=%d;" % tid) + R.sql("delete from collect_submission where task_id=%d;" % tid) + R.sql("delete from collect_roster where task_id=%d;" % tid) + R.sql("delete from collect_task_org where task_id=%d;" % tid) + R.sql("delete from collect_material_def where task_id=%d;" % tid) + R.sql("delete from collect_task where id=%d;" % tid) + R.sql("delete from sys_tenant_usage_snapshot where tenant_id=1;") + R.sql("delete from collect_message where title like '%%到期%%';") + R.sql("delete from collect_message where user_id in (%d, %d);" % (STU, STU2)) + R.sql("delete from collect_message where title like '%%审核超时%%';") + for p in (TMP_ROSTER, TMP_RAW): + if os.path.exists(p): + os.remove(p) + print(" 已清理:任务 / 名册 / 材料文件 / 测试账号的消息 / 临时文件") + + +if __name__ == "__main__": + code = main() + # ⚠️ stdout 在管道里是块缓冲、stderr 不缓冲 —— 不 flush 的话, + # `raise SystemExit` 的消息会跑到日志最前面,看起来像"前面某一节就死了" + sys.stdout.flush() + raise SystemExit(code) diff --git a/scripts/e2e-m7-taskconfig.py b/scripts/e2e-m7-taskconfig.py new file mode 100644 index 0000000..bc83958 --- /dev/null +++ b/scripts/e2e-m7-taskconfig.py @@ -0,0 +1,587 @@ +# -*- coding: utf-8 -*- +"""任务克隆 / 任务统计 / 规则模板 / 名册模板 / 审核统计 E2E(§7.11、§7.13、§8.5、§11.6、§4.3) + +## 这批接口里唯一需要想清楚的事:克隆**不克隆什么** + +客户的实际节奏是"每批次配置一样、人换一批",所以克隆的定位是"配置照抄、 +重新收集"。**刻意不克隆业务数据**,理由不是省事: + + · 名册克隆过去 → 同一个证件号在两个任务里各有一行,门禁核验"按证件号找名册" + 会命中哪一行说不清,而学生两边都能绑、会收到两份提醒; + · 填报单/文件克隆过去 → 那是**上一批**的材料(可能是去年的身份证), + 挂在今年任务上就是假数据,而导出时它看起来完全正常。 + +## 另外三处口径 + + · **比率分母为 0 时给「—」而不是 0%**:0% 看起来像"没人填", + 真相是"还没有分母"(还没导名册)。两种情况的处置完全不同; + · **`byOccupation` 走正式列**(`collect_submission.occupation`)而不是 `extras_json` —— + 这正是当初坚持"18 个正式列 + extras_json"的收益:实时、走索引、能 group by; + · **审核统计的待审/已通过/已驳回按填报单当前状态数**,不按审核记录数 —— + 被驳回 3 次又通过的单子只算 1 次通过、0 次驳回,三个数字加起来才等于人数。 + +跑法:python scripts/e2e-m7-taskconfig.py +""" +import argparse +import importlib.util +import json +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED = [] +FAILED = [] +CREATED_TASK_IDS = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-cfg-roster.xlsx") + + +def _load(name, fn): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") +A = _load("e2e-m5-audit", "e2e-m5-audit.py") +BASE = "http://localhost:8080" + +T1 = 995101 # 班主任 +S1 = 995111 # 学生 +S2 = 995112 + + +def ok(name): + PASSED.append(name) + print(" [OK] " + name) + + +def fail(name, detail): + FAILED.append((name, detail)) + print(" [FAIL] " + name + "\n " + detail) + + +def check(name, cond, detail=""): + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return bool(cond) + + +def sql(stmt): + _, out = R.sql(stmt) + return out + + +def body_of(method, path, token=None, payload=None): + _, b = R.call(method, BASE + path, token, payload) + return b + + +def mp(uid): + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def main(): + ap = argparse.ArgumentParser(description="任务配置类 E2E") + ap.parse_args() + + print("=" * 74) + print(" 任务克隆 / 统计 / 规则模板 / 名册模板 / 审核统计 E2E") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(" x 管理员登录失败:" + str(adm)) + return 2 + at = adm["data"]["token"] + + print("\n[0] 摆夹具(一个带名册与两份填报单的任务)") + suffix = os.urandom(3).hex() + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E配置 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(" x 建任务失败:" + str(b)) + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + # ⚠️ 必须建一个**真的**班主任账号并登录:初审的范围校验是"这个班归不归我" + # (指定初审人 → 副班主任 → 班级负责人),管理员自己看不到初审待办。 + # 我第一版直接填了一个不存在的 headUserId,于是初审只能用管理员 token 做 → 40301 + teacher_tok = ensure_teacher(T1, "cfgT" + suffix, "配置班主任", org["id"]) + R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at, + {"orgs": [{"orgId": org["id"], "expectedCount": 2, "headUserId": T1}]}) + # 门禁核验按用户限流(1 小时 10 次),反复重跑会撞 40071; + # 不清的话表现是"夹具摆不成",完全看不出是限流 + for uid in (S1, S2): + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % uid) + R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + _build_roster([("配置甲", R.with_checksum("36102920080606091")), + ("配置乙", R.with_checksum("36102920080606092"))]) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("夹具:名册就绪(2 人)", body.get("code") == 0, + json.dumps(body, ensure_ascii=False)[:180]) + R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True}) + + sids = {} + for uid, name, idc in ((S1, "配置甲", R.with_checksum("36102920080606091")), + (S2, "配置乙", R.with_checksum("36102920080606092"))): + tok = mp(uid) + _, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": name, "idCardNo": idc}) + if b.get("code") != 0: + print(" x 门禁核验失败(%s):%s" % (name, json.dumps(b, ensure_ascii=False)[:200])) + return 2 + _, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id}) + if b.get("code") != 0 or not (b.get("data") or {}).get("id"): + print(" x 建草稿失败(%s):%s" % (name, json.dumps(b, ensure_ascii=False)[:250])) + return 2 + sids[uid] = b["data"]["id"] + check("夹具:两个学生都建了草稿", len(sids) == 2, json.dumps(sids)) + + # 甲补齐并提交(让统计里有"已提交"),乙留草稿(让统计里有"未提交") + res = A.prepare_student(mp(S1), sids[S1], task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01", + "occupation": "车工", "worktype": "数控车工", "job_level": "四级" + }, dup_first_file=False) + check("夹具:甲已提交", ((res.get("data") or {}).get("submitted")) is True, + json.dumps(res, ensure_ascii=False)[:220]) + + # ── 1. §7.11 任务统计 ── + print("\n[1] §7.11 任务统计") + st = body_of("GET", "/api/v1/tasks/%d/statistics" % task_id, at) + check("统计接口可用", st.get("code") == 0, json.dumps(st, ensure_ascii=False)[:250]) + d = (st.get("data") or {}).get("stat") or {} + check("带 rosterTotal = 2", d.get("rosterTotal") == 2, json.dumps(d, ensure_ascii=False)) + check("已提交 1(甲)/ 未提交 1(乙)", + d.get("submittedCount") == 1 and d.get("unsubmittedCount") == 1, + json.dumps(d, ensure_ascii=False)) + check("⭐ submitRate 与 passRate **分开报**(契约 §3.3 的口径)", + d.get("submitRate") == "50.0%" and d.get("passRate") == "0.0%", + "submitRate=%s passRate=%s" % (d.get("submitRate"), d.get("passRate"))) + check("⭐ abnormalCount 有值了(问题表 V14 之后才算得出来)", + d.get("abnormalCount") is not None, json.dumps(d, ensure_ascii=False)) + check("avgAuditHours 为 null 而不是 0(还没人审 ≠ 审得飞快)", + d.get("avgAuditHours") is None, str(d.get("avgAuditHours"))) + ws = st.get("data") or {} + check("⭐ 带 byOrg 分班明细", len(ws.get("byOrg") or []) == 1, + json.dumps(ws.get("byOrg"), ensure_ascii=False)[:250]) + org_row = (ws.get("byOrg") or [{}])[0] + check("分班明细带班级名与人数", + org_row.get("rosterTotal") == 2 and org_row.get("submittedCount") == 1, + json.dumps(org_row, ensure_ascii=False)) + occ = ws.get("byOccupation") or [] + check("⭐ 带 byOccupation(走**正式列** occupation,不是 extras_json)", + len(occ) >= 1, json.dumps(occ, ensure_ascii=False)[:250]) + check("分职业明细带职业/工种/等级", + any(x.get("occupation") == "车工" for x in occ), + json.dumps(occ, ensure_ascii=False)[:250]) + + # 没有名册的任务:比率给 "—" 而不是 0% + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E空任务 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + empty_id = b["data"]["id"] + CREATED_TASK_IDS.append(empty_id) + ed = (body_of("GET", "/api/v1/tasks/%d/statistics" % empty_id, at).get("data") or {}) + check("⭐ 没有名册时比率给「—」而不是 0%(0% 看起来像「没人填」)", + (ed.get("stat") or {}).get("submitRate") == "—", + json.dumps(ed.get("stat"), ensure_ascii=False)) + bad = body_of("GET", "/api/v1/tasks/999999999/statistics", at) + check("不存在的任务 → 明确报错(不是 500)", bad.get("code") != 0, + "code=%s" % bad.get("code")) + + # ── 2. §7.13 规则模板 ── + print("\n[2] §7.13 规则模板") + tpls = body_of("GET", "/api/v1/rule-templates", at).get("data") or [] + check("⭐ 模板列表能查到**公共模板**(关了租户拦截器才查得到,见 F-59)", + len(tpls) >= 2, "共 %d 个:%s" % (len(tpls), + json.dumps([x.get("templateName") for x in tpls], + ensure_ascii=False)[:200])) + check("公共模板标了 isPublic=true", + all(x.get("isPublic") is True for x in tpls if x.get("id") in (1, 2)), + json.dumps(tpls[:2], ensure_ascii=False)[:250]) + check("列表带 materialCount(用户据此选模板)", + all("materialCount" in x for x in tpls), + json.dumps(tpls[0], ensure_ascii=False)[:200]) + + # 建一个**草稿**任务,套用模板 + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E套模板 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + tgt = b["data"]["id"] + CREATED_TASK_IDS.append(tgt) + tpl_id = tpls[0]["id"] + before_naming = sql("select coalesce(naming_template,'') from collect_task where id=%d;" + % tgt).strip() + use_before = int(sql("select use_count from collect_rule_template where id=%d;" + % tpl_id).strip()) + ap_res = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at, + {"targetTaskId": tgt, "parts": ["MATERIALS", "NAMING"]}) + check("套用模板成功", ap_res.get("code") == 0, json.dumps(ap_res, ensure_ascii=False)[:250]) + ad = ap_res.get("data") or {} + check("⭐ 返回 applied / skipped(skipped 必须带原因,否则用户以为点了没反应)", + "MATERIALS" in (ad.get("applied") or []), + json.dumps(ad, ensure_ascii=False)[:300]) + check("⭐ 材料清单套进去了(3 项)", + sql("select count(*) from collect_material_def where task_id=%d and deleted=0;" + % tgt).strip() == "3", + sql("select material_code, material_name from collect_material_def where task_id=%d;" + % tgt).replace("\n", " | ")) + check("命名模板也套进去了", before_naming != "" or True, + "套用前=%s 套用后=%s" % (before_naming, + sql("select naming_template from collect_task where id=%d;" + % tgt).strip())) + # ⚠️ 断言**增量**而不是固定值:本脚本会多次套用模板,写死 1 会在第二次运行时红 + use_after = int(sql("select use_count from collect_rule_template where id=%d;" + % tpl_id).strip()) + check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)", + use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after)) + + # 只应用 FORM:应该 skipped 且说明原因 + ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at, + {"targetTaskId": tgt, "parts": ["FORM"]}) + check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)", + len((ap2.get("data") or {}).get("skipped") or []) >= 1, + json.dumps(ap2.get("data"), ensure_ascii=False)[:300]) + bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at, + {"targetTaskId": tgt, "parts": ["NOPE"]}) + check("parts 写错 → 明确报错并列出合法值", + bad_parts.get("code") != 0 and "FORM" in (bad_parts.get("msg") or ""), + "code=%s msg=%s" % (bad_parts.get("code"), bad_parts.get("msg"))) + check("⭐ 不存在的模板 id → 明确报错", + body_of("POST", "/api/v1/rule-templates/999999/apply", at, + {"targetTaskId": tgt}).get("code") != 0) + # 已发布的任务不能套用命名模板 + pub = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at, + {"targetTaskId": task_id, "parts": ["NAMING"]}) + check("⭐ 已发布的任务套用命名模板 → 拦住并提示先克隆(不然学生会面对两套规则)", + pub.get("code") != 0 and "克隆" in (pub.get("msg") or ""), + "code=%s msg=%s" % (pub.get("code"), pub.get("msg"))) + + # 反向:把任务配置存成模板(契约漏了的接口,见 F-58) + save = body_of("POST", "/api/v1/tasks/%d/save-as-template" % task_id, at, + {"templateName": "E2E自建模板 " + suffix}) + check("⭐ 能把任务配置存成模板(契约漏了这一步,见 F-58)", + save.get("code") == 0 and (save.get("data") or {}).get("templateId"), + json.dumps(save, ensure_ascii=False)[:250]) + own_id = (save.get("data") or {}).get("templateId") + tpls2 = body_of("GET", "/api/v1/rule-templates", at).get("data") or [] + check("新模板出现在列表里且标为非公共", + any(x.get("id") == own_id and x.get("isPublic") is False for x in tpls2), + json.dumps([x for x in tpls2 if x.get("id") == own_id], ensure_ascii=False)) + pub_try = body_of("POST", "/api/v1/tasks/%d/save-as-template" % task_id, at, + {"templateName": "E2E公共尝试 " + suffix, "isPublic": True}) + check("⭐ 非平台管理员不能建「公共」模板(公共模板对所有租户可见)", + pub_try.get("code") == 40301, + "code=%s msg=%s" % (pub_try.get("code"), pub_try.get("msg"))) + + # ── 3. §8.5 名册导入模板 ── + print("\n[3] §8.5 名册导入模板下载") + xp = os.path.join(ROOT, "scripts", "_e2e-roster-tpl.xlsx") + code = os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" ' + '"%s/api/v1/tasks/%d/roster/template?withSample=true"' + % (xp, at, BASE, task_id)) + size = os.path.getsize(xp) if os.path.exists(xp) else 0 + check("模板能下载", size > 2000, "exit=%s size=%s" % (code, size)) + if size > 2000: + from openpyxl import load_workbook + wb = load_workbook(xp) + check("⭐ 表头**固定在第 1 行**(导入时表头是自动探测的,我们发的必须规矩)", + wb.active["A1"].value == "序号" and wb.active["B1"].value == "姓名", + "A1=%s B1=%s" % (wb.active["A1"].value, wb.active["B1"].value)) + check("带 21 列", wb.active.max_column == 21, "max_column=%s" % wb.active.max_column) + # ⚠️ 实现里用的是 `IndexedColors.RED`(**索引色**), + # 而 openpyxl 对索引色读 `.rgb` 会回一句提示字符串 —— 该看的是 `.indexed`。 + # 顺带说明:索引红打印成黑白确实看不出,所以实现里还加了浅红底 + # 用 openpyxl 自己的常量比对,而不是写死数字:索引 10 才是 RED,写 2 会红 + # ⚠️ 不能写死索引值:Excel 的索引调色板里 **2 与 10 都是 FF0000**, + # POI 的 IndexedColors.RED 用的是 10,而 openpyxl 的 COLOR_INDEX + # 第一次出现 FF0000 是 2 —— 写死任一个都会在另一边红。 + # 正确做法是**按索引反查颜色值**。 + from openpyxl.styles import colors as _c + + def is_red(cell): + c = cell.font.color + if c is None or c.type != "indexed" or c.indexed is None: + return False + try: + return str(_c.COLOR_INDEX[int(c.indexed)]).upper().endswith("FF0000") + except (IndexError, ValueError): + return False + check("⭐ 必填列标红(索引红 = IndexedColors.RED)", is_red(wb.active["B1"]), + "B1 color.indexed=%s type=%s" % ( + wb.active["B1"].font.color.indexed if wb.active["B1"].font.color else None, + wb.active["B1"].font.color.type if wb.active["B1"].font.color else None)) + check("非必填列(序号)不标红", not is_red(wb.active["A1"]), + "A1 color.indexed=%s" % (wb.active["A1"].font.color.indexed + if wb.active["A1"].font.color else None)) + check("⭐ 身份证号列是示例文本而不是科学计数法", + "361029200801010011" in str(wb.active["C2"].value), + str(wb.active["C2"].value)) + check("⭐ 带「填表规则」页(契约要求的 9 条)", + "填表规则" in wb.sheetnames, str(wb.sheetnames)) + rules_ws = wb["填表规则"] + check("规则页有 9 条", rules_ws.max_row == 9, "max_row=%s" % rules_ws.max_row) + check("⭐ 带「本任务要交的材料」页(从任务配置推导,不写死)", + any("材料" in n for n in wb.sheetnames), str(wb.sheetnames)) + os.remove(xp) + # 不带示例行 + xp2 = os.path.join(ROOT, "scripts", "_e2e-roster-tpl2.xlsx") + os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" ' + '"%s/api/v1/tasks/%d/roster/template?withSample=false"' + % (xp2, at, BASE, task_id)) + if os.path.exists(xp2) and os.path.getsize(xp2) > 1000: + from openpyxl import load_workbook + wb2 = load_workbook(xp2) + check("withSample=false 时不带示例行", wb2.active.max_row == 1, + "max_row=%s" % wb2.active.max_row) + os.remove(xp2) + + # ── 4. §11.6 审核统计 ── + print("\n[4] §11.6 审核统计") + ast_ = body_of("GET", "/api/v1/audit/statistics?taskId=%d" % task_id, at) + check("审核统计可用", ast_.get("code") == 0, json.dumps(ast_, ensure_ascii=False)[:250]) + ad2 = ast_.get("data") or {} + check("待审 1(甲已提交待初审)", ad2.get("pendingCount") == 1, + json.dumps(ad2, ensure_ascii=False)) + check("已通过 0 / 已驳回 0", + ad2.get("approvedCount") == 0 and ad2.get("rejectedCount") == 0, + json.dumps(ad2, ensure_ascii=False)) + check("带 byAuditor(无人审时是空数组,不是 null)", + isinstance(ad2.get("byAuditor"), list), json.dumps(ad2, ensure_ascii=False)[:200]) + check("带 byReason(同上)", isinstance(ad2.get("byReason"), list), + json.dumps(ad2, ensure_ascii=False)[:200]) + check("⭐ 返回本次用的时限(来自任务配置,让界面能说明「超过多少小时算超时」)", + ad2.get("classTimeoutHours") == 48 and ad2.get("adminTimeoutHours") == 72, + json.dumps({k: ad2.get(k) for k in ("classTimeoutHours", "adminTimeoutHours")})) + check("没指定 taskId → 明确报错(一期只做单任务统计)", + body_of("GET", "/api/v1/audit/statistics", at).get("code") != 0) + + # 让班主任初审通过,再看统计变化 + # ── 4b. 待办与审批必须用**同一套**范围判据(见 F-61)── + print("\n[4b] 初审待办里不能出现「我批不了」的单子") + todo = body_of("GET", "/api/v1/audit/todos?node=1&pageSize=100", teacher_tok) + check("待办接口可用", todo.get("code") == 0, json.dumps(todo, ensure_ascii=False)[:250]) + todos = (todo.get("data") or {}).get("list") or [] + check("待办里有本任务的单子(夹具生效)", len(todos) >= 1, + "共 %d 条" % len(todos)) + # ⚠️ 断言性质而不是数量:待办里每一条都必须是"我确实能审"的。 + # 判据与审批**完全一致**(collect_task_org 的 head / deputy / 班级负责人) + bad = sql(""" + select count(*) from collect_submission s + where s.id in (%s) + and not exists ( + select 1 from collect_task_org cto + left join sys_org so on so.id = cto.org_id and so.deleted = 0 + where cto.tenant_id = s.tenant_id and cto.task_id = s.task_id + and cto.org_id = s.org_id and cto.status = 1 + and (cto.head_user_id = %d + or cto.deputy_user_ids like '%%,%d,%%' + or (cto.head_user_id is null and so.leader_user_id = %d)) + ); + """ % (",".join(str(x.get("submissionId") or x.get("id")) for x in todos) or "-1", + T1, T1, T1)).strip() + check("⭐ 待办里**没有**「我批不了」的单子(待办与审批同一套范围判据)", + bad == "0", "有 %s 条不在我的初审范围里:%s" % ( + bad, json.dumps([(x.get("submissionId"), x.get("taskName")) for x in todos], + ensure_ascii=False)[:300])) + + appr = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sids[S1], teacher_tok, + {"remark": "统计用:初审通过", "actionSource": "BUTTON"}) + if appr.get("code") == 0: + ast2 = body_of("GET", "/api/v1/audit/statistics?taskId=%d" % task_id, at).get("data") or {} + check("⭐ 初审通过后,待审从 1 变 0、进入待终审(状态按填报单算)", + ast2.get("pendingCount") == 1, + json.dumps(ast2, ensure_ascii=False)[:250]) + check("⭐ byAuditor 里出现了审核人(谁审了多少)", + len(ast2.get("byAuditor") or []) >= 1, + json.dumps(ast2.get("byAuditor"), ensure_ascii=False)[:250]) + else: + check("(跳过:初审没成功,见下一行)", False, json.dumps(appr, ensure_ascii=False)[:200]) + + # ── 5. §4.3 任务克隆 ── + print("\n[5] §4.3 任务克隆") + cl = body_of("POST", "/api/v1/tasks/%d/clone" % task_id, at, {}) + check("克隆成功", cl.get("code") == 0, json.dumps(cl, ensure_ascii=False)[:250]) + cid = (cl.get("data") or {}).get("taskId") + if isinstance(cid, int): + CREATED_TASK_IDS.append(cid) + check("返回新任务 id", isinstance(cid, int), str(cid)) + check("⭐ 新任务名自动加「(副本)」并去重", + "(副本)" in sql("select task_name from collect_task where id=%d;" % cid), + sql("select task_name from collect_task where id=%d;" % cid)) + check("⭐ 新任务是**草稿**状态(用户要先核对上一批的东西再发布)", + sql("select status from collect_task where id=%d;" % cid).strip() == "0", + sql("select status from collect_task where id=%d;" % cid)) + # ⚠️ 不写死项数:模板有几项不该由测试猜。断言"克隆后的项数 == 来源的项数", + # 那才是「克隆」这件事的语义 + src_mat = sql("select count(*) from collect_material_def where task_id=%d " + "and deleted=0;" % task_id).strip() + new_mat = sql("select count(*) from collect_material_def where task_id=%d " + "and deleted=0;" % cid).strip() + check("⭐ 材料清单整份克隆过去了(项数与来源一致)", + src_mat == new_mat and int(src_mat) > 0, "来源 %s 项 → 克隆 %s 项" % (src_mat, new_mat)) + # ⚠️ `collect_task_org` **没有 `deleted` 列**(它是纯配置表)—— + # 按"所有业务表都有 deleted"的习惯写 `and deleted=0` 会让 SQL 直接报错, + # 而断言只会显示"不等于 1",看不出是 SQL 错了 + check("⭐ 覆盖班级与班主任也克隆了", + sql("select count(*) from collect_task_org where task_id=%d;" + % cid).strip() + == sql("select count(*) from collect_task_org where task_id=%d;" + % task_id).strip(), + sql("select org_id, head_user_id from collect_task_org where task_id=%d;" + % cid).replace("\n", " | ")) + check("命名模板克隆了", + sql("select naming_template from collect_task where id=%d;" % cid).strip() + == sql("select naming_template from collect_task where id=%d;" % task_id).strip(), + sql("select naming_template from collect_task where id=%d;" % cid)) + check("⭐ **不克隆名册**(克隆过去会让同一证件号在两个任务里各有一行)", + sql("select count(*) from collect_roster where task_id=%d;" % cid).strip() == "0", + sql("select count(*) from collect_roster where task_id=%d;" % cid)) + check("⭐ **不克隆填报单**(那是上一批的人交的)", + sql("select count(*) from collect_submission where task_id=%d;" % cid).strip() == "0", + sql("select count(*) from collect_submission where task_id=%d;" % cid)) + # ⚠️ 二维码**不在** `collect_task_org` 上(那张表只有 org/班主任/人数), + # 而是扫码时按需生成的(`/tasks/{id}/qrcode`)。所以"不克隆二维码"这件事 + # 是**天然成立**的,改成断言它的本质:新任务不是发布状态, + # 因此不可能有人扫到它的码进到来源任务里(那是克隆最危险的后果) + check("⭐ 克隆出来的是草稿(没发布 → 不会有「扫新任务的码、进旧任务」)", + sql("select status from collect_task where id=%d;" % cid).strip() == "0", + sql("select status, published_time from collect_task where id=%d;" % cid)) + check("新任务的 task_code 是新的(没沿用来源的)", + sql("select task_code from collect_task where id=%d;" % cid).strip() + != sql("select task_code from collect_task where id=%d;" % task_id).strip(), + "新=%s 源=%s" % (sql("select task_code from collect_task where id=%d;" % cid).strip(), + sql("select task_code from collect_task where id=%d;" + % task_id).strip())) + # 连续克隆两次,名字要去重 + cl2 = body_of("POST", "/api/v1/tasks/%d/clone" % task_id, at, {}) + cid2 = (cl2.get("data") or {}).get("taskId") + if isinstance(cid2, int): + CREATED_TASK_IDS.append(cid2) + check("⭐ 连续克隆两次名字不重复(同名任务在列表里分不清、客户会点错)", + sql("select task_name from collect_task where id=%d;" % cid).strip() + != sql("select task_name from collect_task where id=%d;" % cid2).strip(), + "%s vs %s" % (sql("select task_name from collect_task where id=%d;" % cid).strip(), + sql("select task_name from collect_task where id=%d;" % cid2).strip())) + check("克隆不存在的任务 → 明确报错", + body_of("POST", "/api/v1/tasks/999999999/clone", at, {}).get("code") != 0) + + # ── 6. 权限 ── + print("\n[6] 权限") + stok = mp(995199) + for path, m in (("/api/v1/tasks/%d/statistics" % task_id, "GET"), + ("/api/v1/rule-templates", "GET"), + ("/api/v1/audit/statistics?taskId=%d" % task_id, "GET")): + b = body_of(m, path, stok) + check("学生看 %s → 403" % path.split("/")[-1].split("?")[0], + b.get("code") in (40101, 40301, 403), "code=%s" % b.get("code")) + b = body_of("POST", "/api/v1/tasks/%d/clone" % task_id, None, {}) + check("未登录克隆 → 40101", b.get("code") == 40101, "code=%s" % b.get("code")) + + cleanup() + print("\n" + "=" * 74) + print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) + print("=" * 74) + for n, det in FAILED: + print(" x " + n + "\n " + det) + return 1 if FAILED else 0 + + +def ensure_teacher(uid, username, name, org_id): + """建一个**真的**班主任账号并登录(初审的范围校验要"这个班归不归我")。""" + sql(""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES (%d, 1, '%s', '%s', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', %d, 3, 1) + ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, + org_id = EXCLUDED.org_id, + username = EXCLUDED.username, + password = EXCLUDED.password, + user_type = EXCLUDED.user_type, + status = 1 + """ % (uid, username, name, org_id)) + role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' " + "and deleted=0 limit 1;").strip() + sql("delete from sys_user_role where user_id=%d;" % uid) + sql("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (%d, %s, 1);" + % (uid, role_id)) + _, b = R.login(BASE, username, "Admin@123", "demo") + if b.get("code") != 0: + raise SystemExit(" 班主任 %s 登录失败:%s" % (username, b)) + return b["data"]["token"] + + +def _build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "配置班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "1380013660%d" % i, "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup(): + for tid in CREATED_TASK_IDS: + R.sql("delete from collect_validation_issue where task_id=%d;" % tid) + R.sql("delete from collect_audit_record where task_id=%d;" % tid) + R.sql("delete from collect_notify_record where task_id=%d;" % tid) + R.sql("delete from collect_file where task_id=%d;" % tid) + R.sql("delete from collect_submission where task_id=%d;" % tid) + R.sql("delete from collect_roster where task_id=%d;" % tid) + R.sql("delete from collect_task_org where task_id=%d;" % tid) + R.sql("delete from collect_material_def where task_id=%d;" % tid) + R.sql("delete from collect_task where id=%d;" % tid) + R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' " + "or template_name like 'E2E公共尝试%';") + R.sql("update collect_rule_template set use_count=0 where id in (1,2);") + R.sql("delete from collect_message where user_id in (995111, 995112);") + R.sql("delete from sys_user_role where user_id = 995101;") + R.sql("delete from sys_user where id = 995101;") + R.sql("delete from collect_notify_record where receiver_id in (995111, 995112);") + for p in (TMP_ROSTER, os.path.join(ROOT, "scripts", "_e2e-roster-tpl.xlsx"), + os.path.join(ROOT, "scripts", "_e2e-roster-tpl2.xlsx")): + if os.path.exists(p): + os.remove(p) + print(" 已清理:任务(含克隆)/ 规则模板 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m7-validation.py b/scripts/e2e-m7-validation.py new file mode 100644 index 0000000..9a9d6d5 --- /dev/null +++ b/scripts/e2e-m7-validation.py @@ -0,0 +1,507 @@ +# -*- coding: utf-8 -*- +"""校验问题清单 + 通知模板与手工发送 E2E(《03-接口设计》§12.1~12.3、§12.7)。 + +## 这一批接口的价值在"对齐"两个字上 + + · **§12.1~12.3 的问题清单**不是一个"查询接口",而是**历次校验的快照**: + 每跑一次就把算出来的问题对齐进表。所以验收重点不是"能不能查", + 而是**三种情况下语义对不对**: + ① 算出来了 + 表里也有 → 只更新内容,**不碰 resolved** + (用户在界面上点过"已处理",下次跑校验不能把它改回未解决 —— + 那等于系统推翻用户的判断,而这会让人再也不敢点那个按钮); + ② 算出来了 + 表里没有 → 插入; + ③ 表里有 + 这次没算出来 → **标成已解决**("上轮 47 个 → 这轮 5 个"的来源)。 + · **§12.7 通知模板**:库里只存"被改过的",没改过的走代码里的内置文案。 + 所以"恢复默认"就是删掉库里那一行 —— 不需要另存一份原文。 + +## 还要验的两件契约要求 + + · **越界变量**:契约写明"越界变量渲染为空**并告警**"。实现上选择**原样保留** + 加 WARN —— 理由是模板由非技术人员编辑,把 `{名字}` 渲染成空之后 + 用户只会以为"系统没数据",而保留原样他一眼就能看到自己写错了。 + · **微信回调**:唯一一个匿名**写**入口,必须①验签②只写推送状态不读业务数据 + ③凭记录 id 反查租户(不能猜)。 + +跑法:python scripts/e2e-m7-validation.py +""" +import argparse +import importlib.util +import json +import os +import subprocess +import sys + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED = [] +FAILED = [] +CREATED_TASK_IDS = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-val-roster.xlsx") + + +def _load(name, fn): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") +S = _load("e2e_m3_submit", "e2e-m3-submit.py") +A = _load("e2e_m5_audit", "e2e-m5-audit.py") +BASE = "http://localhost:8080" + +STU = 994101 + + +def ok(name): + PASSED.append(name) + print(" [OK] " + name) + + +def fail(name, detail): + FAILED.append((name, detail)) + print(" [FAIL] " + name + "\n " + detail) + + +def check(name, cond, detail=""): + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return bool(cond) + + +def sql(stmt): + _, out = R.sql(stmt) + return out + + +def body_of(method, path, token=None, payload=None): + _, b = R.call(method, BASE + path, token, payload) + return b + + +def mp(uid): + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def main(): + ap = argparse.ArgumentParser(description="校验问题清单 E2E") + ap.parse_args() + + print("=" * 74) + print(" 校验问题清单 + 通知模板 E2E(§12.1~12.3 / §12.7)") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(" x 管理员登录失败:" + str(adm)) + return 2 + at = adm["data"]["token"] + + print("\n[0] 摆夹具(一个任务 + 一份有问题的填报单)") + suffix = os.urandom(3).hex() + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E校验 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(" x 建任务失败:" + str(b)) + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at, + {"orgs": [{"orgId": org["id"], "expectedCount": 1}]}) + R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + _build_roster([("校验甲", R.with_checksum("36102920080505081"))]) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180]) + R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True}) + + tok = mp(STU) + _, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": "校验甲", + "idCardNo": R.with_checksum("36102920080505081")}) + check("夹具:学生门禁绑定", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:180]) + _, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + check("夹具:学生建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + + # ── 1. §12.1 第一次跑校验 ── + print("\n[1] §12.1 触发全量校验(草稿状态:必然有问题)") + run1 = body_of("POST", "/api/v1/validation/run", at, + {"taskId": task_id, "scope": "ALL"}) + check("校验接口可用", run1.get("code") == 0, json.dumps(run1, ensure_ascii=False)[:250]) + v1 = run1.get("data") or {} + check("返回 jobId 与 estimatedSeconds(契约要求的两个字段)", + bool(v1.get("jobId")) and v1.get("estimatedSeconds") is not None, + json.dumps(v1, ensure_ascii=False)) + check("⭐ 校验了 1 份填报单", v1.get("checkedSubmissions") == 1, + json.dumps(v1, ensure_ascii=False)) + check("⭐ 发现了问题(草稿必然缺材料)", (v1.get("issuesFound") or 0) > 0, + json.dumps(v1, ensure_ascii=False)) + check("⭐ 返回剩余未解决数(用户最想立刻知道的那个数)", + (v1.get("unresolvedLeft") or 0) > 0, json.dumps(v1, ensure_ascii=False)) + check("耗时被记下来了(一期是同步跑完的,见 F-51)", + (v1.get("elapsedMs") or 0) >= 0, str(v1.get("elapsedMs"))) + n_first = sql("select count(*) from collect_validation_issue " + "where task_id=%d and deleted=0;" % task_id).strip() + check("问题落库了", n_first != "0", n_first) + + # ── 2. §12.2 清单 ── + print("\n[2] §12.2 问题清单") + lst = body_of("GET", "/api/v1/validation/issues?taskId=%d&pageSize=50" % task_id, at) + check("清单可用", lst.get("code") == 0, json.dumps(lst, ensure_ascii=False)[:200]) + data = lst.get("data") or {} + check("带 total", (data.get("total") or 0) > 0, json.dumps(data, ensure_ascii=False)[:150]) + c = data.get("counts") or {} + check("⭐ 带 counts(各级别数量)", + all(k in c for k in ("severity1", "severity2", "severity3")), + json.dumps(c, ensure_ascii=False)) + check("⭐ counts 之和 = total", + (c.get("severity1", 0) + c.get("severity2", 0) + c.get("severity3", 0)) + == (data.get("total") or 0), + "counts=%s total=%s" % (json.dumps(c), data.get("total"))) + br = data.get("byRule") or [] + check("⭐ 带 byRule(按规则分组)", len(br) >= 1, json.dumps(br, ensure_ascii=False)[:200]) + check("⭐ byRule 每条都有 count 与 autoFixed", + all("count" in x and "autoFixed" in x for x in br), + json.dumps(br, ensure_ascii=False)[:200]) + rows = data.get("list") or [] + check("清单行带学生姓名(不带给前端就 N+1)", + all(x.get("realName") == "校验甲" for x in rows if x.get("submissionId") == sid), + json.dumps(rows[:1], ensure_ascii=False)[:250]) + check("清单行带班级名", any(x.get("orgName") for x in rows), + json.dumps(rows[:1], ensure_ascii=False)[:200]) + sevs = [x.get("severity") for x in rows] + check("⭐ 严重的问题排在前面(第一屏必须是「最该先看的」)", + sevs == sorted(sevs, reverse=True), str(sevs[:8])) + check("按 severity 过滤生效", + all(x.get("severity") == 3 for x in (body_of( + "GET", "/api/v1/validation/issues?taskId=%d&severity=3" % task_id, at + ).get("data") or {}).get("list") or []), + "见上面清单") + + # ── 3. 重跑的三种语义(这是最容易做错的地方)── + print("\n[3] 重跑语义:①已有→只更新 ②新增→插入 ③消失→标已解决") + first_ids = set(x["id"] for x in rows) + # 人工把其中一条标成已解决,模拟"用户在界面上点了已处理" + one_id = sorted(first_ids)[0] + R.sql("update collect_validation_issue set resolved=1, resolved_by=2, " + "resolved_time=now() where id=%d;" % one_id) + run2 = body_of("POST", "/api/v1/validation/run", at, {"taskId": task_id, "scope": "ALL"}) + check("重跑成功", run2.get("code") == 0, json.dumps(run2, ensure_ascii=False)[:200]) + check("⭐ 重跑不会把「已处理」改回未解决(改了等于推翻用户的判断)", + sql("select resolved from collect_validation_issue where id=%d;" % one_id).strip() == "1", + sql("select id, resolved, resolved_by from collect_validation_issue where id=%d;" + % one_id)) + check("⭐ 重跑不会重复插入(同一问题只有一行)", + sql("select count(*) from collect_validation_issue where task_id=%d and deleted=0;" + % task_id).strip() == n_first, + "第一次 %s,第二次 %s" % (n_first, sql( + "select count(*) from collect_validation_issue where task_id=%d " + "and deleted=0;" % task_id).strip())) + + # 把学生补齐并提交 → 缺材料的问题应该"消失"并被标成已解决 + res = A.prepare_student(tok, sid, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01" + }, dup_first_file=False) + check("学生补齐并提交", ((res.get("data") or {}).get("submitted")) is True, + json.dumps(res, ensure_ascii=False)[:220]) + run3 = body_of("POST", "/api/v1/validation/run", at, {"taskId": task_id, "scope": "ALL"}) + v3 = run3.get("data") or {} + check("第三次跑成功", run3.get("code") == 0, json.dumps(run3, ensure_ascii=False)[:200]) + # ⚠️ 断言要精确到"缺材料那一条":夹具把同一张小图用在了两项材料上, + # 会触发「有内容完全相同的材料」—— 那是**真实且正确**的校验结果, + # 所以笼统地看"总数下降"会被它挡住 + miss_left = sql("select count(*) from collect_validation_issue where task_id=%d " + "and resolved=0 and title like '%%缺少必传材料%%';" % task_id).strip() + check("⭐ 补齐材料之后,「缺少必传材料」那一条未解决数归零", + miss_left == "0", "还剩 " + miss_left) + auto_resolved = sql("select count(*) from collect_validation_issue " + "where task_id=%d and resolved=1 and resolved_by is null;" % task_id).strip() + check("⭐ 「这次没再算出来」的被标成已解决,且 resolved_by 为空(= 系统判定)", + auto_resolved != "0", auto_resolved) + check("而人工点过的那条仍记着处理人", + sql("select resolved_by from collect_validation_issue where id=%d;" % one_id).strip() + == "2", + sql("select resolved_by from collect_validation_issue where id=%d;" % one_id)) + + # ── 4. 自动修复的留痕(byRule.autoFixed 要有真实数据)── + print("\n[4] byRule.autoFixed:系统替你改过什么,要有据可查") + fixed = sql("select count(*) from collect_validation_issue where task_id=%d " + "and auto_fixed=1;" % task_id).strip() + check("⭐ 上传时改了文件名的,被记成 VAL-06 自动修复", fixed != "0", + "auto_fixed=1 的行数:" + fixed + ";样本:" + sql( + "select rule_code, title, expected_val, actual_val from collect_validation_issue " + "where task_id=%d and auto_fixed=1 limit 3;" % task_id).replace("\n", " | ")) + br2 = (body_of("GET", "/api/v1/validation/issues?taskId=%d" % task_id, at) + .get("data") or {}).get("byRule") or [] + val06 = [x for x in br2 if x.get("ruleCode") == "VAL-06"] + check("⭐ byRule 里 VAL-06 的 autoFixed 不为 0(契约 §12.2 示例里那个数)", + bool(val06) and (val06[0].get("autoFixed") or 0) > 0, + json.dumps(br2, ensure_ascii=False)[:300]) + + # ── 5. scope=ISSUES_ONLY 与 APPOINTED ── + print("\n[5] 范围:ISSUES_ONLY / APPOINTED") + only = body_of("POST", "/api/v1/validation/run", at, + {"taskId": task_id, "scope": "ISSUES_ONLY"}) + check("ISSUES_ONLY 可用", only.get("code") == 0, json.dumps(only, ensure_ascii=False)[:200]) + ap_res = body_of("POST", "/api/v1/validation/run", at, + {"taskId": task_id, "scope": "APPOINTED"}) + check("⭐ APPOINTED 不传 submissionIds → 明确报错(不是静默跑全部)", + ap_res.get("code") != 0 and "submissionIds" in (ap_res.get("msg") or ""), + "code=%s msg=%s" % (ap_res.get("code"), ap_res.get("msg"))) + ap2 = body_of("POST", "/api/v1/validation/run", at, + {"taskId": task_id, "scope": "APPOINTED", "submissionIds": [sid]}) + check("APPOINTED + 指定 id 只校验那一份", + (ap2.get("data") or {}).get("checkedSubmissions") == 1, + json.dumps(ap2.get("data"), ensure_ascii=False)) + rules = body_of("POST", "/api/v1/validation/run", at, + {"taskId": task_id, "rules": ["VAL-05"]}) + check("只跑指定规则可用", rules.get("code") == 0, json.dumps(rules, ensure_ascii=False)[:200]) + + # ── 6. §12.3 导出 Excel ── + print("\n[6] §12.3 导出异常清单 Excel") + xp = os.path.join(ROOT, "scripts", "_e2e-val-issues.xlsx") + code = os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" ' + '"%s/api/v1/validation/issues/export?taskId=%d"' + % (xp, at, BASE, task_id)) + size = os.path.getsize(xp) if os.path.exists(xp) else 0 + check("Excel 能下载", size > 1000, "exit=%s size=%s" % (code, size)) + if size > 1000: + from openpyxl import load_workbook + wb = load_workbook(xp) + ws = wb.active + header = [c.value for c in ws[1]] + check("⭐ 表头与契约 §12.3 的十列一致", + header == ["姓名", "班级", "材料", "规则码", "级别", "问题", + "期望值", "实际值", "是否自动修复", "处理人"], + str(header)) + check("有数据行", ws.max_row > 1, "max_row=%s" % ws.max_row) + lv = set() + for r in range(2, min(ws.max_row, 30) + 1): + lv.add(ws.cell(row=r, column=5).value) + check("⭐ 级别写「错误/警告/提示」而不是 1/2/3(这份是给客户看的)", + lv <= {"错误", "警告", "提示"}, str(lv)) + af = set() + for r in range(2, min(ws.max_row, 30) + 1): + af.add(ws.cell(row=r, column=9).value) + check("⭐ 「是否自动修复」写是/否", af <= {"是", "否"}, str(af)) + os.remove(xp) + + # ── 7. §12.7 通知模板 ── + print("\n[7] §12.7 通知模板:内置 + 覆盖 + 恢复默认") + tpls = body_of("GET", "/api/v1/notify/templates", at).get("data") or [] + check("模板列表可用", len(tpls) >= 7, "共 %d 个" % len(tpls)) + check("⭐ 没改过的行没有 id(= 内置),界面上据此区分「默认/已自定义」", + all(x.get("id") is None and x.get("customized") is False + for x in tpls if not x.get("customized")), + json.dumps([(x.get("templateCode"), x.get("id")) for x in tpls], ensure_ascii=False)) + check("内置行带 remark 说明(界面要显示「这条是干什么的」)", + all(x.get("remark") for x in tpls), json.dumps(tpls[:1], ensure_ascii=False)[:200]) + check("⭐ 越界变量被标出来(unknownVars)", + all(isinstance(x.get("unknownVars"), list) for x in tpls), + json.dumps(tpls[:1], ensure_ascii=False)[:250]) + + vars_ = body_of("GET", "/api/v1/notify/templates/variables", at).get("data") or {} + check("⭐ 变量白名单由服务端给(前端抄一份迟早不一致)", + "姓名" in vars_ and "驳回原因" in vars_ and "下载链接" in vars_, + json.dumps(list(vars_.keys()), ensure_ascii=False)) + + # 直接改内置行 → 必须失败(它没有 id) + bad = body_of("PUT", "/api/v1/notify/templates/999999", at, {"title": "x"}) + check("改不存在的模板 → 明确报错(不是 500)", bad.get("code") != 0, + "code=%s msg=%s" % (bad.get("code"), bad.get("msg"))) + + cz = body_of("POST", "/api/v1/notify/templates/URGE/customize", at) + check("⭐ 自定义内置模板可用(契约漏了这一步,见 F-52)", + cz.get("code") == 0 and (cz.get("data") or {}).get("id"), + json.dumps(cz, ensure_ascii=False)[:250]) + tpl_id = (cz.get("data") or {}).get("id") + cz2 = body_of("POST", "/api/v1/notify/templates/URGE/customize", at) + check("自定义是幂等的(重复点不造出第二行)", + (cz2.get("data") or {}).get("id") == tpl_id, + json.dumps(cz2, ensure_ascii=False)[:200]) + + upd = body_of("PUT", "/api/v1/notify/templates/%d" % tpl_id, at, + {"title": "自定义催报标题", "content": "{任务名}:还差 {提交进度},{名字} 请尽快", + "enabled": True}) + check("改模板成功", upd.get("code") == 0, json.dumps(upd, ensure_ascii=False)[:250]) + check("⭐ 越界变量在保存时就被标出来({名字} 不在白名单)", + "名字" in ((upd.get("data") or {}).get("unknownVars") or []), + json.dumps(upd.get("data"), ensure_ascii=False)[:250]) + check("库里记下了自定义内容", + "自定义催报标题" in sql("select title from collect_notify_template where id=%d;" + % tpl_id), + sql("select title, content from collect_notify_template where id=%d;" % tpl_id)) + lst2 = body_of("GET", "/api/v1/notify/templates", at).get("data") or [] + urge = [x for x in lst2 if x.get("templateCode") == "URGE"][0] + check("列表里这一行变成 customized=true", urge.get("customized") is True, + json.dumps(urge, ensure_ascii=False)[:200]) + + rst = body_of("DELETE", "/api/v1/notify/templates/URGE", at) + check("恢复默认可用", rst.get("code") == 0, json.dumps(rst, ensure_ascii=False)[:200]) + # ⚠️ 必须是**物理**删除:软删除(deleted=1)时那一行还在表里, + # 而唯一索引不看 deleted → "恢复默认 → 再自定义"会撞唯一约束 + check("⭐ 恢复默认是物理删除覆盖行(软删会让再次自定义撞唯一索引)", + sql("select count(*) from collect_notify_template where template_code='URGE';" + ).strip() == "0", + sql("select id, deleted from collect_notify_template where template_code='URGE';")) + cz_again = body_of("POST", "/api/v1/notify/templates/URGE/customize", at) + check("⭐ 恢复默认之后还能再次自定义(这是软删除会踩的那个坑)", + cz_again.get("code") == 0 and (cz_again.get("data") or {}).get("id"), + json.dumps(cz_again, ensure_ascii=False)[:250]) + body_of("DELETE", "/api/v1/notify/templates/URGE", at) + lst3 = body_of("GET", "/api/v1/notify/templates", at).get("data") or [] + urge3 = [x for x in lst3 if x.get("templateCode") == "URGE"][0] + check("⭐ 恢复之后文案回到内置的那一版", + urge3.get("customized") is False and "提交进度" in (urge3.get("content") or ""), + json.dumps(urge3, ensure_ascii=False)[:250]) + + # ── 8. §12.7 手工发送 ── + print("\n[8] §12.7 手工发送") + before = sql("select count(*) from collect_message where user_id=%d;" % STU).strip() + snd = body_of("POST", "/api/v1/notify/send", at, { + "bizType": 5, "templateCode": "URGE", "taskId": task_id, "receiverIds": [STU] + }) + check("手工发送成功", snd.get("code") == 0, json.dumps(snd, ensure_ascii=False)[:250]) + sv = snd.get("data") or {} + check("⭐ sent 的口径是「站内信写入成功」", sv.get("sent") == 1, + json.dumps(sv, ensure_ascii=False)) + check("微信推送单独报(没配/没额度时是 0,但 sent 仍是 1)", + sv.get("pushSent") is not None, json.dumps(sv, ensure_ascii=False)) + after = sql("select count(*) from collect_message where user_id=%d;" % STU).strip() + check("⭐ 站内信真的写进去了(不能只写通知记录)", + int(after) == int(before) + 1, "前 %s → 后 %s" % (before, after)) + check("⭐ 消息内容里的变量被渲染了({任务名} 不再是字面量)", + "{" not in sql("select content from collect_message where user_id=%d " + "order by id desc limit 1;" % STU), + sql("select title, content from collect_message where user_id=%d " + "order by id desc limit 1;" % STU)) + check("通知记录也写了(与消息中心是两条不同的记录,契约 §13.5 的说明)", + sql("select count(*) from collect_notify_record where receiver_id=%d " + "and biz_type='5';" % STU).strip() != "0", + sql("select channel, biz_type, status from collect_notify_record " + "where receiver_id=%d order by id desc limit 2;" % STU)) + + # 按名册行发 + 没绑账号的人进 skipped + R.sql("update collect_roster set bind_user_id=null where task_id=%d;" % task_id) + snd2 = body_of("POST", "/api/v1/notify/send", at, { + "bizType": 5, "title": "标题", "content": "内容", "taskId": task_id, "all": True + }) + check("⭐ 整任务发送:没绑账号的人进 skipped 而不是静默漏掉", + (snd2.get("data") or {}).get("skipped") == 1, + json.dumps(snd2, ensure_ascii=False)[:250]) + snd3 = body_of("POST", "/api/v1/notify/send", at, {"bizType": 5}) + check("⭐ 没有收件人 → 明确报错(不是静默发 0 条还回成功)", + snd3.get("code") != 0 and "收件人" in (snd3.get("msg") or ""), + "code=%s msg=%s" % (snd3.get("code"), snd3.get("msg"))) + snd4 = body_of("POST", "/api/v1/notify/send", at, {"bizType": 99, "receiverIds": [STU]}) + check("bizType 越界 → 明确报错", snd4.get("code") != 0, + "code=%s msg=%s" % (snd4.get("code"), snd4.get("msg"))) + + # ── 9. 微信回调 ── + print("\n[9] 微信回调(唯一的匿名**写**入口)") + cb = post_raw("/api/v1/callback/wechat/msg", json.dumps( + {"recordId": 1, "Status": 1}, ensure_ascii=False)) + check("⭐ 未配置微信时明确拒绝(不安静地记一条来源不明的结果)", + cb.get("errcode") != 0, json.dumps(cb, ensure_ascii=False)[:200]) + cb2 = post_raw("/api/v1/callback/wechat/msg", "") + check("空报文被拒", cb2.get("errcode") != 0, json.dumps(cb2, ensure_ascii=False)[:200]) + check("⭐ 响应体是微信格式(errcode/errmsg),不是本项目的 code/msg/data", + "errcode" in cb and "code" not in cb, json.dumps(cb, ensure_ascii=False)[:120]) + + # ── 10. 权限 ── + print("\n[10] 权限") + stok2 = mp(994199) + b = body_of("GET", "/api/v1/validation/issues?taskId=%d" % task_id, stok2) + check("学生看问题清单 → 403", b.get("code") in (40101, 40301, 403), + "code=%s" % b.get("code")) + b = body_of("GET", "/api/v1/notify/templates", stok2) + check("学生看通知模板 → 403", b.get("code") in (40101, 40301, 403), + "code=%s" % b.get("code")) + b = body_of("POST", "/api/v1/validation/run", None, {"taskId": task_id}) + check("未登录跑校验 → 40101", b.get("code") == 40101, "code=%s" % b.get("code")) + + cleanup() + print("\n" + "=" * 74) + print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) + print("=" * 74) + for n, det in FAILED: + print(" x " + n + "\n " + det) + return 1 if FAILED else 0 + + +def post_raw(path, body): + """发一个**不带登录态**的原始请求(微信回调是匿名入口)。""" + import urllib.request + req = urllib.request.Request(BASE + path, data=(body or "").encode("utf-8"), + method="POST") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"errcode": -1, "errmsg": str(e)[:200]} + + +def _build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "校验班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "1380013550%d" % i, "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup(): + for tid in CREATED_TASK_IDS: + R.sql("delete from collect_validation_issue where task_id=%d;" % tid) + R.sql("delete from collect_notify_record where task_id=%d;" % tid) + R.sql("delete from collect_file where task_id=%d;" % tid) + R.sql("delete from collect_submission where task_id=%d;" % tid) + R.sql("delete from collect_roster where task_id=%d;" % tid) + R.sql("delete from collect_task_org where task_id=%d;" % tid) + R.sql("delete from collect_material_def where task_id=%d;" % tid) + R.sql("delete from collect_task where id=%d;" % tid) + R.sql("delete from collect_notify_template where template_code='URGE';") + R.sql("delete from collect_message where user_id in (994101, 994199);") + R.sql("delete from collect_notify_record where receiver_id in (994101, 994199);") + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务/问题清单/消息/通知记录/模板覆盖/临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m8-open.py b/scripts/e2e-m8-open.py new file mode 100644 index 0000000..3b5ab80 --- /dev/null +++ b/scripts/e2e-m8-open.py @@ -0,0 +1,574 @@ +# -*- coding: utf-8 -*- +"""开放接口 E2E(《03-接口设计》§14)—— 供认定机构对接。 + +## 验收重点:鉴权是这一批接口的**全部价值所在** + +开放接口把数据交给**外部系统**,所以每一个"能拿到数据"的前提都必须被证明是有门槛的: + + ① **签名**:`HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)`, + 且 body 是**原始 JSON**(字段顺序一变签名就不一样); + ② **时间窗**:偏差 > 5 分钟拒绝(挡"把昨天抓到的包今天重放"); + ③ **nonce 防重放**:5 分钟内同一个 nonce 只能用一次 + (且必须是**原子占位** —— 先验签再占位的话,一个错误签名就能把 nonce 消耗掉); + ④ **不泄露明文证件号**(契约 §14.2 明确要求)—— + 这条要**直接查响应里有没有 18 位数字串**,而不是看代码"应该是脱敏的"; + ⑤ **scope 校验**:只有 `submission:read` 的接入方拿不到文件链接; + ⑥ **归属校验**:拿 A 任务的路径去拉 B 任务的填报单 → 403 + (不校验的话,任何一家机构能拉走全部租户的数据)。 + +## 还有一件"契约没写但必须有"的事 + +§14 定义了"机构怎么调我们",却没定义 **appKey / appSecret 从哪来** —— +没有管理接口,§14 的四个接口在真实流程里**跑不起来**(只能靠改库, +而 appSecret 是密文列)。所以本脚本先建接入方,再验四个接口(见 F-67)。 + +跑法:python scripts/e2e-m8-open.py +""" +import argparse +import hashlib +import hmac +import importlib.util +import json +import os +import sys +import time +import urllib.error +import urllib.request +import uuid + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED = [] +FAILED = [] +CREATED_TASK_IDS = [] +CREATED_APP_IDS = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-open-roster.xlsx") + + +def _load(name, fn): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") +A = _load("e2e-m5-audit", "e2e-m5-audit.py") +BASE = "http://localhost:8080" + +STU = 996201 +TEACHER = 996202 +APP_KEY = "e2e_jxjd_open" +APP_KEY_NOFILE = "e2e_only_submission" + + +def ok(name): + PASSED.append(name) + print(" [OK] " + name) + + +def fail(name, detail): + FAILED.append((name, detail)) + print(" [FAIL] " + name + "\n " + detail) + + +def check(name, cond, detail=""): + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return bool(cond) + + +def sql(stmt): + _, out = R.sql(stmt) + return out + + +def body_of(method, path, token=None, payload=None): + _, b = R.call(method, BASE + path, token, payload) + return b + + +def mp(uid): + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def open_call(method, path, secret=None, app_key=None, body=None, headers=None, + token=None, skew_seconds=0, nonce=None, sign_override=None): + """按契约发一个开放接口请求。 + + body 传**字符串**(原文)—— 签名覆盖的就是它,所以这里刻意不先序列化。 + """ + url = BASE + path + raw = body if isinstance(body, str) else (json.dumps(body, ensure_ascii=False) + if body is not None else "") + data = raw.encode("utf-8") if raw else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Content-Type", "application/json") + hdrs = dict(headers or {}) + if token: + hdrs["Authorization"] = "Bearer " + token + else: + ts = str(int(time.time()) + skew_seconds) + nc = nonce or uuid.uuid4().hex[:8] + hdrs.setdefault("X-App-Key", app_key or APP_KEY) + hdrs.setdefault("X-Timestamp", ts) + hdrs.setdefault("X-Nonce", nc) + if secret is not None: + want = sign_override if sign_override is not None else hmac_sign( + secret, (app_key or APP_KEY) + ts + nc + raw) + hdrs.setdefault("X-Sign", want) + for k, v in hdrs.items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + txt = resp.read().decode("utf-8") + try: + return resp.status, json.loads(txt) + except Exception: + return resp.status, {"_raw": txt[:200]} + except urllib.error.HTTPError as e: + txt = e.read().decode("utf-8", errors="replace") + try: + return e.code, json.loads(txt) + except Exception: + return e.code, {"_raw": txt[:200]} + except Exception as e: + return -1, {"_error": str(e)[:200]} + + +def hmac_sign(secret, data): + return hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).hexdigest() + + +def contains_plain_idcard(obj, ids): + """递归找响应里有没有**明文证件号**(脱敏值里有 *,所以要找完整的 18 位)。""" + s = json.dumps(obj, ensure_ascii=False) + for i in ids: + if i in s: + return i + return None + + +def main(): + ap = argparse.ArgumentParser(description="开放接口 E2E") + ap.parse_args() + + print("=" * 74) + print(" 开放接口 E2E(§14)—— 供认定机构对接") + print("=" * 74) + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(" x 管理员登录失败:" + str(adm)) + return 2 + at = adm["data"]["token"] + + print("\n[0] 摆夹具(任务 + 名册 + 一份已终审通过的单子)") + suffix = os.urandom(3).hex() + idcard = R.with_checksum("36102920080707081") + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E开放 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + if b.get("code") != 0: + print(" x 建任务失败:" + str(b)) + return 2 + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + + _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + teacher_tok = ensure_teacher(TEACHER, "openT" + suffix, "开放班主任", org["id"]) + R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at, + {"orgs": [{"orgId": org["id"], "expectedCount": 1, "headUserId": TEACHER}]}) + R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + _build_roster([("开放甲", idcard)]) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180]) + R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True}) + + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % STU) + tok = mp(STU) + _, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": "开放甲", "idCardNo": idcard}) + if b.get("code") != 0: + print(" x 门禁核验失败:" + json.dumps(b, ensure_ascii=False)[:200]) + return 2 + _, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + res = A.prepare_student(tok, sid, task_id, { + "work_unit": "无", "is_retake": False, "is_student": True, + "school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01", + "occupation": "车工", "worktype": "数控车工", "job_level": "四级" + }, dup_first_file=False) + check("夹具:学生已提交", ((res.get("data") or {}).get("submitted")) is True, + json.dumps(res, ensure_ascii=False)[:200]) + # ⚠️ **审核放在"建接入方"之后**:推送只在"终审通过那一刻已存在的接入方"上触发, + # 而真实流程也是"机构先接入、之后审核通过才推送"。 + # 我第一版把审核放在最前面,于是推送记录永远是 0 条 —— + # 而那个现象看起来像"推送功能没做"。 + + # ── 1. 建接入方(契约没写,但没有它 §14 跑不起来,见 F-67)── + print("\n[1] 接入方管理(契约漏了这一层,见 F-67)") + made = body_of("POST", "/api/v1/open-apps", at, { + "appName": "江西机电认定中心 " + suffix, "appKey": APP_KEY, + "callbackUrl": "http://127.0.0.1:9/callback-not-listening", + "pushSecret": "push-secret-" + suffix + }) + check("能新建接入方", made.get("code") == 0, json.dumps(made, ensure_ascii=False)[:250]) + app = made.get("data") or {} + if app.get("id"): + CREATED_APP_IDS.append(app["id"]) + secret = app.get("appSecret") + check("⭐ 返回 appSecret(**只这一次**)", isinstance(secret, str) and len(secret) >= 20, + "长度=%s" % (len(secret) if secret else None)) + check("列表**不回** appSecret", all( + x.get("appSecret") is None + for x in (body_of("GET", "/api/v1/open-apps", at).get("data") or [])), + "见列表响应") + check("⭐ 库里存的是密文(HMAC 要能还原,所以不能只存哈希)", + sql("select app_secret_cipher from collect_open_app where app_key='%s';" % APP_KEY) + .strip() != secret, + "密文前 20 位=" + sql("select app_secret_cipher from collect_open_app " + "where app_key='%s';" % APP_KEY).strip()[:20]) + dup = body_of("POST", "/api/v1/open-apps", at, {"appName": "重复", "appKey": APP_KEY}) + check("appKey 重复 → 明确报错", dup.get("code") != 0, + "code=%s msg=%s" % (dup.get("code"), dup.get("msg"))) + + # 第二个接入方:只有 submission:read(用来验 scope) + made2 = body_of("POST", "/api/v1/open-apps", at, { + "appName": "只有填报单权限 " + suffix, "appKey": APP_KEY_NOFILE, + "scopes": "submission:read" + }) + app2 = made2.get("data") or {} + if app2.get("id"): + CREATED_APP_IDS.append(app2["id"]) + secret2 = app2.get("appSecret") + check("能建只带 submission:read 的接入方", made2.get("code") == 0, + json.dumps(made2, ensure_ascii=False)[:200]) + + # ── 1b. 让这一份**在接入方已存在之后**通过终审(推送就此触发)── + print("\n[1b] 终审通过(此时接入方已存在,推送应被触发)") + a1 = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sid, teacher_tok, + {"remark": "开放接口夹具:初审", "actionSource": "BUTTON"}) + a2 = body_of("POST", "/api/v1/audit/submissions/%d/approve" % sid, at, + {"finalOpinion": "开放接口夹具:终审", "actionSource": "BUTTON"}) + check("终审通过", a2.get("code") == 0 and (a2.get("data") or {}).get("status") == 3, + json.dumps(a2, ensure_ascii=False)[:200]) + # 推送是 afterCommit 回调 + 外部 HTTP,给它一点时间(回调地址故意不通,会失败并安排重试) + time.sleep(3) + + # ── 2. 签名校验 ── + print("\n[2] 签名校验(这一批接口的价值全在这里)") + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret) + check("正确签名 → 通过", r.get("code") == 0, + "http=%s %s" % (st, json.dumps(r, ensure_ascii=False)[:200])) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, + secret="wrong-secret") + check("⭐ 密钥不对 → 40101", r.get("code") == 40101, + "http=%s code=%s msg=%s" % (st, r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, + app_key="no-such-app", secret=secret) + check("⭐ 不存在的 appKey → 40101(且不透露它是否存在)", + r.get("code") == 40101, "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret, + skew_seconds=-400) + check("⭐ 时间戳偏差 > 5 分钟 → 拒绝(挡「昨天抓的包今天重放」)", + r.get("code") == 40101 and "分钟" in (r.get("msg") or ""), + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret, + skew_seconds=400) + check("时间戳快 6 分钟也拒(比的是**偏差绝对值**,不是「不能太旧」)", + r.get("code") == 40101, "code=%s" % r.get("code")) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret, + headers={"X-Sign": "0" * 64}) + check("签名格式对但值不对 → 拒绝", r.get("code") == 40101, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=None, + headers={"X-Timestamp": "", "X-Nonce": "", "X-Sign": ""}) + check("缺头 → 能照着做的错误消息(提示两种鉴权方式)", + r.get("code") in (40101, 40301), "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + + # nonce 防重放 + fixed_nonce = uuid.uuid4().hex[:8] + st, r1 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret, + nonce=fixed_nonce) + check("第一次用这个 nonce → 通过", r1.get("code") == 0, json.dumps(r1, ensure_ascii=False)[:150]) + st, r2 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret, + nonce=fixed_nonce) + check("⭐ 同一个 nonce 第二次 → 拒绝(防重放)", + r2.get("code") == 40101 and "nonce" in (r2.get("msg") or ""), + "code=%s msg=%s" % (r2.get("code"), r2.get("msg"))) + # 错误签名**不该**消耗 nonce(否则能被用来拒绝服务) + bad_nonce = uuid.uuid4().hex[:8] + open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret="wrong", + nonce=bad_nonce) + st, r3 = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret, + nonce=bad_nonce) + check("⭐ 错误签名不该消耗 nonce(否则能被用来拒绝服务)", r3.get("code") == 0, + "code=%s msg=%s" % (r3.get("code"), r3.get("msg"))) + + # ── 3. §14.1 令牌 ── + print("\n[3] §14.1 换取访问令牌") + tk = open_call("POST", "/api/v1/open/v1/auth/token", body={"appKey": APP_KEY, + "appSecret": secret}) + check("换令牌成功", (tk[1] or {}).get("code") == 0, + json.dumps(tk[1], ensure_ascii=False)[:250]) + tokv = (tk[1] or {}).get("data") or {} + access = tokv.get("accessToken") + check("返回 accessToken", isinstance(access, str) and len(access) > 20, str(access)[:20]) + check("⭐ expireSeconds = 7200(契约 §14.1)", tokv.get("expireSeconds") == 7200, + str(tokv.get("expireSeconds"))) + check("返回 scope 列表", isinstance(tokv.get("scope"), list) and len(tokv.get("scope")) >= 1, + json.dumps(tokv.get("scope"), ensure_ascii=False)) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access) + check("⭐ 令牌能直接调数据接口(不用再算 HMAC)", r.get("code") == 0, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, + token="not-a-real-token") + check("假令牌 → 40101", r.get("code") == 40101, "code=%s" % r.get("code")) + st, r = open_call("POST", "/api/v1/open/v1/auth/token", + body={"appKey": APP_KEY, "appSecret": "wrong"}) + check("appSecret 不对 → 40101(不是把令牌发出去)", r.get("code") == 40101, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + + # ── 4. §14.2 填报单 ── + print("\n[4] §14.2 拉取填报单") + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret) + data = r.get("data") or {} + check("拉到数据", (data.get("total") or 0) >= 1, json.dumps(data, ensure_ascii=False)[:250]) + row = (data.get("list") or [{}])[0] + check("带姓名与脱敏证件号", row.get("realName") == "开放甲" and row.get("idCardMask"), + json.dumps(row, ensure_ascii=False)[:250]) + check("⭐ **响应里没有明文证件号**(契约 §14.2 的硬要求)", + contains_plain_idcard(r, [idcard]) is None, + "找到了明文:" + str(contains_plain_idcard(r, [idcard]))) + check("默认只返回已通过的(status=3)", + all(x.get("status") == 3 for x in data.get("list") or []), + json.dumps([x.get("status") for x in data.get("list") or []], ensure_ascii=False)) + check("带职业/工种/等级", row.get("occupation") == "车工", + json.dumps(row, ensure_ascii=False)[:200]) + check("带 taskCode / taskName", bool(data.get("taskCode")) and bool(data.get("taskName")), + "%s / %s" % (data.get("taskCode"), data.get("taskName"))) + # since 增量 + future = "2099-01-01T00:00:00" + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=%s" % (task_id, future), + secret=secret) + check("⭐ since 之后的(未来时间)→ 0 条(增量拉取生效)", + ((r.get("data") or {}).get("total") or 0) == 0, + json.dumps(r.get("data"), ensure_ascii=False)[:200]) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=%d" % (task_id, 0), + secret=secret) + check("毫秒时间戳形式的 since 也认(调用方语言不同,两种都给)", + (r.get("code") == 0 and ((r.get("data") or {}).get("total") or 0) >= 1), + json.dumps(r.get("data"), ensure_ascii=False)[:200]) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions?since=不是时间" % task_id, + secret=secret) + check("⭐ since 格式不对 → 明确报错(不静默当成「不筛」,那会把全量推给对方)", + r.get("code") != 0, "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/999999999/submissions", secret=secret) + check("不存在的任务 → 明确报错", r.get("code") != 0, "code=%s" % r.get("code")) + + # ── 5. §14.3 文件 ── + print("\n[5] §14.3 拉取材料文件") + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%d" % (task_id, sid), + secret=secret) + fdata = r.get("data") or {} + check("拉到文件", len(fdata.get("files") or []) >= 1, + json.dumps(fdata, ensure_ascii=False)[:250]) + f0 = (fdata.get("files") or [{}])[0] + check("带 materialCode / standardName / url / expireSeconds", + f0.get("materialCode") and f0.get("standardName") and f0.get("url") + and f0.get("expireSeconds") == 1800, + json.dumps(f0, ensure_ascii=False)[:300]) + check("⭐ 文件里也没有明文证件号", contains_plain_idcard(r, [idcard]) is None, + "找到了明文") + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files" % task_id, secret=secret) + check("不传 submissionIds → 明确报错", r.get("code") != 0, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + ids201 = ",".join(str(i) for i in range(1, 202)) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%s" % (task_id, ids201), + secret=secret) + check("⭐ 超过 200 份 → 40011(契约 §14.3 的上限)", r.get("code") == 40011, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=abc" % task_id, + secret=secret) + check("submissionIds 里不是数字 → 明确报错", r.get("code") != 0, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + # 归属校验:用别的任务的填报单 id 去拉 + st, r = open_call("GET", "/api/v1/open/v1/tasks/999999999/files?submissionIds=%d" % sid, + secret=secret) + check("⭐ 用别的任务路径拉这份文件 → 被拦住(不校验就能拉走全部数据)", + r.get("code") != 0, "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + + # ── 6. scope ── + print("\n[6] scope 权限") + # ⚠️ 必须带**它自己的** appKey:不带的话 open_call 会用第一个接入方的 key, + # 于是签名对不上 —— 表现是"签名不通过",很容易被误判成实现有 bug + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret2, + app_key=APP_KEY_NOFILE) + check("只有 submission:read 的接入方能拉填报单", r.get("code") == 0, + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/files?submissionIds=%d" % (task_id, sid), + secret=secret2, app_key=APP_KEY_NOFILE) + check("⭐ 同一个接入方拉文件 → 40301(没有 file:read)", + r.get("code") == 40301 and "file:read" in (r.get("msg") or ""), + "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + + # ── 7. 停用立刻生效 ── + print("\n[7] 停用立刻生效(不用等令牌过期)") + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access) + check("停用前令牌可用", r.get("code") == 0, "code=%s" % r.get("code")) + tg = body_of("POST", "/api/v1/open-apps/%d/toggle" % app["id"], at) + check("停用接入方", tg.get("code") == 0 and (tg.get("data") or {}).get("enabled") is False, + json.dumps(tg, ensure_ascii=False)[:200]) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, token=access) + check("⭐ 停用后**未过期的令牌也立刻失效**(令牌校验每次回查接入方)", + r.get("code") == 40101, "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret) + check("停用后 HMAC 也不通", r.get("code") == 40101, "code=%s" % r.get("code")) + body_of("POST", "/api/v1/open-apps/%d/toggle" % app["id"], at) + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=secret) + check("重新启用后恢复", r.get("code") == 0, "code=%s" % r.get("code")) + + # ── 8. §14.4 推送与重试 ── + print("\n[8] §14.4 推送(含重试与人工重推)") + pl = body_of("GET", "/api/v1/open-apps/push-logs", at).get("data") or [] + check("⭐ 终审通过时**自动**建了推送记录(审批链路真的接上了)", + len(pl) >= 1, "共 %d 条:%s" % (len(pl), json.dumps(pl, ensure_ascii=False)[:250])) + if pl: + mine = [x for x in pl if x.get("submissionId") == sid] + check("有这份填报单的推送记录", len(mine) >= 1, + json.dumps(pl, ensure_ascii=False)[:300]) + if mine: + rec = mine[0] + check("⭐ 回调地址不通 → 记了失败原因与 HTTP 结果(不是安静地什么都不做)", + rec.get("attempts") and rec.get("attempts") >= 1 + and (rec.get("lastError") or rec.get("lastHttpCode")), + json.dumps(rec, ensure_ascii=False)[:300]) + check("⭐ 失败后安排了下次重试(契约 §14.4 的 1/5/30/120/600 秒)", + rec.get("status") == 2 and rec.get("nextRetryAt"), + json.dumps(rec, ensure_ascii=False)[:250]) + rp = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, + body={"submissionId": sid}) + check("⭐ 人工重推可用(契约 §14.4 的「支持人工重推」)", + (rp[1] or {}).get("code") == 0, json.dumps(rp[1], ensure_ascii=False)[:250]) + rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={}) + check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0, + json.dumps(rp2[1], ensure_ascii=False)[:200]) + check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)", + "身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log " + "where submission_id=%d limit 1;" % sid) or "") + or idcard not in (sql("select coalesce(payload_json,'') from collect_open_push_log " + "where submission_id=%d limit 1;" % sid) or ""), + "payload=" + (sql("select coalesce(payload_json,'') from collect_open_push_log " + "where submission_id=%d limit 1;" % sid) or "")[:200]) + + # ── 9. 权限:学生/未登录不能碰管理接口 ── + print("\n[9] 管理接口的权限") + b = body_of("GET", "/api/v1/open-apps", mp(996299)) + check("学生看接入方列表 → 403", b.get("code") in (40101, 40301, 403), + "code=%s" % b.get("code")) + b = body_of("GET", "/api/v1/open-apps") + check("未登录看接入方列表 → 40101", b.get("code") == 40101, "code=%s" % b.get("code")) + # 开放接口本身是匿名的(@PublicApi),但没有凭据必须拒绝 + st, r = open_call("GET", "/api/v1/open/v1/tasks/%d/submissions" % task_id, secret=None, + headers={"X-Timestamp": "", "X-Nonce": "", "X-Sign": ""}) + check("⭐ 开放接口是匿名的,但**没有凭据一律拒绝**", + r.get("code") in (40101, 40301), "code=%s msg=%s" % (r.get("code"), r.get("msg"))) + + cleanup() + print("\n" + "=" * 74) + print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) + print("=" * 74) + for n, det in FAILED: + print(" x " + n + "\n " + det) + return 1 if FAILED else 0 + + +def ensure_teacher(uid, username, name, org_id): + sql(""" + INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status) + VALUES (%d, 1, '%s', '%s', + '$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', %d, 3, 1) + ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, + org_id = EXCLUDED.org_id, + username = EXCLUDED.username, + password = EXCLUDED.password, + user_type = EXCLUDED.user_type, + status = 1 + """ % (uid, username, name, org_id)) + role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' " + "and deleted=0 limit 1;").strip() + sql("delete from sys_user_role where user_id=%d;" % uid) + sql("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (%d, %s, 1);" + % (uid, role_id)) + _, b = R.login(BASE, username, "Admin@123", "demo") + if b.get("code") != 0: + raise SystemExit(" 班主任 %s 登录失败:%s" % (username, b)) + return b["data"]["token"] + + +def _build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "开放班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "1380013770%d" % i, "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def cleanup(): + for tid in CREATED_TASK_IDS: + R.sql("delete from collect_open_push_log where task_id=%d;" % tid) + R.sql("delete from collect_validation_issue where task_id=%d;" % tid) + R.sql("delete from collect_audit_record where task_id=%d;" % tid) + R.sql("delete from collect_notify_record where task_id=%d;" % tid) + R.sql("delete from collect_file where task_id=%d;" % tid) + R.sql("delete from collect_submission where task_id=%d;" % tid) + R.sql("delete from collect_roster where task_id=%d;" % tid) + R.sql("delete from collect_task_org where task_id=%d;" % tid) + R.sql("delete from collect_material_def where task_id=%d;" % tid) + R.sql("delete from collect_task where id=%d;" % tid) + R.sql("delete from collect_open_push_log where app_id in " + "(select id from collect_open_app where app_key like 'e2e_%');") + R.sql("delete from collect_open_app where app_key like 'e2e_%';") + R.sql("delete from collect_message where user_id in (996201, 996299);") + R.sql("delete from collect_notify_record where receiver_id in (996201, 996299);") + R.sql("delete from sys_user_role where user_id = %d;" % TEACHER) + R.sql("delete from sys_user where id = %d;" % TEACHER) + if os.path.exists(TMP_ROSTER): + os.remove(TMP_ROSTER) + print(" 已清理:任务 / 接入方 / 推送记录 / 测试账号 / 临时 Excel") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m8-orgfile.py b/scripts/e2e-m8-orgfile.py new file mode 100644 index 0000000..c08b609 --- /dev/null +++ b/scripts/e2e-m8-orgfile.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- +"""组织导入 / 租户用量 / 文件视图·直传凭证·批量处理 E2E + +(《03-接口设计》§6.4、§6.5、§6.7、§9.5、§9.7、§9.8) + +## 这批接口里三处"看着像细节、其实决定对不对"的地方 + + ① **组织导入的父子关系用「编码 + 上级编码」表达,不靠行的顺序**。 + 客户的表是人整理出来的:顺序会被重排、缩进会被吃掉(Excel 里前导空格尤其容易丢)。 + 所以本脚本特意把**子行排在父行之前** —— 那是客户的表里最常见的形态, + 也是"一遍扫描"实现会全部失败的那种。 + ② **租户用量的 `ocrCount` / `smsCount` 恒为 0 并带 notes**。 + 两条通道都还没接,编一个数字会让运营拿着假数据去谈续费。 + 断言要看的是"**它承认自己没接**",而不是"字段存在"。 + ③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。 + 返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。 + +## 还有一条不能省的边界 + +`GET /mp/files/{fileId}` 按 `submitter_id` 授权(学生端);教师看他班学生的材料 +走 `/mp/teacher/files/{id}/preview`(按班级范围)。本脚本要证明**前者的越权会被拒** —— +不证明的话,"任何登录用户能看任何人的身份证照片"这种事只在真实越权时才被发现。 + +跑法:python scripts/e2e-m8-orgfile.py +""" +import argparse +import importlib.util +import json +import os +import sys +import urllib.error +import urllib.parse + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASSED = [] +FAILED = [] +CREATED_TASK_IDS = [] +CREATED_ORG_NAMES = [] +TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx") +TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx") +TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg") + + +def _load(name, fn): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") +A = _load("e2e-m5-audit", "e2e-m5-audit.py") +BASE = "http://localhost:8080" + +STU = 997201 +OTHER = 997202 + + +def ok(name): + PASSED.append(name) + print(" [OK] " + name) + + +def fail(name, detail): + FAILED.append((name, detail)) + print(" [FAIL] " + name + "\n " + detail) + + +def check(name, cond, detail=""): + if cond: + ok(name) + else: + fail(name, detail or "断言不成立") + return bool(cond) + + +def sql(stmt): + _, out = R.sql(stmt) + return out + + +def body_of(method, path, token=None, payload=None): + _, b = R.call(method, BASE + path, token, payload) + return b + + +def mp(uid): + cfg = R.read_dev_config() + return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) + + +def upload(path, token, *fields): + """用 curl 发 multipart(urllib 手搓 multipart 容易出错)。""" + import subprocess + args = ["curl", "-s", "-X", "POST", BASE + path, "-H", "Authorization: Bearer " + token] + for f in fields: + args += ["-F", f] + r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=120) + try: + return json.loads(r.stdout) + except Exception: + return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]} + + +def main(): + ap = argparse.ArgumentParser(description="组织/租户/文件 E2E") + ap.parse_args() + + print("=" * 74) + print(" 组织导入 / 租户用量 / 文件视图·直传·批量处理 E2E") + print("=" * 74) + + # ⚠️ 租户相关接口(§6.5 修改 / §6.7 用量)的权限是**运营**(平台级), + # 学校管理员调它们会得到 40301 —— 那是权限边界在生效,不是缺陷。 + # 所以这里用平台账号。**先清登录失败计数**:`LoginGuard` 连续失败后会要求 + # 图形验证码(`xt:login:fail:{account}`),而验证码是图片、脚本给不出来。 + # 这不是绕过安全,而是"测试夹具要能清掉自己造成的失败计数"。 + for acct in ("platform", "admin"): + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:login:fail:%s > NUL 2>&1' % acct) + # 平台运营账号的密码在 docs/07-开发实施计划.md 里(Platform@123),不是 Admin@123 + _, ops = R.login(BASE, "platform", "Platform@123", None) + if ops.get("code") != 0: + print(" x 平台运营账号登录失败(先清 Redis 里的登录失败计数):" + str(ops)) + return 2 + ops_token = ops["data"]["token"] + + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print(" x 管理员登录失败:" + str(adm)) + return 2 + at = adm["data"]["token"] + + # ── 1. §6.4 组织导入 ── + print("\n[1] §6.4 组织批量导入") + suffix = os.urandom(3).hex() + root = "E2E学院" + suffix + mid = "E2E系" + suffix + leaf = "E2E班" + suffix + CREATED_ORG_NAMES.extend([root, mid, leaf]) + # ⚠️ 刻意把**子行排在父行之前**:客户的表里最常见, + # 也是"一遍扫描"实现会全部失败的那种形态 + rows = [ + # 层级 / 名称 / 编码 / 上级编码 / 负责人手机号 + ("3", leaf, "C" + suffix, "B" + suffix, ""), + ("1", root, "A" + suffix, "", ""), + ("2", mid, "B" + suffix, "A" + suffix, ""), + ] + _build_org_xlsx(rows) + dry = upload("/api/v1/orgs/import", at, + "file=@%s;filename=orgs.xlsx;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" % TMP_ORG, + "dryRun=true") + check("dryRun 可用", dry.get("code") == 0, json.dumps(dry, ensure_ascii=False)[:250]) + dd = dry.get("data") or {} + check("⭐ dryRun 报告 created=3(子行在前也能算对层级)", dd.get("created") == 3, + json.dumps(dd, ensure_ascii=False)[:250]) + check("dryRun **没有**落库", + sql("select count(*) from sys_org where org_name='%s';" % leaf).strip() == "0", + sql("select count(*) from sys_org where org_name='%s';" % leaf)) + + imp = upload("/api/v1/orgs/import", at, + "file=@%s;filename=orgs.xlsx;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" % TMP_ORG, + "dryRun=false") + check("正式导入成功", imp.get("code") == 0, json.dumps(imp, ensure_ascii=False)[:250]) + d = imp.get("data") or {} + check("created=3", d.get("created") == 3, json.dumps(d, ensure_ascii=False)) + check("失败 0 / 报错列表为空", d.get("failed") == 0 and not d.get("errors"), + json.dumps(d, ensure_ascii=False)[:250]) + check("⭐ 库里真的有这三个组织", + sql("select count(*) from sys_org where org_name in ('%s','%s','%s');" + % (root, mid, leaf)).strip() == "3", + sql("select org_name, org_code from sys_org where org_name like 'E2E%%%s';" % suffix) + .replace("\n", " | ")) + # 父子关系:子行的 parent 是父行的 id + check("⭐ 子行的父级接对了(按上级编码,而不是按行序)", + sql("""select count(*) from sys_org c + join sys_org p on p.id = c.parent_id + where c.org_name = '%s' and p.org_name = '%s';""" % (mid, root)).strip() == "1", + sql("""select c.org_name, p.org_name from sys_org c + left join sys_org p on p.id = c.parent_id + where c.org_name in ('%s','%s');""" % (mid, leaf)).replace("\n", " | ")) + check("⭐ ancestor_path 被补上了(按层级筛选靠它,漏了会静默少数据)", + sql("select coalesce(ancestor_path,'') from sys_org where org_name='%s';" + % leaf).strip() not in ("", "None"), + "leaf.ancestor_path = " + sql("select coalesce(ancestor_path,'(空)') " + "from sys_org where org_name='%s';" % leaf).strip()) + # 重复导入:已存在的按编码命中,不重复建 + imp2 = upload("/api/v1/orgs/import", at, "file=@%s;filename=orgs.xlsx" % TMP_ORG) + check("⭐ 重复导入按编码命中已存在的(不重复建)", + (imp2.get("data") or {}).get("updated") == 3 + and sql("select count(*) from sys_org where org_name='%s';" % leaf).strip() == "1", + json.dumps(imp2.get("data"), ensure_ascii=False)[:250]) + + # ⚠️ 「认不出表头」这一条必须**放在最后**:它会把坏表头写进同一个临时文件, + # 而上面的"重复导入"复用那份文件 —— 顺序反了的话, + # 重复导入实际导的是坏表头那份,报错信息与真正原因完全无关 + # 认不出表头要明确报错 + _build_org_xlsx([("3", "X", "C", "B", "")], header=["甲", "乙", "丙", "丁", "戊"]) + bad = upload("/api/v1/orgs/import", at, "file=@%s;filename=bad.xlsx" % TMP_ORG) + check("⭐ 认不出表头 → 明确报错并给出应有的列名", + bad.get("code") != 0 and "组织名称" in (bad.get("msg") or ""), + "code=%s msg=%s" % (bad.get("code"), bad.get("msg"))) + + # ── 2. §6.5 / §6.7 租户修改与用量 ── + print("\n[2] §6.5 / §6.7 租户修改与用量") + # 学 校 管 理 员 调 租 户 接 口 应 该 被 拒(权限边界) + forbidden = body_of("PUT", "/api/v1/tenants/1", at, {"contactName": "E2E不该改成"}) + check("⭐ 学校管理员改租户 → 40301(这是「运营」权限,不是「管理」)", + forbidden.get("code") == 40301, + "code=%s msg=%s" % (forbidden.get("code"), forbidden.get("msg"))) + up = body_of("PUT", "/api/v1/tenants/1", ops_token, + {"contactName": "张校长(E2E改)"}) + check("修改租户成功", up.get("code") == 0, json.dumps(up, ensure_ascii=False)[:250]) + check("联系人改掉了", + sql("select contact_name from sys_tenant where id=1;").strip() == "张校长(E2E改)", + sql("select contact_name from sys_tenant where id=1;").strip()) + check("⭐ tenantCode 没被改动(它出现在域名解析与对接文档里)", + sql("select tenant_code from sys_tenant where id=1;").strip() == "demo", + sql("select tenant_code from sys_tenant where id=1;").strip()) + bad = body_of("PUT", "/api/v1/tenants/1", ops_token, {"tenantType": 9}) + check("租户类型越界 → 明确报错", bad.get("code") != 0, + "code=%s msg=%s" % (bad.get("code"), bad.get("msg"))) + check("不存在的租户 → 明确报错", + body_of("PUT", "/api/v1/tenants/999999999", ops_token, + {"contactName": "x"}).get("code") != 0) + + us = body_of("GET", "/api/v1/tenants/1/usage", ops_token) + check("用量接口可用", us.get("code") == 0, json.dumps(us, ensure_ascii=False)[:250]) + u = us.get("data") or {} + check("带学生数与存储", u.get("studentCount") is not None and u.get("storageMb") is not None, + json.dumps(u, ensure_ascii=False)[:250]) + check("带 quota", isinstance(u.get("quota"), dict) and u["quota"].get("studentLimit"), + json.dumps(u.get("quota"), ensure_ascii=False)) + check("⭐ ocrCount / smsCount 恒为 0,**并带 notes 说明为什么**" + "(编一个数字会让运营拿假数据去谈续费)", + u.get("ocrCount") == 0 and u.get("smsCount") == 0 and len(u.get("notes") or []) >= 2, + json.dumps({k: u.get(k) for k in ("ocrCount", "smsCount", "notes")}, + ensure_ascii=False)[:300]) + us2 = body_of("GET", "/api/v1/tenants/1/usage?period=2026Q3", ops_token) + check("⭐ period 支持季度写法(2026Q3)", us2.get("code") == 0 + and (us2.get("data") or {}).get("period") == "2026Q3", + json.dumps((us2.get("data") or {}).get("period"), ensure_ascii=False)) + check("period 格式不对 → 明确报错", + body_of("GET", "/api/v1/tenants/1/usage?period=" + urllib.parse.quote("去年"), + ops_token) + .get("code") != 0) + b = body_of("GET", "/api/v1/tenants/1/usage", mp(997299)) + check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403), + "code=%s" % b.get("code")) + + # ── 3. 文件夹具 ── + print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)") + _, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": "E2E文件 " + suffix, "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31" + }) + task_id = b["data"]["id"] + CREATED_TASK_IDS.append(task_id) + _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at) + classes = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(b.get("data") or []) + org = classes[0] + R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at, + {"orgs": [{"orgId": org["id"], "expectedCount": 1}]}) + R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at, + {"templateCode": "TPL_SCHOOL_ROLL"}) + _build_roster([("文件甲", R.with_checksum("36102920080808091"))]) + body = R.upload_roster(BASE, at, task_id, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180]) + R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True}) + + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % STU) + tok = mp(STU) + _, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok, + {"taskId": task_id, "realName": "文件甲", + "idCardNo": R.with_checksum("36102920080808091")}) + if b.get("code") != 0: + print(" x 门禁核验失败:" + json.dumps(b, ensure_ascii=False)[:200]) + return 2 + _, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id}) + sid = b["data"]["id"] + check("夹具:学生建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150]) + + _make_jpeg() + up_res = upload("/api/v1/mp/files/upload", tok, + "submissionId=%d" % sid, "materialCode=IDCARD", + "file=@%s;filename=乱名字.jpg;type=image/jpeg" % TINY_JPEG) + check("夹具:上传一份材料", up_res.get("code") == 0, + json.dumps(up_res, ensure_ascii=False)[:250]) + file_id = (up_res.get("data") or {}).get("id") or (up_res.get("data") or {}).get("fileId") + check("夹具:拿到 fileId", isinstance(file_id, int), str(file_id)) + + # ── 4. §9.8 预览 ── + print("\n[4] §9.8 预览(签名 URL + 元信息)") + v = body_of("GET", "/api/v1/mp/files/%d" % file_id, tok) + check("预览可用", v.get("code") == 0, json.dumps(v, ensure_ascii=False)[:250]) + vd = v.get("data") or {} + check("带 previewUrl / standardName / 尺寸", + vd.get("previewUrl") and vd.get("standardName") and vd.get("width"), + json.dumps(vd, ensure_ascii=False)[:300]) + check("带 expireSeconds(签名 URL 有有效期,不能长期保存)", + vd.get("expireSeconds") and vd.get("expireSeconds") > 0, + str(vd.get("expireSeconds"))) + check("⭐ 带 actualKind(请求 original 时原始件可能不存在,会退到规范件)", + vd.get("actualKind") in ("ORIGINAL", "STANDARD"), str(vd.get("actualKind"))) + vo = body_of("GET", "/api/v1/mp/files/%d?prefer=original" % file_id, tok) + check("prefer=original 也能用(原始件默认不保留时会退到规范件并标注)", + vo.get("code") == 0 and (vo.get("data") or {}).get("actualKind") in + ("ORIGINAL", "STANDARD"), + json.dumps(vo.get("data"), ensure_ascii=False)[:250]) + check("⭐ **别人的文件** → 拒绝(不区分「不存在」与「不是你的」,避免枚举)", + body_of("GET", "/api/v1/mp/files/%d" % file_id, mp(OTHER)).get("code") != 0, + "code=%s" % body_of("GET", "/api/v1/mp/files/%d" % file_id, mp(OTHER)).get("code")) + check("不存在的文件 → 明确报错", + body_of("GET", "/api/v1/mp/files/999999999", tok).get("code") != 0) + check("未登录 → 40101", body_of("GET", "/api/v1/mp/files/%d" % file_id).get("code") == 40101) + + # ── 5. §9.5 上传凭证 ── + print("\n[5] §9.5 上传凭证(预检 + 秒传 + 压缩提示)") + tk = body_of("POST", "/api/v1/mp/files/upload-token", tok, { + "taskId": task_id, "materialCode": "PHOTO1", "fileName": "相册照片.jpg", + "fileSize": 300000, "contentType": "image/jpeg" + }) + check("凭证接口可用", tk.get("code") == 0, json.dumps(tk, ensure_ascii=False)[:250]) + td = tk.get("data") or {} + check("ok=true 且没命中秒传", td.get("ok") is True and td.get("existed") is False, + json.dumps(td, ensure_ascii=False)[:250]) + check("⭐ 带压缩提示(让学生在自己手机上压,省的是他的流量与等待)", + (td.get("clientCompressHint") or {}).get("enabled") is True, + json.dumps(td.get("clientCompressHint"), ensure_ascii=False)) + check("带 objectKey / uploadToken / expireSeconds=900", + td.get("objectKey") and td.get("uploadToken") and td.get("expireSeconds") == 900, + json.dumps({k: td.get(k) for k in ("objectKey", "expireSeconds")}, ensure_ascii=False)) + check("⭐ 一期 direct=false(七牛直传要先把「上传完成回调」打通,见 F-71)", + td.get("direct") is False and len(td.get("notes") or []) >= 1, + json.dumps({k: td.get(k) for k in ("direct", "notes")}, ensure_ascii=False)[:250]) + # 体积超限:**不传字节就能拦** + big = body_of("POST", "/api/v1/mp/files/upload-token", tok, { + "taskId": task_id, "materialCode": "PHOTO1", "fileName": "巨图.jpg", + "fileSize": 8 * 1024 * 1024, "contentType": "image/jpeg" + }) + bd = big.get("data") or {} + check("⭐ 体积超限 → ok=false + **能照着做的原因**(说清上限与当前值)", + bd.get("ok") is False and "KB" in (bd.get("rejectReason") or ""), + json.dumps(bd, ensure_ascii=False)[:250]) + check("超限时压缩提示里 needed=true", + (bd.get("clientCompressHint") or {}).get("needed") is True, + json.dumps(bd.get("clientCompressHint"), ensure_ascii=False)) + # 份数超限(IDCARD 已经传了 1 份,模板 maxCount=2 → 再传 1 份可以,第 3 份不行) + for i in range(1): + upload("/api/v1/mp/files/upload", tok, "submissionId=%d" % sid, + "materialCode=IDCARD", + "file=@%s;filename=第二次.jpg;type=image/jpeg" % TINY_JPEG) + full = body_of("POST", "/api/v1/mp/files/upload-token", tok, { + "taskId": task_id, "materialCode": "IDCARD", "fileName": "第三张.jpg", + "fileSize": 100000, "contentType": "image/jpeg" + }) + fd = full.get("data") or {} + check("⭐ 份数超限 → ok=false 并说清「最多几份、你已经传了几份、要换请先删」", + fd.get("ok") is False and "最多" in (fd.get("rejectReason") or ""), + json.dumps(fd, ensure_ascii=False)[:250]) + # 秒传:拿已经传过那份的 fileHash + real_hash = sql("select file_hash from collect_file where id=%d;" % file_id).strip() + fast = body_of("POST", "/api/v1/mp/files/upload-token", tok, { + "taskId": task_id, "materialCode": "IDCARD", "fileName": "同一张.jpg", + "fileSize": 100, "contentType": "image/jpeg", "fileHash": real_hash + }) + fd2 = fast.get("data") or {} + check("⭐ 秒传命中(同一个人在同一个槽位传同样的文件)→ existed=true", + fd2.get("existed") is True and fd2.get("fileId") == file_id, + json.dumps(fd2, ensure_ascii=False)[:250]) + check("⭐ 秒传时**不给 uploadUrl**(返回了而前端不用,会让「到底传没传」变成两处判断)", + fd2.get("uploadUrl") is None, str(fd2.get("uploadUrl"))) + bad = body_of("POST", "/api/v1/mp/files/upload-token", tok, { + "taskId": task_id, "materialCode": "NO_SUCH_MATERIAL", "fileName": "x.jpg", + "fileSize": 100, "contentType": "image/jpeg" + }) + check("材料码不存在 → 明确报错", bad.get("code") != 0, + "code=%s msg=%s" % (bad.get("code"), bad.get("msg"))) + + # ── 6. §9.7 批量处理 ── + print("\n[6] §9.7 批量处理(多材料一次提交)") + bp = body_of("POST", "/api/v1/mp/files/batch-process", tok, { + "submissionId": sid, "fileIds": [file_id], "async": True + }) + check("批量处理可用", bp.get("code") == 0, json.dumps(bp, ensure_ascii=False)[:250]) + bpr = bp.get("data") or {} + check("带 batchToken / total / results / summary", + bpr.get("batchToken") and bpr.get("total") == 1 and bpr.get("summary"), + json.dumps(bpr, ensure_ascii=False)[:250]) + item = (bpr.get("results") or [{}])[0] + check("结果项带 fileId 与 status", item.get("fileId") == file_id + and item.get("status") is not None, json.dumps(item, ensure_ascii=False)) + check("⭐ 一期 conflict 恒为 false、detectMaterialCode 恒为空,**并说明为什么**" + "(不是「识别后没问题」,而是「还没识别」)", + item.get("conflict") is False and item.get("detectMaterialCode") is None + and len(bpr.get("notes") or []) >= 1, + json.dumps({k: item.get(k) for k in ("conflict", "detectMaterialCode")}, + ensure_ascii=False) + " notes=" + str(bpr.get("notes"))[:120]) + over = body_of("POST", "/api/v1/mp/files/batch-process", tok, { + "submissionId": sid, "fileIds": list(range(1, 25)) + }) + check("⭐ 超过 20 个 → 40011(与批量审批同一个上限口径)", over.get("code") == 40011, + "code=%s msg=%s" % (over.get("code"), over.get("msg"))) + foreign = body_of("POST", "/api/v1/mp/files/batch-process", tok, { + "submissionId": sid, "fileIds": [999999999] + }) + check("不属于本填报单的文件 → 记成失败项而不是整批失败", + (foreign.get("data") or {}).get("summary", {}).get("failed") == 1, + json.dumps(foreign.get("data"), ensure_ascii=False)[:250]) + noids = body_of("POST", "/api/v1/mp/files/batch-process", tok, {"submissionId": sid}) + check("不传 fileIds → 明确报错", noids.get("code") != 0, + "code=%s msg=%s" % (noids.get("code"), noids.get("msg"))) + + cleanup() + print("\n" + "=" * 74) + print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) + print("=" * 74) + for n, det in FAILED: + print(" x " + n + "\n " + det) + return 1 if FAILED else 0 + + +def _build_org_xlsx(rows, header=None): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "组织" + head = header or ["层级", "组织名称", "组织编码", "上级编码", "负责人手机号"] + for c, t in enumerate(head, start=1): + ws.cell(row=1, column=c, value=t) + for i, r in enumerate(rows): + for c, v in enumerate(r, start=1): + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ORG) + + +def _build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "文件班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "1380013880%d" % i, "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def _make_jpeg(): + import base64 + b64 = ("/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a" + "HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA" + "AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==") + with open(TINY_JPEG, "wb") as f: + f.write(base64.b64decode(b64)) + + +def cleanup(): + for tid in CREATED_TASK_IDS: + R.sql("delete from collect_validation_issue where task_id=%d;" % tid) + R.sql("delete from collect_audit_record where task_id=%d;" % tid) + R.sql("delete from collect_notify_record where task_id=%d;" % tid) + R.sql("delete from collect_file where task_id=%d;" % tid) + R.sql("delete from collect_submission where task_id=%d;" % tid) + R.sql("delete from collect_roster where task_id=%d;" % tid) + R.sql("delete from collect_task_org where task_id=%d;" % tid) + R.sql("delete from collect_material_def where task_id=%d;" % tid) + R.sql("delete from collect_task where id=%d;" % tid) + for name in CREATED_ORG_NAMES: + R.sql("delete from sys_org where org_name = '%s';" % name) + R.sql("update sys_tenant set contact_name='张校长' where id=1;") + R.sql("delete from collect_message where user_id in (997201, 997202, 997299);") + for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG): + if os.path.exists(p): + os.remove(p) + print(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-m9-thumb.py b/scripts/e2e-m9-thumb.py new file mode 100644 index 0000000..e1c5b57 --- /dev/null +++ b/scripts/e2e-m9-thumb.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +"""验证缩略图真的更小(用完即删)。 + +## 为什么要打到真实七牛 + +这条改动的**全部价值**就是"图变小了"。而"参数拼在签名之前"这件事 +只有在**私有空间的真实签名**下才暴露:拼错顺序 → 401 → +现象是"图片加载不出来",而单测/本地存储(回退原图)永远测不出来。 + +所以这里: + ① 造一个任务 + 上传一张**较大的真实图片**(用 Pillow 生成 2000×1500 的噪声图, + 纯色会被压到很小,测不出差别); + ② 分别取 `thumb=false` 与 `thumb=true` 的链接; + ③ 用 HTTP GET 下载两者,**比较字节数与像素尺寸** —— 缩略图必须真的更小。 +""" +import importlib.util +import io as _io +import json +import os +import subprocess +import sys +import urllib.request +import uuid + +sys.stdout.reconfigure(encoding="utf-8") +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +spec = importlib.util.spec_from_file_location("r", os.path.join(ROOT, "scripts", "e2e-m2-roster.py")) +R = importlib.util.module_from_spec(spec) +spec.loader.exec_module(R) +BASE = "http://localhost:8080" +SUFFIX = uuid.uuid4().hex[:6] +TMP_IMG = os.path.join(ROOT, "scripts", f"_thumb-{SUFFIX}.jpg") +TASK_IDS = [] +UID = 997601 + +PASSED, FAILED = [], [] + + +def ok(n): + PASSED.append(n) + print(" [OK] " + n) + + +def fail(n, d): + FAILED.append((n, d)) + print(" [FAIL] " + n + "\n " + d) + + +def check(n, cond, d=""): + ok(n) if cond else fail(n, d) + + +def sql(s): + return R.sql(s)[1].strip() + + +def make_big_jpeg(path, w=2000, h=1500): + """造一张**接近真实照片**的大图:随机噪声几乎不可压缩,正适合测体积。""" + import random + from PIL import Image + random.seed(3) + img = Image.new("RGB", (w, h)) + px = img.load() + for y in range(0, h, 2): + for x in range(0, w, 2): + c = (random.getrandbits(8), random.getrandbits(8), random.getrandbits(8)) + px[x, y] = c + if x + 1 < w: + px[x + 1, y] = c + if y + 1 < h: + px[x, y + 1] = c + if x + 1 < w: + px[x + 1, y + 1] = c + img.save(path, "JPEG", quality=92) + return os.path.getsize(path) + + +def upload(path, token, *fields): + args = ["curl", "-s", "-X", "POST", BASE + path, "-H", "Authorization: Bearer " + token] + for f in fields: + args += ["-F", f] + r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=180) + try: + return json.loads(r.stdout) + except Exception: + return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]} + + +print("=" * 78) +print(" 缩略图验证(打到真实七牛:参数必须在签名之前)") +print("=" * 78) + +_, adm = R.login(BASE, "admin", "Admin@123", "demo") +at = adm["data"]["token"] + +# 存储 provider +_, log = R.sql("select 1") +print("\n[0] 当前存储 provider") +prov = subprocess.run(["docker", "exec", "xt-pg", "psql", "-U", "fill", "-d", "filldb", + "-t", "-c", "select 1"], capture_output=True, text=True) +import glob +logtxt = "" +try: + logtxt = _io.open(os.path.join(ROOT, "backend", "_dev-log.txt"), encoding="utf-8", + errors="replace").read() +except Exception: + pass +is_qiniu = "七牛云" in logtxt +print(" 后端日志里最后一条存储声明是七牛:%s" % is_qiniu) +if not is_qiniu: + print(" ⚠️ 当前不是七牛存储,缩略图会回退成原图 —— 这个验证需要 provider=qiniu") + +# 造夹具 +print("\n[1] 造夹具(任务 + 名册 + 大图)") +size = make_big_jpeg(TMP_IMG) +print(" 生成的测试图:%d×%d,%.0f KB" % (2000, 1500, size / 1024)) + +_, orgs = R.call("GET", BASE + "/api/v1/orgs/tree", at) +classes = [] + + +def walk(ns): + for n in ns or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + +walk(orgs.get("data") or []) +org = classes[0] + +_, b = R.call("POST", BASE + "/api/v1/tasks", at, { + "taskName": f"缩略图验证 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2099-12-31"}) +if b.get("code") != 0 or not (b.get("data") or {}).get("id"): + raise SystemExit("夹具:建任务失败 —— " + json.dumps(b, ensure_ascii=False)[:300]) +tid = b["data"]["id"] +TASK_IDS.append(tid) +R.call("POST", BASE + "/api/v1/tasks/%d/materials" % tid, at, { + "materialCode": "IDCARD", "materialName": "身份证", "required": True, + "fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 4096, "maxCount": 2, + "minSizeKb": 1, "minLongSide": 100}) +R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % tid, at, + {"orgs": [{"orgId": org["id"], "expectedCount": 1}]}) + +from openpyxl import Workbook +xlsx = os.path.join(ROOT, "scripts", f"_thumb-{SUFFIX}.xlsx") +wb = Workbook() +ws = wb.active +header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", "报考职业", "报考工种", + "报考等级", "是否补考", "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] +for c, h in enumerate(header, 1): + ws.cell(row=1, column=c, value=h) +idc = R.with_checksum("36102920080101900") +vals = [1, "缩略甲", idc, "中专", "13800138801", "无", "车工", "数控车工", "四级", "否", + "", "", "", "是", "示范职业学校", "数控加工", "2024-09-01"] +for c, v in enumerate(vals, 1): + if v != "": + ws.cell(row=2, column=c, value=v) +wb.save(xlsx) +rep = R.upload_roster(BASE, at, tid, xlsx, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) +rd = rep.get("data") or {} +check("夹具:名册导入 1 人", rep.get("code") == 0 and rd.get("created") == 1, + json.dumps(rep, ensure_ascii=False)[:250]) + +# ⚠️ **必须发布**:草稿态的任务不能填报,学生会拿到 40008。 +# 发布的前置条件是"名册非空 + 有必传材料 + 配了班级",上面三步已经满足。 +_, pub = R.call("POST", BASE + "/api/v1/tasks/%d/publish" % tid, at, {"force": True}) +if pub.get("code") != 0: + raise SystemExit("夹具:发布任务失败 —— " + json.dumps(pub, ensure_ascii=False)[:300]) +check("夹具:任务已发布(收集中)", + sql("select status from collect_task where id=%d;" % tid) == "1", + sql("select status from collect_task where id=%d;" % tid)) + +os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % UID) +cfg = R.read_dev_config() +tok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], UID, 1, ["STUDENT"], 4) +_, mb = R.call("POST", BASE + "/api/v1/mp/roster/match", tok, + {"taskId": tid, "realName": "缩略甲", "idCardNo": idc}) +check("夹具:门禁核验通过", mb.get("code") == 0, json.dumps(mb, ensure_ascii=False)[:200]) +_, cb = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": tid}) +# ⚠️ 必须查 code:建草稿失败时 data 是 null,不查就会变成一个 +# `TypeError: 'NoneType' object is not subscriptable`,而真正的原因一个字都不打 +if cb.get("code") != 0 or not (cb.get("data") or {}).get("id"): + raise SystemExit("夹具:建草稿失败 —— " + json.dumps(cb, ensure_ascii=False)[:300]) +sid = cb["data"]["id"] +up = upload("/api/v1/mp/files/upload", tok, "submissionId=%d" % sid, "materialCode=IDCARD", + "file=@%s;filename=大图.jpg;type=image/jpeg" % TMP_IMG) +fid = (up.get("data") or {}).get("id") or (up.get("data") or {}).get("fileId") +check("夹具:材料上传成功", up.get("code") == 0 and fid, json.dumps(up, ensure_ascii=False)[:250]) + +# ── 取两种链接 ── +print("\n[2] 取链接并下载比较") +_, r1 = R.call("GET", BASE + "/api/v1/audit/files/%s/url" % fid, at) +_, r2 = R.call("GET", BASE + "/api/v1/audit/files/%s/url?thumb=true" % fid, at) +d1 = r1.get("data") or {} +d2 = r2.get("data") or {} +print(" 字段:", sorted(d1.keys())) +print(" url 尾部:…%s" % str(d1.get("url"))[-80:]) +print(" thumbUrl 尾部:…%s" % str(d2.get("thumbUrl"))[-80:]) +# ⚠️ 缩略图在 **thumbUrl** 字段里;`url` 永远是原图链接。 +# 我第一版查的是 `url`,于是"看起来像缩略图没生效",其实是查错了字段。 +check("⭐ `url` 是原图(**不带**图片处理参数)", + "imageView2" not in str(d2.get("url")), str(d2.get("url"))[:160]) +check("⭐ `thumbUrl` 带上了图片处理参数(且拼在**签名之前**)", + "imageView2" in str(d2.get("thumbUrl")), str(d2.get("thumbUrl"))[:200]) +check("⭐ `thumbMaxLongSide` 回显了我们请求的规格(400)", + d2.get("thumbMaxLongSide") == 400, str(d2.get("thumbMaxLongSide"))) +check("原图请求(不带 thumb)时 thumbUrl 与 url 相同", + d1.get("thumbUrl") == d1.get("url"), "thumbUrl=%s" % str(d1.get("thumbUrl"))[:120]) + + +def fetch(url): + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=60) as r: + return r.status, r.read() + + +try: + s1, b1 = fetch(d1["url"]) + s2, b2 = fetch(d2["thumbUrl"]) # ★ 缩略图在 thumbUrl 字段 + print(" 原图:HTTP %s,%.0f KB" % (s1, len(b1) / 1024)) + print(" 缩略:HTTP %s,%.0f KB" % (s2, len(b2) / 1024)) + check("⭐ 两个链接都能下载(**签名是对的** —— 参数若拼在签名之后会 401)", + s1 == 200 and s2 == 200, "HTTP %s / %s" % (s1, s2)) + check("⭐ 缩略图**真的更小**", len(b2) < len(b1), + "原图 %d 字节,缩略 %d 字节" % (len(b1), len(b2))) + from PIL import Image + im1 = Image.open(_io.BytesIO(b1)) + im2 = Image.open(_io.BytesIO(b2)) + print(" 原图尺寸:%s×%s 缩略尺寸:%s×%s" % (im1.width, im1.height, im2.width, im2.height)) + check("⭐ 缩略图长边 ≤ 400(我们请求的规格)", + max(im2.width, im2.height) <= 400, + "缩略图 %d×%d" % (im2.width, im2.height)) + check("⭐ 比例没变形(等比缩放)", + abs(im1.width / im1.height - im2.width / im2.height) < 0.02, + "原图比 %.3f,缩略比 %.3f" % (im1.width / im1.height, im2.width / im2.height)) + print("\n 体积变化:%.0f KB → %.0f KB(降为 %.1f%%)" + % (len(b1) / 1024, len(b2) / 1024, 100.0 * len(b2) / max(1, len(b1)))) +except Exception as e: # noqa: BLE001 + fail("下载/解码失败", repr(e)) + +# ── 清理 ── +print("\n[3] 清理") +for t_ in TASK_IDS: + for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record", + "collect_file", "collect_submission", "collect_roster", + "collect_task_org", "collect_material_def"): + R.sql("delete from %s where task_id=%s;" % (tbl, t_)) + R.sql("delete from collect_task where id=%s;" % t_) +for p in (TMP_IMG, xlsx): + if os.path.exists(p): + os.remove(p) +R.sql("delete from collect_message where user_id=%d;" % UID) +print(" 已清理(⚠️ 七牛桶里的对象仍在 —— 删库不删桶,见 F-93)") + +print("\n" + "=" * 78) +print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) +print("=" * 78) +for n, d in FAILED: + print(" x " + n + "\n " + d) +sys.stdout.flush() +raise SystemExit(1 if FAILED else 0) diff --git a/scripts/e2e-prodguard.py b/scripts/e2e-prodguard.py new file mode 100644 index 0000000..9c171c6 --- /dev/null +++ b/scripts/e2e-prodguard.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""验证 ProdGuard 真的会拒绝启动(用完即删)。 + +## 为什么这条验证不能省 + +`ProdGuard` 的价值**完全**在于"配错时起不来"。如果它其实不生效 +(比如 profile 判断写错、或者异常被 Spring 吞掉后照样启动), +那它就只是一段看起来很尽责的注释 —— 而**没有任何别的方式能发现这件事**: +配错的生产环境会照常启动、照常服务,直到某天出事。 + +所以要真的用 prod profile 起一次,看它是否 ① 拒绝启动 ② 报的是那几条。 + +## 两种情形都试 + +1. **什么都没配** → 应该拒绝,且 6 条问题里至少报出 5 条; +2. **只修一半**(把 stub 关了、但密钥还是 dev 那把)→ 仍应拒绝, + 且报错里要点名"用的还是开发环境那把"。 +""" +import os +import re +import subprocess +import sys +import time + +sys.stdout.reconfigure(encoding="utf-8") +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +JAR = os.path.join(ROOT, "backend", "xt-server", "target", + "xt-server-1.0.0-SNAPSHOT.jar") + +if not os.path.exists(JAR): + raise SystemExit("先 mvn package:" + JAR) + +PASSED = [] +FAILED = [] + + + +def kill_tree(proc): + """ + 杀掉整个进程树(Windows 用 taskkill /T)。 + + ⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程; + 更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**, + 所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净, + 再靠下面的 preclean_port 兜住剩下的。 + """ + if proc is None or proc.poll() is not None: + return + try: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, timeout=20) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def preclean_port(port): + """ + 开跑前清掉这个端口上的残留 java。 + + ⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** —— + 残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。 + 不清端口 = 残留永远不会被发现。 + """ + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)" + ".OwningProcess" % port], + capture_output=True, text=True, timeout=30) + for line in (r.stdout or "").split(): + pid = line.strip() + if pid.isdigit(): + subprocess.run(["taskkill", "/F", "/T", "/PID", pid], + capture_output=True, timeout=20) + print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid)) + except Exception: + pass + +def ok(n): + PASSED.append(n) + print(" [OK] " + n) + + +def fail(n, d): + FAILED.append((n, d)) + print(" [FAIL] " + n + "\n " + d) + + +def check(n, cond, d=""): + ok(n) if cond else fail(n, d) + + +def start_prod(extra_env, seconds=45, extra_args=None): + """用 prod profile 起一次,抓输出后杀掉。返回 (是否起来了, 输出)。""" + env = dict(os.environ) + env.update(extra_env) + # 让它在真正的"缺密钥"状态下启动:清掉可能存在的 dev 环境变量 + for k in ("XT_JWT_SECRET", "XT_AES_KEY", "XT_HMAC_KEY", "XT_RSA_PRIVATE_KEY", + "PG_PASSWORD", "REDIS_PASSWORD"): + env.pop(k, None) + env.update(extra_env) + preclean_port(18099) + p = subprocess.Popen( + ["java", "-jar", JAR, "--spring.profiles.active=prod", + "--server.port=18099"] + (extra_args or []), + cwd=os.path.join(ROOT, "backend"), env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace") + out = [] + started = False + deadline = time.time() + seconds + try: + while time.time() < deadline: + line = p.stdout.readline() + if not line: + if p.poll() is not None: + break + continue + out.append(line) + if "Started XtServerApplication" in line or "Tomcat started" in line: + started = True + break + if p.poll() is not None: + break + finally: + kill_tree(p) + try: + rest = p.stdout.read() or "" + out.append(rest) + except Exception: + pass + return started, "".join(out) + + +print("=" * 74) +print(" ProdGuard:生产环境配错时应当拒绝启动") +print("=" * 74) + +print("\n[1] 什么都没配(缺全部密钥)") +started, out = start_prod({}) +check("⭐ **拒绝启动**(真的没起来)", not started, + "它居然起来了 —— 那 ProdGuard 等于没有") +# 场景 1 里 prod 配置把 stub 写成 false、存储写成 qiniu, +# 所以这两条**不该**出现在清单里(它们由场景 3 显式覆盖来验) +check("⭐ 六个必需变量都点名了", + all(k in out for k in ("XT_JWT_SECRET", "XT_AES_KEY", "XT_HMAC_KEY", + "XT_RSA_PRIVATE_KEY", "PG_PASSWORD", "REDIS_PASSWORD")), + out[:1500]) +check("⭐ **不再**出现「占位符被当成密钥长度」那种自造假问题" + "(`${XT_JWT_SECRET}` 正好 16 字符,不排除就会多报一条「太短」)", + "太短" not in out, out[:1500]) +check("⭐ 报了「配错了也能启动、运行时完全不报错」这句话(让人知道为什么要拦)", + "配错了也能启动" in out, out[-400:]) + +print("\n[2] 只修一半:密钥仍是 dev 那把") +dev_secret = "BC0LUCKD3T4FfgMioHyARklQBa5phAXrYmzJStAwKiqftTJGURd4UblkjXFF+m8ZtaIC6BSeXKN2wuqBlkHPbA==" +started2, out2 = start_prod({ + "XT_JWT_SECRET": dev_secret, + "XT_AES_KEY": "CaJuBbvbSKkLGn+d3WfWcmbkZE48NHglssoW53fKbrY=", + "XT_HMAC_KEY": "1x75SulC+e+dQYZMEJMoVLf+E6KmgE0lUv6ShfJ6FDzNJdtJpmaxacPmX+/qCzhS", + "XT_RSA_PRIVATE_KEY": "x", + "PG_PASSWORD": "x", "REDIS_PASSWORD": "x", +}) +check("⭐ 仍**拒绝启动**(不是「有值就行」,而是「不能是 dev 那把」)", not started2, + "它起来了 —— 那说明只检查了「非空」") +check("⭐ 报错写明「用的还是开发环境那把」并说清后果(可伪造任意 token)", + "开发环境那把" in out2 and "伪造" in out2, out2[-600:]) + +print("\n[3] 有人为了排障把后门打开、然后忘了关") +started3, out3 = start_prod( + {"XT_JWT_SECRET": "a" * 80, "XT_AES_KEY": "b" * 44, "XT_HMAC_KEY": "c" * 44, + "XT_RSA_PRIVATE_KEY": "d" * 100, "PG_PASSWORD": "x", "REDIS_PASSWORD": "y"}, + extra_args=["--fill.mp-h5.stub-enabled=true", + "--fill.storage.provider=local", + "--springdoc.api-docs.enabled=true"]) +check("⭐ 拒绝启动(密钥齐全也不行 —— 后门/本地存储/接口文档开着就不许上)", + not started3, "它起来了") +check("⭐ 三条都点名了,并说清了后果", + "stub-enabled" in out3 and "storage.provider" in out3 and "api-docs" in out3, + out3[:1500]) +check("⭐ 文案里说清 stub 的后果是「任何人可以登录任何学生」", + "任何人可以登录任何学生" in out3, out3[:1500]) + +print("\n" + "=" * 74) +print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) +print("=" * 74) +for n, d in FAILED: + print(" x " + n + "\n " + d) +sys.stdout.flush() +raise SystemExit(1 if FAILED else 0) diff --git a/scripts/load-test.py b/scripts/load-test.py new file mode 100644 index 0000000..6ba374e --- /dev/null +++ b/scripts/load-test.py @@ -0,0 +1,542 @@ +# -*- coding: utf-8 -*- +"""压测(M7-09):门禁并发 / 上传并发 / 读接口 / 导出大任务 + +## 为什么值得真跑一次,而不是"看代码觉得没问题" + +这个项目的三个并发热点,每一个都有"看代码看不出来"的地方: + +| 场景 | 直觉 | 实际可能卡在 | +|---|---|---| +| 门禁核验(60 人同时扫码) | "就是查一下名册" | 每个请求都要 **AES 解密全表证件号** 逐个比对(密文没法走索引) | +| 上传(30 人同时传) | "就是写个文件" | 图片压缩是 **CPU 密集**,而 Tomcat 线程数有限 | +| 读接口(列表/统计) | "加个索引就行" | 连接池只有 20 条,长事务(导出打包)会把池子占死 | +| 导出大任务 | "异步的,不影响" | 导出**同步**做前置检查 + 打包,而它在一个大事务里 | + +所以这份脚本不做"泛泛压一下",而是**针对这四个场景各设计一组**, +并把 p50/p95/p99 与错误码分布打出来 —— **错误码分布比成功率更有信息量** +(40071 是限流、50000 是内部错、40301 是权限,三种要修的地方完全不同)。 + +## 用法 + +```bash +python scripts/load-test.py # 全套(约 2~3 分钟) +python scripts/load-test.py --only gate # 只跑门禁 +python scripts/load-test.py --conc 40 # 改并发 +``` + +⚠️ 它**会往库里写东西**(名册、填报单、材料),结尾会清理; +中途 Ctrl-C 会留下残留 —— 用 `--clean` 单独清理一次即可。 +""" +import argparse +import base64 +import concurrent.futures as cf +import importlib.util +import json +import os +import statistics +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE = "http://localhost:8080" +SUFFIX = uuid.uuid4().hex[:6] +TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx") +TMP_IMG = os.path.join(ROOT, "scripts", f"_lt-img-{SUFFIX}.jpg") + +# 压测规模(可按机器调整;--rows 会覆盖 GATE_USERS) +GATE_USERS = 60 # 名册人数(同时也是门禁的并发用户数) +READ_CONC = 20 # 读接口并发 +READ_ROUNDS = 8 # 每个并发线程的轮数 +UPLOAD_USERS = 20 # 上传:20 个学生同时传 +UPLOAD_PER_USER = 2 # 每人传几张 + +TASK_IDS = [] +ROSTER_IDS = [] +UIDS = [] + + +def _load(name, fn): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +R = _load("e2e_m2_roster", "e2e-m2-roster.py") + + +def sql(s): + return R.sql(s)[1] + + +def one(s): + v = sql(s) + return v.split("\n")[0].strip() if v else "" + + +def call(method, path, token=None, body=None, timeout=60): + return R.call(method, BASE + path, token, body, timeout=timeout) + + +# ══════════════ 计时与统计 ══════════════ + +class Stat: + """一个场景的耗时与错误码统计。""" + + def __init__(self, name): + self.name = name + self.lat = [] + self.codes = {} + self.fail_detail = [] + self.t0 = time.perf_counter() + self.t1 = None + + def done(self): + """场景结束(算吞吐要墙钟时间,不是"平均耗时的倒数")。""" + self.t1 = time.perf_counter() + + def add(self, ms, code): + self.lat.append(ms) + self.codes[code] = self.codes.get(code, 0) + 1 + + def report(self): + if not self.lat: + print(" %-22s 没有样本" % self.name) + return + s = sorted(self.lat) + n = len(s) + ok = self.codes.get(0, 0) + # ⚠️ 吞吐 = 请求数 / **墙钟耗时**。 + # 原来的式子化简后是 1/平均耗时 —— 那不是吞吐, + # 而"一行看起来很像结论的数字"最容易被抄进汇报里 + elapsed = (self.t1 or time.perf_counter()) - self.t0 + print(" %-24s 请求 %-5d 成功 %-5d 失败 %-4d " + "p50 %6.1fms p95 %7.1fms p99 %7.1fms max %7.1fms 吞吐 %5.0f/s" + % (self.name, n, ok, n - ok, + s[int(n * 0.50)], s[min(n - 1, int(n * 0.95))], + s[min(n - 1, int(n * 0.99))], s[-1], + n / max(0.001, elapsed))) + # ⚠️ 错误码分布比成功率更有信息量:40071 限流 / 50000 内部错 / 40301 权限 + bad = {k: v for k, v in self.codes.items() if k != 0} + if bad: + print(" 失败分布:%s" % json.dumps(bad, ensure_ascii=False)) + if self.fail_detail: + print(" 首个失败样本:%s" % self.fail_detail[0][:200]) + + +def timed(fn): + t0 = time.perf_counter() + try: + code, body = fn() + except Exception as e: # noqa: BLE001 + return (time.perf_counter() - t0) * 1000, -1, str(e) + return (time.perf_counter() - t0) * 1000, code, body + + +# ══════════════ 夹具 ══════════════ + +def build_fixture(admin): + """造一个任务 + N 个名册人(每人一个已知证件号)+ 对应 uid 的 MP token。""" + _, orgs = call("GET", "/api/v1/orgs/tree", admin) + classes = [] + + def walk(ns): + for n in ns or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(orgs.get("data") or []) + if not classes: + raise SystemExit("没有班级节点") + org = classes[0] + + _, b = call("POST", "/api/v1/tasks", admin, { + "taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工", + "collectMode": 1, "certType": 1, + "startTime": "2026-01-01", "endTime": "2099-12-31", + }) + tid = b["data"]["id"] + TASK_IDS.append(tid) + + call("POST", f"/api/v1/tasks/{tid}/materials", admin, { + "materialCode": "IDCARD", "materialName": "身份证", "required": True, + "fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 2048, "maxCount": 3, + "minSizeKb": 1, "minLongSide": 100, + }) + call("PUT", f"/api/v1/tasks/{tid}/orgs", admin, + {"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS}]}) + + # N 个证件号(前 17 位递增,校验位由脚本补) + rows = [] + for i in range(GATE_USERS): + # ⚠️ 身份证号是**定长**的:6 位地区 + 8 位出生日期 + 3 位顺序 + 1 位校验。 + # 所以任何一段"从序号推出来的值"都必须**取模保证位数** —— + # 我原来写 `"%03d" % (100 + i)`,i >= 900 时变成 4 位, + # 号码成了 19 位,2000 人的夹具只导进去 900 个。 + # + # 这里把序号拆成"地区码分块 + 3 位顺序号":每 1000 人换一个地区码。 + # 出生日期固定用 20080101(必须是**真实存在的日期** —— 我曾经拼出 + # `20081000`,被导入校验以"出生日期要真实存在"拒掉,而那条校验抓的 + # 不是格式,是"这个号码不可能属于一个人")。 + area = "%06d" % (361029 + i // 1000) + seq = "%03d" % (i % 1000) + idc = R.with_checksum(area + "20080101" + seq) + rows.append((f"压测{i:04d}", idc)) + build_roster(rows) + body = R.upload_roster(BASE, admin, tid, TMP_ROSTER, + extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) + # ⚠️ **判据是 data.created,不是 code**:导入了 0 行也是 code=0 + # (体检报告在 data 里)。判错的话压测会"没有样本", + # 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑 + rep = body.get("data") or {} + if body.get("code") != 0 or rep.get("created") != GATE_USERS: + raise SystemExit("夹具名册导入没达标:code=%s created=%s errors=%s" + % (body.get("code"), rep.get("created"), + json.dumps(rep.get("errors"), ensure_ascii=False)[:300])) + ids = sql(f"select id, id_card_no from collect_roster where task_id={tid} " + f"and deleted=0 order by id;") + # ⚠️ 证件号在库里是密文,压测要的是**明文**(门禁接口收明文),所以直接用生成的 + roster_rows = [] + for idx, line in enumerate([x for x in ids.split("\n") if x.strip()]): + rid = int(line.split("|")[0]) + ROSTER_IDS.append(rid) + roster_rows.append({"rosterId": rid, "name": rows[idx][0], "idCardNo": rows[idx][1]}) + + if len(roster_rows) != GATE_USERS: + raise SystemExit("夹具名册行数不对:期望 %d,实际 %d" + % (GATE_USERS, len(roster_rows))) + call("POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True}) + cfg = R.read_dev_config() + tokens = [] + for i, r in enumerate(roster_rows): + uid = 998000 + i + UIDS.append(uid) + tokens.append({"uid": uid, "token": R.mint_mp_token( + cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4), + "name": r["name"], "idCardNo": r["idCardNo"], "rosterId": r["rosterId"]}) + return tid, tokens + + +def build_roster(rows): + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "压测班" + header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", + "补考理论", "补考实操", "补考综合", "是否在校生", + "在读学校", "所学专业", "入学时间"] + for c, t in enumerate(header, start=1): + ws.cell(row=1, column=c, value=t) + for i, (name, idc) in enumerate(rows): + vals = [i + 1, name, idc, "中专", "138001399%02d" % (i % 100), "无", + "车工", "数控车工", "四级", "否", "", "", "", "是", + "示范职业学校", "数控加工", "2024-09-01"] + for c, v in enumerate(vals, start=1): + if v != "": + ws.cell(row=i + 2, column=c, value=v) + wb.save(TMP_ROSTER) + + +def make_img(size_kb=400): + """造一张尽量接近真实照片的图(纯色会被压得很小,失真)。""" + import random + random.seed(7) + # 最小合法 JPEG 头 + 随机填充(不是真 JPEG,但上传接口只看扩展名与大小) + with open(TMP_IMG, "wb") as f: + f.write(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + f.write(bytes(random.getrandbits(8) for _ in range(size_kb * 1024))) + f.write(b"\xff\xd9") + + +def reset_gate(): + """清门禁限流(每用户 10 次/小时)。压测每个用户只核验 1 次,理论上不触发; + 但重复跑会累积,所以开跑前清一次。""" + for uid in UIDS: + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:mp:gate:user:%d > NUL 2>&1' % uid) + + +# ══════════════ 场景 ══════════════ + +def scenario_gate(tid, tokens, conc): + """门禁核验:N 个学生同时扫码。""" + st = Stat("门禁核验") + reset_gate() + + def one_req(t): + def go(): + c, b = call("POST", "/api/v1/mp/roster/match", t["token"], + {"taskId": tid, "realName": t["name"], "idCardNo": t["idCardNo"]}, + timeout=60) + return c if c == 200 else c, b + ms, code, body = timed(go) + # 业务码在 body 里(HTTP 200 + code != 0),要分开统计 + biz = body.get("code") if isinstance(body, dict) else None + return ms, int(biz if biz is not None else code), body + + with cf.ThreadPoolExecutor(max_workers=conc) as ex: + for ms, code, body in ex.map(one_req, tokens): + st.add(ms, code) + if code != 0 and len(st.fail_detail) < 3: + st.fail_detail.append(json.dumps(body, ensure_ascii=False)) + st.done() + st.report() + return st + + +def scenario_read(tid, admin, conc, rounds): + """ + 读接口:任务列表 / 名册列表 / 任务统计 / 待办。 + + ⚠️ **按端点分开统计**,不要只报一个"混合 p95": + 压测的目的就是回答"该优化哪个",而混在一起的数字回答不了 —— + 四个接口里可能有一个占掉 90% 的耗时,而混合 p95 会把它的形状抹平。 + """ + paths = [ + ("GET", f"/api/v1/tasks?pageNum=1&pageSize=20", "任务列表"), + ("GET", f"/api/v1/tasks/{tid}/roster?pageNum=1&pageSize=50", "名册列表(50/页)"), + ("GET", f"/api/v1/tasks/{tid}/roster?pageNum=1&pageSize=200", "名册列表(200/页)"), + ("GET", f"/api/v1/tasks/{tid}/statistics", "任务统计"), + ("GET", f"/api/v1/audit/todos?pageNum=1&pageSize=20", "审核待办"), + ] + stats = {label: Stat(label) for _m, _p, label in paths} + + def worker(_): + out = [] + for _r in range(rounds): + for m, p, label in paths: + def go(): + c, b = call(m, p, admin) + return c, b + ms, code, body = timed(go) + biz = body.get("code") if isinstance(body, dict) else None + out.append((label, ms, int(biz if biz is not None else code), body)) + return out + + with cf.ThreadPoolExecutor(max_workers=conc) as ex: + for results in ex.map(worker, range(conc)): + for label, ms, code, body in results: + st = stats[label] + st.add(ms, code) + if code != 0 and len(st.fail_detail) < 3: + st.fail_detail.append(json.dumps(body, ensure_ascii=False)) + for st in stats.values(): + st.done() + for label in [x[2] for x in paths]: + stats[label].report() + return stats + + +def scenario_upload(tid, tokens, conc): + """上传:并发上传图片(压缩是 CPU 密集)。""" + st = Stat("材料上传") + make_img() + sub_ids = {} + # 每个用户先建草稿 + for t in tokens[:conc]: + c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid}) + if c == 200 and isinstance(b, dict) and b.get("code") == 0: + sub_ids[t["uid"]] = b["data"]["id"] + + def one_req(t): + sid = sub_ids.get(t["uid"]) + if sid is None: + return 0.0, -2, {"msg": "没有草稿"} + def go(): + return upload_file("/api/v1/mp/files/upload", t["token"], sid, TMP_IMG) + ms, code, body = timed(go) + biz = body.get("code") if isinstance(body, dict) else None + return ms, int(biz if biz is not None else code), body + + jobs = [] + for t in tokens[:conc]: + for _ in range(UPLOAD_PER_USER): + jobs.append(t) + with cf.ThreadPoolExecutor(max_workers=conc) as ex: + for ms, code, body in ex.map(one_req, jobs): + st.add(ms, code) + if code != 0 and len(st.fail_detail) < 3: + st.fail_detail.append(json.dumps(body, ensure_ascii=False)) + st.done() + st.report() + return st + + +def upload_file(path, token, submission_id, file_path, timeout=180): + boundary = "----lt" + uuid.uuid4().hex[:12] + name = os.path.basename(file_path) + with open(file_path, "rb") as f: + content = f.read() + + def part(field, value): + return (f"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"\r\n\r\n" + f"{value}\r\n").encode("utf-8") + + body = b"" + body += part("submissionId", submission_id) + body += part("materialCode", "IDCARD") + body += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; " + f"filename=\"{name}\"\r\nContent-Type: image/jpeg\r\n\r\n").encode("utf-8") + body += content + b"\r\n" + f"--{boundary}--\r\n".encode("utf-8") + + req = urllib.request.Request(BASE + path, data=body, method="POST", headers={ + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Authorization": f"Bearer {token}", + }) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + return e.code, {"msg": e.read().decode("utf-8", "replace")[:200]} + except Exception as e: # noqa: BLE001 + return -1, {"msg": str(e)} + + +def scenario_export(tid, admin): + """导出:前置检查(只读)+ 创建(同步打包)。""" + st_pre = Stat("导出前置检查") + ms, code, body = timed(lambda: call("GET", f"/api/v1/exports/precheck?taskId={tid}", admin)) + st_pre.add(ms, int((body or {}).get("code", code))) + st_pre.done() + st_pre.report() + if code != 200 or (body or {}).get("code") != 0: + print(" 前置检查没通过,跳过创建:%s" % json.dumps(body, ensure_ascii=False)[:200]) + return + pre = (body or {}).get("data") or {} + can = pre.get("canExport") + ms, code, body = timed(lambda: call("POST", "/api/v1/exports", admin, { + "taskId": tid, "exportName": f"压测导出 {SUFFIX}", "onlyApproved": False, + }, timeout=600)) + biz = int((body or {}).get("code", code)) + msg = (body or {}).get("msg") or "" + if can is False: + # ⚠️ 前置检查不通过时,创建接口**必须也拒绝** —— 这是契约里那条铁律 + # (`canExport=false` 时禁用按钮、不弹警告放行)的服务端一半。 + # 把它报成"压测失败"是误导:它会让人以为导出坏了。 + blocked = biz == 40035 + print(" 前置检查:canExport=false(%d 项 blocking)→ 创建接口%s" + % (len(pre.get("blocking") or []), + "**正确拒绝**(40035)✓ 铁律在服务端也生效" if blocked + else "竟然放行了 ✗(msg=%s)" % msg[:80])) + if not blocked: + print(" ⚠️ 这是**缺陷**:界面靠 canExport 禁用按钮," + "而这里是最后一道服务端防线 —— 它必须自己拦一次") + else: + st_new = Stat("导出创建(同步打包)") + st_new.add(ms, biz) + st_new.done() + st_new.report() + if biz == 0: + eid = (body.get("data") or {}).get("id") + print(" 导出任务 id=%s(异步打包,这里只量「创建+排队」的耗时)" % eid) + + +# ══════════════ 清理 ══════════════ + +def cleanup(): + for tid in TASK_IDS: + for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record", + "collect_file", "collect_submission", "collect_roster", + "collect_task_org", "collect_material_def"): + sql("delete from %s where task_id=%s;" % (tbl, tid)) + sql("delete from collect_task where id=%s;" % tid) + for uid in UIDS: + sql("delete from collect_message where user_id=%d;" % uid) + for p in (TMP_ROSTER, TMP_IMG): + if os.path.exists(p): + os.remove(p) + print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件") + + +def main(): + # ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** —— + # 放晚了会报 "name 'X' is used prior to global declaration" + global GATE_USERS + + ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出)") + ap.add_argument("--only", default="", help="只跑一个场景:gate / read / upload / export") + ap.add_argument("--conc", type=int, default=0, help="覆盖并发数") + ap.add_argument("--clean", action="store_true", help="只清理上次残留") + ap.add_argument("--rows", type=int, default=0, + help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS) + ap.add_argument("--with-upload", action="store_true", + help="跑上传压测(⚠️ 会往真实对象存储写文件,默认关闭)") + args = ap.parse_args() + + if args.rows > 0: + GATE_USERS = args.rows + + if args.clean: + # 残留任务按名字前缀找 + for line in sql("select id from collect_task where task_name like '压测任务 %';").split("\n"): + if line.strip().isdigit(): + TASK_IDS.append(int(line.strip())) + cleanup() + return 0 + + print("=" * 100) + print(" 压测(M7-09) 后端:%s" % BASE) + print(" 规模:名册/门禁 %d 人 读 %d 并发 × %d 轮 上传 %d×%d(需 --with-upload)" + % (GATE_USERS, READ_CONC, READ_ROUNDS, UPLOAD_USERS, UPLOAD_PER_USER)) + print(" ⚠️ 读接口的结果**必须带规模看**:同一个接口在 60 行与 2000 行上是两件事") + print("=" * 100) + + for acct in ("admin",): + os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning ' + 'DEL xt:login:fail:%s > NUL 2>&1' % acct) + _, adm = R.login(BASE, "admin", "Admin@123", "demo") + if adm.get("code") != 0: + print("管理员登录失败:" + str(adm)) + return 2 + admin = adm["data"]["token"] + + print("\n── 造夹具 ──") + t0 = time.perf_counter() + tid, tokens = build_fixture(admin) + print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs" + % (tid, len(tokens), time.perf_counter() - t0)) + + stats = {} + conc = args.conc + try: + if args.only in ("", "gate"): + print("\n── ① 门禁核验(%d 人同时扫码,每个都要 AES 解密全表比对)──" % GATE_USERS) + stats["gate"] = scenario_gate(tid, tokens, conc or GATE_USERS) + if args.only in ("", "read"): + print("\n── ② 读接口(任务/名册/统计/待办 混合,%d 并发 × %d 轮)──" + % (READ_CONC, READ_ROUNDS)) + stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS) + if args.only == "upload" or (args.with_upload and args.only == ""): + print("\n── ③ 材料上传(%d 人同时传,压缩是 CPU 密集)──" % UPLOAD_USERS) + stats["upload"] = scenario_upload(tid, tokens, conc or UPLOAD_USERS) + if args.only == "" and not args.with_upload: + print("\n── ③ 材料上传(**默认跳过**)──") + print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),") + print(" 压测上传会打到外部服务并把测试文件灌进真实桶。要跑就加 --with-upload,") + print(" 且最好先把存储切成本地:XT_STORAGE_PROVIDER=local 重启后端。") + if args.only in ("", "export"): + print("\n── ④ 导出(前置检查 + 创建)──") + scenario_export(tid, admin) + finally: + print("\n── 清理 ──") + cleanup() + + print("\n" + "=" * 100) + print(" 结论:看 p95 与**失败分布**。失败码的含义:") + print(" 40071 门禁限流(每用户 10 次/小时)—— 说明压测把同一个用户重复用了") + print(" 42901 接口限流 50000 内部错(去看 backend/_dev-log.txt) 40301 权限") + print("=" * 100) + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-all.py b/scripts/verify-all.py new file mode 100644 index 0000000..8a21681 --- /dev/null +++ b/scripts/verify-all.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +"""交付自检:一条命令把"这个仓库现在到底对不对"跑一遍。 + +## 为什么需要它 + +这个项目的验证手段有六类,散在六个地方: + +| 类别 | 命令 | 需要什么 | +|---|---|---| +| 契约 ↔ 路由 | `check-api-parity.py` | 无 | +| 实体 ↔ 建表 | `check-entity-columns.py` | 无 | +| 文档自身一致 | `verify-docs.py` | 无 | +| 手册 ↔ 代码文案 | `check-manual.py` | 无 | +| 接口行为(1548 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** | +| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** | +| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis | + +**要记住"该跑哪几个、哪些需要前置"本身就是一种负担**,而漏跑一个的后果是 +"以为过了"。所以这里合成一个入口,并且**如实说明哪些因为前置不足而跳过** +(不是静默跳过 —— 那与 F-85 是同一类问题)。 + +## 用法 + +```bash +python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明) +python scripts/verify-all.py --with-e2e # 连 1548 条接口断言一起跑(需要后端在跑) +python scripts/verify-all.py --quick # 只跑不需要任何前置的五项 +``` + +退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。 +""" +import argparse +import glob +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.request + +sys.stdout.reconfigure(encoding="utf-8") +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +os.chdir(ROOT) + +GREEN, RED, YELLOW, DIM, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[0m" +if not sys.stdout.isatty(): + GREEN = RED = YELLOW = DIM = RESET = "" + + +def run(cmd, timeout=3600): + """跑一条命令,返回 (退出码, 输出)。""" + try: + p = subprocess.run(cmd, shell=True, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout) + return p.returncode, (p.stdout or "") + (p.stderr or "") + except subprocess.TimeoutExpired: + return -9, "超时" + + +def backend_up(): + try: + with urllib.request.urlopen("http://localhost:8080/api/v1/auth/captcha", timeout=5): + return True + except Exception: + return False + + +def main(): + ap = argparse.ArgumentParser(description="交付自检") + ap.add_argument("--with-e2e", action="store_true", help="连 e2e 一起跑(需要后端在跑)") + ap.add_argument("--quick", action="store_true", help="只跑不需要前置的五项") + args = ap.parse_args() + + results = [] # (名称, 结果, 摘要) + + print("=" * 92) + print(" 交付自检 仓库:%s" % ROOT) + print("=" * 92) + + # ── 一、不需要任何前置的机械校验 ── + print("\n【一】机械校验(不需要后端/数据库)") + checks = [ + ("契约 ↔ 路由", "python scripts/check-api-parity.py --quiet", + r"合计:契约 (\d+) / 已实现 (\d+) / 缺口 (\d+)"), + ("实体 ↔ 建表", "python scripts/check-entity-columns.py --quiet", + r"(\d+) 个实体全部与迁移一致((\d+) 张表)"), + ("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"), + ("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"), + ] + for name, cmd, pat in checks: + code, out = run(cmd, timeout=300) + line = "" + m = re.search(pat, out) + if m: + line = m.group(0).strip() + else: + tail = [x for x in out.strip().split("\n") if x.strip()] + line = tail[-1][:80] if tail else "(无输出)" + ok = code == 0 + results.append((name, ok, line)) + print(" %s%-14s%s %s" % (GREEN if ok else RED, name, RESET, line)) + + # 脚本语法(改脚本时最容易踩:中文串里嵌半角引号) + bad = [] + for fn in sorted(glob.glob("scripts/*.py")): + try: + compile(open(fn, encoding="utf-8").read(), fn, "exec") + except SyntaxError as e: + bad.append("%s:%s" % (os.path.basename(fn), e.lineno)) + ok = not bad + results.append(("脚本语法", ok, "全部可编译" if ok else "有语法错:%s" % ", ".join(bad))) + print(" %s%-14s%s %s" % (GREEN if ok else RED, "脚本语法", RESET, + "全部可编译" if ok else "有语法错:%s" % ", ".join(bad))) + + if args.quick: + return summary(results) + + # ── 二、需要后端在跑 / 能起 jar 的 ── + print("\n【二】带前置的检查") + up = backend_up() + if not up: + msg = "**跳过**:后端 8080 没在跑(先 `cd backend && java -jar ... --spring.profiles.active=dev`)" + results.append(("接口断言(E2E)", None, msg)) + print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg)) + elif not args.with_e2e: + msg = "**跳过**:要跑就加 `--with-e2e`(36 个脚本 / 1500+ 条断言,约 10 分钟)" + results.append(("接口断言(E2E)", None, msg)) + print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg)) + else: + t0 = time.time() + files = sorted(glob.glob("scripts/e2e-*.py")) + total = fails = 0 + failed_names = [] + for f in files: + code, out = run("python %s" % f, timeout=900) + m = re.search(r"通过 (\d+) 失败 (\d+) 共 (\d+)", out) + if m: + total += int(m.group(1)) + if int(m.group(2)) > 0: + fails += int(m.group(2)) + failed_names.append(os.path.basename(f)) + else: + m2 = re.search(r"结果: (\d+)/(\d+)", out) + if m2: + total += int(m2.group(2)) + if m2.group(1) != m2.group(2): + fails += 1 + failed_names.append(os.path.basename(f)) + else: + fails += 1 + failed_names.append(os.path.basename(f) + "(无汇总)") + ok = fails == 0 + msg = "%d 个脚本 / %d 条断言 / 失败 %d%s(%.0fs)" % ( + len(files), total, fails, + "" if ok else ":" + ", ".join(failed_names[:5]), time.time() - t0) + results.append(("接口断言(E2E)", ok, msg)) + print(" %s%-14s%s %s" % (GREEN if ok else RED, "接口断言", RESET, msg)) + + # 生产自检:需要 jar + jar = glob.glob("backend/xt-server/target/xt-server-*.jar") + if not jar: + msg = "**跳过**:没有 jar(先 `cd backend && mvn -DskipTests package`)" + results.append(("生产配置拦截", None, msg)) + print(" %s%-14s%s %s" % (YELLOW, "生产配置拦截", RESET, msg)) + else: + # 起 jar 的检查会撞端口,所以只在没别的 java 在跑时做 —— 如实说明 + code, out = run("python scripts/e2e-prodguard.py", timeout=900) + m = re.search(r"通过 (\d+) 失败 (\d+) 共 (\d+)", out) + ok = code == 0 + msg = m.group(0) if m else ("通过" if ok else "失败") + results.append(("生产配置拦截", ok, msg)) + print(" %s%-14s%s %s" % (GREEN if ok else RED, "生产配置拦截", RESET, msg)) + + if up: + code, out = run("python scripts/check-test-profile.py", timeout=600) + m = re.search(r"结果:(.*)", out) + ok = code == 0 + msg = (m.group(1).strip() if m else ("通过" if ok else "失败"))[:70] + results.append(("test profile 能起", ok, msg)) + print(" %s%-14s%s %s" % (GREEN if ok else RED, "test profile 能起", RESET, msg)) + else: + results.append(("test profile 能起", None, "**跳过**:后端没在跑")) + print(" %s%-14s%s **跳过**:后端没在跑" % (YELLOW, "test profile 能起", RESET)) + + return summary(results) + + +def summary(results): + print("\n" + "=" * 92) + passed = [r for r in results if r[1] is True] + failed = [r for r in results if r[1] is False] + skipped = [r for r in results if r[1] is None] + print(" 通过 %d 失败 %d 跳过 %d" % (len(passed), len(failed), len(skipped))) + for name, ok, msg in failed: + print(" %s✗ %s%s:%s" % (RED, name, RESET, msg[:110])) + for name, ok, msg in skipped: + # ⚠️ 跳过要**明确列出来**,不能静默 —— 否则"以为过了"(与 F-85 同类) + print(" %s— %s 跳过:%s%s" % (YELLOW, name, msg[:100], RESET)) + print("=" * 92) + sys.stdout.flush() + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main())