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