Files
xiaotutongxue/scripts/e2e-m5-logs.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

355 lines
17 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-接口设计》§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())