Files
xiaotutongxue/scripts/e2e-m3-mytask.py
T
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

331 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-接口设计》§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_task where id={tid};")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 材料 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())