# -*- 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())