Files
xiaotutongxue/scripts/e2e-m7-validation.py
T
lovife 30c3714d35 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。
2026-09-30 09:02:57 +08:00

508 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())