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:
@@ -4,7 +4,7 @@
|
||||
用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。
|
||||
—— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。
|
||||
|
||||
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 121 个,全文权威清单)。
|
||||
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 119 个,全文权威清单)。
|
||||
代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。
|
||||
|
||||
★ 两个必须容忍的写法(踩过):
|
||||
@@ -160,9 +160,11 @@ def main() -> int:
|
||||
print()
|
||||
print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。")
|
||||
|
||||
# 契约必须恰好 121(§2 的口径),不等就说明解析或文档出了问题
|
||||
if claimed != 121:
|
||||
print(" [FAIL] 契约接口数应为 121,实际解析出 %d —— 检查 §4 表格格式" % claimed)
|
||||
# 契约必须恰好 119(需求收敛 2026-10 后的口径:删掉「历史整理器」5 个与
|
||||
# 「加入申请」5 个,原 129 → 119;缺口只保留 /mp/ocr/idcard 一个),
|
||||
# 不等就说明解析或文档出了问题
|
||||
if claimed != 119:
|
||||
print(" [FAIL] 契约接口数应为 119,实际解析出 %d —— 检查 §4 表格格式" % claimed)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -51,12 +51,14 @@ for c in used:
|
||||
print(" ✗ %s **ErrorCode 里没有这个码**" % c)
|
||||
|
||||
# ② 关键文案
|
||||
# ⚠️ 2026-10 需求收敛:原列表里的「你已在名单中,请返回重新核验」与
|
||||
# 「你已提交过申请,请耐心等待」两句属于**已删除的加入申请模块**
|
||||
# (`module/join`,连同错误码 40037/40038/40039/40040/40042),
|
||||
# 代码里已不存在 —— 因此从本清单移除,不要再加回来。
|
||||
key_phrases = [
|
||||
"没有找到你的信息,请核对后重试",
|
||||
"操作过于频繁,请稍后再试",
|
||||
"该信息已被登记",
|
||||
"你已在名单中,请返回重新核验",
|
||||
"你已提交过申请,请耐心等待",
|
||||
"这份材料可能不是你的,请重新上传",
|
||||
"已进入审核,需先撤回才能删材料",
|
||||
"已通过终审,不可撤回",
|
||||
|
||||
+124
-8
@@ -21,7 +21,7 @@
|
||||
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
|
||||
而不是靠下次再想一遍)。
|
||||
|
||||
## 查七件事
|
||||
## 查九件事
|
||||
|
||||
| # | 查什么 | 没接上的后果 |
|
||||
|:--:|---|---|
|
||||
@@ -32,6 +32,8 @@
|
||||
| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 |
|
||||
| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
|
||||
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
|
||||
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
|
||||
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
|
||||
|
||||
### 第 7 项是 2026-10-09 补的(自己踩了一次)
|
||||
|
||||
@@ -42,6 +44,20 @@
|
||||
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
|
||||
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
|
||||
|
||||
### 第 8 / 9 项是 2026-10 补的(重搭 PC 端之后)
|
||||
|
||||
第 8 项:仓库**没有 auto-import**,漏 import 的 `<n-button>` 被当成未知自定义元素,
|
||||
只丢样式不报错(实测 4 个文件 6 处)。
|
||||
|
||||
第 9 项:SVG 是 XML,**注释里出现连续两个减号**(比如顺手写下 CSS 自定义属性的
|
||||
写法)就会让整个文件 not well-formed。浏览器对 `<img src="x.svg">` 的失败完全静默 ——
|
||||
控制台不打、`vue-tsc` 不打、`eslint` 不打,页面上只是那个图标裂开。
|
||||
换品牌标时连踩两次:`logo.svg` 的注释里写了变量名,登录页与侧栏的标直接裂图,
|
||||
而 typecheck / lint / 本脚本全绿。
|
||||
|
||||
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
|
||||
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
|
||||
|
||||
|
||||
## 它**不查**什么(免得被当成"全绿就没事")
|
||||
|
||||
@@ -61,6 +77,7 @@ import glob
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import xml.dom.minidom
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
@@ -78,8 +95,6 @@ XT_ROUTES = 'frontend-pc/src/config/xt-routes.ts'
|
||||
# ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ──
|
||||
# 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker)
|
||||
PERM_NO_CHECK = {
|
||||
'ROLE_MANAGE': '「角色与授权」页是二期功能(权限码先定下来,见 08 文档 6n):'
|
||||
'一期不开放自定义角色,所以没有任何接口需要它',
|
||||
'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支'
|
||||
'(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW',
|
||||
'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)',
|
||||
@@ -291,9 +306,9 @@ def strip_js_comments(src):
|
||||
def template_send_points(sources, code, tpl_file):
|
||||
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
|
||||
|
||||
⚠️ 判据不能用"标识符出现次数":`JOIN_APPROVE` 同时是
|
||||
`OperationLogService.ACTION_JOIN_APPROVE`(操作日志动作码)与
|
||||
`NotificationService.TPL_JOIN_APPROVE`(微信订阅消息模板码)的值 ——
|
||||
⚠️ 判据不能用"标识符出现次数":模板码常常**同时是别的常量的值** ——
|
||||
例如 `URGE` 既是站内信的模板码,又是催报业务类型(`SUBJECT_URGE`)的名字;
|
||||
早先的 `JOIN_APPROVE` 更是同时挂在操作日志动作码与微信订阅消息模板码上。
|
||||
按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。
|
||||
|
||||
所以认两件事:
|
||||
@@ -488,8 +503,13 @@ def main():
|
||||
|
||||
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
|
||||
#
|
||||
# 两个写法都认:`userStore.permissions.includes('org:view')` 与
|
||||
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。
|
||||
# 三种写法都认:
|
||||
# · `userStore.permissions.includes('org:view')`(重搭前的写法,保留兼容)
|
||||
# · `v-hasPerm="['audit:final']"`(重搭前的指令参数,保留兼容)
|
||||
# · `user.has('task:manage')` / `user.hasAny('a','b')`
|
||||
# —— **2026-10 重搭为纯 Vue 3 + Naive 后的唯一写法**(已无 v-hasPerm 指令)。
|
||||
# ⚠️ 少了这一条,本项会永远报"PC 端引用的权限码 0 个":
|
||||
# 看起来是"通过",实际是**检查器失效**(安全网静默消失,正是本脚本要防的那类错)。
|
||||
#
|
||||
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
|
||||
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
|
||||
@@ -507,7 +527,15 @@ def main():
|
||||
for i, line in enumerate(src.split('\n'), 1):
|
||||
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
|
||||
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
|
||||
# `user.has('码')` / `user.hasAny('码', ...)`。
|
||||
# ⚠️ 这里加了 `域:动作` 的形状约束:`.has(` 在别处也用于 Set/Map
|
||||
# (`changed.has(key)`、`set.has(r.orgId)`),不约束形状会收进一堆假阳性。
|
||||
# 本项目的权限码一律是 `域:动作` 形状(见 PermissionDef)。
|
||||
for m in re.finditer(r"\.has(?:Any)?\(([^)]*)\)", line):
|
||||
for c in re.findall(r"'([a-zA-Z]+:[a-zA-Z]+)'", m.group(1)):
|
||||
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
|
||||
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
|
||||
# (指令本身已随 2026-10 重搭移除,这里保留是为了旧分支/文档示例仍能被查出来)
|
||||
if is_vue:
|
||||
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
|
||||
for c in re.findall(r"'([^']+)'", m.group(1)):
|
||||
@@ -526,6 +554,94 @@ def main():
|
||||
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
|
||||
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
|
||||
|
||||
# ── ⑧ PC 端 `<n-*>` 组件 ↔ import ────────────────────────────
|
||||
#
|
||||
# ⚠️ 为什么它属于「声明了但没接上」:本仓库**没有 auto-import**(见 frontend-pc/AGENTS.md
|
||||
# 第 1 条),漏 import 的后果是 Vue 把它当成**未知自定义元素**渲染 ——
|
||||
# 不报错、不崩、lint 与 vue-tsc 也全绿,只是样式整块丢掉。
|
||||
# 2026-10 重搭成纯 Vue 3 + Naive 后实测踩到 4 个文件 6 处:
|
||||
# `<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、
|
||||
# `<n-form>` 的表单排版全丢 —— 表现就是"这个页面样式坏了",但控制台只给一句 warn。
|
||||
#
|
||||
# 判定方式:在 `<template>` 段里出现的 `<n-xxx>`,其 PascalCase 名字必须在本文件
|
||||
# 的某个 `import { ... } from '...'` 里出现过。
|
||||
naive_missing = []
|
||||
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)):
|
||||
try:
|
||||
raw = read(p)
|
||||
except Exception:
|
||||
continue
|
||||
m = re.search(r'<template>(.*)</template>', raw, re.S)
|
||||
if not m:
|
||||
continue
|
||||
used = set(re.findall(r'<(n-[a-z0-9-]+)[\s/>]', m.group(1)))
|
||||
if not used:
|
||||
continue
|
||||
imported = set()
|
||||
for im in re.finditer(r'import\s+([^;]+?)\s+from\s+[\'"][^\'"]+[\'"]', raw, re.S):
|
||||
clause = im.group(1)
|
||||
brace = re.search(r'\{([^}]*)\}', clause, re.S)
|
||||
if brace:
|
||||
for item in brace.group(1).split(','):
|
||||
item = item.split(' as ')[-1].strip()
|
||||
if item:
|
||||
imported.add(item)
|
||||
head = clause.split('{')[0].strip().rstrip(',')
|
||||
if head and not head.startswith('*'):
|
||||
imported.add(head)
|
||||
for tag in sorted(used):
|
||||
comp = ''.join(part.capitalize() for part in tag.split('-'))
|
||||
if comp not in imported:
|
||||
naive_missing.append((p, tag, comp))
|
||||
|
||||
say('\n【⑧】PC 端 `<n-*>` 组件未 import 的 %d 处' % len(naive_missing))
|
||||
seen_files = set()
|
||||
for p, tag, comp in naive_missing:
|
||||
problems.append(('未 import 的组件', '<%s>' % tag,
|
||||
'%s 里没 import %s(会被当成未知元素渲染,样式整块丢失)'
|
||||
% (p, comp)))
|
||||
if p not in seen_files:
|
||||
seen_files.add(p)
|
||||
say(' %s' % p)
|
||||
say(' ✗ <%-16s 需要 import { %s } from \'naive-ui\'' % (tag + '>', comp))
|
||||
if not naive_missing:
|
||||
say(' ✓ 模板里用到的 <n-*> 组件都有对应 import')
|
||||
|
||||
# ── ⑨ SVG 资产是不是合法 XML ──────────────────────────────
|
||||
#
|
||||
# ⚠️ 为什么它属于这一族:SVG 是 XML,**注释里出现连续两个减号**
|
||||
# (比如顺手写下 CSS 自定义属性的写法)就会让整个文件 not well-formed。
|
||||
# 浏览器对 `<img src="x.svg">` 的失败**完全静默** ——
|
||||
# 控制台不打、vue-tsc 不打、eslint 不打,页面上只是那个图标裂开。
|
||||
# 2026-10 换品牌标时连踩两次:logo.svg 注释里写了变量名,
|
||||
# 登录页(34px)与侧栏(24px)的标一起变成裂图,而三样检查全绿。
|
||||
#
|
||||
# 判定方式:拿标准库把每个 .svg 当 XML 解析一遍 —— 不需要浏览器,
|
||||
# 也不依赖某个具体 SVG 特性。
|
||||
svg_paths = []
|
||||
for pattern in ('frontend-pc/src/**/*.svg', 'frontend-pc/public/**/*.svg',
|
||||
'frontend-mp/src/**/*.svg', 'backend/**/*.svg'):
|
||||
svg_paths.extend(glob.glob(pattern, recursive=True))
|
||||
svg_paths = sorted(p for p in set(svg_paths)
|
||||
if '/node_modules/' not in p and '\\node_modules\\' not in p
|
||||
and '/dist/' not in p and '\\dist\\' not in p
|
||||
and '/target/' not in p and '\\target\\' not in p)
|
||||
|
||||
svg_bad = []
|
||||
for p in svg_paths:
|
||||
try:
|
||||
xml.dom.minidom.parse(p)
|
||||
except Exception as e:
|
||||
svg_bad.append((p, str(e)))
|
||||
|
||||
say('\n【⑨】SVG 资产 %d 个 / XML 非法的 %d 个' % (len(svg_paths), len(svg_bad)))
|
||||
for p, why in svg_bad:
|
||||
problems.append(('SVG 资产', p,
|
||||
'%s(浏览器静默不渲染:图标裂开,任何检查都不报)' % why))
|
||||
say(' ✗ %s\n %s' % (p, why))
|
||||
if not svg_bad:
|
||||
say(' ✓ 全部能被解析为合法 XML')
|
||||
|
||||
# ── 汇总 ────────────────────────────────────────────────
|
||||
print('\n' + '=' * 88)
|
||||
if notes:
|
||||
|
||||
@@ -218,16 +218,15 @@ def main() -> int:
|
||||
|
||||
# ── 演示班主任:**必须显式指定初审人**,否则这一屏验不了 ──
|
||||
#
|
||||
# 初审的授权口径是"这个班的初审人 / 副班主任 / 班级负责人"(`AuditScopeMapper`
|
||||
# 的三级回退),而不是"有 audit:first 权限就行"。所以管理员自己**看不到初审待办** ——
|
||||
# 初审的授权口径是"这个班的初审人 / 班级负责人"(`AuditScopeMapper`
|
||||
# 的两级回退),而不是"有 audit:first 权限就行"。所以管理员自己**看不到初审待办** ——
|
||||
# 这是设计(权限 ≠ 范围),但演示时必须有一个真实的班主任才能把初审那一屏走通。
|
||||
teacher_id, teacher_name = ensure_teacher(token, org["id"])
|
||||
st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{
|
||||
"orgId": org["id"],
|
||||
"expectedCount": len(STUDENTS),
|
||||
"headUserId": teacher_id,
|
||||
"deputyUserIds": []
|
||||
"headUserId": teacher_id
|
||||
}]
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
@@ -351,7 +350,7 @@ def ensure_teacher(token: str, org_id: int) -> tuple[int, str]:
|
||||
准备一个演示班主任(已存在就复用),返回 (id, 姓名)。
|
||||
|
||||
为什么必须有一个**真实的班主任账号**:
|
||||
初审的范围校验是"这个班归不归我"(指定初审人 → 副班主任 → 班级负责人),
|
||||
初审的范围校验是"这个班归不归我"(指定初审人 → 班级负责人),
|
||||
管理员自己**看不到初审待办** —— 这是设计,不是缺陷。
|
||||
所以演示/验收初审那一屏时,必须有一个真账号登进去看。
|
||||
|
||||
|
||||
+28
-4
@@ -351,6 +351,17 @@ def main() -> int:
|
||||
|
||||
# ── 6. 查询 ────────────────────────────────────────
|
||||
print("\n[6] 查询与分页")
|
||||
|
||||
# ⚠️ 这条是 2026-10 需求收敛时补的防线回归:
|
||||
# 删掉「副班主任」角色后,前端一度还在传 roleCode=DEPUTY_TEACHER,
|
||||
# 而旧实现对**不认识的角色码是静默忽略过滤条件** → 返回全校账号(含学生),
|
||||
# 且不报任何错。现在必须**失败关闭**(空列表)。
|
||||
status, body = call("GET", f"{base}/api/v1/users?roleCode=DEPUTY_TEACHER", token)
|
||||
ghost = (body.get("data") or {}).get("list")
|
||||
check("⭐ 按已删除的角色码过滤 → 返回空(不是「忽略过滤条件」返回全校)",
|
||||
body.get("code") == 0 and ghost == [],
|
||||
f"code={body.get('code')} 条数={len(ghost) if isinstance(ghost, list) else ghost}")
|
||||
|
||||
status, body = call("GET", f"{base}/api/v1/users?pageNum=1&pageSize=5", token)
|
||||
data = body.get("data") or {}
|
||||
check("列表返回 {list,total,pageNum,pageSize}",
|
||||
@@ -398,10 +409,13 @@ def main() -> int:
|
||||
|
||||
# ── 7. 修改 ────────────────────────────────────────
|
||||
print("\n[7] 修改账号")
|
||||
# ⚠️ 2026-10 需求收敛:删掉「副班主任」后,user_type=3 的内置角色只剩班主任一个,
|
||||
# 所以这里改成单角色改名,并**另加一条**断言把"多角色必须同属一个用户类型"这条
|
||||
# 规则本身验出来(以前拿「班主任 + 副班主任」验的是"两个同类角色都生效")。
|
||||
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
|
||||
"realName": teacher_name + "改",
|
||||
"phone": teacher_phone,
|
||||
"roleCodes": ["HEAD_TEACHER", "DEPUTY_TEACHER"],
|
||||
"roleCodes": ["HEAD_TEACHER"],
|
||||
"userType": 3,
|
||||
})
|
||||
check("修改姓名与角色成功", body.get("code") == 0,
|
||||
@@ -411,9 +425,19 @@ def main() -> int:
|
||||
|
||||
status, body = call("GET", f"{base}/api/v1/users?keyword={teacher_user}", token)
|
||||
row = next((h for h in (body.get("data") or {}).get("list", []) if h["id"] == teacher_uid), None)
|
||||
check("两个角色都生效", row is not None and sorted(row.get("roleCodes") or [])
|
||||
== ["DEPUTY_TEACHER", "HEAD_TEACHER"],
|
||||
check("改后的角色生效", row is not None and sorted(row.get("roleCodes") or [])
|
||||
== ["HEAD_TEACHER"],
|
||||
f"roleCodes={row.get('roleCodes') if row else 'N/A'}")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
|
||||
"realName": teacher_name + "改",
|
||||
"phone": teacher_phone,
|
||||
"roleCodes": ["HEAD_TEACHER", "SCHOOL_ADMIN"],
|
||||
"userType": 3,
|
||||
})
|
||||
check("⭐ 多角色必须同属一个用户类型(班主任 + 学校管理员 → 40001)",
|
||||
body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
check("登录名没有被改掉(username 不可改)",
|
||||
row is not None and row.get("username") == teacher_user,
|
||||
f"username={row.get('username') if row else 'N/A'}")
|
||||
@@ -643,7 +667,7 @@ def build_import_xlsx(suffix: str, base: str, token: str) -> str | None:
|
||||
# 3 条合法
|
||||
ws.append([f"导入甲{suffix}", f"imp1{suffix}", "13900139001", org_name, "班主任"])
|
||||
ws.append([f"导入乙{suffix}", f"imp2{suffix}", "13900139002", org_name, None])
|
||||
ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "副班主任"])
|
||||
ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "学校管理员"])
|
||||
# 4 条非法
|
||||
ws.append(["手机号错", f"imp4{suffix}", "1390013", None, None])
|
||||
ws.append(["账号重复", f"imp1{suffix}", "13900139005", None, None])
|
||||
|
||||
@@ -254,11 +254,18 @@ def main() -> int:
|
||||
print("\n[2] 材料目录接口")
|
||||
status, body = call("GET", f"{base}/api/v1/material-catalog", token)
|
||||
cat = body.get("data") or {}
|
||||
check("目录返回 6 种材料码", len(cat.get("codes") or []) == 6,
|
||||
# ⚠️ 2026-10 需求收敛:可选材料码 6 → 4(`XJW_BA`/`OTHER_PROOF` 属已取消的二期,
|
||||
# 它们**读得到、选不到** —— 仍留在目录里只为让历史任务的材料项显示得出名字)。
|
||||
check("目录只返回 4 种**可选**材料码(学信网备案表/其它佐证材料不再可选)",
|
||||
len(cat.get("codes") or []) == 4,
|
||||
f"{[c.get('code') for c in (cat.get('codes') or [])]}")
|
||||
check("目录返回 6 个压缩档位", len(cat.get("profiles") or []) == 6, f"{cat.get('profiles')}")
|
||||
check("目录返回 5 种允许格式", len(cat.get("fileTypes") or []) == 5, f"{cat.get('fileTypes')}")
|
||||
check("目录返回 3 个内置模板", len(cat.get("templates") or []) == 3, f"{cat.get('templates')}")
|
||||
# 内置模板 3 → 1:社会收集与专项职业能力属已取消的二期,只剩学校收集一种。
|
||||
check("目录只返回 1 个内置模板(学校收集)",
|
||||
len(cat.get("templates") or []) == 1
|
||||
and (cat.get("templates") or [{}])[0].get("templateCode") == "TPL_SCHOOL_ROLL",
|
||||
f"{cat.get('templates')}")
|
||||
check("目录返回体积可配范围 307~5120",
|
||||
cat.get("maxSizeRange") == [307, 5120], f"{cat.get('maxSizeRange')}")
|
||||
|
||||
|
||||
+13
-15
@@ -331,8 +331,8 @@ def main() -> int:
|
||||
c1, c2 = classes[0]["id"], classes[1]["id"]
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [
|
||||
{"orgId": c1, "headUserId": head_id, "deputyUserIds": [], "expectedCount": 28},
|
||||
{"orgId": c2, "headUserId": None, "deputyUserIds": [head_id], "expectedCount": 31},
|
||||
{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
|
||||
{"orgId": c2, "headUserId": None, "expectedCount": 31},
|
||||
],
|
||||
})
|
||||
check("保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||||
@@ -350,15 +350,14 @@ def main() -> int:
|
||||
and f"({ht_user})" in (r1.get("headUserName") or ""),
|
||||
f"headUserName={r1.get('headUserName')}")
|
||||
check("第一个班:应填人数保存正确", r1.get("expectedCount") == 28, f"{r1.get('expectedCount')}")
|
||||
check("第一个班:副班主任为空数组(不是 null)",
|
||||
r1.get("deputyUserIds") == [] and r1.get("deputyUserNames") == [],
|
||||
f"{r1.get('deputyUserIds')} / {r1.get('deputyUserNames')}")
|
||||
check("第一个班:响应里不再有「协办人」字段(V19 已删列)",
|
||||
"deputyUserIds" not in r1 and "deputyUserNames" not in r1,
|
||||
f"keys={sorted(r1.keys())}")
|
||||
check("第二个班:初审人为空(表示取班级负责人,不是「没人审」)",
|
||||
r2.get("headUserId") is None and r2.get("headUserName") is None,
|
||||
f"headUserId={r2.get('headUserId')} headUserName={r2.get('headUserName')}")
|
||||
check("第二个班:副班主任姓名已装配",
|
||||
r2.get("deputyUserNames") and r2["deputyUserNames"][0].startswith(f"E2E班主任{suffix}"),
|
||||
f"deputyUserNames={r2.get('deputyUserNames')}")
|
||||
check("第二个班:应填人数保存正确", r2.get("expectedCount") == 31,
|
||||
f"{r2.get('expectedCount')}")
|
||||
check("status 恒为 1(只返回生效行)", all(r.get("status") == 1 for r in rows),
|
||||
f"{[r.get('status') for r in rows]}")
|
||||
|
||||
@@ -396,11 +395,10 @@ def main() -> int:
|
||||
"不是班主任" in (body.get("msg") or "") and "账号管理" in (body.get("msg") or ""),
|
||||
f"msg={body.get('msg')}")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "deputyUserIds": [head_id]}],
|
||||
})
|
||||
check("主班主任与副班主任同一人被拒 40001", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
okk, 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 列已被 V19 删除(协办人机制下线)",
|
||||
okk and out.strip() == "0", f"查询结果={out}")
|
||||
|
||||
if other_org_id:
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
@@ -437,7 +435,7 @@ def main() -> int:
|
||||
okk, before = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
|
||||
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
|
||||
{"orgId": c2, "expectedCount": 31}],
|
||||
})
|
||||
check("把移除的班加回来成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||||
okk, after = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
|
||||
@@ -459,7 +457,7 @@ def main() -> int:
|
||||
# 恢复 2 个班,然后造发布前置数据走到"收集中"
|
||||
call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
|
||||
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
|
||||
{"orgId": c2, "expectedCount": 31}],
|
||||
})
|
||||
sql(f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
|
||||
f"VALUES ({tenant_id}, {task_id}, {c1}, 'E2E学生A', 'seed', 'seed')")
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""角色与授权 E2E(二期批次 1~4)。
|
||||
|
||||
这个脚本验的不是"接口返回 200",而是**"改完之后,系统真的按新授权做事"** ——
|
||||
权限系统最容易出的错是"看起来配好了、其实没生效",而这个脚本专门盯这一类:
|
||||
|
||||
| # | 要证明的事 | 怎么证明 |
|
||||
|---|---|---|
|
||||
| 1 | 自定义角色的**权限码真的在挡** | 给自定义角色只授 `task:view` → 该账号 GET /tasks 通、POST /tasks 得 40301 |
|
||||
| 2 | 改授权**立即生效**(不必重新登录) | 用**同一个 token** 改完授权马上再请求,权限随之变化 |
|
||||
| 3 | 停用角色**立即生效** | 停用后同一个 token 立刻失去该角色的权限 |
|
||||
| 4 | 菜单跟着角色走 | `/auth/me` 的 menus 随角色配置增减(登录响应里也必须有,否则"刷新一次才看得见") |
|
||||
| 5 | 内置角色**只读** | 改/删/授权/停用内置角色一律 40001 |
|
||||
| 6 | 越权防线在**服务端** | 学校租户授平台级权限码 → 40301;建"平台运营"类角色 → 40301;发 PLATFORM_ADMIN → 40301 |
|
||||
| 7 | 内置角色的权限**以代码为准** | 人为删掉库里的一行授权 → 接口返回的仍是代码那一套;跑一次同步任务 → 库里那行回来 |
|
||||
| 8 | 防自锁 | 管理员把自己唯一一条 `role:manage` / `role` 菜单去掉 → 40001(而不是真的锁死) |
|
||||
| 9 | 删除守卫 | 还有账号在用的角色删不掉(40045);解绑后能删,且账号立刻失去权限 |
|
||||
|
||||
跑法:python scripts/e2e-m10-role.py
|
||||
前置:后端 8080 在跑、xt-pg / xt-redis 在跑(与其它 e2e 相同)。
|
||||
"""
|
||||
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__)))
|
||||
BASE = "http://localhost:8080"
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[tuple[str, str]] = []
|
||||
CREATED_USERS: list[int] = []
|
||||
CREATED_ROLES: list[int] = []
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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 clear_login_fail(*accounts: str) -> None:
|
||||
"""清掉登录失败计数:脚本反复跑会把账号锁掉,而锁定看起来像"密码错了"。"""
|
||||
for a in accounts:
|
||||
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
|
||||
'DEL xt:login:fail:%s > NUL 2>&1' % a)
|
||||
|
||||
|
||||
def login(username: str, password: str, tenant: str | None = "demo"):
|
||||
_, b = R.login(BASE, username, password, tenant)
|
||||
if b.get("code") != 0:
|
||||
return None, b
|
||||
return b["data"]["token"], b["data"]
|
||||
|
||||
|
||||
def role_by_code(token: str, code: str) -> dict:
|
||||
for r in (body_of("GET", "/api/v1/roles", token).get("data") or []):
|
||||
if r.get("roleCode") == code:
|
||||
return r
|
||||
return {}
|
||||
|
||||
|
||||
def create_role(token: str, code: str, name: str, user_type: int,
|
||||
perms: list[str], menus: list[str]) -> dict:
|
||||
b = body_of("POST", "/api/v1/roles", token, {
|
||||
"roleCode": code, "roleName": name, "userType": user_type,
|
||||
"permCodes": perms, "menus": menus,
|
||||
})
|
||||
if b.get("code") == 0 and b.get("data"):
|
||||
CREATED_ROLES.append(b["data"])
|
||||
return b
|
||||
|
||||
|
||||
def create_user(token: str, username: str, real_name: str, phone: str,
|
||||
role_codes: list[str], user_type: int) -> dict:
|
||||
b = body_of("POST", "/api/v1/users", token, {
|
||||
"username": username, "realName": real_name, "phone": phone,
|
||||
"roleCodes": role_codes, "userType": user_type,
|
||||
})
|
||||
if b.get("code") == 0 and (b.get("data") or {}).get("id"):
|
||||
CREATED_USERS.append(b["data"]["id"])
|
||||
return b
|
||||
|
||||
|
||||
def cleanup() -> None:
|
||||
"""只删本脚本造的东西(演示租户里别人的数据一律不碰)。"""
|
||||
for uid in CREATED_USERS:
|
||||
R.sql(f"delete from sys_user_role where user_id={uid};")
|
||||
R.sql(f"delete from sys_user where id={uid};")
|
||||
for rid in CREATED_ROLES:
|
||||
R.sql(f"delete from sys_role_permission where role_id={rid};")
|
||||
R.sql(f"delete from sys_role_menu where role_id={rid};")
|
||||
R.sql(f"delete from sys_role where id={rid};")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="角色与授权 E2E")
|
||||
ap.parse_args()
|
||||
|
||||
print("=" * 82)
|
||||
print(" 角色与授权 E2E(自定义角色真的生效 / 内置角色只读 / 越权与自锁防线)")
|
||||
print("=" * 82)
|
||||
|
||||
suffix = os.urandom(3).hex()
|
||||
role_a_code = f"E2E_ROLE_A_{suffix.upper()}"
|
||||
role_b_code = f"E2E_ROLE_B_{suffix.upper()}"
|
||||
user_a = f"e2e_role_a_{suffix}"
|
||||
user_b = f"e2e_role_b_{suffix}"
|
||||
clear_login_fail("admin", "platform", user_a, user_b)
|
||||
|
||||
at, adm = login("admin", "Admin@123")
|
||||
if not at:
|
||||
print(f" x 管理员登录失败:{adm}")
|
||||
return 2
|
||||
admin_uid = adm["user"]["id"]
|
||||
|
||||
# ══════════════ 1. 权限码目录 ══════════════
|
||||
print("\n[1] 权限码目录(角色授权界面的勾选项来源)")
|
||||
b = body_of("GET", "/api/v1/permissions", at)
|
||||
groups = b.get("data") or []
|
||||
check("权限码目录可读且按分组返回", b.get("code") == 0 and len(groups) >= 5,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
items = [i for g in groups for i in (g.get("items") or [])]
|
||||
check("目录里 28 个权限码一个不少(删掉 join:audit / cleanup:run 之后)",
|
||||
len(items) == 28, f"实际 {len(items)}")
|
||||
by_code = {i.get("code"): i for i in items}
|
||||
check("每条权限码都带中文名与适用端",
|
||||
all(i.get("label") and i.get("platform") for i in items))
|
||||
check("⭐ 平台级权限被标出来(tenant:manage → platformOnly=true)",
|
||||
by_code.get("tenant:manage", {}).get("platformOnly") is True)
|
||||
check("role:manage 不是平台级(学校管理员可以授予)",
|
||||
by_code.get("role:manage", {}).get("platformOnly") is False)
|
||||
|
||||
# ══════════════ 2. 内置角色:只读 + 权限来自代码 ══════════════
|
||||
print("\n[2] 内置角色:看得见、改不了")
|
||||
roles = body_of("GET", "/api/v1/roles", at).get("data") or []
|
||||
check("角色列表可读(内置 4 个 + 可能的自定义角色)", len(roles) >= 4, f"实际 {len(roles)} 个")
|
||||
check("⭐ 内置角色一律 editable=false(权限由代码决定)",
|
||||
all(r.get("editable") is False for r in roles if r.get("roleType") == 1),
|
||||
json.dumps([(r.get("roleCode"), r.get("editable")) for r in roles],
|
||||
ensure_ascii=False)[:220])
|
||||
sa = role_by_code(at, "SCHOOL_ADMIN")
|
||||
check("内置角色的权限码取代码定义(含 role:manage / task:manage)",
|
||||
"role:manage" in (sa.get("permCodes") or []) and "task:manage" in (sa.get("permCodes") or []),
|
||||
json.dumps(sa, ensure_ascii=False)[:220])
|
||||
check("内置角色的菜单取代码定义(含 role 菜单)",
|
||||
"role" in (sa.get("menus") or []))
|
||||
check("内置角色带 userCount(删除守卫与界面都要用)",
|
||||
isinstance(sa.get("userCount"), int) and sa.get("userCount") >= 1,
|
||||
f"userCount={sa.get('userCount')}")
|
||||
check("⭐ 可分配的内置角色 assignable=true(学校管理员能发给账号)",
|
||||
sa.get("assignable") is True, f"assignable={sa.get('assignable')}")
|
||||
# ⚠️ demo 租户里有一行历史遗留的 PLATFORM_ADMIN(早于"平台角色只发平台租户"那次修正)。
|
||||
# 它正好是这条断言的样本:**看得见、发不出去** —— 界面据此不显示它。
|
||||
pa = role_by_code(at, "PLATFORM_ADMIN")
|
||||
if pa:
|
||||
check("⭐ 学校租户里的平台运营角色 assignable=false(看得见但发不出去)",
|
||||
pa.get("assignable") is False, f"assignable={pa.get('assignable')}")
|
||||
else:
|
||||
check("学校租户里没有平台运营角色(也没问题)", True)
|
||||
|
||||
# ══════════════ 3. 建自定义角色:服务端校验 ══════════════
|
||||
print("\n[3] 建自定义角色(含服务端白名单校验)")
|
||||
b = create_role(at, role_a_code, "E2E 年级审核员", 3,
|
||||
["task:view", "material:manage", "progress:viewOwn"],
|
||||
["dashboard", "task"])
|
||||
check("自定义角色创建成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
role_a = role_by_code(at, role_a_code)
|
||||
check("列表里能看到它,且 editable=true", role_a.get("editable") is True)
|
||||
check("权限码原样保存", sorted(role_a.get("permCodes") or [])
|
||||
== sorted(["task:view", "material:manage", "progress:viewOwn"]))
|
||||
check("菜单原样保存", sorted(role_a.get("menus") or []) == sorted(["dashboard", "task"]))
|
||||
check("userType 落库(账号的用户类型由它推导)", role_a.get("userType") == 3)
|
||||
check("新建角色默认 dataScope=4 仅本人(最保守)", role_a.get("dataScope") == 4)
|
||||
check("新建角色 userCount=0", role_a.get("userCount") == 0)
|
||||
|
||||
check("角色码重复 → 40001",
|
||||
create_role(at, role_a_code, "重名", 3, ["task:view"], ["task"]).get("code") == 40001)
|
||||
check("⭐ 占用内置角色码 → 40001(不能造一个假 SCHOOL_ADMIN)",
|
||||
create_role(at, "SCHOOL_ADMIN", "假管理员", 2,
|
||||
["task:view"], ["task"]).get("code") == 40001)
|
||||
check("角色码格式不合法(小写)→ 40001",
|
||||
create_role(at, f"e2e_bad_{suffix}", "小写码", 3,
|
||||
["task:view"], ["task"]).get("code") == 40001)
|
||||
check("⭐ 学校租户授平台级权限 → 40301",
|
||||
create_role(at, f"E2E_ESC_{suffix.upper()}", "越权角色", 2,
|
||||
["tenant:manage"], ["tenant"]).get("code") == 40301,
|
||||
json.dumps(create_role(at, f"E2E_ESC2_{suffix.upper()}", "越权角色2", 2,
|
||||
["tenant:manage"], ["tenant"]), ensure_ascii=False)[:200])
|
||||
check("不存在的权限码 → 40001",
|
||||
create_role(at, f"E2E_P_{suffix.upper()}", "坏权限", 3,
|
||||
["no:such:perm"], ["task"]).get("code") == 40001)
|
||||
check("不存在的菜单键 → 40001",
|
||||
create_role(at, f"E2E_M_{suffix.upper()}", "坏菜单", 3,
|
||||
["task:view"], ["no-such-menu"]).get("code") == 40001)
|
||||
check("⭐ 学校租户配平台专用菜单(租户管理)→ 40301",
|
||||
create_role(at, f"E2E_MT_{suffix.upper()}", "平台菜单", 3,
|
||||
["task:view"], ["tenant"]).get("code") == 40301)
|
||||
check("⭐ 学校租户建「平台运营」类角色 → 40301",
|
||||
create_role(at, f"E2E_U1_{suffix.upper()}", "平台类角色", 1,
|
||||
["task:view"], ["task"]).get("code") == 40301)
|
||||
|
||||
# ══════════════ 4. 发出去 + 权限真的在挡 ══════════════
|
||||
print("\n[4] 把自定义角色发给账号,验证权限码真的在挡")
|
||||
phone_a = "139" + "".join(c for c in suffix if c.isdigit()).ljust(8, "0")[:8]
|
||||
b = create_user(at, user_a, "E2E角色甲", phone_a, [role_a_code], 3)
|
||||
check("给账号发自定义角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
uid_a = (b.get("data") or {}).get("id")
|
||||
pwd_a = (b.get("data") or {}).get("initialPassword")
|
||||
check("新增账号返回初始密码", bool(pwd_a))
|
||||
|
||||
check("⭐ 用户类型与角色不符 → 40001(角色说 3,传 4)",
|
||||
create_user(at, f"{user_a}_x", "E2E角色甲x", "13900000001",
|
||||
[role_a_code], 4).get("code") == 40001)
|
||||
check("⭐ 学校租户发 PLATFORM_ADMIN → 40301(原有防线未回归)",
|
||||
create_user(at, f"{user_a}_p", "E2E越权", "13900000002",
|
||||
["PLATFORM_ADMIN"], 1).get("code") == 40301)
|
||||
|
||||
ta, prof_a = login(user_a, pwd_a)
|
||||
check("自定义角色账号能登录", bool(ta), json.dumps(prof_a, ensure_ascii=False)[:200])
|
||||
check("⭐ 登录响应里就带着自定义角色的权限(否则要刷新一次才看得见按钮)",
|
||||
"task:view" in (prof_a.get("permissions") or []),
|
||||
json.dumps(prof_a.get("permissions"), ensure_ascii=False)[:220])
|
||||
check("登录响应里带着自定义角色的菜单",
|
||||
"task" in (prof_a.get("menus") or []) and "role" not in (prof_a.get("menus") or []),
|
||||
json.dumps(prof_a.get("menus"), ensure_ascii=False)[:220])
|
||||
|
||||
b = body_of("GET", "/api/v1/auth/me", ta)
|
||||
check("/auth/me 的 permissions 与登录响应一致",
|
||||
"task:view" in (b.get("data", {}).get("permissions") or []),
|
||||
json.dumps(b, ensure_ascii=False)[:220])
|
||||
|
||||
b = body_of("GET", "/api/v1/tasks", ta)
|
||||
check("有 task:view → 任务列表可读", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
|
||||
b = body_of("POST", "/api/v1/tasks", ta, {
|
||||
"taskName": "不该建出来", "batchNo": "202610", "majorName": "数控加工",
|
||||
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"})
|
||||
check("⭐ 没有 task:manage → 建任务被 40301 拦住",
|
||||
b.get("code") == 40301, f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("GET", "/api/v1/roles", ta)
|
||||
check("⭐ 没有 role:manage / user:view → 读角色列表 40301",
|
||||
b.get("code") == 40301, f"code={b.get('code')}")
|
||||
|
||||
# ══════════════ 5. 改授权:立即生效(同一个 token)══════════════
|
||||
print("\n[5] 改授权立即生效(不需要重新登录)")
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_a['id']}/permissions", at,
|
||||
{"permCodes": ["roster:view"]})
|
||||
check("管理员改角色权限成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
|
||||
b = body_of("GET", "/api/v1/tasks", ta)
|
||||
check("⭐ 用**同一个 token** 立刻失去 task:view(40301,不必重新登录)",
|
||||
b.get("code") == 40301, f"code={b.get('code')}")
|
||||
b = body_of("GET", "/api/v1/auth/me", ta)
|
||||
check("同一 token 的 /auth/me 权限同步变化",
|
||||
"task:view" not in (b.get("data", {}).get("permissions") or []),
|
||||
json.dumps(b.get("data", {}).get("permissions"), ensure_ascii=False)[:200])
|
||||
|
||||
body_of("PUT", f"/api/v1/roles/{role_a['id']}/permissions", at,
|
||||
{"permCodes": ["task:view", "material:manage"]})
|
||||
b = body_of("GET", "/api/v1/tasks", ta)
|
||||
check("权限加回来 → 立刻又能读(缓存双向都生效)", b.get("code") == 0, f"code={b.get('code')}")
|
||||
|
||||
# ══════════════ 6. 菜单随角色走 ══════════════
|
||||
print("\n[6] 菜单跟着角色配置走")
|
||||
body_of("PUT", f"/api/v1/roles/{role_a['id']}/menus", at, {"menus": ["dashboard", "export"]})
|
||||
menus = (body_of("GET", "/api/v1/auth/me", ta).get("data", {}) or {}).get("menus") or []
|
||||
check("⭐ 菜单改成 export → /auth/me 里出现 export、task 消失",
|
||||
"export" in menus and "task" not in menus, json.dumps(menus, ensure_ascii=False)[:200])
|
||||
body_of("PUT", f"/api/v1/roles/{role_a['id']}/menus", at, {"menus": ["dashboard", "task"]})
|
||||
menus = (body_of("GET", "/api/v1/auth/me", ta).get("data", {}) or {}).get("menus") or []
|
||||
check("菜单改回来 → task 回来", "task" in menus)
|
||||
|
||||
# ══════════════ 7. 停用角色立即生效 ══════════════
|
||||
print("\n[7] 停用角色:立刻不再给权限")
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=0", at)
|
||||
check("停用成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
|
||||
b = body_of("GET", "/api/v1/tasks", ta)
|
||||
check("⭐ 同一个 token 立刻失去权限(40301)", b.get("code") == 40301, f"code={b.get('code')}")
|
||||
body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=1", at)
|
||||
check("重新启用 → 权限回来",
|
||||
body_of("GET", "/api/v1/tasks", ta).get("code") == 0)
|
||||
check("非法状态值 → 40001",
|
||||
body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=9", at).get("code") == 40001)
|
||||
|
||||
# ══════════════ 8. 内置角色不可改 ══════════════
|
||||
print("\n[8] 内置角色:改 / 删 / 授权 / 停用 一律拒绝")
|
||||
sid = sa["id"]
|
||||
check("改内置角色 → 40001",
|
||||
body_of("PUT", f"/api/v1/roles/{sid}", at,
|
||||
{"roleName": "改个名", "userType": 2}).get("code") == 40001)
|
||||
check("改内置角色授权 → 40001",
|
||||
body_of("PUT", f"/api/v1/roles/{sid}/permissions", at,
|
||||
{"permCodes": ["task:view"]}).get("code") == 40001)
|
||||
check("改内置角色菜单 → 40001",
|
||||
body_of("PUT", f"/api/v1/roles/{sid}/menus", at,
|
||||
{"menus": ["task"]}).get("code") == 40001)
|
||||
check("停用内置角色 → 40001",
|
||||
body_of("PUT", f"/api/v1/roles/{sid}/status?status=0", at).get("code") == 40001)
|
||||
check("删内置角色 → 40001",
|
||||
body_of("DELETE", f"/api/v1/roles/{sid}", at).get("code") == 40001)
|
||||
check("改一个不存在的角色 → 40001",
|
||||
body_of("PUT", "/api/v1/roles/99999999", at,
|
||||
{"roleName": "x", "userType": 3}).get("code") == 40001)
|
||||
|
||||
# ══════════════ 9. 账号列表里的角色名(读库)══════════════
|
||||
print("\n[9] 账号列表的角色名读库(自定义角色的名字不在代码里)")
|
||||
b = body_of("GET", f"/api/v1/users?keyword={user_a}", at)
|
||||
row = next((u for u in (b.get("data", {}).get("list") or []) if u.get("id") == uid_a), {})
|
||||
check("⭐ 自定义角色显示的是库里的角色名",
|
||||
row.get("roleNames") == ["E2E 年级审核员"],
|
||||
json.dumps(row.get("roleNames"), ensure_ascii=False)[:200])
|
||||
|
||||
# ══════════════ 10. 删除守卫 ══════════════
|
||||
print("\n[10] 删除守卫:还有账号在用就删不掉")
|
||||
b = body_of("DELETE", f"/api/v1/roles/{role_a['id']}", at)
|
||||
check("⭐ 还有账号在用 → 40045", b.get("code") == 40045,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("PUT", f"/api/v1/users/{uid_a}", at, {
|
||||
"realName": "E2E角色甲", "phone": phone_a,
|
||||
"roleCodes": ["HEAD_TEACHER"], "userType": 3})
|
||||
check("把该账号换成内置角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
b = body_of("DELETE", f"/api/v1/roles/{role_a['id']}", at)
|
||||
check("解绑后可以删除", b.get("code") == 0, f"code={b.get('code')} msg={b.get('msg')}")
|
||||
check("删除后列表里不再有它", not role_by_code(at, role_a_code))
|
||||
ta2, _ = login(user_a, pwd_a)
|
||||
perms = (body_of("GET", "/api/v1/auth/me", ta2).get("data", {}) or {}).get("permissions") or []
|
||||
# ⚠️ 判据要选**只有自定义角色才有**的那条(material:manage)。
|
||||
# 第一版断言的是 roster:view / audit:first —— 而 HEAD_TEACHER 本来就有这两条,
|
||||
# 于是"换角色成功"被读成了"权限没清掉"(断言写错,不是功能错)。
|
||||
check("⭐ 换角色后重新登录:自定义角色的权限没了、新角色的权限在",
|
||||
"material:manage" not in perms and "task:view" in perms,
|
||||
json.dumps(perms, ensure_ascii=False)[:220])
|
||||
|
||||
# ══════════════ 11. 防自锁 ══════════════
|
||||
print("\n[11] 防自锁:不能把自己管理角色的最后一条路去掉")
|
||||
b = create_role(at, role_b_code, "E2E 角色管理员", 2,
|
||||
["role:manage", "user:manage", "user:view"],
|
||||
["dashboard", "role", "user"])
|
||||
check("建「角色管理员」角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
role_b = role_by_code(at, role_b_code)
|
||||
b = create_user(at, user_b, "E2E角色乙", "13900000003", [role_b_code], 2)
|
||||
check("发给账号成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
uid_b = (b.get("data") or {}).get("id")
|
||||
pwd_b = (b.get("data") or {}).get("initialPassword")
|
||||
tb, prof_b = login(user_b, pwd_b)
|
||||
check("该账号能登录,且带着 role:manage",
|
||||
"role:manage" in (prof_b.get("permissions") or []),
|
||||
json.dumps(prof_b.get("permissions"), ensure_ascii=False)[:220])
|
||||
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/permissions", tb,
|
||||
{"permCodes": ["user:manage", "user:view"]})
|
||||
check("⭐ 去掉自己唯一的 role:manage → 40001(防自锁)", b.get("code") == 40001,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/menus", tb,
|
||||
{"menus": ["dashboard", "user"]})
|
||||
check("⭐ 去掉自己唯一的 role 菜单 → 40001(否则菜单里再也点不到本页)",
|
||||
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/status?status=0", tb)
|
||||
check("⭐ 停用自己唯一的角色 → 40001", b.get("code") == 40001,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("PUT", f"/api/v1/users/{uid_b}", tb, {
|
||||
"realName": "E2E角色乙", "phone": "13900000003",
|
||||
"roleCodes": ["HEAD_TEACHER"], "userType": 3})
|
||||
check("⭐ 把自己换成普通角色(会丢掉账号管理)→ 40001",
|
||||
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/permissions", tb,
|
||||
{"permCodes": ["role:manage", "user:manage", "user:view", "task:view"]})
|
||||
check("加权限(不减少)不受防自锁影响", b.get("code") == 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
b = body_of("PUT", f"/api/v1/users/{uid_b}", tb, {
|
||||
"realName": "E2E角色乙", "phone": "13900000003",
|
||||
"roleCodes": [role_b_code], "userType": 2})
|
||||
check("改自己的其它字段(角色不变)不受影响", b.get("code") == 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
|
||||
# ══════════════ 12. 内置角色授权同步(跨租户定时任务)══════════════
|
||||
print("\n[12] 内置角色的权限以代码为准 + 每日同步回填")
|
||||
role_id_sa = sa["id"]
|
||||
R.sql(f"delete from sys_role_permission where role_id={role_id_sa} and perm_code='task:clone';")
|
||||
check("夹具:库里那一行已删掉",
|
||||
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
|
||||
f"and perm_code='task:clone' and deleted=0;").strip() == "0")
|
||||
sa2 = role_by_code(at, "SCHOOL_ADMIN")
|
||||
check("⭐ 接口返回的仍是代码那一套(库里缺了也不影响显示与判定)",
|
||||
"task:clone" in (sa2.get("permCodes") or []))
|
||||
|
||||
_, ops = R.login(BASE, "platform", "Platform@123", None)
|
||||
ops_token = (ops.get("data") or {}).get("token")
|
||||
check("平台运营账号可登录(同步任务是运维动作)", bool(ops_token))
|
||||
b = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "role"})
|
||||
check("手工触发「内置角色授权同步」", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
check("⭐ 同步后库里那行回来了",
|
||||
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
|
||||
f"and perm_code='task:clone' and deleted=0;").strip() == "1")
|
||||
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "role"})
|
||||
check("⭐ 再跑一次不产生重复行(幂等)",
|
||||
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
|
||||
f"and perm_code='task:clone' and deleted=0;").strip() == "1")
|
||||
|
||||
# ══════════════ 13. 越权:跨租户拿不到别人的角色 ══════════════
|
||||
print("\n[13] 跨租户防线")
|
||||
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}", ops_token,
|
||||
{"roleName": "平台改名", "userType": 2})
|
||||
check("⭐ 平台运营不选学校就改学校的角色 → 40001(必须先选定学校)",
|
||||
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("GET", "/api/v1/roles", None)
|
||||
check("未登录读角色列表 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
|
||||
|
||||
cleanup()
|
||||
print("\n" + "=" * 82)
|
||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||
print("=" * 82)
|
||||
for n, det in FAILED:
|
||||
print(f" ✗ {n}\n {det}")
|
||||
sys.stdout.flush()
|
||||
return 1 if FAILED else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,406 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B1 名单加入申请 E2E(FR-509,《03-接口设计》§8.7)。
|
||||
|
||||
它验的是这条通道的**四条边界**(都是刻意的设计,不是"随手加的校验"):
|
||||
|
||||
1. **不绕过门禁**:申请期间学生**仍然不能填报** —— 这条最容易被实现成
|
||||
"提交申请就能填",那样门禁就变成形式主义,整套"凭证件号认人"的根基没了。
|
||||
2. **已在名册 → 40039 让他回去重新核验**,而不是受理申请(他只是姓名输错了)。
|
||||
3. **每人每任务最多 2 次**(40037):没有上限的话,一个人能刷出几十条待办。
|
||||
4. **通过即自动绑定**:学生返回重新核验就能直接进,不用再输一遍姓名与证件号。
|
||||
|
||||
跑法(前提:后端 8080 在跑、xt-pg 在跑;脚本自己摆数据、自己清理):
|
||||
|
||||
python scripts/e2e-m2-join.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]] = []
|
||||
|
||||
|
||||
def _load(module_name: str, filename: str):
|
||||
"""复用既有 E2E 的工具与夹具,不复制第二份(两份必然分叉)。"""
|
||||
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")
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
CREATED_TASK_IDS: list[int] = []
|
||||
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-join-roster.xlsx")
|
||||
|
||||
# 名册里的那个人(老师已导入)
|
||||
IN_ROSTER_NAME = "曾伊豪"
|
||||
IN_ROSTER_ID = R.ID_A
|
||||
# 不在名册里的人(要申请加入)——身份证号用校验位合法的
|
||||
OUT_NAME = "新来同学"
|
||||
OUT_ID = R.with_checksum("36102920090101005")
|
||||
OUT_PHONE = "13900139009"
|
||||
|
||||
|
||||
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)
|
||||
if not okk:
|
||||
fail("SQL 执行失败", f"{stmt[:80]} → {out}")
|
||||
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 ensure_student(uid: int) -> str:
|
||||
"""
|
||||
准备一个"有 openid 的学生",返回他的 token。
|
||||
|
||||
为什么要自己建账号:这条通道按 **openid** 限次与去重(契约 §8.7.1),
|
||||
而"从微信进来的人才有 openid"是产品的真实前提。用 SQL 直接造出带 open_id 的账号,
|
||||
是把"微信授权"这一步(需要真公众号,E2E 走不了)替换成等价的前置数据 ——
|
||||
**其余全走真实接口**。`ON CONFLICT` 让脚本可以重复跑。
|
||||
"""
|
||||
openid = f"e2e-join-{uid}"
|
||||
R.sql(f"""
|
||||
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
|
||||
VALUES ({uid}, 1, 'join{uid}', '加入申请学生{uid}', '{openid}', 4, 1)
|
||||
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1
|
||||
""")
|
||||
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="B1 名单加入申请 E2E")
|
||||
ap.parse_args()
|
||||
|
||||
print("=" * 74)
|
||||
print(" B1 名单加入申请 E2E(FR-509)")
|
||||
print("=" * 74)
|
||||
|
||||
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
|
||||
if adm.get("code") != 0:
|
||||
print(f" ✗ 管理员登录失败:{adm}")
|
||||
return 2
|
||||
at = adm["data"]["token"]
|
||||
cfg = R.read_dev_config()
|
||||
|
||||
# ── 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 not classes:
|
||||
print(" ✗ demo 租户里没有班级")
|
||||
return 2
|
||||
org = classes[0]
|
||||
|
||||
_, b = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {
|
||||
"orgs": [{"orgId": org["id"], "expectedCount": 2}]
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
print(f" ✗ 配班级失败:{b}")
|
||||
return 2
|
||||
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
|
||||
{"templateCode": "TPL_SCHOOL_ROLL"})
|
||||
if b.get("code") != 0:
|
||||
print(f" ✗ 应用材料模板失败:{b}")
|
||||
return 2
|
||||
|
||||
R.build_fixture(TMP_ROSTER)
|
||||
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
|
||||
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||
check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
|
||||
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
|
||||
check("任务发布成功(收集中)", b.get("code") == 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
|
||||
out_tok = ensure_student(993301)
|
||||
|
||||
# ── 1. 门禁失败:不在名册里的人进不去 ──
|
||||
print("\n[1] 不在名册的人:门禁必须拦住(这是产品根基)")
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", out_tok,
|
||||
{"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID})
|
||||
check("门禁不通过(提示信息统一、不透露名册里有什么)", b.get("code") != 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
check("失败提示里**没有**列出名册中的任何姓名",
|
||||
IN_ROSTER_NAME not in json.dumps(b, ensure_ascii=False),
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
|
||||
# ── 2. 提交申请 ──
|
||||
print("\n[2] 提交加入申请")
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, {
|
||||
"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID,
|
||||
"phone": OUT_PHONE, "orgHint": org["orgName"], "reason": "我是转班过来的"
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
# 学生账号可能没有 open_id(token 直连不会自动建号)→ 明确告知原因并跳过
|
||||
check("提交申请(需要该学生账号有 open_id)", False,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
cleanup()
|
||||
return 1
|
||||
req_id = b["data"]["requestId"]
|
||||
check("返回 requestId", isinstance(req_id, int), f"{req_id}")
|
||||
check("状态为待审(0)", b["data"]["status"] == 0, f"{b['data']}")
|
||||
check("提示语说清是「等待学校审核」", "审核" in (b["data"].get("statusText") or ""),
|
||||
f"{b['data'].get('statusText')}")
|
||||
check("告知还剩几次机会(remainAttempts=1)", b["data"].get("remainAttempts") == 1,
|
||||
f"{b['data'].get('remainAttempts')}")
|
||||
|
||||
check("库里证件号是 HMAC(不是明文)",
|
||||
sql(f"select length(id_card_hash) from collect_join_request where id={req_id};").strip()
|
||||
== "64")
|
||||
check("库里**没有**证件号明文列(只有 hash + mask)",
|
||||
sql(f"select id_card_mask from collect_join_request where id={req_id};").strip()
|
||||
.replace("*", "").isdigit()
|
||||
or "*" in sql(f"select id_card_mask from collect_join_request where id={req_id};"))
|
||||
check("手机号也只存 hash + 脱敏值",
|
||||
sql(f"select phone_mask from collect_join_request where id={req_id};").strip()
|
||||
== "139****9009",
|
||||
sql(f"select phone_mask from collect_join_request where id={req_id};"))
|
||||
check("记录了申请人 uid 与 openid(通过时要用来绑定)",
|
||||
sql(f"select applicant_uid from collect_join_request where id={req_id};").strip()
|
||||
== "993301")
|
||||
|
||||
# ── 3. 申请期间:**仍然不能填报**(这条最容易被做错)──
|
||||
print("\n[3] 申请期间不能填报(不绕过门禁)")
|
||||
b = body_of("POST", "/api/v1/mp/submissions", out_tok, {"taskId": task_id})
|
||||
check("提交申请**不等于**能建填报单", b.get("code") != 0,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
# ── 4. 重复申请与已在名册 ──
|
||||
print("\n[4] 重复申请 / 已在名册")
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, {
|
||||
"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, "phone": OUT_PHONE
|
||||
})
|
||||
check("同证件号已有待审申请 → 40038", b.get("code") == 40038,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
in_tok = ensure_student(993302)
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", in_tok, {
|
||||
"taskId": task_id, "realName": IN_ROSTER_NAME, "idCardNo": IN_ROSTER_ID,
|
||||
"phone": "13900139008"
|
||||
})
|
||||
check("证件号已在名册 → 40039(引导他回去重新核验)", b.get("code") == 40039,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, {
|
||||
"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, "phone": "139"
|
||||
})
|
||||
check("手机号不合法 → 40001(老师要能联系到他)", b.get("code") == 40001,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
# ── 5. 管理端待办 ──
|
||||
print("\n[5] 管理端待办列表")
|
||||
b = body_of("GET", f"/api/v1/roster/join-requests?taskId={task_id}&status=0", at)
|
||||
check("待办列表可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
d = b.get("data") or {}
|
||||
row = next((x for x in (d.get("list") or []) if x.get("requestId") == req_id), None)
|
||||
check("列表里有这条申请", row is not None, json.dumps(d, ensure_ascii=False)[:300])
|
||||
if row:
|
||||
check("列表里证件号是脱敏的(不含 18 位明文)",
|
||||
"*" in (row.get("idCardMask") or "") and len(row.get("idCardMask") or "") <= 18,
|
||||
f"{row.get('idCardMask')}")
|
||||
check("列表里手机号是脱敏的", "*" in (row.get("phoneMask") or ""),
|
||||
f"{row.get('phoneMask')}")
|
||||
check("列表带等待时长", row.get("waitHours") is not None, f"{row.get('waitHours')}")
|
||||
check("列表带自述班级与申请说明",
|
||||
row.get("orgHint") == org["orgName"] and row.get("reason") == "我是转班过来的",
|
||||
json.dumps(row, ensure_ascii=False)[:200])
|
||||
check("counts 里有三个计数", (d.get("counts") or {}).get("pending", 0) >= 1,
|
||||
json.dumps(d.get("counts"), ensure_ascii=False))
|
||||
|
||||
# 权限:学生不能看管理端列表
|
||||
b = body_of("GET", f"/api/v1/roster/join-requests?taskId={task_id}", out_tok)
|
||||
check("学生调管理端接口 → 40301 或 403",
|
||||
b.get("code") in (40301, 403) or b.get("_http") == 403,
|
||||
f"code={b.get('code')} http={b.get('_http')}")
|
||||
|
||||
# ── 6. 审批通过 ──
|
||||
print("\n[6] 审批通过(建名册 + 自动绑定)")
|
||||
b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at,
|
||||
{"orgId": org["id"]})
|
||||
check("通过成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:300])
|
||||
av = b.get("data") or {}
|
||||
roster_id = av.get("rosterId")
|
||||
check("返回新建的名册项 id", isinstance(roster_id, int), f"{roster_id}")
|
||||
check("rosterCreated=true", av.get("rosterCreated") is True, f"{av}")
|
||||
check("canFillNow=true(已绑定,学生重进就能填)", av.get("canFillNow") is True, f"{av}")
|
||||
check("notifySent=false(通知渠道未实现,不谎报)", av.get("notifySent") is False, f"{av}")
|
||||
|
||||
check("名册里真的多了一行,且已绑定到申请人",
|
||||
sql(f"select bind_user_id from collect_roster where id={roster_id};").strip() == "993301",
|
||||
sql(f"select bind_user_id, bind_type from collect_roster where id={roster_id};"))
|
||||
check("bind_type=3(来自申请)",
|
||||
sql(f"select bind_type from collect_roster where id={roster_id};").strip() == "3")
|
||||
check("名册行的班级是管理员指定的那个",
|
||||
sql(f"select org_id from collect_roster where id={roster_id};").strip() == str(org["id"]))
|
||||
check("名册行留了来源痕迹(ext_json.source=JOIN_REQUEST)",
|
||||
"JOIN_REQUEST" in sql(f"select ext_json from collect_roster where id={roster_id};"))
|
||||
check("申请单回写了 roster_id 与审批人",
|
||||
sql(f"select roster_id from collect_join_request where id={req_id};").strip()
|
||||
== str(roster_id))
|
||||
|
||||
# ⭐ 通过之后:门禁必须放行(自动绑定的效果)
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", out_tok,
|
||||
{"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID})
|
||||
check("⭐ 通过后门禁放行(自动绑定生效)", b.get("code") == 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
b = body_of("POST", "/api/v1/mp/submissions", out_tok, {"taskId": task_id})
|
||||
check("⭐ 通过后能建填报单了(申请期间不行)", b.get("code") == 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
|
||||
# ── 7. 幂等与状态边界 ──
|
||||
print("\n[7] 幂等与状态边界")
|
||||
b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at, {"orgId": org["id"]})
|
||||
check("重复通过 → 40040(不是再建一条名册)", b.get("code") == 40040,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
# ⚠️ 不能写死总行数:夹具本身导入了 3 个人(曾伊豪/赵六/钱七),
|
||||
# 加上审批新建的 1 个 = 4。写死"==2"会让断言因为**夹具**而红。
|
||||
# 这里验的是真正要守的两件事:申请人只有一行、重复通过不再新建行。
|
||||
check("申请人在名册里**只有一行**(重复通过没再建一条)",
|
||||
sql(f"select count(*) from collect_roster where task_id={task_id} "
|
||||
f"and bind_user_id=993301 and deleted=0;").strip() == "1",
|
||||
sql(f"select count(*) from collect_roster where task_id={task_id} "
|
||||
f"and bind_user_id=993301 and deleted=0;"))
|
||||
check("名册总行数 = 夹具 3 人 + 审批新建 1 人",
|
||||
sql(f"select count(*) from collect_roster where task_id={task_id} and deleted=0;").strip()
|
||||
== "4",
|
||||
sql(f"select real_name from collect_roster where task_id={task_id} and deleted=0;")
|
||||
.replace("\n", " | "))
|
||||
b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/reject", at, {"reason": "信息不符"})
|
||||
check("已通过再驳回 → 40042(要撤销请删名册项)", b.get("code") == 40042,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
# ── 8. 驳回路径 ──
|
||||
print("\n[8] 驳回路径(原因必填)")
|
||||
other_tok = ensure_student(993303)
|
||||
other_name, other_id = "再来同学", R.with_checksum("36102920090202006")
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, {
|
||||
"taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007"
|
||||
})
|
||||
check("第二个学生也能申请", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
req2 = (b.get("data") or {}).get("requestId")
|
||||
|
||||
b = body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at, {})
|
||||
check("驳回不填原因 → 40001(原因要给学生看)", b.get("code") == 40001,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
b = body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at,
|
||||
{"reason": "你不是本校学生", "reasonCode": "NOT_THIS_SCHOOL"})
|
||||
check("驳回成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
|
||||
check("驳回后状态=2", sql(f"select status from collect_join_request where id={req2};").strip()
|
||||
== "2")
|
||||
check("驳回原因落库(学生要能看到)",
|
||||
"NOT_THIS_SCHOOL" in sql(f"select reject_reason from collect_join_request where id={req2};"))
|
||||
|
||||
b = body_of("GET", f"/api/v1/mp/roster/join-request?taskId={task_id}", other_tok)
|
||||
mine = (b.get("data") or {}).get("list") or []
|
||||
check("学生能看到自己的申请与驳回原因",
|
||||
any(x.get("requestId") == req2 and x.get("rejectReason") for x in mine),
|
||||
json.dumps(mine, ensure_ascii=False)[:300])
|
||||
|
||||
# ── 9. 次数上限 ──
|
||||
print("\n[9] 申请次数上限(每人每任务 ≤2)")
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, {
|
||||
"taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007"
|
||||
})
|
||||
check("被驳回后可以再申请一次(remainAttempts=0)",
|
||||
b.get("code") == 0 and (b.get("data") or {}).get("remainAttempts") == 0,
|
||||
json.dumps(b, ensure_ascii=False)[:200])
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, {
|
||||
"taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007"
|
||||
})
|
||||
check("第 3 次 → 40037(上限就是上限)", b.get("code") == 40037,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
# ── 10. 任务状态边界 ──
|
||||
print("\n[10] 任务不在收集期时不受理")
|
||||
_, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/close", at, {})
|
||||
check("任务已截止", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
|
||||
tok4 = ensure_student(993304)
|
||||
b = body_of("POST", "/api/v1/mp/roster/join-request", tok4, {
|
||||
"taskId": task_id, "realName": "截止后同学",
|
||||
"idCardNo": R.with_checksum("36102920090303007"), "phone": "13900139006"
|
||||
})
|
||||
check("已截止 → 不受理(40008)", b.get("code") == 40008,
|
||||
f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
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_join_request where task_id={tid};")
|
||||
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};")
|
||||
R.sql("delete from collect_join_request where applicant_uid in (993301, 993302, 993303, 993304);")
|
||||
R.sql("delete from collect_roster where bind_user_id in (993301, 993302, 993303, 993304);")
|
||||
if os.path.exists(TMP_ROSTER):
|
||||
os.remove(TMP_ROSTER)
|
||||
print(" 已清理:加入申请 / 名册 / 任务 / 临时 Excel")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -320,7 +320,6 @@ def cleanup() -> None:
|
||||
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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -301,7 +301,6 @@ def cleanup() -> None:
|
||||
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_join_request 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};")
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""历史数据整理器 E2E(《03-接口设计》§13.2)。"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import importlib.util
|
||||
import io as _io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PASSED = []
|
||||
FAILED = []
|
||||
CREATED_TASK_IDS = []
|
||||
CREATED_JOB_IDS = []
|
||||
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-cleanup-roster.xlsx")
|
||||
|
||||
|
||||
def _load(module_name, filename):
|
||||
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")
|
||||
BASE = "http://localhost:8080"
|
||||
|
||||
S_A = ("整理甲", R.with_checksum("36102920080101071"))
|
||||
S_B = ("整理乙", R.with_checksum("36102920080202072"))
|
||||
S_C = ("整理丙", R.with_checksum("36102920080303073"))
|
||||
WRONG_ID = R.with_checksum("44052419900101099")
|
||||
|
||||
|
||||
def ok(name):
|
||||
PASSED.append(name)
|
||||
print(" [OK] " + name)
|
||||
|
||||
|
||||
def fail(name, detail):
|
||||
FAILED.append((name, detail))
|
||||
print(" [FAIL] " + name + "\n " + detail)
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
if cond:
|
||||
ok(name)
|
||||
else:
|
||||
fail(name, detail or "断言不成立")
|
||||
return bool(cond)
|
||||
|
||||
|
||||
def sql(stmt):
|
||||
_, out = R.sql(stmt)
|
||||
return out
|
||||
|
||||
|
||||
def body_of(method, path, token=None, payload=None):
|
||||
_, b = R.call(method, BASE + path, token, payload)
|
||||
return b
|
||||
|
||||
|
||||
def wait_job(job_id, token, tries=60):
|
||||
d = {}
|
||||
for _ in range(tries):
|
||||
d = body_of("GET", "/api/v1/cleanup/jobs/%d" % job_id, token).get("data") or {}
|
||||
if d.get("status") in (3, 4):
|
||||
return d
|
||||
time.sleep(1)
|
||||
return d
|
||||
|
||||
|
||||
def make_zip(files):
|
||||
bos = _io.BytesIO()
|
||||
with zipfile.ZipFile(bos, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return bos.getvalue()
|
||||
|
||||
|
||||
def tiny(tag):
|
||||
return tag.encode("utf-8") * 40
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="历史整理器 E2E")
|
||||
ap.parse_args()
|
||||
|
||||
print("=" * 74)
|
||||
print(" 历史数据整理器 E2E(§13.2)")
|
||||
print("=" * 74)
|
||||
|
||||
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
|
||||
if adm.get("code") != 0:
|
||||
print(" x 管理员登录失败:" + str(adm))
|
||||
return 2
|
||||
at = adm["data"]["token"]
|
||||
|
||||
print("\n[0] 摆夹具(正式任务 + 名册)")
|
||||
suffix = os.urandom(3).hex()
|
||||
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||
"taskName": "E2E整理名册 " + suffix, "batchNo": "202609", "majorName": "数控加工",
|
||||
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
|
||||
})
|
||||
if b.get("code") != 0:
|
||||
print(" x 建任务失败:" + str(b))
|
||||
return 2
|
||||
task_id = b["data"]["id"]
|
||||
CREATED_TASK_IDS.append(task_id)
|
||||
|
||||
_, b = R.call("GET", 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", BASE + "/api/v1/tasks/%d/orgs" % task_id, at,
|
||||
{"orgs": [{"orgId": org["id"], "expectedCount": 3}]})
|
||||
R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at,
|
||||
{"templateCode": "TPL_SCHOOL_ROLL"})
|
||||
_build_roster([S_A, S_B, S_C])
|
||||
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
|
||||
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
|
||||
check("正式任务的名册就绪(3 人)", body.get("code") == 0,
|
||||
json.dumps(body, ensure_ascii=False)[:200])
|
||||
|
||||
print("\n[1] 造一个形态真实的历史包")
|
||||
cls = "24中技数控1班"
|
||||
files = {
|
||||
cls + "/整理甲/身份证.jpg.jpg": tiny("a1"),
|
||||
cls + "/整理甲-" + S_A[1] + "/一寸照片.png": tiny("a2"),
|
||||
cls + "/整理甲/学籍表(1).JPG": tiny("a3"),
|
||||
cls + "/整理乙/身份证-" + WRONG_ID + ".jpg": tiny("b1"),
|
||||
cls + "/整理乙/工作证明-" + WRONG_ID + ".png": tiny("b2"),
|
||||
cls + "/新建文件夹/IMG_2034.jpg": tiny("c1"),
|
||||
"__MACOSX/" + cls + "/._身份证.jpg": tiny("d1"),
|
||||
cls + "/.DS_Store": tiny("d2"),
|
||||
}
|
||||
zip_bytes = make_zip(files)
|
||||
check("历史包已生成", len(zip_bytes) > 100, "%d 字节" % len(zip_bytes))
|
||||
|
||||
print("\n[2] 解析预览(调规则时看每一步)")
|
||||
zp = os.path.join(ROOT, "scripts", "_e2e-cleanup.zip")
|
||||
with open(zp, "wb") as f:
|
||||
f.write(zip_bytes)
|
||||
r = subprocess.run(["curl", "-s", "-X", "POST",
|
||||
BASE + "/api/v1/cleanup/jobs/parse-preview",
|
||||
"-H", "Authorization: Bearer " + at,
|
||||
"-F", "file=@" + zp + ";filename=history.zip;type=application/zip"],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=120)
|
||||
try:
|
||||
pv = json.loads(r.stdout)
|
||||
except Exception:
|
||||
pv = {"code": -1, "msg": (r.stdout or "")[:200]}
|
||||
check("解析预览可用", pv.get("code") == 0, json.dumps(pv, ensure_ascii=False)[:250])
|
||||
plist = pv.get("data") or []
|
||||
check("macOS 元数据被跳过(不然报告会虚高)", len(plist) == 6,
|
||||
"%d 条:%s" % (len(plist), [x.get("path") for x in plist]))
|
||||
by_path = {}
|
||||
for x in plist:
|
||||
by_path[x.get("path")] = x
|
||||
k1 = cls + "/整理甲/身份证.jpg.jpg"
|
||||
check("从目录名解析出姓名", by_path.get(k1, {}).get("studentName") == "整理甲",
|
||||
json.dumps(by_path.get(k1), ensure_ascii=False))
|
||||
check("解析出材料码 IDCARD", by_path.get(k1, {}).get("materialCode") == "IDCARD",
|
||||
json.dumps(by_path.get(k1), ensure_ascii=False))
|
||||
k2 = cls + "/整理甲-" + S_A[1] + "/一寸照片.png"
|
||||
check("从「姓名-证件号」目录名里解析出号码(且校验位合法)",
|
||||
by_path.get(k2, {}).get("idCard") is not None
|
||||
and by_path.get(k2, {}).get("idCardValid") is True,
|
||||
json.dumps(by_path.get(k2), ensure_ascii=False))
|
||||
check("解析结果里的证件号是脱敏值(不能把明文回给前端)",
|
||||
"*" in (by_path.get(k2, {}).get("idCard") or ""),
|
||||
str(by_path.get(k2, {}).get("idCard")))
|
||||
k3 = cls + "/新建文件夹/IMG_2034.jpg"
|
||||
check("认不出的文件不硬塞分类(姓名与材料码都为空)",
|
||||
by_path.get(k3, {}).get("studentName") is None
|
||||
and by_path.get(k3, {}).get("materialCode") is None,
|
||||
json.dumps(by_path.get(k3), ensure_ascii=False))
|
||||
|
||||
print("\n[3] 创建整理任务(带 rosterTaskId)")
|
||||
c = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
|
||||
"jobName": "E2E历史整理 " + suffix,
|
||||
"sourceType": 1,
|
||||
"rosterTaskId": task_id,
|
||||
"zipBase64": base64.b64encode(zip_bytes).decode()
|
||||
})
|
||||
check("创建成功", c.get("code") == 0, json.dumps(c, ensure_ascii=False)[:250])
|
||||
job_id = (c.get("data") or {}).get("jobId")
|
||||
CREATED_JOB_IDS.append(job_id)
|
||||
check("返回 jobId 与估时",
|
||||
isinstance(job_id, int) and bool((c.get("data") or {}).get("estimatedSeconds")),
|
||||
json.dumps(c.get("data"), ensure_ascii=False))
|
||||
|
||||
d = wait_job(job_id, at)
|
||||
check("整理完成(status=3)", d.get("status") == 3,
|
||||
"status=%s err=%s" % (d.get("status"), d.get("errorMsg")))
|
||||
check("进度 100", d.get("progress") == 100, str(d.get("progress")))
|
||||
check("总文件数 6(元数据已排除)", d.get("totalFiles") == 6, str(d.get("totalFiles")))
|
||||
check("解析出 2 个学员", d.get("parsedStudents") == 2, str(d.get("parsedStudents")))
|
||||
check("解析出材料种类 >= 3", (d.get("parsedMaterials") or 0) >= 3,
|
||||
str(d.get("parsedMaterials")))
|
||||
# ⚠️ 断言不能只是 `"%" in compressRatio` —— 那对 `-568.3%` 也成立,
|
||||
# 而负数压缩率是**没有意义**的(包里的文件本来就压过、或总量太小,
|
||||
# 重新打 zip 的包头开销比省下来的还多)。服务端对这种情况回「—」。
|
||||
# 所以这里断言"要么是个非负百分比,要么是 —"。
|
||||
_ratio = str(d.get("compressRatio"))
|
||||
check("压缩率要么形如 12.3%(非负),要么是「—」" + "(不可是负百分比)",
|
||||
_ratio == "—" or (re.fullmatch(r"\d+(\.\d+)?%", _ratio) is not None),
|
||||
"compressRatio=" + _ratio)
|
||||
check("结果包路径非空", bool(d.get("resultPath")), str(d.get("resultPath")))
|
||||
|
||||
print("\n[4] 体检报告(契约里最重要的一句话)")
|
||||
rep = body_of("GET", "/api/v1/cleanup/jobs/%d/report" % job_id, at).get("data") or {}
|
||||
s = rep.get("summary") or {}
|
||||
check("报告可用", s.get("totalFiles") == 6, json.dumps(s, ensure_ascii=False))
|
||||
check("算出 autoHandled / needManual(服务端算,不让前端减)",
|
||||
rep.get("autoHandled") is not None and rep.get("needManual") is not None,
|
||||
json.dumps({"autoHandled": rep.get("autoHandled"),
|
||||
"needManual": rep.get("needManual")}))
|
||||
check("autoHandled + needManual = 总文件数(不多不少)",
|
||||
(rep.get("autoHandled") or 0) + (rep.get("needManual") or 0) == 6,
|
||||
"%s + %s != 6" % (rep.get("autoHandled"), rep.get("needManual")))
|
||||
check("检测出疑似串档 1 例(乙的两项材料号都不是名册里那个,且彼此一致)",
|
||||
s.get("crossFileSuspected") == 1, json.dumps(s, ensure_ascii=False))
|
||||
check("串档进了 highlights 且级别是 3(必须人工)",
|
||||
any(h.get("type") == "CROSS_FILE" and h.get("level") == 3
|
||||
for h in rep.get("highlights") or []),
|
||||
json.dumps(rep.get("highlights"), ensure_ascii=False)[:300])
|
||||
check("无法归位的记进 orphanImages(不硬塞分类)",
|
||||
(s.get("orphanImages") or 0) >= 1, json.dumps(s, ensure_ascii=False))
|
||||
check("改名样例给了(客户要看到系统到底做了什么)",
|
||||
len(rep.get("nameFixedSamples") or []) >= 1,
|
||||
json.dumps(rep.get("nameFixedSamples"), ensure_ascii=False)[:250])
|
||||
check("改名样例里的规范名带姓名",
|
||||
any("整理甲" in (x.get("fixed") or "") for x in rep.get("nameFixedSamples") or []),
|
||||
json.dumps(rep.get("nameFixedSamples"), ensure_ascii=False)[:300])
|
||||
conf = rep.get("idcardConflicts") or []
|
||||
check("证件号冲突有明细", len(conf) >= 1, json.dumps(conf, ensure_ascii=False)[:250])
|
||||
check("没有 OCR 时判定一律是 NEED_CONFIRM(不假装能自动修)",
|
||||
all(x.get("verdict") == "NEED_CONFIRM" for x in conf),
|
||||
json.dumps([x.get("verdict") for x in conf], ensure_ascii=False))
|
||||
check("冲突明细里的证件号都是脱敏值(报告不外泄明文)",
|
||||
all("*" in (x.get("fileNameValue") or "") or "*" in (x.get("rosterValue") or "")
|
||||
for x in conf),
|
||||
json.dumps(conf, ensure_ascii=False)[:300])
|
||||
|
||||
print("\n[5] 整理结果包(客户拿到能直接用)")
|
||||
rp = os.path.join(ROOT, "scripts", "_e2e-cleanup-result.zip")
|
||||
code = os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" "%s/api/v1/cleanup/jobs/%d/result"'
|
||||
% (rp, at, BASE, job_id))
|
||||
size = os.path.getsize(rp) if os.path.exists(rp) else 0
|
||||
check("结果包能下载", size > 100, "exit=%s size=%s" % (code, size))
|
||||
if size > 100:
|
||||
with zipfile.ZipFile(rp) as z:
|
||||
names = z.namelist()
|
||||
check("结果包是合法 ZIP", z.testzip() is None)
|
||||
check("目录结构是班级/姓名/材料/规范文件名",
|
||||
any(n.startswith(cls + "/整理甲/") and n.count("/") >= 3 for n in names),
|
||||
str(names[:4]))
|
||||
check("规范文件名已修好(不再是 .jpg.jpg 双扩展名)",
|
||||
not any(n.endswith(".jpg.jpg") for n in names),
|
||||
str([n for n in names if n.endswith(".jpg.jpg")][:2]))
|
||||
check("结果包里带了「待归位」目录(认不出的那些)",
|
||||
any("/待归位/" in n for n in names),
|
||||
str([n for n in names if "待归位" in n][:2]))
|
||||
os.remove(rp)
|
||||
|
||||
print("\n[6] 导入为正式任务(幂等)")
|
||||
imp = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, {
|
||||
"taskName": "E2E整理导入 " + suffix, "collectMode": 1, "batchNo": "202609"
|
||||
})
|
||||
check("导入成功", imp.get("code") == 0, json.dumps(imp, ensure_ascii=False)[:250])
|
||||
new_task = (imp.get("data") or {}).get("taskId")
|
||||
if isinstance(new_task, int):
|
||||
CREATED_TASK_IDS.append(new_task)
|
||||
check("返回新任务 id", isinstance(new_task, int), str(new_task))
|
||||
check("名册建了 2 人(甲、乙各 1 行;丙在名册里但材料里没出现)",
|
||||
(imp.get("data") or {}).get("rosterCount") == 2,
|
||||
json.dumps(imp.get("data"), ensure_ascii=False))
|
||||
check("新任务的名册行数与返回一致",
|
||||
sql("select count(*) from collect_roster where task_id=%d and deleted=0;"
|
||||
% new_task).strip() == "2",
|
||||
sql("select real_name, id_card_mask from collect_roster where task_id=%d;"
|
||||
% new_task))
|
||||
check("名册里的证件号三列都写了(密文 + hash + 脱敏)",
|
||||
sql("select count(*) from collect_roster where task_id=%d "
|
||||
"and id_card_no is not null and id_card_hash is not null "
|
||||
"and id_card_mask is not null;" % new_task).strip() == "2",
|
||||
sql("select id_card_no is not null, id_card_hash is not null, id_card_mask "
|
||||
"from collect_roster where task_id=%d;" % new_task))
|
||||
check("新任务是草稿状态(让用户先核对再发布)",
|
||||
sql("select status from collect_task where id=%d;" % new_task).strip() == "0",
|
||||
sql("select status from collect_task where id=%d;" % new_task))
|
||||
|
||||
imp2 = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, {
|
||||
"taskName": "E2E整理导入-第二次 " + suffix, "collectMode": 1, "batchNo": "202609"
|
||||
})
|
||||
check("重复导入不新建任务(客户手滑点两次是常事)",
|
||||
(imp2.get("data") or {}).get("taskId") == new_task,
|
||||
json.dumps(imp2, ensure_ascii=False)[:250])
|
||||
check("库里没有多出第二个任务",
|
||||
sql("select count(*) from collect_task where task_name like 'E2E整理导入%' "
|
||||
"and deleted=0;").strip() == "1",
|
||||
sql("select id, task_name from collect_task where task_name like 'E2E整理导入%';"))
|
||||
|
||||
print("\n[7] 做不到的要如实拒绝(不安静忽略)")
|
||||
imp3 = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, {
|
||||
"taskName": "E2E不该建出来", "collectMode": 1, "batchNo": "202609", "importFiles": True
|
||||
})
|
||||
check("importFiles=true 明确拒绝(一期还不能自动挂文件)",
|
||||
imp3.get("code") == 40044, "code=%s msg=%s" % (imp3.get("code"), imp3.get("msg")))
|
||||
check("拒绝时没有偷偷建任务",
|
||||
sql("select count(*) from collect_task where task_name='E2E不该建出来';").strip() == "0")
|
||||
cloud = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
|
||||
"jobName": "云盘链接", "sourceType": 3, "rosterTaskId": task_id,
|
||||
"zipBase64": base64.b64encode(zip_bytes).decode()
|
||||
})
|
||||
check("sourceType=3(云盘)返回 40044,且提示改成 zip",
|
||||
cloud.get("code") == 40044 and "zip" in (cloud.get("msg") or ""),
|
||||
"code=%s msg=%s" % (cloud.get("code"), cloud.get("msg")))
|
||||
folder = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
|
||||
"jobName": "文件夹", "sourceType": 2, "rosterTaskId": task_id,
|
||||
"zipBase64": base64.b64encode(zip_bytes).decode()
|
||||
})
|
||||
check("sourceType=2(文件夹)返回 40044", folder.get("code") == 40044,
|
||||
"code=%s msg=%s" % (folder.get("code"), folder.get("msg")))
|
||||
no_roster = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
|
||||
"jobName": "无名单", "sourceType": 1,
|
||||
"zipBase64": base64.b64encode(zip_bytes).decode()
|
||||
})
|
||||
check("不传 rosterTaskId 也能跑(只做文件名规范化)", no_roster.get("code") == 0,
|
||||
json.dumps(no_roster, ensure_ascii=False)[:200])
|
||||
if no_roster.get("code") == 0:
|
||||
jid2 = no_roster["data"]["jobId"]
|
||||
CREATED_JOB_IDS.append(jid2)
|
||||
d2 = wait_job(jid2, at)
|
||||
check("无名单时也能完成", d2.get("status") == 3, "status=%s" % d2.get("status"))
|
||||
rep2 = body_of("GET", "/api/v1/cleanup/jobs/%d/report" % jid2, at).get("data") or {}
|
||||
check("无名单时串档检测恒为 0(没有基准就无从比对,不是没检测出来)",
|
||||
(rep2.get("summary") or {}).get("crossFileSuspected") == 0,
|
||||
json.dumps(rep2.get("summary"), ensure_ascii=False))
|
||||
|
||||
bad = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
|
||||
"jobName": "坏包", "sourceType": 1, "zipBase64": base64.b64encode(b"not a zip").decode()
|
||||
})
|
||||
if bad.get("code") == 0:
|
||||
jid3 = bad["data"]["jobId"]
|
||||
CREATED_JOB_IDS.append(jid3)
|
||||
d3 = wait_job(jid3, at)
|
||||
check("坏包最终是「失败」且给了可读原因(不是挂在那里不动)",
|
||||
d3.get("status") == 4 and bool(d3.get("errorMsg")),
|
||||
"status=%s err=%s" % (d3.get("status"), d3.get("errorMsg")))
|
||||
else:
|
||||
check("坏包在创建时就被拒(形态在入口就拦)", bad.get("code") != 0,
|
||||
json.dumps(bad, ensure_ascii=False)[:200])
|
||||
|
||||
print("\n[8] 列表与权限")
|
||||
lst = body_of("GET", "/api/v1/cleanup/jobs?pageSize=5", at).get("data") or {}
|
||||
check("列表可用", len(lst.get("list") or []) >= 1, json.dumps(lst, ensure_ascii=False)[:200])
|
||||
check("列表项带状态文案", all(x.get("statusText") for x in lst.get("list") or []),
|
||||
json.dumps((lst.get("list") or [])[:1], ensure_ascii=False)[:200])
|
||||
cfg = R.read_dev_config()
|
||||
stok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], 993801, 1, ["STUDENT"], 4)
|
||||
b = body_of("GET", "/api/v1/cleanup/jobs", stok)
|
||||
check("学生看整理任务返回 403", b.get("code") in (40101, 40301, 403),
|
||||
"code=%s" % b.get("code"))
|
||||
b = body_of("GET", "/api/v1/cleanup/jobs")
|
||||
check("未登录返回 40101", b.get("code") == 40101, "code=%s" % b.get("code"))
|
||||
|
||||
cleanup()
|
||||
print("\n" + "=" * 74)
|
||||
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
|
||||
print("=" * 74)
|
||||
for n, det in FAILED:
|
||||
print(" x " + n + "\n " + det)
|
||||
return 1 if FAILED else 0
|
||||
|
||||
|
||||
def _build_roster(rows):
|
||||
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, "中专", "138001333%d" % 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():
|
||||
for jid in CREATED_JOB_IDS:
|
||||
R.sql("delete from collect_cleanup_job where id=%d;" % jid)
|
||||
for tid in CREATED_TASK_IDS:
|
||||
R.sql("delete from collect_roster where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_task_org where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_material_def where task_id=%d;" % tid)
|
||||
R.sql("delete from collect_task where id=%d;" % tid)
|
||||
R.sql("delete from collect_cleanup_job where job_name like 'E2E历史整理%' "
|
||||
"or job_name in ('无名单','云盘链接','文件夹','坏包');")
|
||||
R.sql("delete from collect_task where task_name like 'E2E整理导入%' "
|
||||
"or task_name='E2E不该建出来';")
|
||||
for p in (TMP_ROSTER, os.path.join(ROOT, "scripts", "_e2e-cleanup.zip"),
|
||||
os.path.join(ROOT, "scripts", "_e2e-cleanup-result.zip")):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
print(" 已清理:整理任务 / 测试任务与名册 / 临时 zip")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -431,7 +431,8 @@ def main():
|
||||
check("待办里有本任务的单子(夹具生效)", len(todos) >= 1,
|
||||
"共 %d 条" % len(todos))
|
||||
# ⚠️ 断言性质而不是数量:待办里每一条都必须是"我确实能审"的。
|
||||
# 判据与审批**完全一致**(collect_task_org 的 head / deputy / 班级负责人)
|
||||
# 判据与审批**完全一致**(collect_task_org 的两级回退:head_user_id → 班级负责人)。
|
||||
# 2026-10 需求收敛:删掉「副班主任/协办人」后,这里不再有 deputy_user_ids 这一支。
|
||||
bad = sql("""
|
||||
select count(*) from collect_submission s
|
||||
where s.id in (%s)
|
||||
@@ -441,11 +442,10 @@ def main():
|
||||
where cto.tenant_id = s.tenant_id and cto.task_id = s.task_id
|
||||
and cto.org_id = s.org_id and cto.status = 1
|
||||
and (cto.head_user_id = %d
|
||||
or cto.deputy_user_ids like '%%,%d,%%'
|
||||
or (cto.head_user_id is null and so.leader_user_id = %d))
|
||||
);
|
||||
""" % (",".join(str(x.get("submissionId") or x.get("id")) for x in todos) or "-1",
|
||||
T1, T1, T1)).strip()
|
||||
T1, T1)).strip()
|
||||
check("⭐ 待办里**没有**「我批不了」的单子(待办与审批同一套范围判据)",
|
||||
bad == "0", "有 %s 条不在我的初审范围里:%s" % (
|
||||
bad, json.dumps([(x.get("submissionId"), x.get("taskName")) for x in todos],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 文档自身一致 | `verify-docs.py` | 无 |
|
||||
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
|
||||
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
|
||||
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
|
||||
| 接口行为(断言总数见下方"用法"里的实测值) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
|
||||
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
|
||||
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
|
||||
|
||||
@@ -24,10 +24,13 @@
|
||||
|
||||
```bash
|
||||
python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明)
|
||||
python scripts/verify-all.py --with-e2e # 连 1548 条接口断言一起跑(需要后端在跑)
|
||||
python scripts/verify-all.py --with-e2e # 连 37 个脚本的接口断言一起跑(需要后端在跑)
|
||||
python scripts/verify-all.py --quick # 只跑不需要任何前置的五项
|
||||
```
|
||||
|
||||
> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,断言总数以本命令
|
||||
> 实际输出为准 —— 别再往文档里抄一个会过期的固定数字)。
|
||||
|
||||
退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。
|
||||
"""
|
||||
import argparse
|
||||
@@ -127,7 +130,7 @@ def main():
|
||||
results.append(("接口断言(E2E)", None, msg))
|
||||
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
|
||||
elif not args.with_e2e:
|
||||
msg = "**跳过**:要跑就加 `--with-e2e`(36 个脚本 / 1500+ 条断言,约 10 分钟)"
|
||||
msg = "**跳过**:要跑就加 `--with-e2e`(37 个脚本、约 10 分钟)"
|
||||
results.append(("接口断言(E2E)", None, msg))
|
||||
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user