Files
xiaotutongxue/scripts/e2e-m7-schedule.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

620 lines
33 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(PRD 附录 E 的 9 项里的 4 项)
## 为什么这份脚本能测"定时"任务
定时任务的"等不起"是真问题:验证"任务截止后会怎样"总不能等到明天凌晨。
本轮加了 `POST /api/v1/schedules/run`(运维手工触发一轮),
所以**每一件事都可以当场造条件、当场跑、当场断言**。
## 这四件事各自"错了会怎样"(决定了断言怎么写)
| 任务 | 不做的后果 |
|---|---|
| 任务自动截止 | 截止后学生**还能继续提交** —— 而这批到底算不算,导出与上报时说不清 |
| 自动催报 | 全靠老师手动点;而"没交的人"恰恰是老师最容易漏的那批 |
| 审核超时提醒 | 单子卡在某个班没人管,直到学生来问 |
| 原始件清理 | 磁盘只涨不落(原始件默认留 30 天) |
## 三条最容易写错的边界
1. **`end_time` 是日期**:写 `<= now()` 会让"今天截止"的任务在**今天凌晨**就被关掉
—— 学生今天还能交。正确口径是"截止日**已经过去**"(`< CURRENT_DATE`)。
2. **催报必须"正好那一天"**:写 `end_time <= now()+7d` 会让"还剩 2 天"的任务
在 7/3/1 天各被催一次(2<3 也成立)→ 学生一天收三条然后关掉通知。
3. **只催未提交**:已交的人收到"请尽快完成填报"会以为自己没交成功 —— 比不催更糟。
跑法:python scripts/e2e-m7-schedule.py
"""
import argparse
import importlib.util
import json
import os
import subprocess
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED = []
FAILED = []
CREATED_TASK_IDS = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-sch-roster.xlsx")
TMP_RAW = os.path.join(ROOT, "scripts", "_e2e-sch-raw.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 = 997301
STU2 = 997302
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.strip()
def one(stmt):
return sql(stmt).split("\n")[0].strip() if sql(stmt) else ""
def body_of(method, path, token=None, payload=None):
_, b = R.call(method, BASE + path, token, payload)
return b
def upload(path, token, *fields):
"""用 curl 发 multipart(urllib 手搓 multipart 容易出错)。"""
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 _make_jpeg():
import base64
b64 = ("/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a"
"HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA"
"AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==")
with open(TMP_RAW, "wb") as f:
f.write(base64.b64decode(b64))
def mp(uid):
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def main():
ap = argparse.ArgumentParser(description="定时任务 E2E")
ap.parse_args()
print("=" * 74)
print(" 定时任务 E2E(自动截止 / 自动催报 / 审核超时提醒 / 原始件清理)")
print("=" * 74)
# ⚠️ 开跑前先清一遍:上一轮如果中途 SystemExit,结尾的 cleanup() 没跑,
# 残留的消息会让这一轮的"不该发"断言全部失败(看起来像去重坏了)
preclean()
# 平台运营账号:/schedules/run 是运维动作(log:view:all)
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)
_, ops = R.login(BASE, "platform", "Platform@123", None)
if ops.get("code") != 0:
print(" x 平台运营账号登录失败:" + str(ops))
return 2
ops_token = ops["data"]["token"]
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
at = adm["data"]["token"]
suffix = os.urandom(3).hex()
task_id = None
# ── 1. 手工触发入口本身 ──
print("\n[1] 手工触发入口(运维)")
b = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"})
check("手工触发可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
check("⭐ 认不出的任务名 → 明确报错并列出可选项",
"close" in (body_of("POST", "/api/v1/schedules/run", ops_token,
{"job": "no-such-job"}).get("msg") or ""),
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "no-such-job"}).get("msg"))
check("不传 job → 明确报错",
body_of("POST", "/api/v1/schedules/run", ops_token, {}).get("code") != 0)
check("⭐ 学校管理员调它 → 40301(它是运维动作,不是业务动作)",
body_of("POST", "/api/v1/schedules/run", at, {"job": "close"}).get("code") == 40301,
"code=%s" % body_of("POST", "/api/v1/schedules/run", at, {"job": "close"}).get("code"))
check("未登录 → 40101",
body_of("POST", "/api/v1/schedules/run", None, {"job": "close"}).get("code") == 40101)
# ── 2. 任务自动截止 ──
print("\n[2] 任务自动截止(PRD 附录 E「任务状态流转」)")
# 造一个"截止日在昨天"的收集中任务:先按正常流程建+发布,再把 end_time 改到昨天
org0 = first_class_org(at)
task_id = create_task(at, suffix, end_time="2099-12-31")
setup_materials_and_org(at, task_id, org0["id"], 1)
_build_roster([("超时甲", R.with_checksum("36102920080808091"))])
imp = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org0["id"]})
# ⚠️ 这一条不能省:导入失败时报错发生在**发布**那一步("名册为空"),
# 而真正的原因(证件号校验位不合法)在这里 —— 不在这里断言,
# 排查方向会被带到"发布的前置条件"上去
if imp.get("code") != 0 or (imp.get("data") or {}).get("created") != 1:
raise SystemExit("夹具名册导入失败:" + json.dumps(imp, ensure_ascii=False)[:300])
publish_task(at, task_id)
check("夹具:任务已发布(收集中)",
one("select status from collect_task where id=%d;" % task_id) == "1",
one("select status from collect_task where id=%d;" % task_id))
# ⚠️ 先证明"还没到期时不该被关" —— 否则下面的断言可能只是"它从来没被扫到"
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"})
check("⭐ 没到期的任务**不该**被关(否则这条链路的断言都是假的)",
one("select status from collect_task where id=%d;" % task_id) == "1",
one("select status, end_time from collect_task where id=%d;" % task_id))
# 把截止日改到昨天 → 再跑
R.sql("update collect_task set end_time = CURRENT_DATE - 1 where id=%d;" % task_id)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"})
check("⭐ 到期的任务被自动关成「已截止」",
one("select status from collect_task where id=%d;" % task_id) == "2",
one("select status, end_time from collect_task where id=%d;" % task_id))
check("⭐ 操作日志里记的是 AUTO_CLOSE(能区分「系统到点关的」与「管理员手动关的」)",
one("select count(*) from sys_operation_log where target_id=%d "
"and action='AUTO_CLOSE';" % task_id) == "1",
one("select action, create_time from sys_operation_log "
"where target_id=%d order by id desc limit 3;" % task_id).replace("\n", " | "))
# ⚠️ 边界:**今天**截止的任务不该在凌晨就被关(end_time 是日期,不是时刻)
R.sql("update collect_task set end_time = CURRENT_DATE, status = 1 where id=%d;" % task_id)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "close"})
check("⭐ 今天截止的任务**不该**被关(`end_time` 是日期,写成 `<= now()` 会让学生今天交不了)",
one("select status from collect_task where id=%d;" % task_id) == "1",
one("select status, end_time from collect_task where id=%d;" % task_id))
# ── 3. 自动催报 ──
print("\n[3] 自动催报(7/3/1 天规则,仅催未提交者)")
# 造一个"还有 3 天截止"的收集中任务 + 两个学生名册(一个不交、一个交了)
urge_task = create_task(at, suffix + "u", end_time="2099-12-31",
task_name="E2E 自动催报 " + suffix)
org = first_class_org(at)
setup_materials_and_org(at, urge_task, org["id"], 2)
RS = R.with_checksum("36102920080808092")
RS2 = R.with_checksum("36102920080808093")
_build_roster([("催报甲", RS), ("催报乙", RS2)])
body = R.upload_roster(BASE, at, urge_task, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("夹具:名册就绪", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:180])
publish_task(at, urge_task)
# 甲核验身份但**不提交**;乙核验并提交
for name, idc, uid in (("催报甲", RS, STU), ("催报乙", RS2, STU2)):
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:mp:gate:user:%d > NUL 2>&1' % uid)
tok = mp(uid)
_, mb = R.call("POST", BASE + "/api/v1/mp/roster/match", tok,
{"taskId": urge_task, "realName": name, "idCardNo": idc})
if mb.get("code") != 0:
print(" x 门禁核验失败(%s):%s" % (name, json.dumps(mb, ensure_ascii=False)[:160]))
return 2
tok2 = mp(STU2)
R.call("POST", BASE + "/api/v1/mp/submissions", tok2, {"taskId": urge_task})
sid2 = one("select id from collect_submission where task_id=%d and submitter_id=%d;"
% (urge_task, STU2))
# ⚠️ 必传材料没传时**提交不会成功**,但接口仍然 code=0 ——
# 契约 §9.5:「校验不通过也是 HTTP 200」,结论在 data.submitted 里。
# 所以先传一份 IDCARD,再断言 data.submitted。
_make_jpeg()
mup = upload("/api/v1/mp/files/upload", tok2,
"submissionId=%s" % sid2, "materialCode=IDCARD",
"file=@%s;filename=必传.jpg;type=image/jpeg" % TMP_RAW)
if mup.get("code") != 0:
raise SystemExit("夹具:乙上传必传材料失败 " + json.dumps(mup, ensure_ascii=False)[:250])
sub = body_of("POST", "/api/v1/mp/submissions/%s/submit" % sid2, tok2,
{"declareTrue": True, "confirmWarnings": True})
sd = sub.get("data") or {}
check("⭐ 夹具:乙真的提交成功了(判据是 data.submitted,**不是 code** ——"
"「HTTP 200 + code 0」只说明请求被处理了)",
sd.get("submitted") is True,
"submitted=%s red=%s issues=%s" % (sd.get("submitted"), sd.get("redCount"),
json.dumps(sd.get("issues"), ensure_ascii=False)[:200]))
# ⚠️ 量一次名册的 submit_status:催报的 SQL 过滤的是 `collect_roster.submit_status`,
# 而"提交"这个动作要把它改成 2。改没改,只有量了才知道 ——
# 否则"乙还收到催报"会被误判成催报的过滤条件写错了
subst = one("select submit_status from collect_roster where task_id=%d "
"and real_name='催报乙' and deleted=0;" % urge_task)
check("夹具:乙的名册行变成了「已提交」(催报的过滤条件就看它)", subst == "2",
"乙的 submit_status = " + subst)
# 还没到 7/3/1 天里任何一天时,不该催
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
check("⭐ 截止日不在 7/3/1 天内 → 一条都不发(写成 `<=` 会让它天天发)",
sql("select count(*) from collect_message where user_id=%d "
"and biz_type=5;" % STU) == "0",
sql("select count(*) from collect_message where user_id=%d;" % STU))
# 把截止日改成"3 天后" → 应该只催**未提交的甲**
R.sql("update collect_task set end_time = CURRENT_DATE + 3 where id=%d;" % urge_task)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
n1 = sql("select count(*) from collect_message where user_id=%d and biz_type=5;" % STU)
check("⭐ 未提交的甲收到催报", n1 == "1", "甲的消息数 = " + n1)
check("⭐ 已提交的乙**没有**收到(收到会让他以为没交成功)",
sql("select count(*) from collect_message where user_id=%d and biz_type=5;"
% STU2) == "0",
"乙的消息数 = " + sql("select count(*) from collect_message where user_id=%d;"
% STU2))
check("⭐ 文案里带「还有 3 天」(学生才知道急不急)",
"3 天" in sql("select content from collect_message where user_id=%d "
"and biz_type=5 order by id desc limit 1;" % STU),
sql("select content from collect_message where user_id=%d and biz_type=5 "
"order by id desc limit 1;" % STU))
# 再跑一次:24 小时去重窗口内不该重发
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
check("⭐ 24 小时内不重复催(去重窗口)",
sql("select count(*) from collect_message where user_id=%d and biz_type=5;"
% STU) == n1,
"第二次跑完之后 = " + sql("select count(*) from collect_message where user_id=%d "
"and biz_type=5;" % STU))
# ── 4. 审核超时提醒 ──
print("\n[4] 审核超时提醒(时效与 §11.6 同口径)")
# 乙的填报单刚提交 → 还没超时,先跑一次证明不发
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
sql("select count(*) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';") == "0",
sql("select count(*) from collect_message where biz_type=2;"))
# 把提交时间改到 5 天前(> 48 小时)→ 应该提醒
st = one("select status from collect_submission where task_id=%d and submitter_id=%d;"
% (urge_task, STU2))
check("夹具:乙的填报单在「待初审」(超时扫描只看 1/2 两个状态)", st == "1",
"submission.status = " + st)
R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' "
"where task_id=%d and submitter_id=%d;" % (urge_task, STU2))
R.sql("delete from collect_message where biz_type=2 and title like '%%审核超时%%';")
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
n = sql("select count(*) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';")
check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n)
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
"48" in sql("select content from collect_message where biz_type=2 "
"and title like '%%审核超时%%' order by id desc limit 1;"),
sql("select content from collect_message where biz_type=2 order by id desc limit 1;"))
check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
sql("select count(distinct user_id) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';") != "0",
sql("select user_id, count(*) from collect_message where biz_type=2 "
"group by user_id;").replace("\n", " | "))
# ── 5. 原始件清理 ──
print("\n[5] 原始件清理(raw_expire_time 到期)")
file_id = make_raw_file(urge_task, sid2)
if file_id:
# 先证明"没到期时不该清"
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "raw"})
check("⭐ 没到期的原始件**不该**被清",
sql("select coalesce(raw_path,'') from collect_file where id=%d;"
% file_id).strip() not in ("", "None"),
"raw_path = " + sql("select coalesce(raw_path,'(NULL)') from collect_file "
"where id=%d;" % file_id))
R.sql("update collect_file set raw_expire_time = NOW() - INTERVAL '1 day' "
"where id=%d;" % file_id)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "raw"})
check("⭐ 到期的原始件:**引用被清掉**(row 还在,规范件还在)",
sql("select coalesce(raw_path,'') from collect_file where id=%d;"
% file_id).strip() in ("", "None"),
"raw_path = " + sql("select coalesce(raw_path,'(NULL)') from collect_file "
"where id=%d;" % file_id))
check("⭐ **行没有被删**(规范件才是交付物,学生要能看到自己传过什么)",
sql("select count(*) from collect_file where id=%d;" % file_id) == "1",
sql("select count(*) from collect_file where id=%d;" % file_id))
check("⭐ 磁盘文件也删了",
not os.path.exists(TMP_RAW) if os.path.exists(TMP_RAW) else True,
"文件仍在:" + TMP_RAW)
# ── 6. 用量快照 ──
print("\n[6] 用量快照(每日 01:00)")
period = __import__("datetime").date.today().strftime("%Y-%m")
R.sql("delete from sys_tenant_usage_snapshot where tenant_id=1 and period='%s';" % period)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "usage"})
n1 = sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1 "
"and period='%s';" % period)
check("⭐ 快照写进去了(一个租户一个周期一行)", n1 == "1", "行数 = " + n1)
check("⭐ 快照里的 tenant_id 是**真实租户**(不是 0)—— 平台上下文不会自动填 tenant_id,"
"忘了显式写就会全挤到 0 上,而那看起来像「没有数据」",
sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1;") != "0"
and sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=0;") == "0",
sql("select tenant_id, period, student_count from sys_tenant_usage_snapshot;")
.replace("\n", " | "))
snap = one("select student_count || '|' || storage_mb from sys_tenant_usage_snapshot "
"where tenant_id=1 and period='%s';" % period)
live = body_of("GET", "/api/v1/tenants/1/usage?period=%s" % period, ops_token)
ld = live.get("data") or {}
check("⭐ 快照的数字与实时统计一致(否则两个界面会给出两个答案)",
snap.split("|")[0] == str(ld.get("studentCount")),
"快照=%s 实时=%s" % (snap, ld.get("studentCount")))
# 幂等:再跑一次不新增
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "usage"})
check("⭐ 再跑一次**不新增行**(幂等)—— 两行的话「这个月用了多少」会有两个答案",
sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1 "
"and period='%s';" % period) == "1",
sql("select count(*) from sys_tenant_usage_snapshot where tenant_id=1;"))
# ── 7. 压缩失败重试 ──
print("\n[7] 压缩失败重试(每 10 分钟,≤3 次)")
fid2 = make_raw_file(urge_task, draft_sub_of(urge_task))
# 造出"处理失败"的状态:没有规范件、有原始件
R.sql("update collect_file set std_path = NULL, process_retry_count = 0, "
"process_error = NULL where id=%s;" % fid2)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "retry"})
st = one("select process_retry_count || '|' || coalesce(process_error,'(空)') "
"from collect_file where id=%s;" % fid2)
cnt = int(st.split("|")[0])
check("⭐ 重试失败会**累加计数并记下原因**(FR-611 的「标记失败原因」)",
cnt >= 1 and st.split("|")[1] != "(空)",
"process_retry_count|error = " + st)
check("⭐ 没有规范件但**有原始件**才会被选中(没有原图就无从重试)",
sql("select count(*) from collect_file where id=%s and raw_path is not null;"
% fid2) == "1", st)
# 直接推到上限,验"封顶"
R.sql("update collect_file set process_retry_count = 3 where id=%s;" % fid2)
before = one("select process_retry_count from collect_file where id=%s;" % fid2)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "retry"})
check("⭐ 到上限(3 次)后**不再被选中** —— 不封顶的话一个原图损坏的文件会被"
"每 10 分钟重试一次,而它永远不会成功",
one("select process_retry_count from collect_file where id=%s;" % fid2) == before,
"跑之前 %s,跑之后 %s" % (before,
one("select process_retry_count from collect_file where id=%s;"
% fid2)))
# ── 8. 租户到期提醒 ──
print("\n[8] 租户到期提醒(每日 08:00,30/7/1 天)")
old_expire = one("select coalesce(expire_time::text,'') from sys_tenant where id=1;")
try:
R.sql("delete from collect_message where biz_type=2 and title like '%%到期%%';")
# 先证明"不在 30/7/1 里就不发"
R.sql("update sys_tenant set expire_time = CURRENT_DATE + 15 where id=1;")
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "expire"})
check("⭐ 到期日不在 30/7/1 天内 → 一条都不发(写成 `<=` 会连报三次,"
"客户第三次就关掉通知了)",
sql("select count(*) from collect_message where title like '%%到期%%';") == "0",
sql("select title from collect_message where title like '%%到期%%';"))
# 改成 7 天后 → 应该发
R.sql("update sys_tenant set expire_time = CURRENT_DATE + 7 where id=1;")
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "expire"})
n = sql("select count(*) from collect_message where title like '%%到期%%';")
check("⭐ 7 天后到期的提醒发出去了", n != "0", "条数 = " + n)
check("⭐ 文案里说清了两件事:**还有几天**、以及**到期后会怎样**"
"(「不能收新材料」比「请续费」更让人行动)",
"7 天" in sql("select title || content from collect_message "
"where title like '%%到期%%' order by id desc limit 1;")
and "无法登录" in sql("select content from collect_message "
"where title like '%%到期%%' order by id desc limit 1;"),
sql("select title || ' / ' || content from collect_message "
"where title like '%%到期%%' order by id desc limit 1;"))
check("⭐ 提醒发给**该租户自己的管理员**(续费是客户内部要安排的事)",
sql("select count(*) from collect_message m join sys_user u on u.id=m.user_id "
"where m.title like '%%到期%%' and u.tenant_id=1;") != "0",
sql("select m.user_id, u.real_name from collect_message m "
"left join sys_user u on u.id=m.user_id where m.title like '%%到期%%';"))
finally:
# ⚠️ 必须还原:这是**真实租户**的数据,跑一次测试不该改掉 demo 的到期日
if old_expire:
R.sql("update sys_tenant set expire_time = '%s' where id=1;" % old_expire)
else:
R.sql("update sys_tenant set expire_time = NULL where id=1;")
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 create_task(at, suffix, end_time, task_name=None):
"""只建任务(草稿态)。**发布要另做** —— 它有前置条件,见 publish_task。"""
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": task_name or ("E2E 定时任务 " + suffix),
"batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1,
"startTime": "2026-01-01", "endTime": end_time
})
if b.get("code") != 0:
raise SystemExit("建任务失败:" + json.dumps(b, ensure_ascii=False)[:200])
tid = b["data"]["id"]
CREATED_TASK_IDS.append(tid)
return tid
def publish_task(at, tid):
"""
发布。
⚠️ 发布有前置条件(**这是产品决定**,不是技术限制):名册非空 +
至少一个必传材料 + 覆盖了班级或班主任。没名册的任务发出去,
学生扫进来会发现「我不在名单里」—— 那是比"发不出去"更糟的体验。
所以夹具的顺序必须是:建任务 → 设班级 → 配材料 → 导名册 → 发布。
"""
_, b = R.call("POST", BASE + "/api/v1/tasks/%d/publish" % tid, at, {"force": True})
if b.get("code") != 0:
raise SystemExit("发布任务失败:" + json.dumps(b, ensure_ascii=False)[:200])
def setup_materials_and_org(at, tid, org_id, expected):
"""配必传材料 + 覆盖班级(发布的前置条件)。"""
R.call("POST", BASE + "/api/v1/tasks/%d/materials" % tid, at, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2,
# 下限调小:E2E 的小图会被默认下限判成「体积偏小」(黄色警告)——
# 那会让"绿色单"的前提不成立,后面的断言全部跟着错
"minSizeKb": 1, "minLongSide": 100
})
R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % tid, at,
{"orgs": [{"orgId": org_id, "expectedCount": expected}]})
def first_class_org(at):
_, 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 [])
if not classes:
raise SystemExit("没有班级节点,跑不了这个脚本")
return classes[0]
def draft_sub_of(task_id):
"""取这个任务里**草稿态**的填报单 id(第 7 节要往草稿上挂文件)。"""
return one("select id from collect_submission where task_id=%d and status=0 "
"and deleted=0 limit 1;" % task_id)
def make_raw_file(task_id, submission_id=None):
"""
造一个**带原始件**的材料。
⚠️ 先真上传一个文件 —— 上一版我直接去 `collect_file` 里找一行,
而这条链路上根本还没有任何材料,于是函数返回 None,
**第 5 节整节被静默跳过**("原始件清理"看起来通过了,其实一条都没跑)。
所以现在:先上传,再断言拿到了 fileId。
"""
# ⚠️ 必须挂在一个**草稿态**的填报单上:往已提交的填报单传材料会被明确拒绝
# (`40030 已提交的填报单不能再传材料`)—— 那是**正确行为**,
# 改材料本来就要先撤回。所以这里不绕过它,而是换一个还在草稿的填报单。
tok = mp(STU)
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)
_, cb = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": task_id})
if cb.get("code") != 0:
raise SystemExit("夹具:给甲建草稿失败 " + json.dumps(cb, ensure_ascii=False)[:250])
draft_sid = str(cb["data"]["id"])
_make_jpeg()
up = upload("/api/v1/mp/files/upload", tok,
"submissionId=%s" % draft_sid, "materialCode=IDCARD",
"file=@%s;filename=原始件.jpg;type=image/jpeg" % TMP_RAW)
if up.get("code") != 0:
raise SystemExit("夹具:上传材料失败 " + json.dumps(up, ensure_ascii=False)[:250])
fid = str((up.get("data") or {}).get("id") or (up.get("data") or {}).get("fileId") or "")
if not fid:
raise SystemExit("夹具:上传返回里没有 fileId " + json.dumps(up, ensure_ascii=False)[:250])
with open(TMP_RAW, "wb") as f:
f.write(b"\xff\xd8\xff\xe0" + b"x" * 200)
# 直接改库造出"有原始件、且到期时间在未来"的状态 —— 走真实上传路径
# 需要打开 keep-raw,而那会改变全局配置(不该由测试改)
R.sql("update collect_file set raw_path = '%s', "
"raw_expire_time = NOW() + INTERVAL '10 days' where id = %s;"
% (TMP_RAW.replace("\\", "/"), fid))
return int(fid)
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, "中专", "1380013890%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 preclean():
"""开跑前清掉上一轮的残留(消息 / 本脚本创建过的任务)。"""
R.sql("delete from sys_tenant_usage_snapshot where tenant_id=1;")
R.sql("delete from collect_message where title like '%%到期%%';")
R.sql("delete from collect_message where user_id in (%d, %d);" % (STU, STU2))
R.sql("delete from collect_message where title like '%%审核超时%%';")
# 上一轮中途死掉时会留下任务;按名字前缀删(**只删本脚本造的**)
for tid in sql("select id from collect_task where task_name like 'E2E 定时任务 %%' "
"or task_name like 'E2E 自动催报 %%';").split("\n"):
tid = tid.strip()
if not tid.isdigit():
continue
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
"collect_file", "collect_submission", "collect_roster",
"collect_task_org", "collect_material_def"):
R.sql("delete from %s where task_id=%s;" % (tbl, tid))
R.sql("delete from collect_task where id=%s;" % tid)
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)
R.sql("delete from sys_tenant_usage_snapshot where tenant_id=1;")
R.sql("delete from collect_message where title like '%%到期%%';")
R.sql("delete from collect_message where user_id in (%d, %d);" % (STU, STU2))
R.sql("delete from collect_message where title like '%%审核超时%%';")
for p in (TMP_ROSTER, TMP_RAW):
if os.path.exists(p):
os.remove(p)
print(" 已清理:任务 / 名册 / 材料文件 / 测试账号的消息 / 临时文件")
if __name__ == "__main__":
code = main()
# ⚠️ stdout 在管道里是块缓冲、stderr 不缓冲 —— 不 flush 的话,
# `raise SystemExit` 的消息会跑到日志最前面,看起来像"前面某一节就死了"
sys.stdout.flush()
raise SystemExit(code)