Files
xiaotutongxue/scripts/e2e-m8-orgfile.py
T
lovife 30c3714d35 test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
2026-09-30 09:02:57 +08:00

502 lines
26 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**。
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
## 还有一条不能省的边界
`GET /mp/files/{fileId}` 按 `submitter_id` 授权(学生端);教师看他班学生的材料
走 `/mp/teacher/files/{id}/preview`(按班级范围)。本脚本要证明**前者的越权会被拒** ——
不证明的话,"任何登录用户能看任何人的身份证照片"这种事只在真实越权时才被发现。
跑法:python scripts/e2e-m8-orgfile.py
"""
import argparse
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 = []
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"))
# ── 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))
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)
R.sql("update sys_tenant set contact_name='张校长' 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(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件")
if __name__ == "__main__":
raise SystemExit(main())