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
+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())