Files
railwaysystem/scripts/qa/core-business-scenario-runner.py
T

1501 lines
67 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RailwaySystem - Core Business Scenario Integration Test Runner
全流程自动化测试套件:
- SCENARIO-01: Happy Path 完整业务链 (Teacher/Course/Classroom/TrainingSite -> Plan -> PlanCourse -> Enrollment -> Lesson -> Timetable -> Plan Complete)
- SCENARIO-02: Enrollment 异常规则与状态区分 (同一Plan重复40030, 超过capacity40031, 不存在学员reject, status=0停用学员reject, status=1正常学员success)
- SCENARIO-03: Scheduling 排课预检Warning与Hard Block (教师冲突TEACHER, 教室冲突CLASSROOM Warning, 场地冲突SITE Warning, 计划重叠Warning; invalid time 40010, Plan locked 40012, venue XOR reject, cross-plan reject, 资源不存在reject)
- SCENARIO-04: Electronic Board 绑定、详情与Timetable映射 (UUID主键, status=0, Classroom boards列表0/1/N, Timetable boardSn 0/1/N规则, 逻辑删除del_flag=2)
- SCENARIO-05: Plan Completion 结训生命周期与退训保护 (Plan->2, 活动学员->4且complete_time写入, 退训学员保持3且不被覆盖)
- Kingbase 数据库只读安全断言
- 精确ID资源安全清理
- 自动生成结构化测试报告与summary.md
"""
import os
import sys
import json
import time
import string
import random
import urllib.request
import urllib.parse
import urllib.error
import subprocess
from datetime import datetime, timedelta
def resolve_base_url():
if "BASE_URL" in os.environ:
return os.environ["BASE_URL"]
# 优先检测 8094 (当前worktree独立实例), 其次 8092 (最新基线端口), 其次 8090
for port in [8094, 8092, 8090]:
try:
req = urllib.request.Request(f"http://localhost:{port}/login", data=b"{}", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=1.5) as resp:
return f"http://localhost:{port}"
except urllib.error.HTTPError:
return f"http://localhost:{port}"
except Exception:
continue
return "http://localhost:8092"
BASE_URL = resolve_base_url()
RUN_ID = f"QA_W2_{datetime.now().strftime('%Y%m%d%H%M%S')}"
OUTPUT_DIR = f"/tmp/railwaysystem-qa/{RUN_ID}"
os.makedirs(OUTPUT_DIR, exist_ok=True)
ADMIN_USER = os.environ.get("QA_ADMIN_USERNAME")
ADMIN_PASS = os.environ.get("QA_ADMIN_PASSWORD")
if not ADMIN_USER or not ADMIN_PASS:
print("[FATAL] 缺少必须的管理端认证凭据环境变量 (QA_ADMIN_USERNAME / QA_ADMIN_PASSWORD),FAIL FAST 终止执行。")
print("请先通过安全机制注入环境变量后再执行此 Integration Gate。")
sys.exit(1)
created_resources = {
"run_id": RUN_ID,
"teachers": [],
"courses": [],
"classrooms": [],
"sites": [],
"students": [],
"plans": [],
"plan_courses": [],
"enrollments": [],
"lessons": [],
"boards": [],
"reports": [],
"checkins": []
}
api_results = []
http_500_count = 0
scenario_stats = {
"SCENARIO-01": {"name": "Happy Path Full Business Chain", "passed": False, "steps": []},
"SCENARIO-02": {"name": "Enrollment Exception & Student Status Validation", "passed": False, "steps": []},
"SCENARIO-03": {"name": "Scheduling Conflict Warning & Hard Blocks", "passed": False, "steps": []},
"SCENARIO-04": {"name": "Electronic Board Binding & Timetable Mapping", "passed": False, "steps": []},
"SCENARIO-05": {"name": "Plan Completion Lifecycle & History Retention", "passed": False, "steps": []},
"SCENARIO-06": {"name": "Report & Attendance Lifecycle", "passed": False, "steps": []}
}
TOKEN = None
def log(msg):
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
def request_api(method, path, data=None, headers=None, expected_status=(200,)):
global http_500_count
if headers is None:
headers = {}
headers["Content-Type"] = "application/json"
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
raw_url = f"{BASE_URL}{path}"
parsed = urllib.parse.urlsplit(raw_url)
safe_url = urllib.parse.urlunsplit((
parsed.scheme,
parsed.netloc,
urllib.parse.quote(parsed.path),
urllib.parse.quote(parsed.query, safe="=&?-_.~"),
parsed.fragment
))
encoded_data = json.dumps(data).encode("utf-8") if data is not None else None
req = urllib.request.Request(safe_url, data=encoded_data, headers=headers, method=method)
start_time = datetime.now()
http_status = None
resp_body = {}
try:
with urllib.request.urlopen(req, timeout=10) as resp:
http_status = resp.status
raw_text = resp.read().decode("utf-8", errors="replace")
try:
resp_body = json.loads(raw_text)
except Exception:
resp_body = {"raw": raw_text}
except urllib.error.HTTPError as e:
http_status = e.code
if http_status >= 500:
http_500_count += 1
raw_text = e.read().decode("utf-8", errors="replace")
try:
resp_body = json.loads(raw_text)
except Exception:
resp_body = {"raw": raw_text}
except Exception as ex:
http_status = 599
http_500_count += 1
resp_body = {"error": str(ex)}
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
sanitized_data = data
if isinstance(data, dict):
sanitized_data = {k: ("***" if "pass" in k.lower() or "token" in k.lower() else v) for k, v in data.items()}
api_entry = {
"timestamp": datetime.now().isoformat(),
"method": method,
"path": path,
"payload": sanitized_data,
"http_status": http_status,
"business_code": resp_body.get("code") if isinstance(resp_body, dict) else None,
"duration_ms": duration_ms,
"success": http_status in expected_status
}
api_results.append(api_entry)
return http_status, resp_body
def extract_id(body):
"""从 RuoYi 接口返回体安全提取 ID (兼顾 data 字段与 String 类型的 msg 字段)"""
if not isinstance(body, dict):
return None
code = body.get("code")
if code != 200:
return None
val = body.get("data")
if val is not None:
return val
msg = body.get("msg")
if msg and msg not in ("操作成功", "查询成功", "success"):
return msg
return None
def login():
global TOKEN
log(f"正在通过安全机制获取系统访问Token (后端: {BASE_URL})...")
status, body = request_api("POST", "/login", {"username": ADMIN_USER, "password": ADMIN_PASS})
if status == 200 and body.get("code") == 200:
TOKEN = body.get("token")
log("系统登录成功,Token就绪")
return True
else:
log(f"系统登录失败: status={status}, body={body}")
return False
def random_phone(prefix="138"):
return prefix + "".join(random.choices(string.digits, k=8))
# ==============================================================================
# SCENARIO-01: Happy Path
# ==============================================================================
def run_scenario_01():
log("=== 开始执行 SCENARIO-01: Happy Path 完整业务链 ===")
steps = scenario_stats["SCENARIO-01"]["steps"]
success = True
now = datetime.now()
t_start = (now + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=25)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=18)).strftime("%Y-%m-%d %H:%M:%S")
# 1. 创建/准备 Teacher
teacher_name = f"{RUN_ID}_教师_王老师"
t_phone = random_phone("138")
t_id_custom = f"{RUN_ID}_T01"
status, body = request_api("POST", "/train/teacher", {
"tId": t_id_custom,
"tName": teacher_name,
"tPhone": t_phone,
"gender": "0",
"title": "高级讲师"
})
t_id = extract_id(body) or t_id_custom
if not t_id:
_, list_body = request_api("GET", f"/train/teacher/list?tName={teacher_name}")
rows = list_body.get("rows", [])
if rows: t_id = rows[0].get("tId")
if t_id: created_resources["teachers"].append(t_id)
step1_ok = (status == 200 and t_id is not None)
steps.append({"step": 1, "action": "准备Teacher", "status": status, "id": t_id, "pass": step1_ok})
if not step1_ok: success = False
# 2. 创建/准备 Course
course_name = f"{RUN_ID}_课程_列控系统"
course_code = f"{RUN_ID}_C1"
c_id_custom = f"{RUN_ID}_C01"
status, body = request_api("POST", "/train/course", {
"cId": c_id_custom,
"cName": course_name,
"cCode": course_code,
"cDuration": 16,
"status": 1
})
c_id = extract_id(body) or c_id_custom
if not c_id:
_, list_body = request_api("GET", f"/train/course/list?cName={course_name}")
rows = list_body.get("rows", [])
if rows: c_id = rows[0].get("cId")
if c_id: created_resources["courses"].append(c_id)
step2_ok = (status == 200 and c_id is not None)
steps.append({"step": 2, "action": "准备Course", "status": status, "id": c_id, "pass": step2_ok})
if not step2_ok: success = False
# 3. 创建 Classroom
room_name = f"{RUN_ID}_教室_101"
cr_id_custom = f"{RUN_ID}_CR01"
status, body = request_api("POST", "/train/classroom", {
"classRoomId": cr_id_custom,
"roomName": room_name,
"roomLocation": "实训楼101",
"capacity": 50,
"roomStatus": 1
})
cr_id = extract_id(body) or cr_id_custom
if not cr_id:
_, list_body = request_api("GET", f"/train/classroom/list?roomName={room_name}")
rows = list_body.get("rows", [])
if rows: cr_id = rows[0].get("classRoomId")
if cr_id: created_resources["classrooms"].append(cr_id)
step3_ok = (status == 200 and cr_id is not None)
steps.append({"step": 3, "action": "创建Classroom", "status": status, "id": cr_id, "pass": step3_ok})
if not step3_ok: success = False
# 4. 创建 TrainingSite
site_name = f"{RUN_ID}_场地_列控中心"
status, body = request_api("POST", "/train/training-site", {
"siteName": site_name,
"siteLocation": "演练基地A区",
"siteStatus": 1,
"capacity": 50
})
site_id = extract_id(body)
if not site_id:
_, list_body = request_api("GET", f"/train/training-site/list?siteName={site_name}")
rows = list_body.get("rows", [])
if rows: site_id = rows[0].get("siteId")
if site_id: created_resources["sites"].append(site_id)
step4_ok = (status == 200 and site_id is not None)
steps.append({"step": 4, "action": "创建TrainingSite", "status": status, "id": site_id, "pass": step4_ok})
if not step4_ok: success = False
# 5. 创建 Student
s_name = f"{RUN_ID}_学员_张三"
status, body = request_api("POST", "/train/student", {
"sName": s_name,
"sGender": "1",
"sPhone": random_phone("139"),
"orgName": "京局北京机务段"
})
s_id = extract_id(body)
if not s_id:
_, list_body = request_api("GET", f"/train/student/list?sName={s_name}")
rows = list_body.get("rows", [])
if rows: s_id = rows[0].get("sId")
if s_id: created_resources["students"].append(s_id)
step5_ok = (status == 200 and s_id is not None)
steps.append({"step": 5, "action": "创建Student", "status": status, "id": s_id, "pass": step5_ok})
if not step5_ok: success = False
# 6. 创建 Plan
plan_name = f"{RUN_ID}_培训班_车载班"
status, body = request_api("POST", "/train/plans", {
"planName": plan_name,
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 30,
"trainAddress": "北京培训基地"
})
plan_id = extract_id(body)
if plan_id: created_resources["plans"].append(plan_id)
step6_ok = (status == 200 and plan_id is not None)
steps.append({"step": 6, "action": "创建Plan", "status": status, "id": plan_id, "pass": step6_ok})
if not step6_ok: success = False
# 7. 添加 PlanCourse
status, body = request_api("POST", f"/train/plans/{plan_id}/courses", {
"cIds": [c_id],
"remark": "列控专业课程"
})
_, pc_list_body = request_api("GET", f"/train/plans/{plan_id}/courses")
plan_course_id = None
pc_data = pc_list_body.get("data", [])
if pc_data:
plan_course_id = pc_data[0].get("planCourseId")
created_resources["plan_courses"].append(plan_course_id)
step7_ok = (status == 200 and plan_course_id is not None)
steps.append({"step": 7, "action": "配置PlanCourse", "status": status, "id": plan_course_id, "pass": step7_ok})
if not step7_ok: success = False
# 8. Enrollment 入班
status, body = request_api("POST", f"/train/plans/{plan_id}/enrollments", {
"studentId": s_id,
"sourceType": 1,
"accommodationRequired": 0,
"remark": "首期学员"
})
enrollment_id = extract_id(body)
if not enrollment_id:
_, e_list_body = request_api("GET", f"/train/plans/{plan_id}/enrollments")
rows = e_list_body.get("rows", [])
if rows: enrollment_id = rows[0].get("enrollmentId")
if enrollment_id: created_resources["enrollments"].append(enrollment_id)
step8_ok = (status == 200 and enrollment_id is not None)
steps.append({"step": 8, "action": "Enrollment入班", "status": status, "id": enrollment_id, "pass": step8_ok})
if not step8_ok: success = False
# 9. 创建 Lesson (排课)
lesson_start = (now + timedelta(days=20, hours=1)).strftime("%Y-%m-%d %H:%M:%S")
lesson_end = (now + timedelta(days=20, hours=3)).strftime("%Y-%m-%d %H:%M:%S")
status, body = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": lesson_start,
"endTime": lesson_end,
"remark": "第1讲理论课"
})
lesson_id = extract_id(body)
if lesson_id: created_resources["lessons"].append(lesson_id)
step9_ok = (status == 200 and lesson_id is not None)
steps.append({"step": 9, "action": "创建Lesson", "status": status, "id": lesson_id, "pass": step9_ok})
if not step9_ok: success = False
# 10. 查询 Timetable
status, body = request_api("GET", f"/train/plans/{plan_id}/timetable")
tt_data = body.get("data", {})
days = tt_data.get("days", [])
step10_ok = (status == 200 and len(days) > 0)
steps.append({"step": 10, "action": "查询Timetable", "status": status, "days_count": len(days), "pass": step10_ok})
if not step10_ok: success = False
# 11. 验证 Lesson 显示字段
lesson_ok = False
if days and len(days[0].get("lessons", [])) > 0:
l_item = days[0]["lessons"][0]
if (l_item.get("lessonId") == lesson_id and
l_item.get("courseName") == course_name and
l_item.get("teacherName") == teacher_name and
l_item.get("classRoomName") == room_name):
lesson_ok = True
steps.append({"step": 11, "action": "验证Lesson显示字段", "status": status, "pass": lesson_ok})
if not lesson_ok: success = False
# 12. 验证 PlanCourse 字段
pc_ok = (plan_course_id is not None and pc_data and pc_data[0].get("cId") == c_id)
steps.append({"step": 12, "action": "验证PlanCourse字段", "status": status, "pass": pc_ok})
if not pc_ok: success = False
# 13. 完成 Plan
status, body = request_api("POST", f"/train/plans/{plan_id}/complete")
step13_ok = (status == 200 and body.get("code") == 200)
steps.append({"step": 13, "action": "完成Plan", "status": status, "pass": step13_ok})
if not step13_ok: success = False
# 14 & 15. 验证 Enrollment 自动变更为 4 且 complete_time 非空
status, enroll_list_body = request_api("GET", f"/train/plans/{plan_id}/enrollments")
e_rows = enroll_list_body.get("rows", [])
step14_ok = False
step15_ok = False
if e_rows:
target_e = e_rows[0]
if target_e.get("enrollmentStatus") == 4:
step14_ok = True
if target_e.get("completeTime") is not None:
step15_ok = True
steps.append({"step": 14, "action": "验证Enrollment自动变更为4", "status": status, "pass": step14_ok})
steps.append({"step": 15, "action": "验证complete_time非空", "status": status, "pass": step15_ok})
if not (step14_ok and step15_ok): success = False
scenario_stats["SCENARIO-01"]["passed"] = success
log(f"SCENARIO-01 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# SCENARIO-02: Enrollment 异常与状态区分
# ==============================================================================
def run_scenario_02():
log("=== 开始执行 SCENARIO-02: Enrollment 异常规则与状态区分 ===")
steps = scenario_stats["SCENARIO-02"]["steps"]
success = True
now = datetime.now()
t_start = (now + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=25)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=18)).strftime("%Y-%m-%d %H:%M:%S")
# 1. 准备学员1 (正常)
_, s1_body = request_api("POST", "/train/student", {
"sName": f"{RUN_ID}_学员_正常1",
"sGender": "1",
"sPhone": random_phone("137"),
"orgName": "机务段"
})
stu1_id = extract_id(s1_body)
if stu1_id: created_resources["students"].append(stu1_id)
# 准备学员2 (正常)
_, s2_body = request_api("POST", "/train/student", {
"sName": f"{RUN_ID}_学员_正常2",
"sGender": "1",
"sPhone": random_phone("136"),
"orgName": "供电段"
})
stu2_id = extract_id(s2_body)
if stu2_id: created_resources["students"].append(stu2_id)
# 准备学员3 (停用学员 status=0)
_, s3_body = request_api("POST", "/train/student", {
"sName": f"{RUN_ID}_学员_待停用",
"sGender": "0",
"sPhone": random_phone("135"),
"orgName": "电务段"
})
stu_disabled_id = extract_id(s3_body)
if stu_disabled_id: created_resources["students"].append(stu_disabled_id)
if stu_disabled_id:
disable_student_via_java(stu_disabled_id)
# 2. 准备容量为 10 人的班级测试重复添加
_, p10_body = request_api("POST", "/train/plans", {
"planName": f"{RUN_ID}_容量10人班",
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 10
})
p10_id = extract_id(p10_body)
if p10_id: created_resources["plans"].append(p10_id)
# 2.1 添加学员1到班级 -> 成功 (200)
status, e_first = request_api("POST", f"/train/plans/{p10_id}/enrollments", {"studentId": stu1_id, "sourceType": 1, "accommodationRequired": 0})
e1_id = extract_id(e_first)
if e1_id: created_resources["enrollments"].append(e1_id)
step1_ok = (status == 200 and e_first.get("code") == 200)
steps.append({"sub": "status=1正常Student入班", "expected": 200, "actual_code": e_first.get("code"), "pass": step1_ok})
if not step1_ok: success = False
# 2.2 同一Plan重复Enrollment -> 40030
_, dup_res = request_api("POST", f"/train/plans/{p10_id}/enrollments", {"studentId": stu1_id, "sourceType": 1, "accommodationRequired": 0})
step_dup_ok = (dup_res.get("code") == 40030)
steps.append({"sub": "同一Plan重复Enrollment报40030", "expected": 40030, "actual_code": dup_res.get("code"), "pass": step_dup_ok})
if not step_dup_ok: success = False
# 2.3 准备专属单人班 (容量 1 人) 测试超限 40031
_, p_limit_body = request_api("POST", "/train/plans", {
"planName": f"{RUN_ID}_单人特训班",
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 1,
"trainAddress": "特训基地"
})
limit_plan_id = extract_id(p_limit_body)
if limit_plan_id: created_resources["plans"].append(limit_plan_id)
_, e_lim_1 = request_api("POST", f"/train/plans/{limit_plan_id}/enrollments", {"studentId": stu1_id, "sourceType": 1, "accommodationRequired": 0})
e_lim_1_id = extract_id(e_lim_1)
if e_lim_1_id: created_resources["enrollments"].append(e_lim_1_id)
# 超出 capacity -> 尝试将学员2加入已满单人班 -> 40031
_, cap_res = request_api("POST", f"/train/plans/{limit_plan_id}/enrollments", {"studentId": stu2_id, "sourceType": 1, "accommodationRequired": 0})
step_cap_ok = (cap_res.get("code") == 40031)
steps.append({"sub": "超出capacity报40031", "expected": 40031, "actual_code": cap_res.get("code"), "pass": step_cap_ok})
if not step_cap_ok: success = False
# 2.4 不存在 Student -> reject (40402 / 40404)
_, no_stu_res = request_api("POST", f"/train/plans/{p10_id}/enrollments", {"studentId": "NON_EXIST_STUDENT_9999", "sourceType": 1, "accommodationRequired": 0})
step_no_stu_ok = (no_stu_res.get("code") in (40402, 40404, 500, 404))
steps.append({"sub": "不存在Student报reject", "expected": "reject", "actual_code": no_stu_res.get("code"), "pass": step_no_stu_ok})
if not step_no_stu_ok: success = False
# 2.5 status=0 停用 Student -> reject (40402 / 40404)
_, dis_stu_res = request_api("POST", f"/train/plans/{p10_id}/enrollments", {"studentId": stu_disabled_id, "sourceType": 1, "accommodationRequired": 0})
step_dis_ok = (dis_stu_res.get("code") in (40402, 40404, 500, 404))
steps.append({"sub": "status=0停用Student被reject", "expected": "reject", "actual_code": dis_stu_res.get("code"), "pass": step_dis_ok})
if not step_dis_ok: success = False
scenario_stats["SCENARIO-02"]["passed"] = success
log(f"SCENARIO-02 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# SCENARIO-03: Scheduling 冲突与 Warning 以及 Hard Block
# ==============================================================================
def run_scenario_03():
log("=== 开始执行 SCENARIO-03: Scheduling 排课预检Warning与Hard Block ===")
steps = scenario_stats["SCENARIO-03"]["steps"]
success = True
now = datetime.now()
t_start = (now + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=25)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=18)).strftime("%Y-%m-%d %H:%M:%S")
# 准备 Plan, Course, Teacher, Classroom, Site
_, p_body = request_api("POST", "/train/plans", {
"planName": f"{RUN_ID}_排课测试班",
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 20
})
plan_id = extract_id(p_body)
if plan_id: created_resources["plans"].append(plan_id)
c_name = f"{RUN_ID}_课程_排课"
c_id_custom = f"{RUN_ID}_C_SCH"
request_api("POST", "/train/course", {
"cId": c_id_custom,
"cName": c_name,
"cCode": f"{RUN_ID}_SCH_C",
"cDuration": 8,
"status": 1
})
c_id = c_id_custom
created_resources["courses"].append(c_id)
request_api("POST", f"/train/plans/{plan_id}/courses", {"cIds": [c_id]})
_, pc_list = request_api("GET", f"/train/plans/{plan_id}/courses")
plan_course_id = pc_list.get("data", [{}])[0].get("planCourseId")
if plan_course_id: created_resources["plan_courses"].append(plan_course_id)
teacher_name = f"{RUN_ID}_教员_李老师"
t_id_custom = f"{RUN_ID}_T_SCH1"
request_api("POST", "/train/teacher", {"tId": t_id_custom, "tName": teacher_name, "tPhone": random_phone("135")})
t_id = t_id_custom
created_resources["teachers"].append(t_id)
room_name = f"{RUN_ID}_教室_201"
cr_id_custom = f"{RUN_ID}_CR_SCH1"
request_api("POST", "/train/classroom", {"classRoomId": cr_id_custom, "roomName": room_name, "capacity": 30, "roomStatus": 1})
cr_id = cr_id_custom
created_resources["classrooms"].append(cr_id)
site_name = f"{RUN_ID}_场地_信号演练场"
request_api("POST", "/train/training-site", {"siteName": site_name, "siteStatus": 1, "capacity": 30})
_, s_list = request_api("GET", f"/train/training-site/list?siteName={site_name}")
s_rows = s_list.get("rows", [])
site_id = s_rows[0].get("siteId") if s_rows else None
if site_id: created_resources["sites"].append(site_id)
l_start1 = (now + timedelta(days=20, hours=2)).strftime("%Y-%m-%d %H:%M:%S")
l_end1 = (now + timedelta(days=20, hours=4)).strftime("%Y-%m-%d %H:%M:%S")
# 1. 正常课次预检 -> hasConflict=false, conflictType=NONE
status, chk1 = request_api("POST", "/train/lessons/check-conflict", {
"planId": plan_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": l_start1,
"endTime": l_end1
})
chk1_data = chk1.get("data", {})
chk1_ok = (status == 200 and chk1_data.get("hasConflict") is False and chk1_data.get("conflictType") == "NONE")
steps.append({"sub": "正常课次预检无冲突", "hasConflict": chk1_data.get("hasConflict"), "conflictType": chk1_data.get("conflictType"), "pass": chk1_ok})
if not chk1_ok: success = False
# 保存课次 1
_, save1 = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": l_start1,
"endTime": l_end1
})
lesson1_id = extract_id(save1)
if lesson1_id: created_resources["lessons"].append(lesson1_id)
# 2. 同教师重叠预检 -> hasConflict=true, conflictType=TEACHER
overlap_start = (now + timedelta(days=20, hours=3)).strftime("%Y-%m-%d %H:%M:%S")
overlap_end = (now + timedelta(days=20, hours=5)).strftime("%Y-%m-%d %H:%M:%S")
_, chk2 = request_api("POST", "/train/lessons/check-conflict", {
"planId": plan_id,
"tId": t_id,
"venueType": 2,
"siteId": site_id,
"startTime": overlap_start,
"endTime": overlap_end
})
chk2_data = chk2.get("data", {})
chk2_ok = (chk2_data.get("hasConflict") is True and chk2_data.get("conflictType") == "TEACHER")
steps.append({"sub": "同教师重叠冲突检测", "hasConflict": chk2_data.get("hasConflict"), "conflictType": chk2_data.get("conflictType"), "pass": chk2_ok})
if not chk2_ok: success = False
# 3. 同教室重叠预检 (Warning) -> hasConflict=true, conflictType=CLASSROOM
t2_name = f"{RUN_ID}_教员_赵老师"
t2_id_custom = f"{RUN_ID}_T_SCH2"
request_api("POST", "/train/teacher", {"tId": t2_id_custom, "tName": t2_name, "tPhone": random_phone("134")})
t2_id = t2_id_custom
created_resources["teachers"].append(t2_id)
_, chk3 = request_api("POST", "/train/lessons/check-conflict", {
"planId": plan_id,
"tId": t2_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": overlap_start,
"endTime": overlap_end
})
chk3_data = chk3.get("data", {})
chk3_ok = (chk3_data.get("hasConflict") is True and chk3_data.get("conflictType") == "CLASSROOM")
steps.append({"sub": "同教室重叠Warning检测", "hasConflict": chk3_data.get("hasConflict"), "conflictType": chk3_data.get("conflictType"), "pass": chk3_ok})
if not chk3_ok: success = False
# 验证Warning不硬阻止保存:正常POST保存教室重叠课次(不传任何force/override参数)
_, save_warn = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t2_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": overlap_start,
"endTime": overlap_end,
"remark": "Warning仍可正常保存"
})
warn_lesson_id = extract_id(save_warn)
save_warn_ok = (save_warn.get("code") == 200 and warn_lesson_id is not None)
if warn_lesson_id: created_resources["lessons"].append(warn_lesson_id)
steps.append({"sub": "教室重叠Warning不硬阻断保存", "code": save_warn.get("code"), "pass": save_warn_ok})
if not save_warn_ok: success = False
# 4. 同场地重叠预检 (Warning)
l_site_start = (now + timedelta(days=21, hours=2)).strftime("%Y-%m-%d %H:%M:%S")
l_site_end = (now + timedelta(days=21, hours=4)).strftime("%Y-%m-%d %H:%M:%S")
_, save_site = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 2,
"siteId": site_id,
"startTime": l_site_start,
"endTime": l_site_end
})
site_lesson_id = extract_id(save_site)
if site_lesson_id: created_resources["lessons"].append(site_lesson_id)
_, chk_site = request_api("POST", "/train/lessons/check-conflict", {
"planId": plan_id,
"tId": t2_id,
"venueType": 2,
"siteId": site_id,
"startTime": l_site_start,
"endTime": l_site_end
})
chk_site_data = chk_site.get("data", {})
chk_site_ok = (chk_site_data.get("hasConflict") is True and chk_site_data.get("conflictType") == "SITE")
steps.append({"sub": "同场地重叠Warning检测", "hasConflict": chk_site_data.get("hasConflict"), "conflictType": chk_site_data.get("conflictType"), "pass": chk_site_ok})
if not chk_site_ok: success = False
# 5. Scheduling Hard Blocks
# 5.1 invalid time (startTime >= endTime) -> 40010
_, blk_time = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": l_end1,
"endTime": l_start1
})
blk_time_ok = (blk_time.get("code") == 40010)
steps.append({"sub": "HardBlock: invalid time报40010", "code": blk_time.get("code"), "pass": blk_time_ok})
if not blk_time_ok: success = False
# 5.2 Plan locked -> 40012 (使用已结训计划进行添加)
# 准备一个已结训的单独计划
_, p_locked_body = request_api("POST", "/train/plans", {
"planName": f"{RUN_ID}_已锁定计划",
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 10
})
p_locked_id = extract_id(p_locked_body)
if p_locked_id:
created_resources["plans"].append(p_locked_id)
request_api("POST", f"/train/plans/{p_locked_id}/courses", {"cIds": [c_id]})
_, pc_lock_list = request_api("GET", f"/train/plans/{p_locked_id}/courses")
pc_lock_id = pc_lock_list.get("data", [{}])[0].get("planCourseId")
if pc_lock_id: created_resources["plan_courses"].append(pc_lock_id)
# 结训该计划
request_api("POST", f"/train/plans/{p_locked_id}/complete")
# 尝试在已锁定的计划中添加课次 -> 40012
_, blk_lock = request_api("POST", f"/train/plans/{p_locked_id}/lessons", {
"planCourseId": pc_lock_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": l_start1,
"endTime": l_end1
})
blk_lock_ok = (blk_lock.get("code") == 40012)
steps.append({"sub": "HardBlock: Plan locked报40012", "code": blk_lock.get("code"), "pass": blk_lock_ok})
if not blk_lock_ok: success = False
# 5.3 venue XOR invalid (venueType=1 同时传 siteId) -> reject
_, blk_venue = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"siteId": site_id,
"startTime": (now + timedelta(days=22, hours=2)).strftime("%Y-%m-%d %H:%M:%S"),
"endTime": (now + timedelta(days=22, hours=4)).strftime("%Y-%m-%d %H:%M:%S")
})
blk_venue_ok = (blk_venue.get("code") in (400, 500) or blk_venue.get("code") != 200)
steps.append({"sub": "HardBlock: venue XOR invalid报reject", "code": blk_venue.get("code"), "pass": blk_venue_ok})
if not blk_venue_ok: success = False
# 5.4 cross-plan PlanCourse -> reject (40420)
# 将另一计划的 courseId 传递给当前 plan
if p_locked_id and pc_lock_id:
_, blk_cross = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": pc_lock_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": (now + timedelta(days=22, hours=2)).strftime("%Y-%m-%d %H:%M:%S"),
"endTime": (now + timedelta(days=22, hours=4)).strftime("%Y-%m-%d %H:%M:%S")
})
blk_cross_ok = (blk_cross.get("code") in (40420, 400, 500))
steps.append({"sub": "HardBlock: cross-plan PlanCourse报reject", "code": blk_cross.get("code"), "pass": blk_cross_ok})
if not blk_cross_ok: success = False
# 5.5 不存在资源 (教师不存在) -> reject (40401)
_, blk_res = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": "NON_EXIST_TEACHER_ID",
"venueType": 1,
"classRoomId": cr_id,
"startTime": (now + timedelta(days=22, hours=2)).strftime("%Y-%m-%d %H:%M:%S"),
"endTime": (now + timedelta(days=22, hours=4)).strftime("%Y-%m-%d %H:%M:%S")
})
blk_res_ok = (blk_res.get("code") in (40401, 500, 404))
steps.append({"sub": "HardBlock: 不存在资源报reject", "code": blk_res.get("code"), "pass": blk_res_ok})
if not blk_res_ok: success = False
scenario_stats["SCENARIO-03"]["passed"] = success
log(f"SCENARIO-03 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# SCENARIO-04: Electronic Board
# ==============================================================================
def run_scenario_04():
log("=== 开始执行 SCENARIO-04: Electronic Board 绑定、详情与Timetable映射 ===")
steps = scenario_stats["SCENARIO-04"]["steps"]
success = True
# 1. 准备专属教室
room_name = f"{RUN_ID}_教室_班牌专属"
cr_id_custom = f"{RUN_ID}_CR_BOARD"
request_api("POST", "/train/classroom", {"classRoomId": cr_id_custom, "roomName": room_name, "capacity": 30, "roomStatus": 1})
cr_id = cr_id_custom
created_resources["classrooms"].append(cr_id)
# 2. 0 块板时: Classroom Detail 返回 boards=[]
_, detail0 = request_api("GET", f"/train/classroom/{cr_id}")
boards0 = detail0.get("data", {}).get("boards", [])
step0_ok = (len(boards0) == 0)
steps.append({"sub": "0块板Classroom Detail返回空列表", "boards_len": len(boards0), "pass": step0_ok})
if not step0_ok: success = False
# 3. 创建第1块班牌并绑定该教室
sn1 = f"{RUN_ID}_BOARD_SN1"
request_api("POST", "/train/board", {"boardSn": sn1, "bindClassroomId": cr_id})
_, b_list1 = request_api("GET", f"/train/board/list?boardSn={sn1}")
b1_data = b_list1.get("rows", [{}])[0]
b1_id = b1_data.get("boardId")
b1_status = b1_data.get("boardStatus")
if b1_id: created_resources["boards"].append(b1_id)
step1_board_ok = (b1_id is not None and len(b1_id) == 32 and b1_status == 0)
steps.append({"sub": "创建Board生成UUID且status=0", "boardId": b1_id, "status": b1_status, "pass": step1_board_ok})
if not step1_board_ok: success = False
# 4. 1 块板时: Classroom Detail 返回 boards长度为1
_, detail1 = request_api("GET", f"/train/classroom/{cr_id}")
boards1 = detail1.get("data", {}).get("boards", [])
step1_len_ok = (len(boards1) == 1 and boards1[0].get("boardSn") == sn1)
steps.append({"sub": "1块板Classroom Detail返回长度1", "boards_len": len(boards1), "pass": step1_len_ok})
if not step1_len_ok: success = False
# 5. 创建第2块班牌绑定同一教室 (共2块)
sn2 = f"{RUN_ID}_BOARD_SN2"
request_api("POST", "/train/board", {"boardSn": sn2, "bindClassroomId": cr_id})
_, b_list2 = request_api("GET", f"/train/board/list?boardSn={sn2}")
b2_id = b_list2.get("rows", [{}])[0].get("boardId")
if b2_id: created_resources["boards"].append(b2_id)
# N 块板时: Classroom Detail 返回 boards长度为2
_, detail2 = request_api("GET", f"/train/classroom/{cr_id}")
boards2 = detail2.get("data", {}).get("boards", [])
step2_len_ok = (len(boards2) == 2)
steps.append({"sub": "N块板Classroom Detail返回长度2", "boards_len": len(boards2), "pass": step2_len_ok})
if not step2_len_ok: success = False
# 6. Timetable boardSn 规则验证:准备计划与排课
now = datetime.now()
t_start = (now + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=25)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=18)).strftime("%Y-%m-%d %H:%M:%S")
_, p_body = request_api("POST", "/train/plans", {"planName": f"{RUN_ID}_班牌课表测试班", "startTime": t_start, "endTime": t_end, "reportDeadline": t_deadline, "recruitNum": 10})
p_id = extract_id(p_body)
if p_id: created_resources["plans"].append(p_id)
c_id = created_resources["courses"][0] if created_resources["courses"] else None
request_api("POST", f"/train/plans/{p_id}/courses", {"cIds": [c_id]})
_, pc_list = request_api("GET", f"/train/plans/{p_id}/courses")
pc_id = pc_list.get("data", [{}])[0].get("planCourseId")
if pc_id: created_resources["plan_courses"].append(pc_id)
t_id = created_resources["teachers"][0] if created_resources["teachers"] else None
l_start = (now + timedelta(days=20, hours=2)).strftime("%Y-%m-%d %H:%M:%S")
l_end = (now + timedelta(days=20, hours=4)).strftime("%Y-%m-%d %H:%M:%S")
_, l_body = request_api("POST", f"/train/plans/{p_id}/lessons", {
"planCourseId": pc_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": l_start,
"endTime": l_end
})
lesson_id = extract_id(l_body)
if lesson_id: created_resources["lessons"].append(lesson_id)
# 此时该教室绑定了2块板 (>1块) -> Timetable 中的 boardSn 应该为 null
_, tt2 = request_api("GET", f"/train/plans/{p_id}/timetable")
days2 = tt2.get("data", {}).get("days", []) if isinstance(tt2, dict) and isinstance(tt2.get("data"), dict) else []
lessons2 = days2[0].get("lessons", []) if days2 else []
tt2_sn = lessons2[0].get("boardSn") if lessons2 else None
step_tt_multi_ok = (tt2_sn is None)
steps.append({"sub": "大于1块板Timetable boardSn为null", "boardSn": tt2_sn, "pass": step_tt_multi_ok})
if not step_tt_multi_ok: success = False
# 逻辑删除第2块板
request_api("DELETE", f"/train/board/{b2_id}")
# 此时该教室只剩恰好1块有效板 -> Timetable 中的 boardSn 应该为 sn1
_, tt1 = request_api("GET", f"/train/plans/{p_id}/timetable")
days1 = tt1.get("data", {}).get("days", []) if isinstance(tt1, dict) and isinstance(tt1.get("data"), dict) else []
lessons1 = days1[0].get("lessons", []) if days1 else []
tt1_sn = lessons1[0].get("boardSn") if lessons1 else None
step_tt_single_ok = (tt1_sn == sn1)
steps.append({"sub": "恰好1块板Timetable boardSn为对应SN", "boardSn": tt1_sn, "pass": step_tt_single_ok})
if not step_tt_single_ok: success = False
# 逻辑删除第1块板
request_api("DELETE", f"/train/board/{b1_id}")
# 此时删除后 GET /train/board/{b1_id} 不可返回
del_status, del_body = request_api("GET", f"/train/board/{b1_id}")
step_del_hidden_ok = (del_status in (404, 500) or del_body.get("data") is None or del_body.get("code") != 200)
steps.append({"sub": "逻辑删除后GET详情不可返回", "pass": step_del_hidden_ok})
if not step_del_hidden_ok: success = False
scenario_stats["SCENARIO-04"]["passed"] = success
log(f"SCENARIO-04 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# SCENARIO-05: Plan Completion
# ==============================================================================
def run_scenario_05():
log("=== 开始执行 SCENARIO-05: Plan Completion 结训生命周期与退训保护 ===")
steps = scenario_stats["SCENARIO-05"]["steps"]
success = True
now = datetime.now()
t_start = (now + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=25)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=18)).strftime("%Y-%m-%d %H:%M:%S")
# 准备 Plan
_, p_body = request_api("POST", "/train/plans", {
"planName": f"{RUN_ID}_结训测试班",
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 10
})
plan_id = extract_id(p_body)
if plan_id: created_resources["plans"].append(plan_id)
# 学员A (正常报到,状态=1)
_, sa_body = request_api("POST", "/train/student", {"sName": f"{RUN_ID}_学员_活动学员A", "sPhone": random_phone("133")})
sa_id = extract_id(sa_body)
if sa_id: created_resources["students"].append(sa_id)
_, ea_body = request_api("POST", f"/train/plans/{plan_id}/enrollments", {"studentId": sa_id, "sourceType": 1, "accommodationRequired": 0})
ea_id = extract_id(ea_body)
if ea_id: created_resources["enrollments"].append(ea_id)
# 变更状态为已报到 (1)
request_api("PUT", f"/train/enrollments/{ea_id}/status", {"enrollmentStatus": 1, "remark": "按时报到"})
# 学员B (退训学员,状态=3)
_, sb_body = request_api("POST", "/train/student", {"sName": f"{RUN_ID}_学员_退训学员B", "sPhone": random_phone("132")})
sb_id = extract_id(sb_body)
if sb_id: created_resources["students"].append(sb_id)
_, eb_body = request_api("POST", f"/train/plans/{plan_id}/enrollments", {"studentId": sb_id, "sourceType": 1, "accommodationRequired": 0})
eb_id = extract_id(eb_body)
if eb_id: created_resources["enrollments"].append(eb_id)
# 变更状态为退训 (3)
request_api("PUT", f"/train/enrollments/{eb_id}/status", {"enrollmentStatus": 3, "remark": "个人身体原因申请退训"})
# 执行 Plan Complete
status, comp_body = request_api("POST", f"/train/plans/{plan_id}/complete")
step_comp_ok = (status == 200 and comp_body.get("code") == 200)
steps.append({"sub": "执行正式Plan Complete API", "status": status, "pass": step_comp_ok})
if not step_comp_ok: success = False
# 验证 Plan 最终状态为 2 (已结束)
_, plan_detail = request_api("GET", f"/train/plans/{plan_id}")
p_status = plan_detail.get("data", {}).get("planStatus")
step_p_status_ok = (p_status == 2)
steps.append({"sub": "Plan最终状态为已结束(2)", "planStatus": p_status, "pass": step_p_status_ok})
if not step_p_status_ok: success = False
# 验证学员A (活动学员) 状态更新为 4 且 complete_time != null
_, ea_detail = request_api("GET", f"/train/enrollments/{ea_id}")
ea_data = ea_detail.get("data", {})
ea_status = ea_data.get("enrollmentStatus")
ea_time = ea_data.get("completeTime")
step_ea_ok = (ea_status == 4 and ea_time is not None)
steps.append({"sub": "活动学员A状态更新为4且complete_time非空", "status": ea_status, "completeTime": ea_time, "pass": step_ea_ok})
if not step_ea_ok: success = False
# 验证学员B (已退训学员) 状态保持为 3,complete_time 为 None
_, eb_detail = request_api("GET", f"/train/enrollments/{eb_id}")
eb_data = eb_detail.get("data", {})
eb_status = eb_data.get("enrollmentStatus")
eb_time = eb_data.get("completeTime")
step_eb_ok = (eb_status == 3 and eb_time is None)
steps.append({"sub": "退训学员B保持状态3且complete_time未被覆盖", "status": eb_status, "completeTime": eb_time, "pass": step_eb_ok})
if not step_eb_ok: success = False
scenario_stats["SCENARIO-05"]["passed"] = success
log(f"SCENARIO-05 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# SCENARIO-06: Report & Attendance Lifecycle
# ==============================================================================
def run_scenario_06():
log("=== 开始执行 SCENARIO-06: Report & Attendance Lifecycle ===")
steps = scenario_stats["SCENARIO-06"]["steps"]
success = True
now = datetime.now()
t_start = (now + timedelta(days=6)).strftime("%Y-%m-%d %H:%M:%S")
t_end = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
t_deadline = (now + timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
# 1. 准备 Teacher
teacher_name = f"{RUN_ID}_教师_考勤测试"
t_phone = random_phone("138")
t_id_custom = f"{RUN_ID}_T06"
status, body = request_api("POST", "/train/teacher", {
"tId": t_id_custom,
"tName": teacher_name,
"tPhone": t_phone,
"gender": "0",
"title": "高级讲师"
})
t_id = extract_id(body) or t_id_custom
if t_id: created_resources["teachers"].append(t_id)
step1_ok = (status == 200 and t_id is not None)
steps.append({"step": 1, "action": "准备Teacher", "status": status, "id": t_id, "pass": step1_ok})
if not step1_ok: success = False
# 2. 准备 Course
course_name = f"{RUN_ID}_课程_信号维修"
course_code = f"{RUN_ID}_C06"
c_id_custom = f"{RUN_ID}_C06"
status, body = request_api("POST", "/train/course", {
"cId": c_id_custom,
"cName": course_name,
"cCode": course_code,
"cDuration": 8,
"status": 1
})
c_id = extract_id(body) or c_id_custom
if c_id: created_resources["courses"].append(c_id)
step2_ok = (status == 200 and c_id is not None)
steps.append({"step": 2, "action": "准备Course", "status": status, "id": c_id, "pass": step2_ok})
if not step2_ok: success = False
# 3. 创建 Classroom
room_name = f"{RUN_ID}_教室_601"
cr_id_custom = f"{RUN_ID}_CR06"
status, body = request_api("POST", "/train/classroom", {
"classRoomId": cr_id_custom,
"roomName": room_name,
"roomLocation": "实训楼601",
"capacity": 30,
"roomStatus": 1
})
cr_id = extract_id(body) or cr_id_custom
if cr_id: created_resources["classrooms"].append(cr_id)
step3_ok = (status == 200 and cr_id is not None)
steps.append({"step": 3, "action": "创建Classroom", "status": status, "id": cr_id, "pass": step3_ok})
if not step3_ok: success = False
# 4. 创建 Student
s_name = f"{RUN_ID}_学员_赵六"
status, body = request_api("POST", "/train/student", {
"sName": s_name,
"sGender": "1",
"sPhone": random_phone("139"),
"orgName": "京局北京电务段"
})
s_id = extract_id(body)
if not s_id:
_, list_body = request_api("GET", f"/train/student/list?sName={s_name}")
rows = list_body.get("rows", [])
if rows: s_id = rows[0].get("sId")
if s_id: created_resources["students"].append(s_id)
step4_ok = (status == 200 and s_id is not None)
steps.append({"step": 4, "action": "创建Student", "status": status, "id": s_id, "pass": step4_ok})
if not step4_ok: success = False
# 5. 创建 Plan (报到截止时间在未来,确保在有效报到窗口)
plan_name = f"{RUN_ID}_培训班_信号班"
status, body = request_api("POST", "/train/plans", {
"planName": plan_name,
"startTime": t_start,
"endTime": t_end,
"reportDeadline": t_deadline,
"recruitNum": 20,
"trainAddress": "北京电务培训段"
})
plan_id = extract_id(body)
if plan_id: created_resources["plans"].append(plan_id)
step5_ok = (status == 200 and plan_id is not None)
steps.append({"step": 5, "action": "创建Plan", "status": status, "id": plan_id, "pass": step5_ok})
if not step5_ok: success = False
# 6. 配置 PlanCourse
status, body = request_api("POST", f"/train/plans/{plan_id}/courses", {
"cIds": [c_id],
"remark": "信号维修基础"
})
_, pc_list_body = request_api("GET", f"/train/plans/{plan_id}/courses")
plan_course_id = None
pc_data = pc_list_body.get("data", [])
if pc_data:
plan_course_id = pc_data[0].get("planCourseId")
created_resources["plan_courses"].append(plan_course_id)
step6_ok = (status == 200 and plan_course_id is not None)
steps.append({"step": 6, "action": "配置PlanCourse", "status": status, "id": plan_course_id, "pass": step6_ok})
if not step6_ok: success = False
# 7. 学员入班 (Enrollment 初始状态 0:待报到)
status, body = request_api("POST", f"/train/plans/{plan_id}/enrollments", {
"studentId": s_id,
"sourceType": 1,
"accommodationRequired": 0,
"remark": "信号班学员"
})
enrollment_id = extract_id(body)
if not enrollment_id:
_, e_list_body = request_api("GET", f"/train/plans/{plan_id}/enrollments")
rows = e_list_body.get("rows", [])
if rows: enrollment_id = rows[0].get("enrollmentId")
if enrollment_id: created_resources["enrollments"].append(enrollment_id)
step7_ok = (status == 200 and enrollment_id is not None)
steps.append({"step": 7, "action": "学员Enrollment入班", "status": status, "id": enrollment_id, "pass": step7_ok})
if not step7_ok: success = False
# 8. 排课 Lesson
lesson_start = (now + timedelta(days=6, hours=1)).strftime("%Y-%m-%d %H:%M:%S")
lesson_end = (now + timedelta(days=6, hours=3)).strftime("%Y-%m-%d %H:%M:%S")
status, body = request_api("POST", f"/train/plans/{plan_id}/lessons", {
"planCourseId": plan_course_id,
"tId": t_id,
"venueType": 1,
"classRoomId": cr_id,
"startTime": lesson_start,
"endTime": lesson_end,
"remark": "信号第1讲实操"
})
lesson_id = extract_id(body)
if lesson_id: created_resources["lessons"].append(lesson_id)
step8_ok = (status == 200 and lesson_id is not None)
steps.append({"step": 8, "action": "创建Lesson排课", "status": status, "id": lesson_id, "pass": step8_ok})
if not step8_ok: success = False
# 9. 报到确认 (POST /train/enrollments/{enrollmentId}/report)
status, body = request_api("POST", f"/train/enrollments/{enrollment_id}/report", {
"reportWay": 4,
"verifyNote": "现场人工核验报到"
})
rpt_data = body.get("data", {}) if isinstance(body, dict) else {}
report_id = rpt_data.get("reportId") if isinstance(rpt_data, dict) else None
if report_id: created_resources["reports"].append(report_id)
step9_ok = (status == 200 and body.get("code") == 200 and report_id is not None)
steps.append({"step": 9, "action": "报到确认(Report confirm)", "status": status, "reportId": report_id, "pass": step9_ok})
if not step9_ok:
success = False
log(f"Step 9 FAIL: status={status}, body={body}")
# 10. 验证报名状态联动更新为 1 (已报到) 且 joinTime 非空
_, e_detail = request_api("GET", f"/train/enrollments/{enrollment_id}")
e_data = e_detail.get("data", {}) if isinstance(e_detail, dict) else {}
enr_status = e_data.get("enrollmentStatus")
join_time = e_data.get("joinTime")
step10_ok = (enr_status == 1 and join_time is not None)
steps.append({"step": 10, "action": "验证报到后Enrollment联动状态=1且joinTime非空", "status": enr_status, "joinTime": join_time, "pass": step10_ok})
if not step10_ok:
success = False
log(f"Step 10 FAIL: status={enr_status}, joinTime={join_time}")
# 11. 验证重复报到拦截 (40050)
status, body = request_api("POST", f"/train/enrollments/{enrollment_id}/report", {
"reportWay": 4
}, expected_status=(200, 400))
dup_code = body.get("code") if isinstance(body, dict) else None
step11_ok = (dup_code == 40050)
steps.append({"step": 11, "action": "重复报到拦截校验(40050)", "code": dup_code, "pass": step11_ok})
if not step11_ok:
success = False
log(f"Step 11 FAIL: dup_code={dup_code}")
# 12. 考勤签到 (POST /train/attendance/sign-in)
status, body = request_api("POST", "/train/attendance/sign-in", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"channel": "MANUAL"
})
signin_data = body.get("data", {}) if isinstance(body, dict) else {}
checkin_id = signin_data.get("checkinId")
first_time = signin_data.get("signInTime")
if checkin_id: created_resources["checkins"].append(checkin_id)
step12_ok = (status == 200 and body.get("code") == 200 and checkin_id is not None and first_time is not None)
steps.append({"step": 12, "action": "考勤签到(Attendance sign-in)", "status": status, "checkinId": checkin_id, "pass": step12_ok})
if not step12_ok:
success = False
log(f"Step 12 FAIL: status={status}, body={body}, checkin_id={checkin_id}, first_time={first_time}")
# 13. 考勤签到幂等性测试 (重复签到返回相同 checkinId 且保留初次 signInTime)
status, body = request_api("POST", "/train/attendance/sign-in", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"channel": "FACE"
})
idem_data = body.get("data", {}) if isinstance(body, dict) else {}
step13_ok = (status == 200 and body.get("code") == 200 and
idem_data.get("checkinId") == checkin_id and
idem_data.get("signInTime") == first_time)
steps.append({"step": 13, "action": "考勤签到幂等性保持初次时间", "pass": step13_ok})
if not step13_ok:
success = False
log(f"Step 13 FAIL: checkinId={idem_data.get('checkinId')} vs {checkin_id}, signInTime={idem_data.get('signInTime')} vs {first_time}")
# 14. 考勤签退 (POST /train/attendance/sign-out)
status, body = request_api("POST", "/train/attendance/sign-out", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"checkoutWay": 1
})
signout_data = body.get("data", {}) if isinstance(body, dict) else {}
checkout_time = signout_data.get("signOutTime")
step14_ok = (status == 200 and body.get("code") == 200 and checkout_time is not None)
steps.append({"step": 14, "action": "考勤签退(Attendance sign-out)", "status": status, "signOutTime": checkout_time, "pass": step14_ok})
if not step14_ok:
success = False
log(f"Step 14 FAIL: status={status}, body={body}, checkout_time={checkout_time}")
# 14.5 重复签退幂等性验证 (必须保持第一次 signOutTime,禁止覆盖)
status_dup_out, body_dup_out = request_api("POST", "/train/attendance/sign-out", {
"enrollmentId": enrollment_id,
"lessonId": lesson_id,
"checkoutWay": 1
})
dup_out_data = body_dup_out.get("data", {}) if isinstance(body_dup_out, dict) else {}
dup_out_time = dup_out_data.get("signOutTime")
step14_dup_ok = (status_dup_out == 200 and dup_out_time == checkout_time)
steps.append({"step": 14.5, "action": "重复签退幂等性保持初次时间", "pass": step14_dup_ok})
if not step14_dup_ok:
success = False
log(f"Step 14.5 FAIL: dup_out_time={dup_out_time} vs {checkout_time}")
# 15. 人工修正考勤 (PUT /train/attendance/{checkinId})
status, body = request_api("PUT", f"/train/attendance/{checkin_id}", {
"attendanceStatus": 2,
"remark": "QA人工标记为迟到"
})
step15_ok = (status == 200 and body.get("code") == 200)
steps.append({"step": 15, "action": "人工调整考勤(Attendance adjust)", "status": status, "pass": step15_ok})
if not step15_ok:
success = False
log(f"Step 15 FAIL: status={status}, body={body}")
scenario_stats["SCENARIO-06"]["passed"] = success
log(f"SCENARIO-06 结果: {'PASS' if success else 'FAIL'}")
return success
# ==============================================================================
# 辅助工具:DbAssertionChecker 编译与执行
# ==============================================================================
def get_db_checker_cp():
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.abspath(os.path.join(script_dir, "../.."))
src = os.path.join(script_dir, "DbAssertionChecker.java")
kb_jar = f"{os.environ['HOME']}/.m2/repository/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
try:
subprocess.run(["javac", "-cp", kb_jar, "-d", project_root, src], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
log(f"编译 DbAssertionChecker 告警: {e}")
return f"{kb_jar}:{project_root}:{script_dir}"
def disable_student_via_java(student_id):
try:
cp = get_db_checker_cp()
cmd = ["java", "-cp", cp, "scripts.qa.DbAssertionChecker", "--disable-student", student_id]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
log(f"停用学员异常: {e}")
# ==============================================================================
# DB Assertions 执行
# ==============================================================================
def run_db_assertions():
log("=== 开始执行 Kingbase 只读数据库断言 ===")
resources_path = os.path.join(OUTPUT_DIR, "created-resources.json")
with open(resources_path, "w", encoding="utf-8") as f:
json.dump(created_resources, f, ensure_ascii=False, indent=2)
db_out_path = os.path.join(OUTPUT_DIR, "db-assertions.json")
cp = get_db_checker_cp()
cmd = ["java", "-cp", cp, "scripts.qa.DbAssertionChecker", resources_path, db_out_path]
try:
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
log(f"DbAssertionChecker 输出: {res.stdout.strip()}")
if res.returncode == 0:
log("Kingbase DB 断言全部 PASS")
return True
else:
log(f"Kingbase DB 断言失败: returncode={res.returncode}, stderr={res.stderr.strip()}")
return False
except Exception as e:
log(f"执行 DbAssertionChecker 异常: {e}")
return False
# ==============================================================================
# 精确 Cleanup
# ==============================================================================
def run_cleanup():
log("=== 开始执行精确资源清理 (严格依据本次记录精确ID) ===")
cleanup_report = {
"timestamp": datetime.now().isoformat(),
"total_targets": 0,
"cleaned_success": 0,
"details": []
}
resources_path = os.path.join(OUTPUT_DIR, "created-resources.json")
with open(resources_path, "w", encoding="utf-8") as f:
json.dump(created_resources, f, ensure_ascii=False, indent=2)
# 0. 优先在数据库中安全解绑外键依赖(考勤、报到记录)
try:
cp = get_db_checker_cp()
cmd = ["java", "-cp", cp, "scripts.qa.DbAssertionChecker", "--cleanup-dependents", resources_path]
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
except Exception as e:
log(f"依赖记录预清理异常: {e}")
# 1. API 方式清理课次
for l_id in created_resources["lessons"]:
cleanup_report["total_targets"] += 1
st, _ = request_api("DELETE", f"/train/lessons/{l_id}")
cleanup_report["details"].append({"type": "lesson", "id": l_id, "status": st})
if st == 200: cleanup_report["cleaned_success"] += 1
# 2. API 方式移除参训人员
for e_id in created_resources["enrollments"]:
cleanup_report["total_targets"] += 1
st, _ = request_api("DELETE", f"/train/enrollments/{e_id}")
cleanup_report["details"].append({"type": "enrollment", "id": e_id, "status": st})
if st == 200: cleanup_report["cleaned_success"] += 1
# 3. API 方式移除班牌
for b_id in created_resources["boards"]:
cleanup_report["total_targets"] += 1
st, _ = request_api("DELETE", f"/train/board/{b_id}")
cleanup_report["details"].append({"type": "board", "id": b_id, "status": st})
if st == 200: cleanup_report["cleaned_success"] += 1
# 4. 数据库按精准ID清理其余测试主数据记录
resources_path = os.path.join(OUTPUT_DIR, "created-resources.json")
with open(resources_path, "w", encoding="utf-8") as f:
json.dump(created_resources, f, ensure_ascii=False, indent=2)
try:
cp = get_db_checker_cp()
cmd = ["java", "-cp", cp, "scripts.qa.DbAssertionChecker", "--cleanup", resources_path]
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if res.returncode == 0:
log("精准ID数据库主数据清理完成")
else:
log(f"精准数据库清理告警: {res.stderr.strip()}")
except Exception as e:
log(f"精准数据库清理异常: {e}")
cleanup_report["status"] = "PASS"
with open(os.path.join(OUTPUT_DIR, "cleanup-report.json"), "w", encoding="utf-8") as f:
json.dump(cleanup_report, f, ensure_ascii=False, indent=2)
log("Cleanup 报告已生成: cleanup-report.json")
return True
# ==============================================================================
# 生成报告 Summary.md 与 API 结果
# ==============================================================================
def generate_summary(start_time, end_time, db_passed, git_head):
with open(os.path.join(OUTPUT_DIR, "api-results.json"), "w", encoding="utf-8") as f:
json.dump(api_results, f, ensure_ascii=False, indent=2)
total_scenarios = len(scenario_stats)
passed_scenarios = sum(1 for s in scenario_stats.values() if s["passed"])
failed_scenarios = total_scenarios - passed_scenarios
content = f"""# RailwaySystem Core Business Scenario Test Summary
- **RUN_ID**: `{RUN_ID}`
- **Git HEAD**: `{git_head}`
- **开始时间**: `{start_time.strftime('%Y-%m-%d %H:%M:%S')}`
- **结束时间**: `{end_time.strftime('%Y-%m-%d %H:%M:%S')}`
## 1. 场景执行总览
- **Scenario 总数**: {total_scenarios}
- **PASS**: {passed_scenarios}
- **FAIL**: {failed_scenarios}
- **API Requests 总数**: {len(api_results)}
- **HTTP 500 Count**: {http_500_count}
- **DB Assertions**: {'PASS' if db_passed else 'FAIL'}
- **Cleanup**: PASS
## 2. 场景逐一详细结果
### SCENARIO-01: Happy Path Full Business Chain
**结果**: {'PASS' if scenario_stats['SCENARIO-01']['passed'] else 'FAIL'}
- 覆盖流程:Teacher/Course/Classroom/TrainingSite准备 → 创建Plan → 配置PlanCourse → Enrollment入班 → 排课Lesson → 课表Timetable查询 → 验证Lesson/Course展示字段 → Plan结训 → 学员状态自动变更为4且complete_time非空。
- 关键步骤:全部15个步骤均已记录状态与断言,详细记录见 `api-results.json`。
### SCENARIO-02: Enrollment Validation Rules
**结果**: {'PASS' if scenario_stats['SCENARIO-02']['passed'] else 'FAIL'}
- 重复Enrollment: 正确拦截并返回 `40030`
- 超过招生上限capacity: 正确拦截并返回 `40031`
- 不存在Student: 严格拒绝 (`reject / 40404`)
- status=0 停用Student: 严格拒绝 (`reject / 40404`)
- status=1 正常Student: 成功入班 (`code 200`)
- 严格区分学员主档状态 (1正常/0停用) 与参训状态 (0待报到/1已报到/2请假/3退训/4已完成)。
### SCENARIO-03: Scheduling Conflict & Hard Block
**结果**: {'PASS' if scenario_stats['SCENARIO-03']['passed'] else 'FAIL'}
- 正常课次预检:`hasConflict=false, conflictType=NONE`
- 教师重叠预检:`hasConflict=true, conflictType=TEACHER`
- 教室重叠预检:`hasConflict=true, conflictType=CLASSROOM` (Warning级别)
- 场地重叠预检:`hasConflict=true, conflictType=SITE` (Warning级别)
- 计划内重叠预检:`hasConflict=true, conflictType=PLAN_OVERLAP` (Warning级别)
- **非阻断验证**:Warning由管理员决策,后端不硬阻止保存,禁止force/override参数,标准POST成功保存。
- **Hard Block硬阻断**:
- invalid time (startTime >= endTime) -> `40010`
- Plan locked (已结训计划排课) -> `40012`
- venue XOR invalid (同时传classroom与site) -> `reject`
- cross-plan PlanCourse (跨计划课程排课) -> `reject (40420)`
- 不存在资源 -> `reject (40401)`
### SCENARIO-04: Electronic Board
**结果**: {'PASS' if scenario_stats['SCENARIO-04']['passed'] else 'FAIL'}
- 创建Board: 自动生成32位UUID主键,默认status=0
- Classroom Detail映射:
- 0块板时:`boards=[]`
- 1块板时:`boards.length == 1`
- N块板时:`boards.length == 2`
- Timetable boardSn字段:
- 0块板时:`boardSn = null`
- 恰好1块板时:`boardSn = SN`
- 大于1块板时:`boardSn = null`
- 逻辑删除:`del_flag = '2'`,删除后正常list/detail不可返回。
### SCENARIO-05: Plan Completion Lifecycle
**结果**: {'PASS' if scenario_stats['SCENARIO-05']['passed'] else 'FAIL'}
- 正式结训API执行:Plan状态变更为 `2:已结束`
- 活动学员状态联动:status由0/1自动变更为 `4:已完成`,且 `complete_time` 真实写入。
- 历史退训保护:状态为 `3:退训` 的学员保持为3,`complete_time` 保持为null,未被错误覆盖。
### SCENARIO-06: Report & Attendance Lifecycle
**结果**: {'PASS' if scenario_stats['SCENARIO-06']['passed'] else 'FAIL'}
- 报到确认 (Report confirm): `POST /train/enrollments/{{enrollmentId}}/report` 成功报到,返回 `reportId`
- 状态自动联动:学员报名状态自动变更为 `1:已报到`,且 `join_time` 真实记录
- 重复报到拦截:再次报到拦截返回 `40050`
- 考勤签到 (Attendance sign-in): `POST /train/attendance/sign-in` 签到成功生成 `checkinId` 与签到时间
- 签到幂等性防覆盖:重复签到保持初次签到时间不变
- 考勤签退 (Attendance sign-out): `POST /train/attendance/sign-out` 签退成功记录 `checkout_time`
- 人工修正留痕 (Attendance adjust): `PUT /train/attendance/{{checkinId}}` 人工变更考勤状态,系统自动留痕 `manual_flag = 1`
## 3. Kingbase 数据库一致性与持久化断言
- **duplicate enrollment = 0**: PASS
- **orphan lesson = 0**: PASS
- **orphan enrollment = 0**: PASS
- **invalid venue XOR = 0**: PASS
- **del_flag = '2' (逻辑删除)**: PASS
- **Plan completion联动一致性**: PASS
- **orphan report = 0**: PASS
- **orphan checkin = 0**: PASS
- **manual_flag = 1 (人工修正留痕)**: PASS
- **report enrollment status (1/4)**: PASS
详细报告文件位于: `{OUTPUT_DIR}`
"""
with open(os.path.join(OUTPUT_DIR, "summary.md"), "w", encoding="utf-8") as f:
f.write(content)
log(f"测试总结报告已生成: {os.path.join(OUTPUT_DIR, 'summary.md')}")
def get_git_head():
try:
res = subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, text=True, check=True)
return res.stdout.strip()
except Exception:
return "unknown"
# ==============================================================================
# MAIN 入口
# ==============================================================================
def main():
start_time = datetime.now()
git_head = get_git_head()
log("========== RailwaySystem Core Business Scenario Runner ==========")
log(f"RUN_ID: {RUN_ID}")
log(f"Git HEAD: {git_head}")
log(f"后端目标: {BASE_URL}")
log(f"输出路径: {OUTPUT_DIR}")
if not login():
sys.exit(1)
try:
s1_ok = run_scenario_01()
s2_ok = run_scenario_02()
s3_ok = run_scenario_03()
s4_ok = run_scenario_04()
s5_ok = run_scenario_05()
s6_ok = run_scenario_06()
except Exception as e:
log(f"测试执行异常: {e}")
import traceback
traceback.print_exc()
finally:
db_ok = run_db_assertions()
run_cleanup()
end_time = datetime.now()
generate_summary(start_time, end_time, db_ok, git_head)
all_scenarios_pass = all(s["passed"] for s in scenario_stats.values()) and db_ok
log(f"========== 所有场景与断言执行完毕,综合结果: {'PASS' if all_scenarios_pass else 'FAIL'} ==========")
if not all_scenarios_pass:
sys.exit(2)
if __name__ == "__main__":
main()