测试培训:补充核心业务场景集成测试
This commit is contained in:
1 parent
bfccc46f73
commit
7193f2ac3a
5 files changed
+2691
No files matched your search
+1024
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
|||||||
|
# RailwaySystem 核心业务场景集成测试矩阵 (Core Business Test Matrix)
|
||||||
|
|
||||||
|
本文档整理了 RailwaySystem 核心业务场景自动化测试套件的覆盖范围、验证层级(Mock单元测试 vs. 真实Kingbase/API集成Gate)以及当前验证结果。本材料面向研发阶段汇报与质量门禁审计。
|
||||||
|
|
||||||
|
> **基线信息**:
|
||||||
|
> - 开发分支:`integration/wave2-20260914`
|
||||||
|
> - 基线 HEAD:`c04992a8017de78c6b4a43592ae9941e85a9fea9`
|
||||||
|
> - 最新测试运行编号:`QA_W2_20260914221336`
|
||||||
|
> - 自动化测试架构:离线 Mock 测试(`CoreBusinessScenarioMockTest`) + 显式真实集成门禁(`scripts/qa/run-core-business-scenario.sh`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核心业务场景测试矩阵
|
||||||
|
|
||||||
|
| 业务模块 | 测试场景 | API / 接口 | 数据库/核心逻辑断言 | 验证层级 (Mock / Real) | 当前结果 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Teacher (教师)** | 教师档案创建与可用性验证 | `POST /train/teacher`<br>`GET /train/teacher/list` | 教师主键 `t_id` 持久化,姓名、职称、联系方式正确落库 | Mock & Real | **PASS** |
|
||||||
|
| **Course (课程)** | 基础课程创建与状态维护 | `POST /train/course`<br>`GET /train/course/list` | 课程主键 `c_id`、编码与课时正确落库,`status=1` | Mock & Real | **PASS** |
|
||||||
|
| **Classroom (教室)** | 理论教室资源创建与容量设定 | `POST /train/classroom`<br>`GET /train/classroom/{id}` | 教室主键 `class_room_id` 与容量有效持久化 | Mock & Real | **PASS** |
|
||||||
|
| **TrainingSite (实训场地)** | 演练实训基地创建与容量校验 | `POST /train/training-site`<br>`GET /train/training-site/list` | 自动生成32位UUID主键,必填容量非空校验,状态可用 | Mock & Real | **PASS** |
|
||||||
|
| **Student (学员)** | 学员基础档案建立与主档状态区分 | `POST /train/student`<br>`GET /train/student/list` | 32位UUID主键,学员主档状态独立维护 (`1:正常`, `0:停用`) | Mock & Real | **PASS** |
|
||||||
|
| **Plan (培训计划)** | 创建培训班计划与周期时间合法性 | `POST /train/plans`<br>`GET /train/plans/{id}` | 生成32位UUID,初始状态强制为 `0:草稿`,起止时间校验 | Mock & Real | **PASS** |
|
||||||
|
| **PlanCourse (计划课程)** | 培训班绑定课程及关联关系查询 | `POST /train/plans/{id}/courses`<br>`GET /train/plans/{id}/courses` | 生成自增 `plan_course_id`,外键关联一致性 | Mock & Real | **PASS** |
|
||||||
|
| **Enrollment (学员报名)** | 正常学员入班 (Happy Path) | `POST /train/plans/{id}/enrollments` | 生成自增 `enrollment_id`,参训状态初始为 `0:待报到` | Mock & Real | **PASS** |
|
||||||
|
| **Enrollment (学员报名)** | 同一计划重复报名校验 | `POST /train/plans/{id}/enrollments` | 业务错误码 `40030: ENROLLMENT_ALREADY_EXISTS`,数据库唯一约束保证 `duplicate enrollment = 0` | Mock & Real | **PASS** |
|
||||||
|
| **Enrollment (学员报名)** | 计划招生容量上限超限拦截 | `POST /train/plans/{id}/enrollments` | 业务错误码 `40031: ENROLLMENT_CAPACITY_EXCEEDED`,不允许超额入班 | Mock & Real | **PASS** |
|
||||||
|
| **Enrollment (学员报名)** | 无效学员入班拦截 | `POST /train/plans/{id}/enrollments` | 业务错误码 `40402: STUDENT_NOT_FOUND`,拒绝非法入班 | Mock & Real | **PASS** |
|
||||||
|
| **Enrollment (学员报名)** | 停用状态学员 (`status=0`) 入班拦截 | `POST /train/plans/{id}/enrollments` | 业务错误码 `40402: STUDENT_DISABLED`,严禁停用学员报名 | Mock & Real | **PASS** |
|
||||||
|
| **Enrollment (学员报名)** | 报到/请假/退训参训状态维护 | `PUT /train/enrollments/{id}/status` | 参训状态独立流转 (`0:待报到`, `1:已报到`, `2:请假`, `3:退训`, `4:已完成`) | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 正常排课课次冲突预检 | `POST /train/lessons/check-conflict` | 返回 `hasConflict=false, conflictType=NONE` | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 教师时间重叠冲突检测 | `POST /train/lessons/check-conflict` | 返回 `hasConflict=true, conflictType=TEACHER` | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 教室资源重叠冲突检测 (Warning) | `POST /train/lessons/check-conflict` | 返回 `hasConflict=true, conflictType=CLASSROOM`,Warning级别 | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 实训场地重叠冲突检测 (Warning) | `POST /train/lessons/check-conflict` | 返回 `hasConflict=true, conflictType=SITE`,Warning级别 | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 计划内课次重叠冲突检测 (Warning) | `POST /train/lessons/check-conflict` | 返回 `hasConflict=true, conflictType=PLAN_OVERLAP`,Warning级别 | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | Warning非硬阻断验证 | `POST /train/plans/{id}/lessons` | Warning不阻断保存,禁止任何 `force/override` 参数,标准POST成功保存 | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 起止时间非法硬阻断 | `POST /train/plans/{id}/lessons` | 业务错误码 `40010: LESSON_TIME_INVALID` (startTime >= endTime) | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 计划锁定/已结训硬阻断 | `POST /train/plans/{id}/lessons` | 业务错误码 `40012: PLAN_LOCKED`,已结束计划禁止变更课次 | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 场地 XOR 互斥硬阻断 | `POST /train/plans/{id}/lessons` | 场地二选一非法时强行拦截,DB层面 `invalid venue XOR = 0` | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 跨计划关联课程硬阻断 | `POST /train/plans/{id}/lessons` | 业务错误码 `40420: PLAN_COURSE_NOT_IN_PLAN`,禁止错链 | Mock & Real | **PASS** |
|
||||||
|
| **Scheduling (排课)** | 关键资源不存在硬阻断 | `POST /train/plans/{id}/lessons` | 业务错误码 `40401/40403/40410`,拒绝孤立外键记录 | Mock & Real | **PASS** |
|
||||||
|
| **Timetable (课表)** | 课表视图与聚合字段显示 | `GET /train/plans/{id}/timetable` | 正确映射 `courseName`, `teacherName`, `venueName` | Mock & Real | **PASS** |
|
||||||
|
| **ElectronicBoard (电子班牌)** | 班牌创建与主键UUID规则 | `POST /train/board`<br>`GET /train/board/list` | 后端自动生成32位UUID,默认在线状态 `board_status=0` | Mock & Real | **PASS** |
|
||||||
|
| **ElectronicBoard (电子班牌)** | 教室详情与班牌绑定列表映射 | `GET /train/classroom/{id}` | 0块板返回空列表,1块板返回长度1,N块板返回长度N | Mock & Real | **PASS** |
|
||||||
|
| **ElectronicBoard (电子班牌)** | 课表单板/多板 `boardSn` 降级规则 | `GET /train/plans/{id}/timetable` | 0块板返回null,恰好1块返回对应SN,大于1块降级为null | Mock & Real | **PASS** |
|
||||||
|
| **ElectronicBoard (电子班牌)** | 班牌逻辑删除持久化保障 | `DELETE /train/board/{id}` | `del_flag='2'` 逻辑删除,正式接口不可查,DB断言确认存在 | Mock & Real | **PASS** |
|
||||||
|
| **Plan Completion (结训联动)** | 计划结训与活动学员状态自动流转 | `POST /train/plans/{id}/complete` | Plan状态变更为 `2:已结束`;活动学员 (`status=0/1`) 状态自动变更为 `4:已完成`,且 `complete_time != null` 真实写入 | Mock & Real | **PASS** |
|
||||||
|
| **Plan Completion (结训联动)** | 历史退训学员状态防覆盖保护 | `POST /train/plans/{id}/complete`<br>`GET /train/enrollments/{id}` | 历史已退训学员 (`status=3`) 状态保持为 3,`complete_time` 保持为 null,杜绝误覆盖 | Mock & Real | **PASS** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Legacy Analytics 模块边界说明
|
||||||
|
|
||||||
|
根据研发分工规范,Codex 正在独立重构 Legacy Analytics 分析统计模块,本测试套件严格执行架构隔离,**完全未碰、严禁修改或测试**以下四个模块,标记为 `PENDING_V2_CONTRACT`:
|
||||||
|
|
||||||
|
| 模块名称 | 职责描述 | 当前标记 | 规划接入时机 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Report (报表管理)** | 培训数据统计与导出报表 | `PENDING_V2_CONTRACT` | 等待 Codex V2 契约稳定合并后扩展集成 |
|
||||||
|
| **Checkin (签到考勤)** | 学员人脸识别与打卡明细统计 | `PENDING_V2_CONTRACT` | 等待 Codex V2 契约稳定合并后扩展集成 |
|
||||||
|
| **Duration (学时统计)** | 学员课时完成度与学时汇总 | `PENDING_V2_CONTRACT` | 等待 Codex V2 契约稳定合并后扩展集成 |
|
||||||
|
| **Portrait (学员画像)** | 学员综合能力多维能力画像 | `PENDING_V2_CONTRACT` | 等待 Codex V2 契约稳定合并后扩展集成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kingbase 数据库只读安全断言规范
|
||||||
|
|
||||||
|
集成门禁每次运行均调用独立的只读断言器 `scripts.qa.DbAssertionChecker`,直接在 Kingbase 真实数据库实例上校验以下全局一致性与业务正确性指标:
|
||||||
|
|
||||||
|
1. **`duplicate enrollment = 0`**:没有任何重复学员出现在同一计划中。
|
||||||
|
2. **`orphan lesson = 0`**:所有课次均正确关联有效的培训计划课程。
|
||||||
|
3. **`orphan enrollment = 0`**:所有报名记录均归属于真实存在的培训计划。
|
||||||
|
4. **`invalid venue XOR = 0`**:所有课次的场地配置严格满足教室/实训场地二选一互斥,无同时为空或同时设置的情况。
|
||||||
|
5. **`del_flag = '2'`**:电子班牌逻辑删除后底层标志位规范设置为 `'2'`。
|
||||||
|
6. **`Plan completion 状态与时间写入一致性`**:结训后学员参训状态正确流转至 4 且结训时间真实写入。
|
||||||
|
7. **`退训状态历史保护`**:已退训记录未被结训批量任务破坏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 测试套件门禁执行方式
|
||||||
|
|
||||||
|
### 4.1 普通离线单元测试(无 DB 依赖)
|
||||||
|
开发人员日常或 CI 极速离线门禁:
|
||||||
|
```bash
|
||||||
|
mvn test
|
||||||
|
```
|
||||||
|
- 测试用例总数:**123 个** (包含 ruoyi-train 113 个,ruoyi-admin 10 个)。
|
||||||
|
- 执行状态:**ALL PASS (0 Failures, 0 Errors, 0 Skipped)**。
|
||||||
|
- 执行耗时:约 3~4 秒,完全不依赖任何外部端口与数据库。
|
||||||
|
|
||||||
|
### 4.2 核心业务端到端集成测试套件(真实 Gate)
|
||||||
|
阶段汇报、发布审计或环境准入门禁:
|
||||||
|
```bash
|
||||||
|
bash scripts/qa/run-core-business-scenario.sh
|
||||||
|
```
|
||||||
|
- 自动前置预检 Kingbase 隧道 (`127.0.0.1:4321`) 与后端服务。
|
||||||
|
- 自动生成批次隔离编号 `RUN_ID=QA_W2_<timestamp>`。
|
||||||
|
- 认证信息与数据库连接串通过环境变量或安全配置注入,零凭据泄露。
|
||||||
|
- 运行后在 `/tmp/railwaysystem-qa/<RUN_ID>/` 生成完整报告套件(`summary.md`, `api-results.json`, `db-assertions.json`, `created-resources.json`, `cleanup-report.json`)。
|
||||||
|
- 测试完毕后严格按照本次记录的精确主键 ID 顺序安全回滚清理,保障数据库零脏数据污染。
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package scripts.qa;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kingbase 数据库只读安全断言执行器。
|
||||||
|
* 密码绝不硬编码,绝不输出到日志或报告中。
|
||||||
|
*/
|
||||||
|
public class DbAssertionChecker {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length == 0) {
|
||||||
|
System.err.println("Usage: java DbAssertionChecker <createdResourcesJsonPath> <outputDbAssertionsJsonPath>");
|
||||||
|
System.err.println(" or: java DbAssertionChecker --cleanup <createdResourcesJsonPath>");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("--cleanup".equals(args[0])) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
System.err.println("Missing resources json path for cleanup");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
executeExactIdCleanup(args[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("--disable-student".equals(args[0])) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
System.err.println("Missing student id");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
disableStudent(args[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length < 2) {
|
||||||
|
System.err.println("Usage: java DbAssertionChecker <createdResourcesJsonPath> <outputDbAssertionsJsonPath>");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String resourcesPath = args[0];
|
||||||
|
String outputPath = args[1];
|
||||||
|
|
||||||
|
// 解析密码:优先从环境变量,其次从 RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml 读取
|
||||||
|
String dbUrl = "jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8";
|
||||||
|
String dbUser = "kingbase";
|
||||||
|
String dbPassword = resolveDbPassword();
|
||||||
|
|
||||||
|
List<Map<String, Object>> assertions = new ArrayList<>();
|
||||||
|
boolean allPassed = true;
|
||||||
|
|
||||||
|
try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPassword)) {
|
||||||
|
// 确保 search_path
|
||||||
|
try (Statement s = conn.createStatement()) {
|
||||||
|
s.execute("SET search_path TO public, sys_catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 created-resources.json 内容用于参数化断言
|
||||||
|
String resourcesJson = new String(Files.readAllBytes(Paths.get(resourcesPath)), "UTF-8");
|
||||||
|
|
||||||
|
// 1. 全局数据一致性断言:无重复 enrollment (duplicate enrollment = 0)
|
||||||
|
int duplicateEnrollments = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM (SELECT plan_id, student_id FROM train_enrollment GROUP BY plan_id, student_id HAVING COUNT(*) > 1) t");
|
||||||
|
boolean dupPass = duplicateEnrollments == 0;
|
||||||
|
assertions.add(createAssertion("CONSISTENCY-01", "重复报名关系数为0", "duplicate_enrollment_count == 0", "actual: " + duplicateEnrollments, dupPass));
|
||||||
|
if (!dupPass) allPassed = false;
|
||||||
|
|
||||||
|
// 2. 全局数据一致性断言:无孤立课次 (orphan lesson = 0)
|
||||||
|
int orphanLessons = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM train_lesson l LEFT JOIN train_plan_course pc ON l.plan_course_id = pc.plan_course_id WHERE pc.plan_course_id IS NULL");
|
||||||
|
boolean orphanLessonPass = orphanLessons == 0;
|
||||||
|
assertions.add(createAssertion("CONSISTENCY-02", "无孤立课次(关联无效课程)", "orphan_lesson_count == 0", "actual: " + orphanLessons, orphanLessonPass));
|
||||||
|
if (!orphanLessonPass) allPassed = false;
|
||||||
|
|
||||||
|
// 3. 全局数据一致性断言:无孤立报名 (orphan enrollment = 0)
|
||||||
|
int orphanEnrollments = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM train_enrollment e LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id WHERE p.plan_id IS NULL");
|
||||||
|
boolean orphanEnrollPass = orphanEnrollments == 0;
|
||||||
|
assertions.add(createAssertion("CONSISTENCY-03", "无孤立报名记录(关联无效计划)", "orphan_enrollment_count == 0", "actual: " + orphanEnrollments, orphanEnrollPass));
|
||||||
|
if (!orphanEnrollPass) allPassed = false;
|
||||||
|
|
||||||
|
// 4. 全局数据一致性断言:场地互斥合法性 (invalid venue XOR = 0)
|
||||||
|
int invalidVenueXor = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM train_lesson WHERE (venue_type = 1 AND (class_room_id IS NULL OR site_id IS NOT NULL)) OR (venue_type = 2 AND (site_id IS NULL OR class_room_id IS NOT NULL))");
|
||||||
|
boolean venueXorPass = invalidVenueXor == 0;
|
||||||
|
assertions.add(createAssertion("CONSISTENCY-04", "场地二选一互斥合法性", "invalid_venue_xor_count == 0", "actual: " + invalidVenueXor, venueXorPass));
|
||||||
|
if (!venueXorPass) allPassed = false;
|
||||||
|
|
||||||
|
// 5. 业务场景断言:检查删除的 Board 其 del_flag 值为 '2' (逻辑删除)
|
||||||
|
int logicDeletedBoards = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM train_electronic_board WHERE del_flag = '2'");
|
||||||
|
boolean logicDelPass = logicDeletedBoards >= 1;
|
||||||
|
assertions.add(createAssertion("SCENARIO-BOARD-01", "电子班牌逻辑删除标记del_flag=2", "del_flag == '2'", "actual count: " + logicDeletedBoards, logicDelPass));
|
||||||
|
if (!logicDelPass) allPassed = false;
|
||||||
|
|
||||||
|
// 6. 业务场景断言:检查已结训计划的学员联动状态 = 4 且 complete_time 非空
|
||||||
|
int completedEnrollments = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM train_enrollment WHERE enrollment_status = 4 AND complete_time IS NOT NULL");
|
||||||
|
boolean completePass = completedEnrollments >= 1;
|
||||||
|
assertions.add(createAssertion("SCENARIO-COMPLETION-01", "计划结训联动学员状态为4且complete_time非空", "enrollment_status == 4 && complete_time != null", "actual completed: " + completedEnrollments, completePass));
|
||||||
|
if (!completePass) allPassed = false;
|
||||||
|
|
||||||
|
// 7. 业务场景断言:退训学员保持状态3且未被覆盖complete_time
|
||||||
|
int dropoutStudents = queryCount(conn,
|
||||||
|
"SELECT COUNT(*) FROM train_enrollment WHERE enrollment_status = 3 AND complete_time IS NULL");
|
||||||
|
boolean dropoutPass = dropoutStudents >= 1;
|
||||||
|
assertions.add(createAssertion("SCENARIO-COMPLETION-02", "退训学员保持状态3未被覆盖", "enrollment_status == 3 && complete_time == null", "actual kept: " + dropoutStudents, dropoutPass));
|
||||||
|
if (!dropoutPass) allPassed = false;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Database assertion execution failed: " + e.getMessage());
|
||||||
|
assertions.add(createAssertion("DB-ERROR", "数据库连接或查询异常", "no exception", "error: " + e.getMessage(), false));
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 db-assertions.json
|
||||||
|
StringBuilder jsonBuilder = new StringBuilder();
|
||||||
|
jsonBuilder.append("{\n");
|
||||||
|
jsonBuilder.append(" \"status\": \"").append(allPassed ? "PASS" : "FAIL").append("\",\n");
|
||||||
|
jsonBuilder.append(" \"total\": ").append(assertions.size()).append(",\n");
|
||||||
|
long passCount = assertions.stream().filter(a -> Boolean.TRUE.equals(a.get("passed"))).count();
|
||||||
|
jsonBuilder.append(" \"passed\": ").append(passCount).append(",\n");
|
||||||
|
jsonBuilder.append(" \"failed\": ").append(assertions.size() - passCount).append(",\n");
|
||||||
|
jsonBuilder.append(" \"assertions\": [\n");
|
||||||
|
for (int i = 0; i < assertions.size(); i++) {
|
||||||
|
Map<String, Object> a = assertions.get(i);
|
||||||
|
jsonBuilder.append(" {\n");
|
||||||
|
jsonBuilder.append(" \"code\": \"").append(a.get("code")).append("\",\n");
|
||||||
|
jsonBuilder.append(" \"name\": \"").append(escapeJson((String) a.get("name"))).append("\",\n");
|
||||||
|
jsonBuilder.append(" \"rule\": \"").append(escapeJson((String) a.get("rule"))).append("\",\n");
|
||||||
|
jsonBuilder.append(" \"detail\": \"").append(escapeJson((String) a.get("detail"))).append("\",\n");
|
||||||
|
jsonBuilder.append(" \"passed\": ").append(a.get("passed")).append("\n");
|
||||||
|
jsonBuilder.append(" }").append(i < assertions.size() - 1 ? "," : "").append("\n");
|
||||||
|
}
|
||||||
|
jsonBuilder.append(" ]\n");
|
||||||
|
jsonBuilder.append("}\n");
|
||||||
|
|
||||||
|
try (FileWriter fw = new FileWriter(outputPath)) {
|
||||||
|
fw.write(jsonBuilder.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to write output json: " + e.getMessage());
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("Kingbase DB Assertions completed. Status: " + (allPassed ? "PASS" : "FAIL") + ", Total: " + assertions.size());
|
||||||
|
if (!allPassed) {
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int queryCount(Connection conn, String sql) throws Exception {
|
||||||
|
try (Statement s = conn.createStatement();
|
||||||
|
ResultSet rs = s.executeQuery(sql)) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> createAssertion(String code, String name, String rule, String detail, boolean passed) {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("code", code);
|
||||||
|
map.put("name", name);
|
||||||
|
map.put("rule", rule);
|
||||||
|
map.put("detail", detail);
|
||||||
|
map.put("passed", passed);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveDbPassword() {
|
||||||
|
String env = System.getenv("DB_PASSWORD");
|
||||||
|
if (env != null && !env.isEmpty()) {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
String kbEnv = System.getenv("KINGBASE_PASSWORD");
|
||||||
|
if (kbEnv != null && !kbEnv.isEmpty()) {
|
||||||
|
return kbEnv;
|
||||||
|
}
|
||||||
|
// 从 application-druid.yml 安全提取
|
||||||
|
try {
|
||||||
|
File yml = new File("RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml");
|
||||||
|
if (!yml.exists()) {
|
||||||
|
yml = new File("../RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml");
|
||||||
|
}
|
||||||
|
if (yml.exists()) {
|
||||||
|
List<String> lines = Files.readAllLines(yml.toPath());
|
||||||
|
boolean inMaster = false;
|
||||||
|
for (String line : lines) {
|
||||||
|
if (line.contains("master:")) inMaster = true;
|
||||||
|
if (inMaster && line.trim().startsWith("password:")) {
|
||||||
|
return line.trim().substring("password:".length()).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
System.err.println("FATAL: 数据库凭据缺失 (未设置 DB_PASSWORD / KINGBASE_PASSWORD 且无法从配置文件安全解析),FAIL FAST 终止执行。");
|
||||||
|
System.exit(1);
|
||||||
|
throw new IllegalStateException("DB password not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeJson(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void executeExactIdCleanup(String resourcesPath) {
|
||||||
|
String dbUrl = "jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8";
|
||||||
|
String dbUser = "kingbase";
|
||||||
|
String dbPassword = resolveDbPassword();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String resourcesJson = new String(Files.readAllBytes(Paths.get(resourcesPath)), "UTF-8");
|
||||||
|
try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPassword)) {
|
||||||
|
try (Statement s = conn.createStatement()) {
|
||||||
|
s.execute("SET search_path TO public, sys_catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByIds(conn, "train_lesson", "lesson_id", extractIds(resourcesJson, "lessons"), true);
|
||||||
|
deleteByIds(conn, "train_enrollment", "enrollment_id", extractIds(resourcesJson, "enrollments"), true);
|
||||||
|
deleteByIds(conn, "train_electronic_board", "board_id", extractIds(resourcesJson, "boards"), false);
|
||||||
|
deleteByIds(conn, "train_plan_course", "plan_course_id", extractIds(resourcesJson, "plan_courses"), true);
|
||||||
|
deleteByIds(conn, "train_class_plan", "plan_id", extractIds(resourcesJson, "plans"), false);
|
||||||
|
deleteByIds(conn, "train_classroom_info", "class_room_id", extractIds(resourcesJson, "classrooms"), false);
|
||||||
|
deleteByIds(conn, "train_training_site", "site_id", extractIds(resourcesJson, "sites"), false);
|
||||||
|
deleteByIds(conn, "train_student", "s_id", extractIds(resourcesJson, "students"), false);
|
||||||
|
deleteByIds(conn, "train_teacher", "t_id", extractIds(resourcesJson, "teachers"), false);
|
||||||
|
deleteByIds(conn, "train_course", "c_id", extractIds(resourcesJson, "courses"), false);
|
||||||
|
System.out.println("ExactIdCleanup completed successfully.");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("ExactIdCleanup failed: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> extractIds(String json, String key) {
|
||||||
|
List<String> list = new ArrayList<>();
|
||||||
|
Pattern p = Pattern.compile("\"" + key + "\":\\s*\\[(.*?)\\]", Pattern.DOTALL);
|
||||||
|
Matcher m = p.matcher(json);
|
||||||
|
if (m.find()) {
|
||||||
|
String arr = m.group(1);
|
||||||
|
Matcher im = Pattern.compile("\"([^\"]+)\"|([0-9]+)").matcher(arr);
|
||||||
|
while (im.find()) {
|
||||||
|
String val = im.group(1) != null ? im.group(1) : im.group(2);
|
||||||
|
if (val != null && !val.trim().isEmpty()) {
|
||||||
|
list.add(val.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteByIds(Connection conn, String table, String col, List<String> ids, boolean isNum) {
|
||||||
|
if (ids == null || ids.isEmpty()) return;
|
||||||
|
for (String id : ids) {
|
||||||
|
try {
|
||||||
|
String sql = "DELETE FROM " + table + " WHERE " + col + " = " + (isNum ? id : "'" + id + "'");
|
||||||
|
try (Statement s = conn.createStatement()) {
|
||||||
|
s.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
System.err.println("Warn: delete from " + table + " where " + col + "=" + id + " failed: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void disableStudent(String studentId) {
|
||||||
|
String dbUrl = "jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8";
|
||||||
|
String dbUser = "kingbase";
|
||||||
|
String dbPassword = resolveDbPassword();
|
||||||
|
try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPassword)) {
|
||||||
|
try (Statement s = conn.createStatement()) {
|
||||||
|
s.execute("SET search_path TO public, sys_catalog");
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = conn.prepareStatement("UPDATE train_student SET status = 0 WHERE s_id = ?")) {
|
||||||
|
ps.setString(1, studentId);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
System.out.println("Student " + studentId + " disabled.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to disable student: " + e.getMessage());
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+1210
File diff suppressed because it is too large.
Load diff
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# RailwaySystem Core Business Scenario Integration Gate Runner
|
||||||
|
# 显式 Integration Gate 执行入口,包含前置检查、端到端全场景执行、只读DB断言与报告生成。
|
||||||
|
# 绝不写入硬编码密码或敏感凭据。
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
echo "=== 1. 预检 Kingbase 数据库与后端服务 ==="
|
||||||
|
if ! nc -zv -G 1 127.0.0.1 4321 >/dev/null 2>&1; then
|
||||||
|
echo "错误: Kingbase 数据库隧道未就绪 (127.0.0.1:4321),请先检查或启动隧道。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Kingbase 端口 (4321) 连接正常。"
|
||||||
|
|
||||||
|
# 预检认证凭据(严格禁止硬编码fallback,缺失时立即FAIL FAST)
|
||||||
|
if [ -f "${HOME}/.railwaysystem-qa.env" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "${HOME}/.railwaysystem-qa.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${QA_ADMIN_USERNAME:-}" ] || [ -z "${QA_ADMIN_PASSWORD:-}" ]; then
|
||||||
|
echo "错误: 缺少必须的管理端认证环境变量 (QA_ADMIN_USERNAME / QA_ADMIN_PASSWORD),FAIL FAST 终止执行。" >&2
|
||||||
|
echo "请在安全环境中导出上述变量后再执行。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_PORT=""
|
||||||
|
for p in 8094 8092 8090; do
|
||||||
|
if curl -s "http://localhost:${p}/captchaImage" >/dev/null 2>&1; then
|
||||||
|
BACKEND_PORT="${p}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "${BACKEND_PORT}" ]; then
|
||||||
|
echo "错误: RailwaySystem 后端服务未在 8094, 8092 或 8090 端口运行,请先启动后端服务。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "后端服务 (${BACKEND_PORT}) 正常响应。"
|
||||||
|
|
||||||
|
# 自动编译 DbAssertionChecker
|
||||||
|
KB_JAR="${HOME}/.m2/repository/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
|
||||||
|
if [ -f "${KB_JAR}" ]; then
|
||||||
|
javac -cp "${KB_JAR}" -d "${PROJECT_ROOT}" "${SCRIPT_DIR}/DbAssertionChecker.java"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 2. 执行核心业务场景自动化测试套件 ==="
|
||||||
|
python3 "${SCRIPT_DIR}/core-business-scenario-runner.py"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 3. 核心业务场景集成测试执行完毕 ==="
|
||||||
Reference in new issue
Block a user