#!/usr/bin/env python # -*- coding: utf-8 -*- """ M1-03 表单 schema 版本化与冲突检测 验收测试。 覆盖《03-接口设计》§7.9 / §3.5: [x] 未保存过时返回内置字段目录的默认定义(官方 21 项,version=0) [x] 22 项内置字段的锁定/必填/来源/选项与官方模板逐条对齐 [x] 保存为**新版本**(旧版本保留),is_current 只有一个 [x] baseVersion 不符 → 40025 **并回传最新定义**(前端据此合并) [x] 缺少内置字段 → 40026(内置字段不可删) [x] 扩展字段:必须 extra_ 前缀、不可与内置重名、不参与统计 [x] 客户端传 builtin=false 不能绕过「不可删除」(内建标志以目录为准) [x] 状态限制与权限 为什么这些值得逐条断言: 表单定义是学生端看到的东西。版本搞错的表现是"学生按旧版填、老师按新版审"; 内置字段被删的表现是"这个人的姓名取不到值"—— 都是不报错、只让人困惑的失败。 用法 python scripts/e2e-m1-form.py 依赖: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.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]] = [] CTX: dict[str, str | None] = {"base": None, "token": None} 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 sql(stmt: str) -> tuple[bool, str]: 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) def cleanup_all() -> None: if CREATED_TASK_IDS: ids = ",".join(str(i) for i in CREATED_TASK_IDS) for stmt in [ f"DELETE FROM collect_form_schema WHERE task_id IN ({ids})", f"DELETE FROM collect_material_def WHERE task_id IN ({ids})", f"DELETE FROM collect_task_org WHERE task_id IN ({ids})", f"DELETE FROM collect_roster WHERE task_id IN ({ids})", f"DELETE FROM collect_task WHERE id IN ({ids})", ]: sql(stmt) base, token = CTX.get("base"), CTX.get("token") if base and token: for uid in CREATED_USER_IDS: try: call("DELETE", f"{base}/api/v1/users/{uid}", token) except Exception: pass 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-03 表单 schema 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("--password", default="Admin@123") args = ap.parse_args() base = args.base.rstrip("/") print("=" * 72) print(f"M1-03 表单 schema 版本化 E2E base={base} 租户={args.tenant}") print("=" * 72) suffix = uuid.uuid4().hex[:6] # ── 0. 准备 ──────────────────────────────────────── print("\n[0] 准备") status, body = login(base, "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") CTX["base"], CTX["token"] = base, token ok("管理员登录") status, body = call("POST", f"{base}/api/v1/tasks", token, { "taskName": f"E2E表单 {suffix}", "collectMode": 1, "batchNo": "2003", "majorName": "数控加工", "certType": 1, "startTime": "2026-09-28", "endTime": "2026-12-31", }) if body.get("code") != 0: fail("建任务", f"code={body.get('code')} msg={body.get('msg')}") return 1 task_id = body["data"]["id"] CREATED_TASK_IDS.append(task_id) ok(f"建任务(id={task_id})") # ── 1. 默认定义 = 官方 21 项 ───────────────────────── print("\n[1] 未保存过时的默认定义") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token) d = body.get("data") or {} fields = d.get("fields") or [] by_key = {f["key"]: f for f in fields} check("返回内置默认定义(version=0,未落库)", d.get("version") == 0 and d.get("latestVersion") == 0, f"version={d.get('version')} latest={d.get('latestVersion')}") check("恰好 21 个内置字段(官方模板 A~U 列)", len(fields) == 21, f"实际 {len(fields)}") check("顺序即 A~U 列顺序", [f["key"] for f in fields] == [ "seq_no", "real_name", "id_card_no", "education", "phone", "work_unit", "occupation", "worktype", "job_level", "is_retake", "retake_theory", "retake_practice", "retake_overall", "is_student", "school", "major", "enroll_date", "work_years", "cert_occupation", "cert_level", "cert_no"], f"{[f['key'] for f in fields]}") check("全部标记为 builtin(内置不可删)", all(f.get("builtin") is True for f in fields), f"{[(f['key'], f.get('builtin')) for f in fields if not f.get('builtin')]}") # 锁定项:PRD §「锁定」= 姓名、身份证号、报考职业、报考工种、报考等级 locked_expected = {"real_name", "id_card_no", "occupation", "worktype", "job_level"} locked_actual = {f["key"] for f in fields if f.get("locked")} check("锁定项与 PRD 一致(姓名/身份证号/报考职业/工种/等级)", locked_expected.issubset(locked_actual), f"应为 {sorted(locked_expected)},实际 {sorted(locked_actual)}") check("联系电话来自名册但**不锁定**(预填可改:名册里常是家长的老号码)", by_key["phone"].get("source") == "ROSTER" and by_key["phone"].get("locked") is False, f"source={by_key['phone'].get('source')} locked={by_key['phone'].get('locked')}") check("姓名/身份证号的来源是名册(不在 collect_submission 里)", by_key["real_name"].get("source") == "ROSTER" and by_key["id_card_no"].get("source") == "ROSTER", f"{by_key['real_name'].get('source')} / {by_key['id_card_no'].get('source')}") check("学历的来源是 submission 且带 11 个官方选项", by_key["education"].get("source") == "SUBMISSION" and len(by_key["education"].get("options") or []) == 11, f"source={by_key['education'].get('source')} options={len(by_key['education'].get('options') or [])}") check("报考等级的 5 个官方选项照抄", (by_key["job_level"].get("options") or [])[:2] == ["五级/初级工", "四级/中级工"], f"{by_key['job_level'].get('options')}") check("补考三科只在「是否补考=是」时显示(官方规则 1)", all((by_key[k].get("visibleWhen") or {}).get("field") == "is_retake" and (by_key[k].get("visibleWhen") or {}).get("value") is True for k in ("retake_theory", "retake_practice", "retake_overall")), f"{[(k, by_key[k].get('visibleWhen')) for k in ('retake_theory', 'retake_practice')]}") check("在校生三项只在「是否在校生=是」时显示(官方规则 3/4)", all((by_key[k].get("visibleWhen") or {}).get("field") == "is_student" for k in ("school", "major", "enroll_date")), f"{[(k, by_key[k].get('visibleWhen')) for k in ('school', 'major', 'enroll_date')]}") check("入学时间带 YYYY-MM-DD 正则(官方原文「格式必须 YYYY-MM-DD」)", (by_key["enroll_date"].get("validate") or {}).get("regex") == "^\\d{4}-\\d{2}-\\d{2}$", f"{by_key['enroll_date'].get('validate')}") check("工作单位的提示语写着「没有则填无」", "无" in (by_key["work_unit"].get("tip") or ""), f"{by_key['work_unit'].get('tip')}") # ── 2. 保存为版本 1 ───────────────────────────────── print("\n[2] 保存为新版本") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 0, "fields": fields, "extras": [{"key": "extra_skill_cert", "label": "其它技能证书编号", "type": "text", "required": False, "sortNo": 100}], }) check("从 version=0 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") saved = body.get("data") or {} check("新版本是 1", saved.get("version") == 1, f"{saved.get('version')}") check("isCurrent=true", saved.get("isCurrent") is True, f"{saved.get('isCurrent')}") check("扩展字段存下来了", len(saved.get("extras") or []) == 1, f"{saved.get('extras')}") check("latestVersion 同步为 1", saved.get("latestVersion") == 1, f"{saved.get('latestVersion')}") okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}") check("库里落了一行版本", okk and out.strip() == "1", f"{out}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token) check("读回的是 version=1", (body.get("data") or {}).get("version") == 1, f"{(body.get('data') or {}).get('version')}") # ── 3. 改一处 → 版本 2 ────────────────────────────── print("\n[3] 再次保存 → 版本递增、旧版保留") fields2 = json.loads(json.dumps(fields)) for f in fields2: if f["key"] == "work_unit": f["required"] = False f["tip"] = "改成选填了" status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 1, "fields": fields2, "extras": [], }) check("从 version=1 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") check("新版本是 2", (body.get("data") or {}).get("version") == 2, f"{(body.get('data') or {}).get('version')}") okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}") check("库里两行版本(旧版保留,不覆盖)", okk and out.strip() == "2", f"{out}") okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id} AND is_current=1") check("当前版本只有一个", okk and out.strip() == "1", f"{out}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token) d2 = {f["key"]: f for f in (body.get("data") or {}).get("fields") or []} check("改动已生效(工作单位变选填)", d2["work_unit"].get("required") is False, f"{d2['work_unit'].get('required')}") check("扩展字段可以整体清空", (body.get("data") or {}).get("extras") == [], f"{(body.get('data') or {}).get('extras')}") # ── 4. 版本冲突 ───────────────────────────────────── print("\n[4] 版本冲突(40025)") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 1, "fields": fields2, "extras": [], }) check("用过期 baseVersion 保存被拒 40025", body.get("code") == 40025, f"code={body.get('code')} msg={body.get('msg')}") latest = body.get("data") or {} check("40025 的响应里**带上了最新定义**(前端据此做差异合并)", latest.get("version") == 2 and len(latest.get("fields") or []) == 21, f"version={latest.get('version')} fields={len(latest.get('fields') or [])}") check("冲突提示里说清了基准版本与最新版本", "基准版本 1" in (body.get("msg") or "") and "最新 2" in (body.get("msg") or ""), f"msg={body.get('msg')}") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 99, "fields": fields2, "extras": [], }) check("baseVersion 比最新大也被拒(不是只防「小于」)", body.get("code") == 40025, f"code={body.get('code')} msg={body.get('msg')}") # ── 5. 内置字段不可删 ─────────────────────────────── print("\n[5] 内置字段不可删(40026)") missing_one = [f for f in fields2 if f["key"] != "id_card_no"] status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 2, "fields": missing_one, "extras": [], }) check("删掉「身份证号」被拒 40026", body.get("code") == 40026, f"code={body.get('code')} msg={body.get('msg')}") check("拒绝理由点出了缺哪个字段,并给出替代做法(隐藏而不是删)", "id_card_no" in (body.get("msg") or "") and "隐藏" in (body.get("msg") or ""), f"msg={body.get('msg')}") spoofed = json.loads(json.dumps(fields2)) spoofed = [f for f in spoofed if f["key"] != "id_card_no"] status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 2, "fields": spoofed, "extras": [], }) check("把 builtin 传成 false 也无法绕过不可删除", body.get("code") == 40026, f"code={body.get('code')} msg={body.get('msg')}") # ── 6. 扩展字段 ───────────────────────────────────── print("\n[6] 扩展字段") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 2, "fields": fields2, "extras": [{"key": "education", "label": "想冒充内置列", "type": "text"}], }) check("扩展字段不能与内置字段重名", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 2, "fields": fields2, "extras": [{"key": "skillCert", "label": "没有 extra_ 前缀", "type": "text"}], }) check("扩展字段 key 必须带 extra_ 前缀", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}") check("以上失败都没有产生新版本", (call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)[1].get("data") or {}).get("version") == 2, "版本被推进了") # ── 7. 状态限制与权限 ─────────────────────────────── print("\n[7] 状态限制与权限") # ⚠️ 要测「已截止不可改表单」,必须先把任务推到「收集中」: # 草稿是**不能直接截止的**(close 只允许 收集中 / 已暂停)—— # 这正是发布前置校验存在的意义:没配齐就发布,学生填了也没人审。 # 所以这里用 SQL 夹具把发布前置的三样备齐。 sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) " f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')") sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) " f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)") okk, out = sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) " f"VALUES ({tenant_id}, {task_id}, " f"(SELECT id FROM sys_org WHERE tenant_id={tenant_id} AND org_type=3 LIMIT 1), 1)") check("夹具:名册 / 材料 / 覆盖班级就位", okk, out) 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')} msg={body.get('msg')}") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 2, "fields": fields2, "extras": [], }) check("收集中允许改表单(§3.5 说「已发布任务改表单必须递增」,说明是允许改的)", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") 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("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, { "baseVersion": 3, "fields": fields2, "extras": [], }) check("已截止不可再改表单", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}") status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token) check("已截止仍可读表单(只读不等于看不见)", body.get("code") == 0, f"code={body.get('code')}") ht_user = f"htf{suffix}" status, body = call("POST", f"{base}/api/v1/users", token, { "username": ht_user, "realName": f"E2E表单班主任{suffix}", "phone": "13900139401", "roleCodes": ["HEAD_TEACHER"], "userType": 3, }) if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): uid, pwd = body["data"]["id"], body["data"]["initialPassword"] CREATED_USER_IDS.append(uid) status, body = login(base, ht_user, pwd, args.tenant) ht_token = body["data"]["token"] status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token) check("班主任能读表单定义", body.get("code") == 0, f"code={body.get('code')}") status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token, { "baseVersion": 2, "fields": fields2, "extras": [], }) check("班主任不能改表单(无 task:manage)", body.get("code") == 40301, f"code={body.get('code')}") # ── 8. 清理 ──────────────────────────────────────── print("\n[8] 清理测试数据") cleanup_all() 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__": try: code = main() except BaseException: print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…") cleanup_all() raise raise SystemExit(code)