Files
xiaotutongxue/scripts/e2e-m8-orgfile.py
T
lovife b516a7c47b fix(tenant,log)!: 平台身份下用量统计算成全平台 + 5 处租户/日志缺陷
做「平台运营的租户管理」时挖出来的,其中第 1 条与那个功能本身无关、但严重得多。

1) 🔴 GET /tenants/{id}/usage 一直算的是**全平台**的数。平台运营的 tenantId 是 0,而
   FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true —— 整张表的租户过滤被关掉,
   于是 distinctStudents()(查 collect_roster)与存储量(查 collect_file)都不带租户条件,
   返回全平台数字却标着某一所学校的名字,运营会拿它去谈续费。而这个接口的调用者**一定是**
   平台运营(tenant:view 在 platformOnlyCodes 里),所以它从来只在"过滤被关掉"的上下文里跑过。
   ⚠️ 本地完全看不出来:实测 34 行 collect_roster、22 行 collect_file 全在 tenant_id=1,
   只有一所学校有数据时错误值与正确值**完全相等**。修法:统计段包进既有的 TenantScope.callAs,
   切走后 isPlatformAdmin() 变 false、过滤自动恢复;**没有**手写 tenant_id 条件 —— 那等于
   放弃拦截器的保护。

2) PUT /tenants/{id} 把手机号**明文**写进加密列(create 走 codec.encrypt,update 是裸赋值)。
   AesGcmCipher.decrypt 对非 Base64 直接抛(该类没有 dev 旁路)→ 存过一次电话,这所学校的
   详情页就永远打不开。库里 id=1 存的就是明文 13800138000。改为非空才加密写入(空 = 不修改),
   详情返回脱敏值;**读路径必须容错**(解密失败按明文处理 + WARN),否则故障形态是"永久 500",
   看起来像系统坏了、而不是"库里有一行旧数据"。历史明文行不做迁移(迁移里拿不到密钥)。

3) 租户两个枚举"能建不能改":tenantType 只放行 1~3(实体/DDL/契约/前端都是 4 值)、
   status 只放行 0/1(实体有 2 试用,而前端的状态筛选按钮里就有「试用」,选了永远是空列表)。
   已按真实枚举放开,报错文案与实体注释逐字对齐。

4) 开通/修改/停用租户原先一条日志都不写(只有给运维看的 log.warn/log.info)。新增
   MODULE_TENANT 与 CREATE_TENANT/UPDATE_TENANT/DISABLE_TENANT/ENABLE_TENANT —— 停用与启用
   不并进 UPDATE_TENANT:停用是这套系统里影响面最大的单个动作,出争议时第一个要看的就是这个
   区别。⚠️ 这些行的 tenant_id 是 0(写日志用的是**操作者**的上下文),查"这所学校被谁停过"
   要用 target_type=TENANT + target_id,**不能**用 tenantId 筛(反直觉,已写进常量注释)。

5) expireBefore 用 atStartOfDay() 与存进去的当天 23:59:59 比 → **差一天**,「今天到期」的租户
   查不出来(同型的坑在 LogController.parseTo 上修过一次,那次是"查 9 月 30 日查不到当天")。
   改为补到当天末尾。格式不对返回 40001 并说明格式 —— **刻意不学** parseTo 的"当没传":
   那侧是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";这侧的值只由界面按
   当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了"。

6) 顺带:
   - AUTO_CLOSE(定时任务自动截止)在 TaskService 里是**字符串字面量**、不在常量表里 ——
     也就是说"后端常量 ↔ 前端文案"这类检查**本来就扫不到它**,而它正是量最大的那类任务日志,
     界面上会原样回显英文码。提成常量(连带 STATUS_CHANGE 兜底名)。
   - 日志列表 SELECT 补 request_method/request_uri/extra_json 三个**轻字段**(实测一页 20 行
     约 2 KB),日志详情因此不需要新开接口;真正重的 request_param/duration_ms 全库 1758 行里
     **0 行有值**(从来没有 setXxx 过),不采集。
   - LoginLogVO 补 tenantId:平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是
     哪所学校的,而"某个账号什么时候登进来的"一定得连着"哪个单位"一起回答。
   - 修正 LoginLogVO.userType 的 Javadoc:原来写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
     与权威口径(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生)错位 —— **同一个坑第三次出现**,
     该错位枚举曾导致"把班主任当成社会人员拒掉"。

新增检查 scripts/check-wiring.py 第 ⑩ 项:后端会写的动作码 ↔ PC 端 OPERATION_ACTIONS 的文案,
逐字核对(缺一个文案,日志页就会原样回显英文码)。**已反向验证**:删掉一条文案 → 报红 + exit 1。
⚠️ 写这个检查时踩到的坑:同文件里 MODULE_OPTIONS / TARGET_TYPE_OPTIONS 也是 { value: 'TASK' }
的形状,整文件扫会把模块名当成动作码收进来 —— 所以只从 OPERATION_ACTIONS 那个数组里取。

验证:python scripts/verify-all.py --with-e2e → **9 项全过 / 0 失败 / 0 跳过**(37 脚本 /
1617 断言)。e2e-m8-orgfile.py **74/74** —— 其中"用量只算这一所学校"这条**必须造夹具**才
证伪得了:它往新建的 E2E 租户**直插一行 collect_roster**(该表 NOT NULL 且无默认值的列只有 5 个,
不需要走导入夹具),再断言"接口值 == 库里 where tenant_id=1 的去重数",并**额外断言
"全平台去重数 > 一号学校去重数"**,否则这条断言是空转的。e2e-m5-logs.py 44/44(回归)。
重打包重启后再验一次:/logs/logins 返回 tenantId、/tenants/1 返回 138****8000(而不是 500)——
判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
2026-10-13 14:04:48 +08:00

643 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""组织导入 / 租户用量 / 文件视图·直传凭证·批量处理 E2E
(《03-接口设计》§6.4、§6.5、§6.7、§9.5、§9.7、§9.8)
## 这批接口里三处"看着像细节、其实决定对不对"的地方
① **组织导入的父子关系用「编码 + 上级编码」表达,不靠行的顺序**。
客户的表是人整理出来的:顺序会被重排、缩进会被吃掉(Excel 里前导空格尤其容易丢)。
所以本脚本特意把**子行排在父行之前** —— 那是客户的表里最常见的形态,
也是"一遍扫描"实现会全部失败的那种。
② **租户用量的 `ocrCount` / `smsCount` 恒为 0 并带 notes**。
两条通道都还没接,编一个数字会让运营拿着假数据去谈续费。
断言要看的是"**它承认自己没接**",而不是"字段存在"。
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
④ **租户用量必须只算"这一所学校"**。平台运营的 `tenantId` 是 0,而租户拦截器
对它**整张表关掉过滤**(`FillTenantLineHandler.ignoreTable`)—— 于是
`/tenants/{id}/usage` 会算成**全平台**的学生数与存储量,挂在某一所学校的名下。
⚠️ 这个 bug 在"库里只有一所学校有数据"时**与正确值完全相等**,光断言
"有值、非 null"永远发现不了 —— 所以本脚本要**先造出别的租户的数据**再断言。
## 还有一条不能省的边界
`GET /mp/files/{fileId}` 按 `submitter_id` 授权(学生端);教师看他班学生的材料
走 `/mp/teacher/files/{id}/preview`(按班级范围)。本脚本要证明**前者的越权会被拒** ——
不证明的话,"任何登录用户能看任何人的身份证照片"这种事只在真实越权时才被发现。
跑法:python scripts/e2e-m8-orgfile.py
"""
import argparse
import datetime
import importlib.util
import json
import os
import sys
import urllib.error
import urllib.parse
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED = []
FAILED = []
CREATED_TASK_IDS = []
CREATED_ORG_NAMES = []
CREATED_TENANT_IDS = []
USAGE_FIXTURE_HASH_PREFIX = "e2e-usage-scope-"
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx")
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg")
def _load(name, fn):
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
A = _load("e2e-m5-audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
STU = 997201
OTHER = 997202
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 mp(uid):
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def upload(path, token, *fields):
"""用 curl 发 multipart(urllib 手搓 multipart 容易出错)。"""
import subprocess
args = ["curl", "-s", "-X", "POST", BASE + path, "-H", "Authorization: Bearer " + token]
for f in fields:
args += ["-F", f]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=120)
try:
return json.loads(r.stdout)
except Exception:
return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]}
def main():
ap = argparse.ArgumentParser(description="组织/租户/文件 E2E")
ap.parse_args()
print("=" * 74)
print(" 组织导入 / 租户用量 / 文件视图·直传·批量处理 E2E")
print("=" * 74)
# ⚠️ 租户相关接口(§6.5 修改 / §6.7 用量)的权限是**运营**(平台级),
# 学校管理员调它们会得到 40301 —— 那是权限边界在生效,不是缺陷。
# 所以这里用平台账号。**先清登录失败计数**:`LoginGuard` 连续失败后会要求
# 图形验证码(`xt:login:fail:{account}`),而验证码是图片、脚本给不出来。
# 这不是绕过安全,而是"测试夹具要能清掉自己造成的失败计数"。
for acct in ("platform", "admin"):
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:login:fail:%s > NUL 2>&1' % acct)
# 平台运营账号的密码在 docs/07-开发实施计划.md 里(Platform@123),不是 Admin@123
_, ops = R.login(BASE, "platform", "Platform@123", None)
if ops.get("code") != 0:
print(" x 平台运营账号登录失败(先清 Redis 里的登录失败计数):" + str(ops))
return 2
ops_token = ops["data"]["token"]
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(" x 管理员登录失败:" + str(adm))
return 2
at = adm["data"]["token"]
# ── 1. §6.4 组织导入 ──
print("\n[1] §6.4 组织批量导入")
suffix = os.urandom(3).hex()
root = "E2E学院" + suffix
mid = "E2E系" + suffix
leaf = "E2E班" + suffix
CREATED_ORG_NAMES.extend([root, mid, leaf])
# ⚠️ 刻意把**子行排在父行之前**:客户的表里最常见,
# 也是"一遍扫描"实现会全部失败的那种形态
rows = [
# 层级 / 名称 / 编码 / 上级编码 / 负责人手机号
("3", leaf, "C" + suffix, "B" + suffix, ""),
("1", root, "A" + suffix, "", ""),
("2", mid, "B" + suffix, "A" + suffix, ""),
]
_build_org_xlsx(rows)
dry = upload("/api/v1/orgs/import", at,
"file=@%s;filename=orgs.xlsx;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" % TMP_ORG,
"dryRun=true")
check("dryRun 可用", dry.get("code") == 0, json.dumps(dry, ensure_ascii=False)[:250])
dd = dry.get("data") or {}
check("⭐ dryRun 报告 created=3(子行在前也能算对层级)", dd.get("created") == 3,
json.dumps(dd, ensure_ascii=False)[:250])
check("dryRun **没有**落库",
sql("select count(*) from sys_org where org_name='%s';" % leaf).strip() == "0",
sql("select count(*) from sys_org where org_name='%s';" % leaf))
imp = upload("/api/v1/orgs/import", at,
"file=@%s;filename=orgs.xlsx;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" % TMP_ORG,
"dryRun=false")
check("正式导入成功", imp.get("code") == 0, json.dumps(imp, ensure_ascii=False)[:250])
d = imp.get("data") or {}
check("created=3", d.get("created") == 3, json.dumps(d, ensure_ascii=False))
check("失败 0 / 报错列表为空", d.get("failed") == 0 and not d.get("errors"),
json.dumps(d, ensure_ascii=False)[:250])
check("⭐ 库里真的有这三个组织",
sql("select count(*) from sys_org where org_name in ('%s','%s','%s');"
% (root, mid, leaf)).strip() == "3",
sql("select org_name, org_code from sys_org where org_name like 'E2E%%%s';" % suffix)
.replace("\n", " | "))
# 父子关系:子行的 parent 是父行的 id
check("⭐ 子行的父级接对了(按上级编码,而不是按行序)",
sql("""select count(*) from sys_org c
join sys_org p on p.id = c.parent_id
where c.org_name = '%s' and p.org_name = '%s';""" % (mid, root)).strip() == "1",
sql("""select c.org_name, p.org_name from sys_org c
left join sys_org p on p.id = c.parent_id
where c.org_name in ('%s','%s');""" % (mid, leaf)).replace("\n", " | "))
check("⭐ ancestor_path 被补上了(按层级筛选靠它,漏了会静默少数据)",
sql("select coalesce(ancestor_path,'') from sys_org where org_name='%s';"
% leaf).strip() not in ("", "None"),
"leaf.ancestor_path = " + sql("select coalesce(ancestor_path,'(空)') "
"from sys_org where org_name='%s';" % leaf).strip())
# 重复导入:已存在的按编码命中,不重复建
imp2 = upload("/api/v1/orgs/import", at, "file=@%s;filename=orgs.xlsx" % TMP_ORG)
check("⭐ 重复导入按编码命中已存在的(不重复建)",
(imp2.get("data") or {}).get("updated") == 3
and sql("select count(*) from sys_org where org_name='%s';" % leaf).strip() == "1",
json.dumps(imp2.get("data"), ensure_ascii=False)[:250])
# ⚠️ 「认不出表头」这一条必须**放在最后**:它会把坏表头写进同一个临时文件,
# 而上面的"重复导入"复用那份文件 —— 顺序反了的话,
# 重复导入实际导的是坏表头那份,报错信息与真正原因完全无关
# 认不出表头要明确报错
_build_org_xlsx([("3", "X", "C", "B", "")], header=["甲", "乙", "丙", "丁", "戊"])
bad = upload("/api/v1/orgs/import", at, "file=@%s;filename=bad.xlsx" % TMP_ORG)
check("⭐ 认不出表头 → 明确报错并给出应有的列名",
bad.get("code") != 0 and "组织名称" in (bad.get("msg") or ""),
"code=%s msg=%s" % (bad.get("code"), bad.get("msg")))
# ── 2. §6.5 / §6.7 租户修改与用量 ──
print("\n[2] §6.5 / §6.7 租户修改与用量")
# 学 校 管 理 员 调 租 户 接 口 应 该 被 拒(权限边界)
forbidden = body_of("PUT", "/api/v1/tenants/1", at, {"contactName": "E2E不该改成"})
check("⭐ 学校管理员改租户 → 40301(这是「运营」权限,不是「管理」)",
forbidden.get("code") == 40301,
"code=%s msg=%s" % (forbidden.get("code"), forbidden.get("msg")))
up = body_of("PUT", "/api/v1/tenants/1", ops_token,
{"contactName": "张校长(E2E改)"})
check("修改租户成功", up.get("code") == 0, json.dumps(up, ensure_ascii=False)[:250])
check("联系人改掉了",
sql("select contact_name from sys_tenant where id=1;").strip() == "张校长(E2E改)",
sql("select contact_name from sys_tenant where id=1;").strip())
check("⭐ tenantCode 没被改动(它出现在域名解析与对接文档里)",
sql("select tenant_code from sys_tenant where id=1;").strip() == "demo",
sql("select tenant_code from sys_tenant where id=1;").strip())
bad = body_of("PUT", "/api/v1/tenants/1", ops_token, {"tenantType": 9})
check("租户类型越界 → 明确报错", bad.get("code") != 0,
"code=%s msg=%s" % (bad.get("code"), bad.get("msg")))
check("不存在的租户 → 明确报错",
body_of("PUT", "/api/v1/tenants/999999999", ops_token,
{"contactName": "x"}).get("code") != 0)
us = body_of("GET", "/api/v1/tenants/1/usage", ops_token)
check("用量接口可用", us.get("code") == 0, json.dumps(us, ensure_ascii=False)[:250])
u = us.get("data") or {}
check("带学生数与存储", u.get("studentCount") is not None and u.get("storageMb") is not None,
json.dumps(u, ensure_ascii=False)[:250])
check("带 quota", isinstance(u.get("quota"), dict) and u["quota"].get("studentLimit"),
json.dumps(u.get("quota"), ensure_ascii=False))
check("⭐ ocrCount / smsCount 恒为 0,**并带 notes 说明为什么**"
"(编一个数字会让运营拿假数据去谈续费)",
u.get("ocrCount") == 0 and u.get("smsCount") == 0 and len(u.get("notes") or []) >= 2,
json.dumps({k: u.get(k) for k in ("ocrCount", "smsCount", "notes")},
ensure_ascii=False)[:300])
us2 = body_of("GET", "/api/v1/tenants/1/usage?period=2026Q3", ops_token)
check("⭐ period 支持季度写法(2026Q3)", us2.get("code") == 0
and (us2.get("data") or {}).get("period") == "2026Q3",
json.dumps((us2.get("data") or {}).get("period"), ensure_ascii=False))
check("period 格式不对 → 明确报错",
body_of("GET", "/api/v1/tenants/1/usage?period=" + urllib.parse.quote("去年"),
ops_token)
.get("code") != 0)
b = body_of("GET", "/api/v1/tenants/1/usage", mp(997299))
check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403),
"code=%s" % b.get("code"))
# ── 2b. 详情脱敏 / 用量口径 / 变更留痕 / 到期日边界 ──
print("\n[2b] 租户:详情脱敏 / 用量口径 / 变更留痕 / 到期日边界")
# ① 详情接口必须可用,且联系电话是**脱敏值**。
# ⚠️ 修复前这条会 500:`update()` 把手机号**明文**写进了加密列,
# 而 `AesGcmCipher.decrypt` 对非 Base64 的输入直接抛 —— 库里 id=1 就是那种行。
det = body_of("GET", "/api/v1/tenants/1", ops_token)
check("⭐ 租户详情可用(历史明文手机号不能让详情页打不开)",
det.get("code") == 0, json.dumps(det, ensure_ascii=False)[:250])
check("详情给出**脱敏**后的联系电话(形如 139****1111)",
"****" in str((det.get("data") or {}).get("contactPhone")),
"contactPhone=%r" % ((det.get("data") or {}).get("contactPhone"),))
# ② 改手机号:落库必须是密文,并且能读回脱敏值(写得进 + 解得开)
upd = body_of("PUT", "/api/v1/tenants/1", ops_token, {"contactPhone": "13712345678"})
check("改联系电话成功", upd.get("code") == 0, json.dumps(upd, ensure_ascii=False)[:250])
stored_phone = sql("select contact_phone from sys_tenant where id=1;").strip()
check("⭐ 联系电话以**密文**落库(明文落库会让详情页从此永远打不开)",
stored_phone != "13712345678" and len(stored_phone) > 20, "stored=%r" % stored_phone)
det2 = body_of("GET", "/api/v1/tenants/1", ops_token)
check("⭐ 新写的号能读回脱敏值(证明密文与解密对得上,不是「写进去就算了」)",
(det2.get("data") or {}).get("contactPhone") == "137****5678",
"contactPhone=%r" % ((det2.get("data") or {}).get("contactPhone"),))
# ③ 到期日筛选:格式不对要**明确报错**,不能静默忽略
bad_exp = body_of("GET", "/api/v1/tenants?expireBefore=" + urllib.parse.quote("去年"), ops_token)
check("⭐ expireBefore 写错 → 明确报错(静默忽略会让人以为「我按到期日筛过了」)",
bad_exp.get("code") != 0, "code=%s msg=%s" % (bad_exp.get("code"), bad_exp.get("msg")))
# ④ 后面几条用一个**新建的**租户(不动 demo,免得影响别的用例)
today = datetime.date.today().isoformat()
_, created = R.call("POST", BASE + "/api/v1/tenants", ops_token, {
"tenantName": "E2E用量学校 " + suffix,
"tenantCode": "e2eusage" + suffix, # ⚠️ 必须以 e2e 开头,dev-clean-e2e.py 按它清
"tenantType": 1, "contactName": "E2E用量联系人", "contactPhone": "13900139009",
"expireTime": today, "adminUsername": "admin",
"adminRealName": "E2E用量管理员", "adminPhone": "13900139010",
})
ntid = ((created or {}).get("data") or {}).get("id")
check("建一所 E2E 租户(后面的断言靠它,收尾会清掉)", isinstance(ntid, int),
json.dumps(created, ensure_ascii=False)[:250])
if isinstance(ntid, int):
CREATED_TENANT_IDS.append(ntid)
# 开通就要留痕(F-1 点名「租户开通」是优先留痕点,而本模块原先一条都不写)
row = sql("select action || '|' || tenant_id from sys_operation_log "
"where target_type='TENANT' and target_id=%d order by id desc limit 1;" % ntid).strip()
check("⭐ 开通租户留痕(target_type=TENANT & target_id=租户 id)",
row.startswith("CREATE_TENANT|"), "最新一行=%r" % row)
check("⭐ 留痕的 tenant_id 是 **0**(平台身份),不是被操作的那所学校 —— "
"所以查「这所学校是谁开通的」要用 target,不能用 tenant_id 筛",
row.split("|")[-1] == "0", "row=%r" % row)
# 状态流转:2 试用 / 0 停用 / 1 启用,各自留痕(停用与启用不能并成一个动作)
for st, act in ((2, "UPDATE_TENANT"), (0, "DISABLE_TENANT"), (1, "ENABLE_TENANT")):
r = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"status": st})
check("status=%d 能改(⚠️ status=2「试用」原先被拒:只放行 0/1,而前端有「试用」)" % st,
r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:200])
got = sql("select action from sys_operation_log where target_type='TENANT' "
"and target_id=%d order by id desc limit 1;" % ntid).strip()
check("status→%d 的留痕动作是 %s" % (st, act), got == act, "实际=%r" % got)
r4 = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"tenantType": 4})
check("⭐ tenantType=4(企业)能改(原先只放行 1~3,而前端下拉有 4 项)",
r4.get("code") == 0, json.dumps(r4, ensure_ascii=False)[:200])
# 到期日**含当天**:把到期日设成今天,`expireBefore=今天` 必须能查到它
body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"expireTime": today})
lst = body_of("GET", "/api/v1/tenants?expireBefore=%s&pageSize=200" % today, ops_token)
hit = [t.get("id") for t in ((lst.get("data") or {}).get("list") or [])]
check("⭐ expireBefore=今天 能查到「今天到期」的租户"
"(原先用 atStartOfDay() 比,差一天,查不到)",
ntid in hit, "今天=%s 命中 id=%s" % (today, hit[:10]))
# ⭐ 用量口径 —— 本段最重要的一条。
# 平台身份会让租户拦截器**整张表关掉过滤**,所以 `/tenants/{id}/usage`
# 曾经返回的是**全平台**的学生数与存储量。库里只有一所学校有数据时
# 它与正确值完全相等,所以这里必须先往**另一个租户**塞一行名册。
R.sql("insert into collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
"values (%d, -1, 'E2E用量范围', 'x', '%s%s');"
% (ntid, USAGE_FIXTURE_HASH_PREFIX, suffix))
own = int(sql("select count(distinct id_card_hash) from collect_roster "
"where tenant_id=1 and deleted=0;").strip() or 0)
allc = int(sql("select count(distinct id_card_hash) from collect_roster "
"where deleted=0;").strip() or 0)
u1 = (body_of("GET", "/api/v1/tenants/1/usage", ops_token).get("data") or {})
check("⭐ 平台看到的「1 号学校学生数」= 库里 tenant_id=1 的去重人数,**不是全平台**",
u1.get("studentCount") == own,
"接口=%s 库里 tenant_id=1 去重=%s 全平台去重=%s(接口等于后者就说明过滤被算丢了)"
% (u1.get("studentCount"), own, allc))
un = (body_of("GET", "/api/v1/tenants/%d/usage" % ntid, ops_token).get("data") or {})
check("⭐ 新租户看到的是**它自己**那一行(顺带证明不是恒 0)",
un.get("studentCount") == 1, "新租户 studentCount=%s" % un.get("studentCount"))
check("全平台确实不止 1 个(否则上面那条断言是空转的)", allc > own,
"全平台=%s 一号学校=%s" % (allc, own))
# ── 3. 文件夹具 ──
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
_, 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"
})
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": 1}]})
R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at,
{"templateCode": "TPL_SCHOOL_ROLL"})
_build_roster([("文件甲", R.with_checksum("36102920080808091"))])
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)[:180])
R.call("POST", BASE + "/api/v1/tasks/%d/publish" % task_id, at, {"force": True})
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:mp:gate:user:%d > NUL 2>&1' % STU)
tok = mp(STU)
_, b = R.call("POST", BASE + "/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": "文件甲",
"idCardNo": R.with_checksum("36102920080808091")})
if b.get("code") != 0:
print(" x 门禁核验失败:" + json.dumps(b, ensure_ascii=False)[:200])
return 2
_, b = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
check("夹具:学生建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
_make_jpeg()
up_res = upload("/api/v1/mp/files/upload", tok,
"submissionId=%d" % sid, "materialCode=IDCARD",
"file=@%s;filename=乱名字.jpg;type=image/jpeg" % TINY_JPEG)
check("夹具:上传一份材料", up_res.get("code") == 0,
json.dumps(up_res, ensure_ascii=False)[:250])
file_id = (up_res.get("data") or {}).get("id") or (up_res.get("data") or {}).get("fileId")
check("夹具:拿到 fileId", isinstance(file_id, int), str(file_id))
# ── 4. §9.8 预览 ──
print("\n[4] §9.8 预览(签名 URL + 元信息)")
v = body_of("GET", "/api/v1/mp/files/%d" % file_id, tok)
check("预览可用", v.get("code") == 0, json.dumps(v, ensure_ascii=False)[:250])
vd = v.get("data") or {}
check("带 previewUrl / standardName / 尺寸",
vd.get("previewUrl") and vd.get("standardName") and vd.get("width"),
json.dumps(vd, ensure_ascii=False)[:300])
check("带 expireSeconds(签名 URL 有有效期,不能长期保存)",
vd.get("expireSeconds") and vd.get("expireSeconds") > 0,
str(vd.get("expireSeconds")))
check("⭐ 带 actualKind(请求 original 时原始件可能不存在,会退到规范件)",
vd.get("actualKind") in ("ORIGINAL", "STANDARD"), str(vd.get("actualKind")))
vo = body_of("GET", "/api/v1/mp/files/%d?prefer=original" % file_id, tok)
check("prefer=original 也能用(原始件默认不保留时会退到规范件并标注)",
vo.get("code") == 0 and (vo.get("data") or {}).get("actualKind") in
("ORIGINAL", "STANDARD"),
json.dumps(vo.get("data"), ensure_ascii=False)[:250])
check("⭐ **别人的文件** → 拒绝(不区分「不存在」与「不是你的」,避免枚举)",
body_of("GET", "/api/v1/mp/files/%d" % file_id, mp(OTHER)).get("code") != 0,
"code=%s" % body_of("GET", "/api/v1/mp/files/%d" % file_id, mp(OTHER)).get("code"))
check("不存在的文件 → 明确报错",
body_of("GET", "/api/v1/mp/files/999999999", tok).get("code") != 0)
check("未登录 → 40101", body_of("GET", "/api/v1/mp/files/%d" % file_id).get("code") == 40101)
# ── 5. §9.5 上传凭证 ──
print("\n[5] §9.5 上传凭证(预检 + 秒传 + 压缩提示)")
tk = body_of("POST", "/api/v1/mp/files/upload-token", tok, {
"taskId": task_id, "materialCode": "PHOTO1", "fileName": "相册照片.jpg",
"fileSize": 300000, "contentType": "image/jpeg"
})
check("凭证接口可用", tk.get("code") == 0, json.dumps(tk, ensure_ascii=False)[:250])
td = tk.get("data") or {}
check("ok=true 且没命中秒传", td.get("ok") is True and td.get("existed") is False,
json.dumps(td, ensure_ascii=False)[:250])
check("⭐ 带压缩提示(让学生在自己手机上压,省的是他的流量与等待)",
(td.get("clientCompressHint") or {}).get("enabled") is True,
json.dumps(td.get("clientCompressHint"), ensure_ascii=False))
check("带 objectKey / uploadToken / expireSeconds=900",
td.get("objectKey") and td.get("uploadToken") and td.get("expireSeconds") == 900,
json.dumps({k: td.get(k) for k in ("objectKey", "expireSeconds")}, ensure_ascii=False))
check("⭐ 一期 direct=false(七牛直传要先把「上传完成回调」打通,见 F-71)",
td.get("direct") is False and len(td.get("notes") or []) >= 1,
json.dumps({k: td.get(k) for k in ("direct", "notes")}, ensure_ascii=False)[:250])
# 体积超限:**不传字节就能拦**
big = body_of("POST", "/api/v1/mp/files/upload-token", tok, {
"taskId": task_id, "materialCode": "PHOTO1", "fileName": "巨图.jpg",
"fileSize": 8 * 1024 * 1024, "contentType": "image/jpeg"
})
bd = big.get("data") or {}
check("⭐ 体积超限 → ok=false + **能照着做的原因**(说清上限与当前值)",
bd.get("ok") is False and "KB" in (bd.get("rejectReason") or ""),
json.dumps(bd, ensure_ascii=False)[:250])
check("超限时压缩提示里 needed=true",
(bd.get("clientCompressHint") or {}).get("needed") is True,
json.dumps(bd.get("clientCompressHint"), ensure_ascii=False))
# 份数超限(IDCARD 已经传了 1 份,模板 maxCount=2 → 再传 1 份可以,第 3 份不行)
for i in range(1):
upload("/api/v1/mp/files/upload", tok, "submissionId=%d" % sid,
"materialCode=IDCARD",
"file=@%s;filename=第二次.jpg;type=image/jpeg" % TINY_JPEG)
full = body_of("POST", "/api/v1/mp/files/upload-token", tok, {
"taskId": task_id, "materialCode": "IDCARD", "fileName": "第三张.jpg",
"fileSize": 100000, "contentType": "image/jpeg"
})
fd = full.get("data") or {}
check("⭐ 份数超限 → ok=false 并说清「最多几份、你已经传了几份、要换请先删」",
fd.get("ok") is False and "最多" in (fd.get("rejectReason") or ""),
json.dumps(fd, ensure_ascii=False)[:250])
# 秒传:拿已经传过那份的 fileHash
real_hash = sql("select file_hash from collect_file where id=%d;" % file_id).strip()
fast = body_of("POST", "/api/v1/mp/files/upload-token", tok, {
"taskId": task_id, "materialCode": "IDCARD", "fileName": "同一张.jpg",
"fileSize": 100, "contentType": "image/jpeg", "fileHash": real_hash
})
fd2 = fast.get("data") or {}
check("⭐ 秒传命中(同一个人在同一个槽位传同样的文件)→ existed=true",
fd2.get("existed") is True and fd2.get("fileId") == file_id,
json.dumps(fd2, ensure_ascii=False)[:250])
check("⭐ 秒传时**不给 uploadUrl**(返回了而前端不用,会让「到底传没传」变成两处判断)",
fd2.get("uploadUrl") is None, str(fd2.get("uploadUrl")))
bad = body_of("POST", "/api/v1/mp/files/upload-token", tok, {
"taskId": task_id, "materialCode": "NO_SUCH_MATERIAL", "fileName": "x.jpg",
"fileSize": 100, "contentType": "image/jpeg"
})
check("材料码不存在 → 明确报错", bad.get("code") != 0,
"code=%s msg=%s" % (bad.get("code"), bad.get("msg")))
# ── 6. §9.7 批量处理 ──
print("\n[6] §9.7 批量处理(多材料一次提交)")
bp = body_of("POST", "/api/v1/mp/files/batch-process", tok, {
"submissionId": sid, "fileIds": [file_id], "async": True
})
check("批量处理可用", bp.get("code") == 0, json.dumps(bp, ensure_ascii=False)[:250])
bpr = bp.get("data") or {}
check("带 batchToken / total / results / summary",
bpr.get("batchToken") and bpr.get("total") == 1 and bpr.get("summary"),
json.dumps(bpr, ensure_ascii=False)[:250])
item = (bpr.get("results") or [{}])[0]
check("结果项带 fileId 与 status", item.get("fileId") == file_id
and item.get("status") is not None, json.dumps(item, ensure_ascii=False))
check("⭐ 一期 conflict 恒为 false、detectMaterialCode 恒为空,**并说明为什么**"
"(不是「识别后没问题」,而是「还没识别」)",
item.get("conflict") is False and item.get("detectMaterialCode") is None
and len(bpr.get("notes") or []) >= 1,
json.dumps({k: item.get(k) for k in ("conflict", "detectMaterialCode")},
ensure_ascii=False) + " notes=" + str(bpr.get("notes"))[:120])
over = body_of("POST", "/api/v1/mp/files/batch-process", tok, {
"submissionId": sid, "fileIds": list(range(1, 25))
})
check("⭐ 超过 20 个 → 40011(与批量审批同一个上限口径)", over.get("code") == 40011,
"code=%s msg=%s" % (over.get("code"), over.get("msg")))
foreign = body_of("POST", "/api/v1/mp/files/batch-process", tok, {
"submissionId": sid, "fileIds": [999999999]
})
check("不属于本填报单的文件 → 记成失败项而不是整批失败",
(foreign.get("data") or {}).get("summary", {}).get("failed") == 1,
json.dumps(foreign.get("data"), ensure_ascii=False)[:250])
noids = body_of("POST", "/api/v1/mp/files/batch-process", tok, {"submissionId": sid})
check("不传 fileIds → 明确报错", noids.get("code") != 0,
"code=%s msg=%s" % (noids.get("code"), noids.get("msg")))
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_org_xlsx(rows, header=None):
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "组织"
head = header or ["层级", "组织名称", "组织编码", "上级编码", "负责人手机号"]
for c, t in enumerate(head, start=1):
ws.cell(row=1, column=c, value=t)
for i, r in enumerate(rows):
for c, v in enumerate(r, start=1):
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ORG)
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, "中专", "1380013880%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 _make_jpeg():
import base64
b64 = ("/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a"
"HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA"
"AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==")
with open(TINY_JPEG, "wb") as f:
f.write(base64.b64decode(b64))
_TENANT_TABLES = None
def _tables_with_tenant_id():
"""
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
"""
global _TENANT_TABLES
if _TENANT_TABLES is None:
out = sql("SELECT table_name FROM information_schema.columns "
"WHERE table_schema='public' AND column_name='tenant_id'")
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
return _TENANT_TABLES
def cleanup():
for tid in CREATED_TASK_IDS:
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
R.sql("delete from collect_audit_record where task_id=%d;" % tid)
R.sql("delete from collect_notify_record where task_id=%d;" % tid)
R.sql("delete from collect_file where task_id=%d;" % tid)
R.sql("delete from collect_submission where task_id=%d;" % tid)
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)
for name in CREATED_ORG_NAMES:
R.sql("delete from sys_org where org_name = '%s';" % name)
# 用量夹具(本脚本往 E2E 租户里插的那一行名册)
R.sql("delete from collect_roster where id_card_hash like '%s%%';" % USAGE_FIXTURE_HASH_PREFIX)
# 本脚本建的 E2E 租户:连带它的用户 / 角色 / 组织一起删。
# ⚠️ 日志行要单独删 —— 它们的 `tenant_id` 是 **0**(平台身份),
# 用 tenant_id 清是清不掉的,得按 target 清。
for tid in CREATED_TENANT_IDS:
R.sql("delete from sys_operation_log where target_type='TENANT' and target_id=%d;" % tid)
for table in _tables_with_tenant_id():
R.sql("delete from %s where tenant_id=%d;" % (table, tid))
R.sql("delete from sys_tenant where id=%d;" % tid)
del CREATED_TENANT_IDS[:]
# 联系人 / 联系电话复原:本条用例改过它们(手机号改完是**密文**,
# 所以复原成改动前那个明文值 —— 顺带保留"库里有一行历史明文"这个现状,
# 那正是详情接口的容错分支要处理的输入)
R.sql("update sys_tenant set contact_name='张校长', contact_phone='13800138000' where id=1;")
R.sql("delete from collect_message where user_id in (997201, 997202, 997299);")
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
if os.path.exists(p):
os.remove(p)
print(" 已清理:任务 / 材料文件 / 测试组织 / E2E 租户与用量夹具 / "
"租户联系人复原 / 临时文件")
if __name__ == "__main__":
raise SystemExit(main())