test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检

新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
2026-09-30 09:02:57 +08:00
parent 6945f194b5
commit 30c3714d35
26 changed files with 9190 additions and 1 deletions
+147
View File
@@ -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())
+186
View File
@@ -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())
+88
View File
@@ -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)
+174
View File
@@ -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)
+411
View File
@@ -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<Void>`(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())
+310
View File
@@ -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())
+406
View File
@@ -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())
+17 -1
View File
@@ -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: 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)) total = sum(int(base17[i]) * WEIGHTS[i] for i in range(17))
return base17 + CHECK_CHARS[total % 11] return base17 + CHECK_CHARS[total % 11]
+331
View File
@@ -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())
+354
View File
@@ -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())
+455
View File
@@ -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())
+450
View File
@@ -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())
+315
View File
@@ -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())
+415
View File
@@ -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())
+325
View File
@@ -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())
+377
View File
@@ -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())
+434
View File
@@ -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())
+619
View File
@@ -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)
+587
View File
@@ -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())
+507
View File
@@ -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())
+574
View File
@@ -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())
+501
View File
@@ -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())
+266
View File
@@ -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)
+193
View File
@@ -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)
+542
View File
@@ -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())
+206
View File
@@ -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())