Files
lovife 8f80744d63 feat(task): 规则模板 FORM 真正可套用 + 已归档任务可删(客户确认的两项)
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
  · FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
    与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
    再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
    逻辑只有一份
  · RuleTemplateService 的 FORM 分支接上它(不再 skipped)
  · 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
    两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
    套用时按「用系统默认,不覆盖」跳过

二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
   演示/测试任务只能改名挪开,库只增不减
  · DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
    拒绝的话说清「先归档」
  · 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
    information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
  · ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
    「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
    两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
    「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
    的「删除后新建不重号」断言守住
  · 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
    删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
    行数/文件数)

验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
      e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
      e2e-m1-task → 71/71(旧口径断言更新为新口径)
2026-09-30 12:38:47 +08:00

573 lines
29 KiB
Python
Raw Permalink 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)
# 2026-09-30 客户确认(§17.2 D-9):已归档的任务**可以**删除(子表级联物理删、
# 任务行软删占住编码)。旧断言"已归档不可删除"随旧口径一起作废。
check("已归档可以删除(2026-09-30 起,级联清理 + 留痕)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 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())