Files
xiaotutongxue/scripts/e2e-m1-task.py
T
lovife daf42608d9 feat(task): M1-01 收集任务 CRUD 与状态机
后端:/tasks 建改删查 + publish/pause/resume/close/archive 六个动作,
按角色过滤可见范围(班主任只看自己带的班级的任务)。
前端:收集任务列表页 + 建/改任务弹窗(命名模板带实时预览与变量插入)。

验收:e2e-m1-task.py 71/71;e2e-m0.py 36/36、e2e-m0-user.py 55/55 回归通过;
前端 typecheck 0 错 / build 通过 / 浏览器肉眼确认。

三条硬约束(改动时当成规则):

1) 「暂停」与「截止」不是一回事。暂停:学生不能交、已提交的照常审、
   且可恢复;截止不可逆。做成一个会让"临时冻一下"变成不可逆操作。
2) 发布前置 5 项全是阻断级,force 绕不过:名册非空(40016)、名册含姓名与
   证件号(40017)、至少 1 项必传材料(40018)、已设置覆盖班级(40019)、
   命名模板可用(40020)。只有 P1 级提醒(某班未指定初审人)可 force 跳过。
3) collectMode / certType 发布后不可改 —— 它们决定审核流与材料清单,
   改了会让已填过的学生面对另一份表单。

本轮修掉的 3 个真问题:

1) collect_task_org 没有 deleted 列,而我让它继承了带 @TableLogic 的基类
   —— MyBatis-Plus 会给每条查询自动加 deleted = 0,运行时报"列不存在",
   编译期完全看不出来。改为不继承基类、显式声明字段;
   "移除"语义走 status = 0,不要对它调 deleteById(会变成物理删除)。

2) 任务编码查重被逻辑删除遮蔽:用 MP 的 selectCount 会自动过滤 deleted = 0,
   但数据库的唯一约束不看软删标记 —— 软删掉的任务仍占着那个编码,
   于是"查重说没有、插进去说重复",报 duplicate key(500)。
   另外原用 COUNT+1 也错:删掉中间一个后计数会退回去,算出已占用的号。
   改为 MAX(序号)+1,且查重语句刻意不加 deleted = 0(与唯一约束同口径);
   编码一次用过就不复用(它同时是文件目录名,复用会把两批文件混进同一目录)。

3) Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,项目没引,
   导致容器里没有 ObjectMapper bean(@Autowired 直接启动失败),
   且 application.yml 的 spring.jackson.* 静默失效 —— 其中
   time-zone: Asia/Shanghai 不生效最危险:本机时区恰好是上海时看不出来,
   部署到 UTC 服务器后所有时间会整体偏 8 小时。
   注意 spring-boot-starter-json 在 Boot 4 已无 4.x 版本(仓库里最高 3.5.6)。
   补依赖,但任务配置的 JSON 读写仍不用 Jackson —— Boot 4 用 Jackson 3
   而 jjwt-jackson 带来 Jackson 2,打包后两个 jackson-databind 并存
   (2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 hutool-all。

已知缺口(都写进了代码注释与交接摘要):

- 发布在前置数据齐备前不可达(名册属 M2、材料属 M1-04、覆盖班级属 M1-02),
  E2E 用 SQL 夹具造数据才走到发布之后的状态 —— M1-02/M1-04/M2 落地后
  要回来把夹具换成真实接口。
- 命名规则试跑目前只做静态校验(变量名合法 + 必含姓名/证件号/材料名),
  完整形态(拿真实名册跑一遍查非空与不重名)属 M1-09 / M3-01。
- stat 恒为 null(依赖名册与提交单)。故意不返回全 0 的对象 ——
  那会让界面显示"完成率 0%",而真相是"还没有分母";界面显示「待导入名册」。
- 任务编码并发撞号由唯一约束兜底 + 可读提示(不再 500),未做分布式锁。

顺带:

- 两个列表页的列宽按实测可用宽度重算(1366 屏:收集任务 824px、
  账号管理因左侧组织树只有 574px),并把"操作列必须 fixed + 列宽口径"
  写进注释 —— 之前把「状态」列整个遮住、把操作链接挤成竖排。
- 状态与最后登录并成一列,给窄屏省出 150px。
2026-09-28 23:48:21 +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())