# -*- 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)