Files
xiaotutongxue/scripts/e2e-m1-task.py
T
lovife e85a25a97f feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了
第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。

一、新增(frontend-pc/)
   src/apis/xt/roster.ts               导入(multipart)+ 分页 + 类型 + 状态字典
   src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
   views/task/index.vue                 加「名单管理」入口;已截止/已归档只读仍可查看
   config/xt-routes.ts                  顶层「名单管理」菜单说明改为指向任务行

二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
   配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
   force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。

三、界面上五个刻意的设计
   1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
      "成功 0 条",然后以为文件坏了
   2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
      用户会去改自己那份本来正确的表格
   3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
   4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
   5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
   原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)

四、顺带修掉一处「界面在说谎」
   名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
   既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
   · TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
   · **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
     而真相是"还没有分母"
   · abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
   · e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
     并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
   这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。

五、为什么顶层「名单管理」菜单还是"待建"
   名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
   「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
   同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。

六、浏览器验证(真链路,只绕开"手指点选")
   文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
     fetch 字节 → new File → DataTransfer → input.files → dispatch change
   实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
   夹具用完即删。

七、回归
   全量 622 条断言、0 失败(名册 E2E 62 → 67)
   frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
   verify-docs.py 19 项全通过

八、两个流程教训(已写进交接摘要)
   · `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
     于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
     必须看退出码 / BUILD SUCCESS。
   · `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
     与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。

九、下一步(第 3 步)
   报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
   先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
   需要先定"计划从哪来"。
2026-09-29 17:17:55 +08:00

570 lines
29 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/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())