#!/usr/bin/env python # -*- coding: utf-8 -*- """ M1-01 收集任务(CRUD + 状态机)验收测试。 覆盖《03-接口设计》§7.1~§7.5: [x] 建任务:默认值、编码生成、一期范围限制(40044) [x] 改任务:草稿可改模式,已发布不可改 [x] 状态机:草稿→发布→暂停⇄恢复→截止→归档,幂等与错误码 [x] 发布前置校验:5 项全部阻断,force 无法绕过 [x] 可见范围:班主任只看自己带的班级的任务 [x] 删除:仅草稿 ⚠️ 关于「发布」的可达性 发布要求名册非空 + ≥1 项必传材料 + 已设置覆盖班级,而这三样分别属于 M2 / M1-04 / M1-02。 所以本脚本用**直接写库**的方式造前置数据(这是测试夹具,不是产品捷径): 只有这样才能真正走到 已发布/暂停/恢复/截止/归档 那几个状态并验证它们。 用法 python scripts/e2e-m1-task.py python scripts/e2e-m1-task.py --base http://localhost:8080 依赖:cryptography;且需要能 `docker exec xt-pg`(写测试夹具) 退出码:0 = 全部通过 """ from __future__ import annotations import argparse import base64 import json import os import subprocess import sys import urllib.error import urllib.parse import urllib.request import uuid if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem") PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg") PG_USER = os.environ.get("PG_USER", "fill") PG_DB = os.environ.get("PG_DB", "filldb") PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd") PASSED: list[str] = [] FAILED: list[tuple[str, str]] = [] CREATED_TASK_IDS: list[int] = [] CREATED_USER_IDS: list[int] = [] # ── 断言 ────────────────────────────────────────────── def ok(name: str) -> None: PASSED.append(name) print(f" [OK] {name}") def fail(name: str, detail: str) -> None: FAILED.append((name, detail)) print(f" [FAIL] {name}\n {detail}") def check(name: str, cond: bool, detail: str = "") -> bool: if cond: ok(name) else: fail(name, detail or "断言不成立") return cond def show(body: dict, indent: int = 9) -> None: pad = " " * indent text = json.dumps(body, ensure_ascii=False) print(f"{pad}完整响应:{text[:400]}{'…' if len(text) > 400 else ''}") # ── SQL(测试夹具)──────────────────────────────────── def sql(stmt: str) -> tuple[bool, str]: """直接执行 SQL —— 只用于造/清测试夹具。""" try: r = subprocess.run( ["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER, "psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt], capture_output=True, text=True, timeout=40) return r.returncode == 0, (r.stdout or r.stderr).strip() except Exception as e: return False, str(e) # ── HTTP ──────────────────────────────────────────── def rsa_encrypt(plain: str) -> str: from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding with open(PUB_KEY, "rb") as f: pub = serialization.load_pem_public_key(f.read()) return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode() def call(method: str, url: str, token: str | None = None, body: dict | None = None, timeout: int = 30) -> tuple[int, dict]: headers = {"Content-Type": "application/json; charset=utf-8"} if token: headers["Authorization"] = f"Bearer {token}" data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: raw = e.read().decode("utf-8") try: return e.code, json.loads(raw) except json.JSONDecodeError: return e.code, {"raw": raw} def login(base: str, username: str, password: str, tenant: str | None = None): payload = {"username": username, "password": rsa_encrypt(password)} if tenant: payload["tenantCode"] = tenant return call("POST", f"{base}/api/v1/auth/login", body=payload) def main() -> int: ap = argparse.ArgumentParser(description="M1-01 收集任务 E2E") ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080")) ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo")) ap.add_argument("--admin", default="admin") ap.add_argument("--password", default="Admin@123") args = ap.parse_args() base = args.base.rstrip("/") print("=" * 72) print(f"M1-01 收集任务 E2E base={base} 租户={args.tenant}") print("=" * 72) suffix = uuid.uuid4().hex[:6] # ── 0. 准备 ──────────────────────────────────────── print("\n[0] 准备") status, body = login(base, args.admin, args.password, args.tenant) if body.get("code") != 0: fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}") return 1 token = body["data"]["token"] tenant_id = (body["data"].get("tenant") or {}).get("id") ok(f"管理员登录(租户 id={tenant_id})") st, bd = call("GET", f"{base}/api/v1/orgs/tree", token) def first_class(nodes): for n in nodes or []: if n.get("orgType") == 3: return n hit = first_class(n.get("children")) if hit: return hit return None cls = first_class(bd.get("data")) if not cls: fail("取一个班级组织", "demo 租户里没有 orgType=3 的组织,E2E 无法继续") return 1 ok(f"取到班级组织:{cls['orgName']}(id={cls['id']})") # ── 1. 建任务:校验与默认值 ───────────────────────── print("\n[1] 建任务") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": "202609", "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) check("collectMode=2(社会收集)被拒 40044", body.get("code") == 40044, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609", "majorName": "数控加工", "certType": 3, "startTime": "2026-09-28", "endTime": "2026-10-15", }) check("certType=3(专项职业能力)被拒 40044", body.get("code") == 40044, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "2026-09", "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) check("批次号格式非法被拒", body.get("code") != 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609", "majorName": "数控加工", "certType": 1, "startTime": "2026-10-15", "endTime": "2026-09-28", }) check("截止早于开始被拒", body.get("code") != 0, f"code={body.get('code')} msg={body.get('msg')}") # 命名模板:缺必备变量 status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609", "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", "namingTemplate": "{姓名}-{材料名}", }) check("命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020, f"code={body.get('code')} msg={body.get('msg')}") # 命名模板:未知变量 status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": "202609", "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", "namingTemplate": "{姓名}-{身证号}-{材料名}", }) check("命名模板含未知变量被拒 40020", body.get("code") == 40020, f"code={body.get('code')} msg={body.get('msg')}") batch = "20" + suffix[:4] if suffix[:4].isdigit() else "2099" # 顺带验证 application.yml 的 spring.jackson.* 真的生效。 # Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson, # 没引它时这些配置是**静默失效**的:看着配了,其实一行都没读。 status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E未知字段 {suffix}", "collectMode": 1, "batchNo": batch, "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", "thisFieldDoesNotExist": "fail-on-unknown-properties=false 生效时才不会报错", }) check("请求带未知字段也能通过(spring.jackson 配置真的生效)", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}" " —— 若为 40001/50000,说明 spring-boot-starter-jackson 没生效") if body.get("code") == 0: CREATED_TASK_IDS.append(body["data"]["id"]) # 合法创建 status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch, "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) if not check("建任务成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): show(body) return 1 task_id = body["data"]["id"] task_code = body["data"]["taskCode"] CREATED_TASK_IDS.append(task_id) print(f" → id={task_id} taskCode={task_code} status={body['data']['status']}") check("返回的状态是草稿(0)", body["data"]["status"] == 0, f"status={body['data']['status']}") check("任务编码 = 批次号 + 4 位序号", task_code.startswith(batch) and len(task_code) == len(batch) + 4 and task_code[len(batch):].isdigit(), f"batch={batch} taskCode={task_code}") # ── 2. 详情与默认值 ───────────────────────────────── print("\n[2] 详情与默认值") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token) d = body.get("data") or {} ac = d.get("auditConfig") or {} cc = d.get("compressConfig") or {} check("rosterRequired 恒为 true", d.get("rosterRequired") is True, f"rosterRequired={d.get('rosterRequired')}") check("stat 为 null(名册未接,不能假装是 0)", d.get("stat") is None, f"stat={d.get('stat')}") check("auditConfig 默认:需班主任初审 + 需终审", ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True, f"auditConfig={ac}") check("auditConfig 默认:48 / 72 / 3", ac.get("classAuditTimeoutHours") == 48 and ac.get("adminAuditTimeoutHours") == 72 and ac.get("maxRejectCount") == 3, f"auditConfig={ac}") check("auditConfig:批量通过只带无问题项恒为 true", ac.get("batchApproveOnlyGreen") is True, f"batchApproveOnlyGreen={ac.get('batchApproveOnlyGreen')}") check("compressConfig 默认:1024KB / 300dpi / q85", cc.get("defaultMaxSizeKb") == 1024 and cc.get("defaultTargetDpi") == 300 and cc.get("defaultQuality") == 85, f"compressConfig={cc}") check("compressConfig 默认:minQuality=70 / hardFloorDpi=200", cc.get("minQuality") == 70 and cc.get("hardFloorDpi") == 200, f"compressConfig={cc}") check("compressConfig:截图保 PNG 恒为 true", cc.get("keepPngForScreenshot") is True, f"keepPngForScreenshot={cc.get('keepPngForScreenshot')}") # 铁律:批量通过只带无问题项不可关闭 status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 1, "batchNo": batch, "majorName": "数控加工改", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-20", "auditConfig": {"batchApproveOnlyGreen": False, "needAdminAudit": False}, }) check("修改草稿任务成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token) d = body.get("data") or {} check("客户端传 batchApproveOnlyGreen=false 被服务端扳回 true(铁律不可关)", (d.get("auditConfig") or {}).get("batchApproveOnlyGreen") is True, f"batchApproveOnlyGreen={(d.get('auditConfig') or {}).get('batchApproveOnlyGreen')}") check("needsAdminAudit=false 被采纳(它是可配的)", (d.get("auditConfig") or {}).get("needAdminAudit") is False, f"needAdminAudit={(d.get('auditConfig') or {}).get('needAdminAudit')}") check("专业已改为「数控加工改」", d.get("majorName") == "数控加工改", f"majorName={d.get('majorName')}") # ── 3. 列表与过滤 ─────────────────────────────────── print("\n[3] 列表与过滤") status, body = call("GET", f"{base}/api/v1/tasks?pageNum=1&pageSize=5", token) dd = body.get("data") or {} check("列表返回 {list,total,pageNum,pageSize}", isinstance(dd.get("list"), list) and dd.get("pageNum") == 1 and dd.get("pageSize") == 5, f"keys={list(dd.keys())}") status, body = call("GET", f"{base}/api/v1/tasks?" + urllib.parse.urlencode({"keyword": f"E2E 任务 {suffix}"}), token) hits = (body.get("data") or {}).get("list") or [] check("按关键字能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条") status, body = call("GET", f"{base}/api/v1/tasks?" + urllib.parse.urlencode({"status": 0, "batchNo": batch}), token) hits = (body.get("data") or {}).get("list") or [] check("按状态+批次过滤能搜到", any(h["id"] == task_id for h in hits), f"命中 {len(hits)} 条") status, body = call("GET", f"{base}/api/v1/tasks?status=3", token) hits = (body.get("data") or {}).get("list") or [] check("按状态过滤排除了草稿任务", not any(h["id"] == task_id for h in hits), f"状态=3 却命中了草稿任务") # ── 4. 发布前置校验(全部阻断)────────────────────── print("\n[4] 发布前置校验(阻断级,force 绕不过)") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True}) check("空任务发布被拒 40016(名册为空)", body.get("code") == 40016, f"code={body.get('code')} msg={body.get('msg')}") msg = body.get("msg") or "" check("一次把所有缺项都列出来了(不是撞到第一个就返回)", ("材料" in msg) and ("班级" in msg), f"msg={msg}") check("force=true 也无法绕过阻断级校验", body.get("code") == 40016, f"code={body.get('code')}") # ── 5. 造前置数据后发布 ───────────────────────────── print("\n[5] 造前置数据(SQL 夹具)后发布") okk, out = sql( f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) " f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 'E2E学生A', 'seed', 'seed')") check("夹具:写入 1 行名册", okk, out) okk, out = sql( f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) " f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)") check("夹具:写入 1 项必传材料", okk, out) okk, out = sql( f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) " f"VALUES ({tenant_id}, {task_id}, {cls['id']}, 1)") check("夹具:写入 1 条覆盖班级(不指定初审人 → 会回退到班级负责人)", okk, out) status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {}) check("班级未指定初审人 → 只给 P1 提醒,不阻断", body.get("code") != 0 and "确认" in (body.get("msg") or ""), f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True}) if not check("带 force 发布成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): show(body) else: check("发布后状态=收集中(1)", (body.get("data") or {}).get("status") == 1, f"status={(body.get('data') or {}).get('status')}") check("发布了时间", bool((body.get("data") or {}).get("publishedTime")), "publishedTime 为空") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True}) check("已发布再发布被拒", body.get("code") != 0, f"code={body.get('code')}") # 已发布不可改模式 status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, { "taskName": f"E2E 任务 {suffix}", "collectMode": 2, "batchNo": batch, "majorName": "数控加工改", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-20", }) check("已发布不可改 collectMode", body.get("code") != 0, f"code={body.get('code')} msg={body.get('msg')}") # ── 6. 暂停 / 恢复(幂等)────────────────────────── print("\n[6] 暂停 / 恢复") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {"reason": "名册需补充 3 人"}) check("暂停成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token) check("暂停后状态=已暂停(4)", (body.get("data") or {}).get("status") == 4, f"status={(body.get('data') or {}).get('status')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {}) check("重复暂停幂等(返回成功,不报错)", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token) check("恢复成功", body.get("code") == 0, f"code={body.get('code')}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token) check("恢复后状态=收集中(1)", (body.get("data") or {}).get("status") == 1, f"status={(body.get('data') or {}).get('status')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token) check("重复恢复幂等", body.get("code") == 0, f"code={body.get('code')}") # ── 7. 截止 / 归档 ───────────────────────────────── print("\n[7] 截止 / 归档") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {}) check("截止成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token) check("截止后状态=已截止(2)", (body.get("data") or {}).get("status") == 2, f"status={(body.get('data') or {}).get('status')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {}) check("已截止不可暂停(40021)", body.get("code") == 40021, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/archive", token, {"archiveRemark": "E2E 归档"}) check("归档成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token) check("归档后状态=已归档(3)", (body.get("data") or {}).get("status") == 3, f"status={(body.get('data') or {}).get('status')}") status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token) check("已归档不可恢复(40022)", body.get("code") == 40022, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}", token, { "taskName": "改名试试", "collectMode": 1, "batchNo": batch, "majorName": "数控加工改", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-20", }) check("已归档仍可改名称(只锁模式与认定类型)", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token) check("已归档不可删除", body.get("code") != 0, f"code={body.get('code')}") # ── 8. 草稿路径与删除 ─────────────────────────────── print("\n[8] 草稿路径与删除") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E 草稿 {suffix}", "collectMode": 1, "batchNo": batch, "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) draft_id = (body.get("data") or {}).get("id") if check("再建一个草稿任务", body.get("code") == 0, f"code={body.get('code')}"): CREATED_TASK_IDS.append(draft_id) check("任务编码递增(同一批次不重号)", (body["data"]["taskCode"] != task_code), f"两次都是 {task_code}") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E草稿2 {suffix}", "collectMode": 1, "batchNo": batch, "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) if check("删掉草稿后再建(同批次)", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): third_id = body["data"]["id"] third_code = body["data"]["taskCode"] CREATED_TASK_IDS.append(third_id) # 回归护栏:软删掉的任务仍然占着唯一约束,编码不能重用, # 否则会报 duplicate key(且编码同时是文件目录名,复用会串目录) check("软删后不改用旧编码(唯一约束不看软删标记)", third_code not in (task_code,), f"复用了编码 {third_code}") check("新编码序号继续递增", third_code > task_code, f"{task_code} → {third_code}") status, body = call("DELETE", f"{base}/api/v1/tasks/{third_id}", token) check("再删掉它", body.get("code") == 0, f"code={body.get('code')}") status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/pause", token, {}) check("草稿不可暂停(40021)", body.get("code") == 40021, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/resume", token) check("草稿不可恢复(40022)", body.get("code") == 40022, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("POST", f"{base}/api/v1/tasks/{draft_id}/archive", token, {}) check("草稿不可归档", body.get("code") != 0, f"code={body.get('code')}") status, body = call("DELETE", f"{base}/api/v1/tasks/{draft_id}", token) check("草稿可以删除", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("GET", f"{base}/api/v1/tasks/{draft_id}", token) check("删除后详情查不到", body.get("code") != 0, f"code={body.get('code')}") # ── 9. 可见范围(班主任只看自己带的班)─────────────── print("\n[9] 可见范围:班主任只看自己带的班级") ht_user = f"htt{suffix}" status, body = call("POST", f"{base}/api/v1/users", token, { "username": ht_user, "realName": f"E2E班主任{suffix}", "phone": "13900139901", "roleCodes": ["HEAD_TEACHER"], "userType": 3, }) if check("造一个班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): ht_id = body["data"]["id"] ht_pwd = body["data"]["initialPassword"] CREATED_USER_IDS.append(ht_id) # 建两个任务:一个把他设为初审人,一个不设 mine, others = [], [] for tag in ("mine", "other"): status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E可见性-{tag}-{suffix}", "collectMode": 1, "batchNo": batch, "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) if body.get("code") != 0: fail(f"造可见性任务 {tag}", f"code={body.get('code')} msg={body.get('msg')}") continue tid = body["data"]["id"] CREATED_TASK_IDS.append(tid) (mine if tag == "mine" else others).append(tid) if tag == "mine": sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, head_user_id, status) " f"VALUES ({tenant_id}, {tid}, {cls['id']}, {ht_id}, 1)") else: sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) " f"VALUES ({tenant_id}, {tid}, {cls['id']}, 1)") status, body = login(base, ht_user, ht_pwd, args.tenant) if check("班主任能登录", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): ht_token = body["data"]["token"] status, body = call("GET", f"{base}/api/v1/tasks?pageSize=200", token=ht_token) seen = {t["id"] for t in (body.get("data") or {}).get("list") or []} check("班主任能看到「自己带的班」的任务", all(t in seen for t in mine), f"应看到 {mine},实际 {sorted(seen)}") check("班主任看不到「不是自己带的班」的任务", all(t not in seen for t in others), f"不该看到 {others},实际 {sorted(seen)}") status, body = call("POST", f"{base}/api/v1/tasks", ht_token, { "taskName": "班主任不该能建任务", "collectMode": 1, "batchNo": batch, "majorName": "x", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-10-15", }) check("班主任建任务被拒(无 task:manage)", body.get("code") == 40301, f"code={body.get('code')}") # ── 10. 清理 ─────────────────────────────────────── print("\n[10] 清理测试数据") if CREATED_TASK_IDS: ids = ",".join(str(i) for i in CREATED_TASK_IDS) for stmt in [ f"DELETE FROM collect_task_org WHERE task_id IN ({ids})", f"DELETE FROM collect_material_def WHERE task_id IN ({ids})", f"DELETE FROM collect_roster WHERE task_id IN ({ids})", f"DELETE FROM collect_task WHERE id IN ({ids})", ]: okk, out = sql(stmt) if not okk: print(f" ⚠ {stmt[:60]}… → {out}") for uid in CREATED_USER_IDS: call("DELETE", f"{base}/api/v1/users/{uid}", token) ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号及其夹具数据") # ── 汇总 ─────────────────────────────────────────── print("\n" + "=" * 72) print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") if FAILED: print("\n失败项:") for name, detail in FAILED: print(f" · {name}\n {detail}") print("=" * 72) return 1 if FAILED else 0 if __name__ == "__main__": raise SystemExit(main())