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 名与全局导航栏标题
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|---|---|---|
|
||||
| F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** |
|
||||
| F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` |
|
||||
| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | 学生申请 → 断言班主任收到;审批通过/驳回 → 断言学生收到 |
|
||||
| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | **通道已删除**(2026-10 需求收敛):断言两张表与两个角色都不在了 |
|
||||
| F-103 | 导出完成后不通知(异步打包,用户只能守着进度条) | 建导出 → 等完成 → 断言发起人收到,且**正文里没有签名 URL** |
|
||||
| F-104 | 自动催报只有 7/3/1 天,**缺"截止当日"**(FR-1003 原文有) | 截止日=今天的任务 → 手工触发催报 → 断言未提交的人收到、已提交的人没收到 |
|
||||
| F-105 | 驳回达上限只写一行日志,而契约说"同时通知管理员" | 连驳 3 次 → 断言管理员收到升级通知 |
|
||||
@@ -38,7 +38,6 @@ TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-wiring-roster.xlsx")
|
||||
|
||||
T1 = 993801 # 班主任(初审人)
|
||||
S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途)
|
||||
JOINER = 993814 # 走「申请加入名单」的学生(不在名册里)
|
||||
|
||||
|
||||
def _load(module_name: str, filename: str):
|
||||
@@ -89,17 +88,34 @@ def mp(uid: int) -> str:
|
||||
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
|
||||
|
||||
|
||||
def latest_msg(uid: int, biz: int) -> tuple[str, str]:
|
||||
"""某人某类的最新一条站内信(标题, 正文)。没有就回空串。"""
|
||||
def latest_msg(uid: int, biz: int, task_id: int | None = None) -> tuple[str, str]:
|
||||
"""某人某类的最新一条站内信(标题, 正文)。没有就回空串。
|
||||
|
||||
⚠️ 传 `task_id` 时按**这条消息指向的任务**收窄(`link_type='TASK'` + `link_id=taskId`,
|
||||
见 `UrgeService`)。不传就是"这个人的全部该类消息"。
|
||||
"""
|
||||
where = f"user_id={uid} and biz_type={biz} and deleted=0"
|
||||
if task_id is not None:
|
||||
where += f" and link_type='TASK' and link_id={task_id}"
|
||||
out = sql(f"select title || '\t' || content from collect_message "
|
||||
f"where user_id={uid} and biz_type={biz} and deleted=0 order by id desc limit 1;")
|
||||
f"where {where} order by id desc limit 1;")
|
||||
parts = out.strip().split("\t")
|
||||
return (parts[0], parts[1]) if len(parts) == 2 else ("", "")
|
||||
|
||||
|
||||
def msg_count(uid: int, biz: int) -> int:
|
||||
v = sql(f"select count(*) from collect_message where user_id={uid} and biz_type={biz} "
|
||||
f"and deleted=0;").strip()
|
||||
def msg_count(uid: int, biz: int, task_id: int | None = None) -> int:
|
||||
"""某人某类站内信条数。
|
||||
|
||||
⚠️ **催报这类断言必须传 `task_id`**(踩过一次):催报任务是按"截止日在今天+N 天"扫
|
||||
**全库所有任务**的,而每次跑 E2E 都会新建一个"今天截止"的任务 ——
|
||||
只要上一次跑留下过一个没清干净的同截止日任务(脚本提前 return 时会跳过清理),
|
||||
下一次的催报就会同时命中两个任务,此人收到 2 条,断言"== 1"随机变红。
|
||||
按 `link_id`(催报消息指向的就是 taskId)收窄之后,这个断言与库里有多少残留任务无关。
|
||||
"""
|
||||
where = f"user_id={uid} and biz_type={biz} and deleted=0"
|
||||
if task_id is not None:
|
||||
where += f" and link_type='TASK' and link_id={task_id}"
|
||||
v = sql(f"select count(*) from collect_message where {where};").strip()
|
||||
return int(v or 0)
|
||||
|
||||
|
||||
@@ -235,21 +251,6 @@ def main() -> int:
|
||||
return 2
|
||||
check("三个学生都绑定了身份", True)
|
||||
|
||||
# ⚠️ 加入名单申请要求申请人有 **openid**(契约:同 openid 每任务 ≤2 次,
|
||||
# 没有 openid 就没法按人限次 → 服务端明确拒绝,见 JoinService.submit)。
|
||||
# 门禁绑定建出来的账号没有 openid,所以这里显式补一个 —— 这是夹具,不是产品行为。
|
||||
# ⚠️ 这两位**不在名册里**,也就不会被门禁绑定建号 → 必须先自己建出来再补 openid。
|
||||
for uid, uname, rname in ((JOINER, "wireJoin1", "接线申请者"),
|
||||
(993815, "wireJoin2", "接线被拒者")):
|
||||
sql(f"""
|
||||
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id,
|
||||
user_type, status, open_id)
|
||||
VALUES ({uid}, 1, '{uname}', '{rname}',
|
||||
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org["id"]}, 4, 1,
|
||||
'e2e_wire_{uid}')
|
||||
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, deleted = 0, status = 1
|
||||
""")
|
||||
|
||||
# ══════════════ F-101:改模板 → 自动驳回真的用新文案 ══════════════
|
||||
print("\n[1] F-101 模板可配置:自动通知是否真的走模板")
|
||||
tpl = customize_template(at, "AUDIT_REJECT")
|
||||
@@ -312,48 +313,41 @@ def main() -> int:
|
||||
back = find_template(at, "AUDIT_REJECT")
|
||||
check("恢复默认后读回内置文案", back.get("customized") is False, f"{back.get('customized')}")
|
||||
|
||||
# ══════════════ F-102:加入名单申请的通知 ══════════════
|
||||
print("\n[3] F-102 加入名单申请:提交要通知老师、审批要通知学生")
|
||||
joiner = mp(JOINER)
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner, {
|
||||
"taskId": task_id, "realName": "接线申请者", "idCardNo": R.with_checksum("36102920080404044"),
|
||||
"phone": "13800137888", "orgHint": org.get("orgName"), "reason": "E2E 申请"
|
||||
})
|
||||
check("学生提交加入申请成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
req_id = (b.get("data") or {}).get("requestId")
|
||||
# ══════════════ 需求收敛:删掉的东西必须真的不在(2026-10)══════════════
|
||||
print("\n[3] 需求收敛:两张表、两个内置角色、两个权限码都已下线")
|
||||
for tbl in ("collect_join_request", "collect_cleanup_job"):
|
||||
out = sql(f"SELECT count(*) FROM information_schema.tables WHERE table_name = '{tbl}'")
|
||||
check(f"⚠️ 表 {tbl} 已被 V19 删除", out.strip() == "0", f"查询结果={out!r}")
|
||||
|
||||
t_title, t_content = latest_msg(T1, 7)
|
||||
check("⭐ 班主任收到了申请通知(以前一个都不发)", bool(t_title), f"title={t_title!r}")
|
||||
check("通知里说清是谁、哪个班、哪个任务",
|
||||
"接线申请者" in t_content and (org.get("orgName") or "") in t_content
|
||||
and "E2E通知接线" in t_content, f"实际={t_content!r}")
|
||||
a_title, _ = latest_msg(admin_uid, 7) # 学校管理员(SCHOOL_ADMIN,兜底收件人)
|
||||
check("学校管理员也收到了(任务没配班主任时的兜底)", bool(a_title), f"title={a_title!r}")
|
||||
out = sql("SELECT count(*) FROM information_schema.columns "
|
||||
"WHERE table_name = 'collect_task_org' AND column_name = 'deputy_user_ids'")
|
||||
check("⚠️ collect_task_org.deputy_user_ids 列已删除(协办人机制下线)",
|
||||
out.strip() == "0", f"查询结果={out!r}")
|
||||
|
||||
before_join = msg_count(JOINER, 7)
|
||||
ap_body = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at,
|
||||
{"orgId": org["id"]})
|
||||
check("审批通过成功", ap_body.get("code") == 0, json.dumps(ap_body, ensure_ascii=False)[:200])
|
||||
check("⭐ 学生收到了审批结果(站内信 bizType=7)",
|
||||
msg_count(JOINER, 7) > before_join,
|
||||
f"审批前={before_join} 审批后={msg_count(JOINER, 7)}")
|
||||
j_title, j_content = latest_msg(JOINER, 7)
|
||||
check("结果通知里带任务名,并告诉他可以开始填报了",
|
||||
"E2E通知接线" in j_content and "填报" in j_content, f"实际={j_content!r}")
|
||||
# ⚠️ 不要用 `string_agg` 拼起来比字符串:它**没有 ORDER BY**,
|
||||
# 行的物理顺序会随 vacuum/重建而变,断言就会"偶尔红一次"(跑全量时才复现)。
|
||||
# 这里改成"数个数 + 点名单",与顺序无关。
|
||||
out = sql("SELECT count(*) FROM sys_role WHERE deleted = 0 AND role_type = 1 AND tenant_id = 1")
|
||||
check("⚠️ 内置角色只剩 4 个", out.strip() == "4", f"实际={out.strip()!r}")
|
||||
|
||||
# 驳回那条路(换个学生,避免"每任务 2 次"的上限)
|
||||
JO = 993815
|
||||
joiner2 = mp(JO)
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner2, {
|
||||
"taskId": task_id, "realName": "接线被拒者", "idCardNo": R.with_checksum("36102920080505045"),
|
||||
"phone": "13800137999", "orgHint": org.get("orgName"), "reason": "E2E 申请2"
|
||||
})
|
||||
req2 = (b.get("data") or {}).get("requestId")
|
||||
body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at,
|
||||
{"reasonCode": "NOT_STUDENT", "reason": "E2E 不是本校学生"})
|
||||
_, rej_content = latest_msg(JO, 7)
|
||||
check("⭐ 驳回也通知学生,且把原因说清(否则他会反复重提)",
|
||||
"E2E 不是本校学生" in rej_content, f"实际={rej_content!r}")
|
||||
out = sql("SELECT count(*) FROM sys_role WHERE deleted = 0 AND role_type = 1 AND tenant_id = 1 "
|
||||
"AND role_code IN ('DEPUTY_TEACHER','AUDITOR')")
|
||||
check("⚠️ 内置角色里没有副班主任 / 审核员", out.strip() == "0", f"查询结果={out!r}")
|
||||
|
||||
out = sql("SELECT count(*) FROM sys_user_role ur JOIN sys_role r ON r.id = ur.role_id "
|
||||
"WHERE ur.deleted = 0 AND r.role_code IN ('DEPUTY_TEACHER','AUDITOR')")
|
||||
check("⚠️ 没有任何账号还挂着这两个角色", out.strip() == "0", f"查询结果={out!r}")
|
||||
|
||||
# 权限码目录:join:audit 与 cleanup:run 必须已经消失(否则「角色与授权」页还能勾到它们)
|
||||
perms = body_of("GET", "/api/v1/permissions", at)
|
||||
codes = set()
|
||||
for grp in (perms.get("data") or []):
|
||||
for it in (grp.get("items") or []):
|
||||
codes.add(it.get("code"))
|
||||
check("⭐ 权限码目录里不再有 join:audit / cleanup:run",
|
||||
"join:audit" not in codes and "cleanup:run" not in codes,
|
||||
f"实际命中={sorted(c for c in codes if c in ('join:audit', 'cleanup:run'))}")
|
||||
check("⭐ 权限码总数收敛为 28", len(codes) == 28, f"实际 {len(codes)} 个")
|
||||
|
||||
# ══════════════ F-103:导出完成的通知 ══════════════
|
||||
print("\n[4] F-103 导出完成:通知发起人(且正文里不放签名 URL)")
|
||||
@@ -406,17 +400,19 @@ def main() -> int:
|
||||
return 2
|
||||
ops_token = ops["data"]["token"]
|
||||
# 丙还没提交 → 该被催;甲、乙已提交 → 不该被催
|
||||
# ⚠️ 计数用 `task_id` 收窄:催报任务扫的是"截止日=今天+N 天"的**全库**任务,
|
||||
# 残留的同截止日任务会让计数变成 2(详见 msg_count 的注释)。
|
||||
sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}) and biz_type=5;")
|
||||
run = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
|
||||
check("手工触发自动催报成功", run.get("code") == 0,
|
||||
json.dumps(run, ensure_ascii=False)[:200])
|
||||
c_title, c_content = latest_msg(S3, 5)
|
||||
c_title, c_content = latest_msg(S3, 5, task_id)
|
||||
check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的",
|
||||
msg_count(S3, 5) == 1, f"丙的催报条数={msg_count(S3, 5)}")
|
||||
msg_count(S3, 5, task_id) == 1, f"丙本任务催报条数={msg_count(S3, 5, task_id)}")
|
||||
check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)",
|
||||
"今天是截止日" in c_content, f"实际={c_content!r}")
|
||||
check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)",
|
||||
msg_count(S1, 5) == 0, f"甲的催报条数={msg_count(S1, 5)}")
|
||||
msg_count(S1, 5, task_id) == 0, f"甲本任务催报条数={msg_count(S1, 5, task_id)}")
|
||||
check("催报正文里带任务名(学生一眼知道是哪件事)",
|
||||
"E2E通知接线" in c_content, f"实际={c_content!r}")
|
||||
check("模板变量 {提交进度} 真的被填了(白名单里有、以前永远空)",
|
||||
@@ -460,7 +456,7 @@ def main() -> int:
|
||||
encoding="utf-8").read() for k in ("'dict'", "'notify'")))
|
||||
|
||||
# ══════════════ F-107:权限码真的挂在接口上 ══════════════
|
||||
print("\n[8] F-107 权限码挂到接口:克隆与整理器")
|
||||
print("\n[8] F-107 权限码挂到接口:克隆任务走 task:clone")
|
||||
cl = body_of("POST", f"/api/v1/tasks/{task_id}/clone", t1, {"taskName": "不该建出来"})
|
||||
check("⭐ 班主任(无 task:clone)克隆任务被 40301 拦住",
|
||||
cl.get("code") == 40301, f"code={cl.get('code')} msg={cl.get('msg')}")
|
||||
@@ -469,9 +465,6 @@ def main() -> int:
|
||||
json.dumps(cl2, ensure_ascii=False)[:200])
|
||||
if cl2.get("code") == 0:
|
||||
CREATED_TASK_IDS.append((cl2.get("data") or {}).get("id"))
|
||||
cu = body_of("POST", "/api/v1/cleanup/jobs/json", t1, {"jobName": "不该建出来"})
|
||||
check("⭐ 班主任(无 cleanup:run)调整理器被 40301 拦住",
|
||||
cu.get("code") == 40301, f"code={cu.get('code')} msg={cu.get('msg')}")
|
||||
|
||||
cleanup()
|
||||
print("\n" + "=" * 78)
|
||||
@@ -499,7 +492,6 @@ def cleanup() -> None:
|
||||
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_join_request 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};")
|
||||
@@ -510,17 +502,14 @@ def cleanup() -> None:
|
||||
# 那会把演示态里别人改过的模板一起删掉(而"演示态不能被脚本弄坏"是这个仓库的硬约束)。
|
||||
for tid in CREATED_TPL_IDS:
|
||||
R.sql(f"delete from collect_notify_template where id={tid};")
|
||||
# 本脚本造的学生/申请人/班主任
|
||||
R.sql(f"delete from collect_message where user_id in "
|
||||
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
|
||||
R.sql(f"delete from collect_notify_record where receiver_id in "
|
||||
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
|
||||
R.sql(f"delete from sys_user_role where user_id in "
|
||||
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
|
||||
R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
|
||||
# 本脚本造的学生/班主任
|
||||
R.sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}, {T1});")
|
||||
R.sql(f"delete from collect_notify_record where receiver_id in ({S1}, {S2}, {S3}, {T1});")
|
||||
R.sql(f"delete from sys_user_role where user_id in ({S1}, {S2}, {S3}, {T1});")
|
||||
R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {T1});")
|
||||
if os.path.exists(TMP_ROSTER):
|
||||
os.remove(TMP_ROSTER)
|
||||
print(" 已清理:任务 / 名册 / 填报单 / 导出 / 申请 / 消息 / 通知记录 / 模板覆盖行 / 测试账号")
|
||||
print(" 已清理:任务 / 名册 / 填报单 / 导出 / 消息 / 通知记录 / 模板覆盖行 / 测试账号")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user