Files
xiaotutongxue/scripts/e2e-m5-teacher-dashboard.py
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

315 lines
14 KiB
Python
Raw Permalink 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-接口设计》§10.1)。
这一屏最容易做错的两件事,脚本就是为它们写的:
1. **口径铁律**:`submitRate` 是**已提交率**,与 `approvedCount` **分开呈现**。
合并成一个"完成率"会让"有人没交"(去催报)和"有人没过"(去看材料)
变成同一个数字 —— 老师看到 60% 不知道该做哪件事。
E2E 刻意造出"3 人全交、只过 1 人"的数据,两个数字必须同时正确且不相等。
2. **范围只算"我负责的班"**:同校另一个班主任的班不能出现在我的看板里,
`rosterTotal` 也不能把他的班算进来。(只查权限不查范围 = 全校数字,
而老师会以为那是自己班的。)
跑法:python scripts/e2e-m5-teacher-dashboard.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] = []
CREATED_USER_IDS: list[int] = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-dash-roster.xlsx")
T1 = 993501 # 班主任甲(指定为 A 班的初审人)
T2 = 993502 # 班主任乙(指定为 B 班的初审人)
STUDENTS = [993511, 993512, 993513]
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"
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 ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str:
"""建一个真实班主任账号(看板走的是"老师"这条路,需要一个能登录的账号)。"""
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES ({uid}, 1, '{username}', '{name}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, status = 1
""")
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"and deleted=0 limit 1;").strip()
sql(f"delete from sys_user_role where user_id={uid};")
sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);")
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(f" 班主任 {username} 登录失败:{b}")
return b["data"]["token"]
def main() -> int:
ap = argparse.ArgumentParser(description="班主任看板 E2E")
ap.parse_args()
print("=" * 74)
print(" 班主任端看板 E2E(§10.1)")
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 [])
if len(classes) < 2:
print(" ✗ 需要至少两个班级组织")
return 2
org_a, org_b = classes[0], classes[1]
t1_tok = ensure_teacher(T1, f"dashT1{suffix}", "看板班主任甲", org_a["id"])
ensure_teacher(T2, f"dashT2{suffix}", "看板班主任乙", org_b["id"])
CREATED_USER_IDS.extend([T1, T2])
ok("两个班主任账号就绪(都能登录)")
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [
{"orgId": org_a["id"], "expectedCount": len(STUDENTS), "headUserId": T1},
{"orgId": org_b["id"], "expectedCount": 1, "headUserId": T2}
]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
rows = [("看板甲", R.with_checksum("36102920080101021")),
("看板乙", R.with_checksum("36102920080202022")),
("看板丙", R.with_checksum("36102920080303023"))]
_build_roster(rows)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]})
if body.get("code") != 0:
print(f" ✗ 导名册失败:{body}")
return 2
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
ok("任务已发布(A 班 3 人,B 班 0 人)")
# ── 1. 三个学生全交 ──
print("\n[1] A 班三人全部提交(已提交率应当 100%)")
sids = []
for i, (uid, (name, idc)) in enumerate(zip(STUDENTS, rows)):
tok = mp(uid)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
if b.get("code") != 0:
print(f" ✗ 门禁失败({name}):{b}")
return 2
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
sids.append(sid)
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)
if not (res.get("data") or {}).get("submitted"):
print(f" ✗ 提交失败({name}):{json.dumps(res, ensure_ascii=False)[:200]}")
return 2
check("三人都已提交", sql(f"select count(*) from collect_submission where task_id={task_id} "
f"and status >= 1 and deleted=0;").strip() == "3")
# ── 2. 只通过一个人 → 让两个数字**不相等** ──
print("\n[2] 只终审通过 1 人(这样「已提交 3」与「已通过 1」必然不同)")
b = body_of("POST", f"/api/v1/audit/submissions/{sids[0]}/approve", t1_tok,
{"actionSource": "BUTTON"})
check("班主任初审通过 1 人", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", f"/api/v1/audit/submissions/{sids[0]}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E"})
check("管理员终审通过 1 人", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
# ── 3. 看板口径 ──
print("\n[3] 看板口径(本节是重点)")
d = body_of("GET", "/api/v1/mp/teacher/dashboard", t1_tok).get("data") or {}
check("看板接口可用", isinstance(d.get("summary"), dict),
json.dumps(d, ensure_ascii=False)[:200])
s = d.get("summary") or {}
check("应填 3 人", s.get("rosterTotal") == 3, f"{s.get('rosterTotal')}")
check("已提交 3 人", s.get("submittedCount") == 3, f"{s.get('submittedCount')}")
check("⭐ 已通过 1 人(与已提交分开)", s.get("approvedCount") == 1,
f"{s.get('approvedCount')}")
check("⭐ submitRate 是**已提交率** 100.0%(不是通过率的 33.3%)",
s.get("submitRate") == "100.0%", f"{s.get('submitRate')}")
check("未提交 0 人", s.get("unsubmittedCount") == 0, f"{s.get('unsubmittedCount')}")
check("待我初审 2 人(三人里过了 1 个)", (d.get("todos") or {}).get("auditCount") == 2,
json.dumps(d.get("todos"), ensure_ascii=False))
orgs = d.get("myOrgs") or []
check("⭐ 只看到我负责的班(1 个,不是 2 个)", len(orgs) == 1,
json.dumps([o.get("orgName") for o in orgs], ensure_ascii=False))
if orgs:
o = orgs[0]
check("班级卡片:人数 3 / 已提交 3 / 已通过 1",
(o.get("rosterTotal"), o.get("submittedCount"), o.get("approvedCount")) == (3, 3, 1),
json.dumps(o, ensure_ascii=False))
check("班级卡片也给出已提交率", o.get("submitRate") == "100.0%", f"{o.get('submitRate')}")
check("标出我是这个班的主班主任", o.get("isHead") is True, f"{o.get('isHead')}")
check("卡片带待审角标(2)", o.get("auditCount") == 2, f"{o.get('auditCount')}")
check("带最近截止的任务信息", (d.get("deadline") or {}).get("taskId") is not None,
json.dumps(d.get("deadline"), ensure_ascii=False))
# ── 4. 另一个班主任看不到我的班 ──
print("\n[4] 范围:另一个班主任看不到我的班")
_, b = R.login(BASE, f"dashT2{suffix}", "Admin@123", "demo")
t2_tok = b["data"]["token"]
d2 = body_of("GET", "/api/v1/mp/teacher/dashboard", t2_tok).get("data") or {}
orgs2 = d2.get("myOrgs") or []
check("⭐ 他只看得到自己的班", all(o.get("orgId") != org_a["id"] for o in orgs2),
json.dumps([o.get("orgName") for o in orgs2], ensure_ascii=False))
check("⭐ 他的人数是 0(我的 3 人没被算进去)",
(d2.get("summary") or {}).get("rosterTotal") == 0,
f"{(d2.get('summary') or {}).get('rosterTotal')}")
check("他的待审也是 0", (d2.get("todos") or {}).get("auditCount") == 0,
json.dumps(d2.get("todos"), ensure_ascii=False))
# ── 5. 空班:分母为 0 时不能显示 0% ──
print("\n[5] 分母为 0 时显示「—」而不是 0%")
if orgs2:
o2 = orgs2[0]
check("空班的已提交率是「—」(0 人 ≠ 没人交)", o2.get("submitRate") == "—",
f"{o2.get('submitRate')}")
# ── 6. 权限 ──
print("\n[6] 权限与学生不该看到看板")
stok = mp(STUDENTS[0])
b = body_of("GET", "/api/v1/mp/teacher/dashboard", stok)
check("学生看班主任看板 → 40301 或 403",
b.get("code") in (40301, 403) or b.get("_http") == 403,
f"code={b.get('code')} http={b.get('_http')}")
b = body_of("GET", "/api/v1/mp/teacher/dashboard")
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"138001388{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_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_org where task_id={tid};")
R.sql(f"delete from collect_material_def where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
for uid in CREATED_USER_IDS:
R.sql(f"delete from sys_user_role where user_id={uid};")
R.sql(f"delete from sys_user where id={uid};")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 材料 / 班主任账号 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())