9e78e1d239
起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。
新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。
F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。
验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
631 lines
34 KiB
Python
631 lines
34 KiB
Python
# -*- 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 同口径)")
|
||
# 乙的填报单刚提交 → 还没超时,先跑一次证明不发
|
||
#
|
||
# ⚠️ 断言必须**限定到本次夹具的收件人**,不能数全库的"审核超时"消息:
|
||
# 超时扫描是跨租户的,库里只要存在**别的**老填报单(几天前别的脚本留下的、
|
||
# 或者上一轮跑到一半留下的),它就会照样提醒 —— 于是这条断言会在
|
||
# **几天之后突然变红**(那些单子的 submit_time 终于超过 48 小时了),
|
||
# 而代码一行都没改。第一版就是这么写的,它在本轮真的红了。
|
||
# 判据改成"这次涉及的两个人有没有收到",就与库里的历史数据无关了。
|
||
def timeout_msgs_to(uid: int) -> str:
|
||
return sql("select count(*) from collect_message where user_id=%d and biz_type=2 "
|
||
"and title like '%%审核超时%%';" % uid)
|
||
|
||
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
|
||
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
|
||
timeout_msgs_to(STU2) == "0",
|
||
"乙收到的审核超时提醒 = " + timeout_msgs_to(STU2))
|
||
# 把提交时间改到 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))
|
||
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 '%%审核超时%%' and content like '%%E2E 自动催报%%';")
|
||
check("⭐ 超时 5 天的被提醒了", n != "0", "本次夹具的提醒条数 = " + n)
|
||
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
|
||
"48" in sql("select content from collect_message where biz_type=2 "
|
||
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%' "
|
||
"order by id desc limit 1;"),
|
||
sql("select content from collect_message where biz_type=2 "
|
||
"and content like '%%E2E 自动催报%%' order by id desc limit 1;"))
|
||
check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
|
||
sql("select count(distinct user_id) from collect_message where biz_type=2 "
|
||
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';") != "0",
|
||
sql("select user_id, count(*) from collect_message where biz_type=2 "
|
||
"and content like '%%E2E 自动催报%%' 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)
|