b516a7c47b
做「平台运营的租户管理」时挖出来的,其中第 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)—— 判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
643 lines
35 KiB
Python
643 lines
35 KiB
Python
# -*- 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())
|