1f3c485ceb
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(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 名与全局导航栏标题
315 lines
14 KiB
Python
315 lines
14 KiB
Python
# -*- 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())
|