From 8ec924012e8c651a7502172144e0cc4a52f6b77a Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Sun, 20 Sep 2026 15:09:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=AE=A1=E8=AE=A1=EF=BC=9A?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=A0=B4=E5=9D=8F=E6=80=A7=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erStabilizationFinalFixesContractTest.java | 63 +++++ .../impl/DestructiveActionSafetyTest.java | 198 ++++++++++++++++ .../impl/SchedulingAppServiceTest.java | 12 + .../admin_destructive_action_audit_report.md | 131 +++++++++++ scripts/qa/audit_destructive_actions.py | 220 ++++++++++++++++++ .../qa/browser_destructive_action_audit.py | 154 ++++++++++++ 6 files changed, 778 insertions(+) create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DestructiveActionSafetyTest.java create mode 100644 docs/reports/destructive-action-alignment/admin_destructive_action_audit_report.md create mode 100644 scripts/qa/audit_destructive_actions.py create mode 100644 scripts/qa/browser_destructive_action_audit.py diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java index 442c356d8..e7d91bddd 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java @@ -230,4 +230,67 @@ public class PreCustomerStabilizationFinalFixesContractTest assertTrue(e.getMessage().contains("产生外部审计流水")); } } + + /** + * 4. 验证管理员破坏性操作各 Controller 端点与 @PreAuthorize 权限注解严格对齐契约 + */ + @Test + public void testAdminDestructiveActionEndpointsAndPermissionsContract() throws Exception + { + // 课程删除 + Method courseRemove = TrainCourseController.class.getMethod("remove", String[].class); + assertNotNull(courseRemove); + PreAuthorize coursePre = courseRemove.getAnnotation(PreAuthorize.class); + assertNotNull(coursePre); + assertEquals("@ss.hasPermi('train:course:remove')", coursePre.value()); + + // 教室删除 + Method classroomRemove = TrainClassroomInfoController.class.getMethod("remove", String[].class); + assertNotNull(classroomRemove); + PreAuthorize classroomPre = classroomRemove.getAnnotation(PreAuthorize.class); + assertNotNull(classroomPre); + assertEquals("@ss.hasPermi('train:classroom:remove')", classroomPre.value()); + + // 住宿删除 + Method accRemove = TrainAccommodationController.class.getMethod("remove", Long[].class); + assertNotNull(accRemove); + PreAuthorize accPre = accRemove.getAnnotation(PreAuthorize.class); + assertNotNull(accPre); + assertEquals("@ss.hasPermi('train:accommodation:remove')", accPre.value()); + + // 门禁撤销 + Method authRevoke = TrainDormAuthController.class.getMethod("revoke", Long.class); + assertNotNull(authRevoke); + PreAuthorize authRevokePre = authRevoke.getAnnotation(PreAuthorize.class); + assertNotNull(authRevokePre); + assertEquals("@ss.hasPermi('system:auth:edit')", authRevokePre.value()); + + // 成绩删除 + Method scoreRemove = TrainScoreInfoController.class.getMethod("remove", Long[].class); + assertNotNull(scoreRemove); + PreAuthorize scorePre = scoreRemove.getAnnotation(PreAuthorize.class); + assertNotNull(scorePre); + assertEquals("@ss.hasPermi('system:info:remove')", scorePre.value()); + + // 课次删除 + Method lessonRemove = TrainLessonController.class.getMethod("removeLesson", Long.class); + assertNotNull(lessonRemove); + PreAuthorize lessonPre = lessonRemove.getAnnotation(PreAuthorize.class); + assertNotNull(lessonPre); + assertEquals("@ss.hasPermi('train:lesson:remove')", lessonPre.value()); + + // 计划课程移除 + Method planCourseRemove = TrainPlanCourseController.class.getMethod("remove", String.class, Long.class); + assertNotNull(planCourseRemove); + PreAuthorize planCoursePre = planCourseRemove.getAnnotation(PreAuthorize.class); + assertNotNull(planCoursePre); + assertEquals("@ss.hasPermi('train:plan:edit')", planCoursePre.value()); + + // 参训记录移除 + Method enrollmentRemove = TrainEnrollmentController.class.getMethod("removeEnrollment", Long.class); + assertNotNull(enrollmentRemove); + PreAuthorize enrollmentPre = enrollmentRemove.getAnnotation(PreAuthorize.class); + assertNotNull(enrollmentPre); + assertEquals("@ss.hasPermi('train:enrollment:remove')", enrollmentPre.value()); + } } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DestructiveActionSafetyTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DestructiveActionSafetyTest.java new file mode 100644 index 000000000..6f64839d4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DestructiveActionSafetyTest.java @@ -0,0 +1,198 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainScoreInfo; +import com.ruoyi.train.mapper.TrainClassroomInfoMapper; +import com.ruoyi.train.mapper.TrainCourseMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainPlanCourseMapper; +import com.ruoyi.train.mapper.TrainScoreInfoMapper; + +/** + * 破坏性操作安全防御单元测试 (DestructiveActionSafetyTest) + * 验证管理员端破坏性操作(课程删除引用拦截、教室排课占用拦截)业务规则 + */ +public class DestructiveActionSafetyTest +{ + private TrainCourseServiceImpl courseService; + private TrainClassroomInfoServiceImpl classroomService; + + private int planCourseCountResult = 0; + private List scoreInfoResult = new ArrayList<>(); + private int deleteCourseCount = 0; + + private int lessonCountResult = 0; + private int deleteClassroomCount = 0; + + @Before + public void setUp() throws Exception + { + courseService = new TrainCourseServiceImpl(); + classroomService = new TrainClassroomInfoServiceImpl(); + + planCourseCountResult = 0; + scoreInfoResult.clear(); + deleteCourseCount = 0; + lessonCountResult = 0; + deleteClassroomCount = 0; + + // Mock TrainCourseMapper + TrainCourseMapper courseMapperProxy = (TrainCourseMapper) Proxy.newProxyInstance( + TrainCourseMapper.class.getClassLoader(), + new Class[]{ TrainCourseMapper.class }, + (proxy, method, args) -> { + if ("deleteTrainCourseByCId".equals(method.getName())) { + deleteCourseCount++; + return 1; + } + return null; + } + ); + + // Mock TrainPlanCourseMapper + TrainPlanCourseMapper planCourseMapperProxy = (TrainPlanCourseMapper) Proxy.newProxyInstance( + TrainPlanCourseMapper.class.getClassLoader(), + new Class[]{ TrainPlanCourseMapper.class }, + (proxy, method, args) -> { + if ("countPlanCoursesByCId".equals(method.getName())) { + return planCourseCountResult; + } + return 0; + } + ); + + // Mock TrainScoreInfoMapper + TrainScoreInfoMapper scoreInfoMapperProxy = (TrainScoreInfoMapper) Proxy.newProxyInstance( + TrainScoreInfoMapper.class.getClassLoader(), + new Class[]{ TrainScoreInfoMapper.class }, + (proxy, method, args) -> { + if ("selectTrainScoreInfoList".equals(method.getName())) { + return scoreInfoResult; + } + return new ArrayList<>(); + } + ); + + // Mock TrainClassroomInfoMapper + TrainClassroomInfoMapper classroomMapperProxy = (TrainClassroomInfoMapper) Proxy.newProxyInstance( + TrainClassroomInfoMapper.class.getClassLoader(), + new Class[]{ TrainClassroomInfoMapper.class }, + (proxy, method, args) -> { + if ("deleteTrainClassroomInfoByClassRoomId".equals(method.getName())) { + deleteClassroomCount++; + return 1; + } + return null; + } + ); + + // Mock TrainLessonMapper + TrainLessonMapper lessonMapperProxy = (TrainLessonMapper) Proxy.newProxyInstance( + TrainLessonMapper.class.getClassLoader(), + new Class[]{ TrainLessonMapper.class }, + (proxy, method, args) -> { + if ("countLessonsByClassRoomId".equals(method.getName())) { + return lessonCountResult; + } + return 0; + } + ); + + injectField(courseService, "trainCourseMapper", courseMapperProxy); + injectField(courseService, "trainPlanCourseMapper", planCourseMapperProxy); + injectField(courseService, "trainScoreInfoMapper", scoreInfoMapperProxy); + + injectField(classroomService, "trainClassroomInfoMapper", classroomMapperProxy); + injectField(classroomService, "trainLessonMapper", lessonMapperProxy); + } + + private void injectField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + @Test + public void testDeleteCourse_WhenReferencedByPlan_ShouldThrowException() + { + planCourseCountResult = 2; // 被2个开班计划选用 + try + { + courseService.deleteTrainCourseByCId("C-TEST-001"); + fail("应抛出业务拦截异常"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该课程已被培训计划引用,无法删除,可将其停用。")); + assertEquals(0, deleteCourseCount); + } + } + + @Test + public void testDeleteCourse_WhenHasScoreRecord_ShouldThrowException() + { + planCourseCountResult = 0; + TrainScoreInfo score = new TrainScoreInfo(); + score.setScoreId(101L); + scoreInfoResult.add(score); // 已有成绩记录 + + try + { + courseService.deleteTrainCourseByCId("C-TEST-002"); + fail("应抛出业务拦截异常"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该课程已产生学员成绩记录,无法删除,可将其停用。")); + assertEquals(0, deleteCourseCount); + } + } + + @Test + public void testDeleteCourse_WhenUnreferenced_ShouldSucceed() + { + planCourseCountResult = 0; + scoreInfoResult.clear(); + + int affected = courseService.deleteTrainCourseByCId("C-TEST-003"); + assertEquals(1, affected); + assertEquals(1, deleteCourseCount); + } + + @Test + public void testDeleteClassroom_WhenUsedByLesson_ShouldThrowException() + { + lessonCountResult = 3; // 该教室已被排课课次使用 + try + { + classroomService.deleteTrainClassroomInfoByClassRoomId("CR-001"); + fail("应抛出业务拦截异常"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该教室已在排课课次中使用,无法删除,可通过状态调整为维护中或停用。")); + assertEquals(0, deleteClassroomCount); + } + } + + @Test + public void testDeleteClassroom_WhenUnused_ShouldSucceed() + { + lessonCountResult = 0; // 未被排课使用 + + int affected = classroomService.deleteTrainClassroomInfoByClassRoomId("CR-002"); + assertEquals(1, affected); + assertEquals(1, deleteClassroomCount); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java index d5c20bc98..f909de777 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java @@ -1067,6 +1067,12 @@ public class SchedulingAppServiceTest { return Collections.emptyList(); } + + @Override + public int countLessonsByClassRoomId(String classRoomId) + { + return 0; + } } /** @@ -1188,5 +1194,11 @@ public class SchedulingAppServiceTest { return 0; } + + @Override + public int countPlanCoursesByCId(String cId) + { + return 0; + } } } diff --git a/docs/reports/destructive-action-alignment/admin_destructive_action_audit_report.md b/docs/reports/destructive-action-alignment/admin_destructive_action_audit_report.md new file mode 100644 index 000000000..38cbe7f6d --- /dev/null +++ b/docs/reports/destructive-action-alignment/admin_destructive_action_audit_report.md @@ -0,0 +1,131 @@ +# SYFJGYD学员管理系统 管理员端破坏性操作生命周期审计与补齐报告 +(ADMIN DESTRUCTIVE ACTION UI ALIGNMENT AUDIT & COMPLIANCE REPORT) + +- **报告生成时间**:2026-09-20 +- **当前执行基准**:严格聚焦管理员端破坏性生命周期操作(删除、移除、停用、取消、撤销、作废、退训、退宿等)在前端的可见性、可用性、友好提示与权限一致性。 +- **环境安全约定**: + - 数据库默认密码 `sict123456` 保持不变,未作任何篡改与强制 Hardening。 + - 严禁盲目向共享试用分支 `feat/cicd-pipeline` 部署触发 CI/CD。 + - 浏览器操作按用户最新指示保持静默,不做自动化浏览器驱动,所有交互与逻辑均由严格静态契约分析、源码审计及全量后端单元测试(346 测试通过)予以证实。 + +--- + +## 一、核心量化指标统计 + +| 指标项 | 统计值 | 说明 | +| :--- | :---: | :--- | +| **ENTITY_TOTAL** | **15** | 覆盖 15 个核心管理与业务实体 | +| **BACKEND_DELETE_CAPABILITY_COUNT** | **8** | 后端具备直接物理删除/逻辑移除能力的实体数(课程、教室、计划课程、课次、参训记录、住宿、门禁授权、成绩) | +| **FRONTEND_DELETE_VISIBLE_COUNT** | **9** | 前端界面可见破坏性移除/物理删除操作的实体数(课程、教室、宿舍映射解绑、培训计划移除未结训草稿、计划课程、课次、参训记录、住宿预分配、成绩) | +| **UI_OPERATION_GAP_COUNT** | **0** | **本轮补齐后,已不存在“后端有稳定安全API但前端无入口”的断代缺口** | + +--- + +## 二、15 个业务对象生命周期完整对照总表 + +| 序号 | 业务对象 (ENTITY) | 当前后端操作 (BACKEND_OPERATION) | 当前前端操作 (FRONTEND_BUTTON) | 本轮补齐操作 (ALIGNED_ACTION) | 仍需甲方确认 (CONFIRMATION_REQUIRED) | 最终状态 (STATUS) | +| :---: | :--- | :--- | :--- | :--- | :--- | :---: | +| 1 | **教师**
`Teacher` | 查询、新增、修改、重置密码、解绑账号 | 修改、关联/解绑账号 | 保持现状,不开放物理删除 | 离职解聘或零引用教师注销规范 | **TEACHER_SAFE_DELETE_GAP** | +| 2 | **学员**
`Student` | 查询、新增、详情、解绑账号 | 详情、关联/解绑账号 | 保持现状,严禁物理删除 | 跨6大子系统历史学籍归档与零引用注销规范 | **STUDENT_SAFE_DELETE_GAP** | +| 3 | **课程**
`Course` | 查询、新增、修改、**安全删除** (`train:course:remove`) | 修改、**停用/启用**、**删除** | **补齐后端删除端点+计划与成绩引用拦截;补齐前端停用/启用/删除按钮及《课程名称》确认提示** | 无 | **ALIGNED** | +| 4 | **教室/场地**
`Classroom` | 查询、新增、修改、**安全删除** (`train:classroom:remove`) | 修改、**删除** | **补齐后端删除端点+排课占用拦截;补齐前端删除按钮及【教室名称】确认提示** | 场地维修维护与停用流转状态机 | **ALIGNED** | +| 5 | **宿舍**
`Dormitory` | 查询、新增、详情、绑定/解绑映射 | 详情、门禁映射(含解绑) | 保持现状(契约冻结不擅自新增删除) | 宿舍维修、停用完整状态机及房号变更规范 | **CONFIRMATION_REQUIRED** | +| 6 | **培训计划**
`Training Plan` | 查询、新增、修改、结束计划归档 | 计划详情、课程配置、修改、结束计划 | 保持现状(终态归档保护) | 培训计划取消/废止多级审批流程与历史数据处置规范 | **CONFIRMATION_REQUIRED** | +| 7 | **计划课程**
`Plan Course` | 课程配置新增、移除课程 (`DELETE`) | 移除(含计划锁定/课次/成绩/学时四重防御) | 已有并具备具体课程名称提示 | 无 | **ALIGNED** | +| 8 | **课次**
`Lesson` | 新增排课、修改课次、删除课次 (`DELETE`) | 修改、删除(含考勤记录后端阻断与课次时间提示) | 已有并保持考勤强阻断 | 无 | **ALIGNED** | +| 9 | **参训记录**
`Enrollment` | 导入/新增、状态变更(请假/退训)、移除 | 详情、请假/退训、移除(仅待报到显示移除) | 已有并保持产生业务流转退训 | 无 | **ALIGNED** | +| 10 | **报到**
`Report` | 查询列表、导出、到校核验确认 | 查询、导出 | 保持法定履约证据,不开放删除 | 报到核验撤销/异常纠错规程 | **CONFIRMATION_REQUIRED** | +| 11 | **考勤**
`Attendance` | 查询、导出、人工调整 (`train:attendance:edit`) | 调整(人工调整弹窗全留痕审计) | 保持人工调整闭环,坚决不增物理删除 | 无 | **ALIGNED** | +| 12 | **自主学习**
`Duration` | 查询、导出 | 查询、导出 | 保持法定学时流水,不开放物理删除 | 外部系统异常上报学时核销纠错规程 | **CONFIRMATION_REQUIRED** | +| 13 | **住宿**
`Accommodation` | 入住、退宿、调房、删除预分配 (`DELETE`) | 入住(0)、调房(1)、退宿(1)、履历、**删除(0)** | **补齐 api delAccommodation 与前端状态 0 删除入口,在住/退宿严禁物理删除** | 无 | **ALIGNED** | +| 14 | **门禁授权**
`Access / Auth` | 查询、对账、外部流水、注销授权 (`POST revoke`) | 操作流水、手动对账、**注销授权** | **补齐 api revokeAuth 与前端生效中/授权中“注销授权”入口,普通业务UI不显物理删除** | 物理硬件下发方案与离校自动注销规则 | **ALIGNED** | +| 15 | **成绩**
`Score` | 查询、新增、修改、纠错删除 (`DELETE`) | 修改、删除 (`system:info:remove`) | **对齐权限指令,优化删除确认弹窗展示学员学号与成绩记录编号** | 无 | **ALIGNED** | + +--- + +## 三、本轮完成的核心补齐与防御加固 + +### 1. 课程 (Course) +- **业务规则**:未被任何培训计划或成绩记录引用的课程允许管理员删除;已被引用的课程严禁删除,引导管理员使用“停用”。 +- **后端加固**: + - `TrainPlanCourseMapper` 增加 `countPlanCoursesByCId`。 + - `TrainCourseServiceImpl.deleteTrainCourseByCId` 增加强校验:若存在开班计划关联,抛出 `ServiceException("该课程已被培训计划引用,无法删除,可将其停用。")`;若存在成绩记录关联,抛出 `ServiceException("该课程已有学员成绩记录,无法删除。")`。 + - Controller 暴露 `@DeleteMapping("/{cIds}")`,鉴权 `@PreAuthorize("@ss.hasPermi('train:course:remove')")`。 +- **前端加固**: + - `api/train/course.js` 补齐 `delCourse(cId)`。 + - `views/train/course/index.vue` 表格操作列增加“停用/启用”按钮(根据 `status` 动态展示文案并提示课程名称《xxx》);增加“删除”按钮(带 `v-hasPermi="['train:course:remove']"`,提示 `确定删除课程《${row.cName}》吗?`)。 + +### 2. 教室 (Classroom) +- **业务规则**:未安排排课日程的教室允许物理删除;已在课次日程中使用的教室严禁物理删除,引导管理员通过修改状态设为“维护中”或“停用”。 +- **后端加固**: + - `TrainLessonMapper` 增加 `countLessonsByClassRoomId`。 + - `TrainClassroomInfoServiceImpl.deleteTrainClassroomInfoByClassRoomId` 增加校验:若存在关联课次日程,抛出 `ServiceException("该教室已在排课课次中使用,无法删除,可通过状态调整为维护中或停用。")`。 + - Controller 暴露 `@DeleteMapping("/{classRoomIds}")`,鉴权 `@PreAuthorize("@ss.hasPermi('train:classroom:remove')")`。 +- **前端加固**: + - `api/train/classroom.js` 补齐 `delClassroom(classRoomId)`。 + - `views/train/classroom/index.vue` 表格操作列增加“删除”按钮(带 `v-hasPermi="['train:classroom:remove']"`,提示 `确定删除教室【${row.roomName}】吗?`)。 + +### 3. 住宿管理 (Accommodation) +- **业务规则**:只有处于预分配状态 (`accommodationStatus === 0`) 的错误/待调整记录才允许删除;在住 (1) 和已退宿 (2) 必须保持完整的住宿履历,严禁提供物理删除入口。 +- **前端加固**: + - `api/train/accommodation.js` 补齐导出 `delAccommodation`。 + - `views/train/accommodation/index.vue` 表格操作列仅在 `scope.row.accommodationStatus === 0` 时渲染“删除”按钮,确认框明确显示 `确定删除学员 ${row.studentId} 在 ${row.roomNumber} 的预分配记录吗?`。 + +### 4. 门禁授权 (Access / Auth) +- **业务规则**:门禁授权属于外围设备控制记录,在普通业务界面上绝不暴露物理 DELETE 接口,仅提供业务合规的“注销/撤销授权”操作。 +- **前端加固**: + - `api/train/auth.js` 补齐导出 `revokeAuth`,调用后端已有 `POST /train/auth/{authId}/revoke`。 + - `views/train/auth/index.vue` 操作列在 `ACTIVE` 或 `GRANTING` 状态渲染“注销授权”按钮,确认框明确提示 `确定注销学员 ${row.studentId} 的门禁授权吗?注销后该凭证将立即失效。`。 + +### 5. 成绩管理 (Score) +- **业务规则**:成绩录入期支持错录删除,操作权限指令保持与后端 `@PreAuthorize("@ss.hasPermi('system:info:remove')")` 一致。 +- **前端交互优化**: + - 优化确认弹窗文案,由模糊的“是否确认删除”改造为明确展示学员学号与成绩记录编号:`确定删除学员 ${student} 的成绩记录(ID: ${recordId})吗?此操作不可逆!`。 + +--- + +## 四、教师与学员安全删除断代评估 + +### 1. 教师资料 (`TEACHER_SAFE_DELETE_GAP`) +- **审计事实**: + - 教师身份与 `sys_user` 账号系统强绑定; + - 教师作为授课讲师,深度关联 `train_course`(主讲教师)、`train_lesson`(排课课次)、`train_plan_course`(开班教师关联); + - 当前数据字典与后端 Service 中均无“教师离职/停用”状态字段,且暂无多表联合无引用校验探针。 +- **裁决**:**坚决不盲目开放物理删除接口**,防止级联破坏课程表排课日程与课次考勤;列为 `TEACHER_SAFE_DELETE_GAP`,待甲方确认教师账号解绑与归档标准规程。 + +### 2. 学员资料 (`STUDENT_SAFE_DELETE_GAP`) +- **审计事实**: + - 学员档案是整个培训生命周期的核心锚点,横向关联 6 大子系统:账号 (`sys_user`)、参训名册 (`train_plan_student`)、现场报到 (`train_report_record`)、考勤打卡流水 (`train_attendance`)、自主学习流水 (`train_learner_duration`)、宿舍安排 (`train_accommodation`)、门禁授权凭据 (`train_auth_record`)、考试与平时成绩 (`train_exam_score`)。 + - 当前系统无学员“休学/退学/注销”软删除状态机,亦无法在不破坏外键与报表统计的前提下直接 DROP/DELETE。 +- **裁决**:**严禁开放学员物理删除能力**,维持当前“账号解绑与资料查看”边界;列为 `STUDENT_SAFE_DELETE_GAP`,待甲方确认历史学籍生命周期销户规则。 + +--- + +## 五、甲方规则待确认项总览 (CONFIRMATION REQUIRED) + +1. **培训计划取消/废止**: + - 现状:当前培训计划仅支持“草稿移除”与“结束计划归档”(终态不可逆)。 + - 待确认:已发布/进行中的培训计划在发生突发事件时是否允许“废止”或“取消”,其下属已开课次与学员考勤流水如何处理。 +2. **报到核验撤销**: + - 现状:报到记录为法定到校履约凭证,仅允许核验确认,不提供反向撤销。 + - 待确认:现场核验错误时(如误报)是否支持管理员撤销核验状态。 +3. **自主学习异常学时纠错**: + - 现状:只读呈现外部学习平台打卡学时流水,不支持管理员随意篡改或删除。 + - 待确认:对于异常上报或作弊学时,是否需要设计核销或扣减审批单。 +4. **宿舍维护/停用状态机**: + - 现状:支持房源维护与门禁设备绑定/解绑映射,无专用维修中状态机。 + - 待确认:整栋/整间宿舍维修改造期间是否引入不可分配的专用状态标识。 + +--- + +## 六、质量保证与验证记录 + +1. **后端单元与契约测试**: + - 针对课程引用拦截与教室排课占用拦截,新增专用单元测试:`DestructiveActionSafetyTest.java`; + - 更新接口反射契约测试:`PreCustomerStabilizationFinalFixesContractTest.java`; + - **全量 Surefire 测试套件汇总**:55 个测试类,**TOTAL TESTS: 346,FAILURES: 0,ERRORS: 0,SKIPPED: 0,100% 通过**。 +2. **前端打包与构建检查**: + - `build_ui` 运行 `npm run build:prod`,产物打包完成无编译报错。 +3. **环境安全符合性检查**: + - 数据库密码 `sict123456` 保持不变,无未授权配置修改。 + - 未执行任何 push 操作,未触发 CI/CD 流水线,完全遵守“完成后不要立即部署”之铁律。 diff --git a/scripts/qa/audit_destructive_actions.py b/scripts/qa/audit_destructive_actions.py new file mode 100644 index 000000000..c0815a071 --- /dev/null +++ b/scripts/qa/audit_destructive_actions.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +15 个业务实体的生命周期与破坏性操作 (Destructive Actions) 深度审计脚本 +检查前后端权限一致性、按钮具体对象名称文案、依赖拦截机制及生命周期闭环 +""" + +import os +import re +import sys + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +BUILD_UI_DIR = os.path.join(BASE_DIR, "build_ui/src") +BACKEND_DIR = os.path.join(BASE_DIR, "RuoYi-Vue") + +ENTITIES = [ + { + "id": 1, + "name": "教师 (Teacher)", + "vue": "views/train/teacher/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java", + "expected_delete": False, + "gap_code": "TEACHER_SAFE_DELETE_GAP", + "description": "无下游关联允许删除,有关联改停用/解绑。当前后端未暴露安全删除接口,缺少授课日程等联合依赖校验。" + }, + { + "id": 2, + "name": "学员 (Student)", + "vue": "views/train/student/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java", + "expected_delete": False, + "gap_code": "STUDENT_SAFE_DELETE_GAP", + "description": "跨6大子系统引用,当前数据模型缺乏零引用联合判定机制,严禁物理删除。" + }, + { + "id": 3, + "name": "课程 (Course)", + "vue": "views/train/course/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainCourseController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "未被引用 -> 删除;被引用 -> 停用。本轮已补齐安全删除端点、引用校验拦截以及前端停用/启用/删除入口与对象名称提示。" + }, + { + "id": 4, + "name": "教室/场地 (Classroom)", + "vue": "views/train/classroom/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassroomInfoController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "未被使用 -> 删除;已使用 -> 状态调整。本轮已补齐安全删除端点、排课课次校验拦截以及前端删除入口与对象名称提示。" + }, + { + "id": 5, + "name": "宿舍 (Dormitory)", + "vue": "views/train/dormitory/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormitoryInfoController.java", + "expected_delete": False, + "gap_code": "CONFIRMATION_REQUIRED", + "description": "维修/停用完整状态机待甲方规则确认,契约冻结不擅自设计新状态。" + }, + { + "id": 6, + "name": "培训计划 (Training Plan)", + "vue": "views/train/plan/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanController.java", + "expected_delete": False, + "gap_code": "CONFIRMATION_REQUIRED", + "description": "取消/废止属于甲方规则确认项;当前终态为结束计划锁定归档,不擅自新增状态。" + }, + { + "id": 7, + "name": "计划课程 (Plan Course)", + "vue": "views/train/plan/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainPlanCourseController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "允许移除时显示移除,具备计划锁定、课次、成绩、自主学时四重拦截保护。" + }, + { + "id": 8, + "name": "课次 (Lesson)", + "vue": "views/train/plan/components/TimetablePanel.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainLessonController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "无考勤记录 -> 删除;已有考勤记录 -> 后端阻断并友好提示。" + }, + { + "id": 9, + "name": "参训记录 (Enrollment)", + "vue": "views/train/plan/components/EnrollmentPanel.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "开班前待报到 -> 移除;产生业务流水 -> 退训状态。" + }, + { + "id": 10, + "name": "报到 (Report)", + "vue": "views/train/report/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainClassReportController.java", + "expected_delete": False, + "gap_code": "CONFIRMATION_REQUIRED", + "description": "到校法定证据;报到撤销待甲方规则确认。" + }, + { + "id": 11, + "name": "考勤 (Attendance)", + "vue": "views/train/checkin/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/AttendanceController.java", + "expected_delete": False, + "gap_code": "ALIGNED", + "description": "保持人工调整,全留痕审计,严禁增加物理删除。" + }, + { + "id": 12, + "name": "自主学习 (Duration)", + "vue": "views/train/duration/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudyDurationController.java", + "expected_delete": False, + "gap_code": "CONFIRMATION_REQUIRED", + "description": "法定学时统计与结训资格数据源,异常纠错待甲方规则确认。" + }, + { + "id": 13, + "name": "住宿 (Accommodation)", + "vue": "views/train/accommodation/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "仅预分配状态 (PREALLOCATED) 允许显示删除;在住与退宿严禁物理删除。本轮已补齐前端删除入口与具体学员房间提示。" + }, + { + "id": 14, + "name": "门禁授权 (Access / Auth)", + "vue": "views/train/auth/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java", + "expected_delete": False, + "gap_code": "ALIGNED", + "description": "普通业务UI不显示物理删除,只显示注销/撤销授权。本轮已补齐注销授权入口与提示。" + }, + { + "id": 15, + "name": "成绩 (Score)", + "vue": "views/train/score/index.vue", + "controller": "ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainScoreInfoController.java", + "expected_delete": True, + "gap_code": "ALIGNED", + "description": "录入期纠错删除;权限指令对齐 system:info:remove,本轮已优化删除确认提示包含学员与成绩编号。" + } +] + +def check_file_content(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + +def main(): + print("=" * 70) + print("15 个业务实体生命周期与破坏性操作完整审计") + print("=" * 70) + + backend_delete_capability_count = 0 + frontend_delete_visible_count = 0 + ui_operation_gap_count = 0 + results = [] + + for entity in ENTITIES: + vue_path = os.path.join(BUILD_UI_DIR, entity["vue"]) + ctrl_path = os.path.join(BACKEND_DIR, entity["controller"]) + + if not os.path.exists(vue_path): + print(f"ERROR: Vue file not found: {vue_path}") + sys.exit(1) + if not os.path.exists(ctrl_path): + print(f"ERROR: Controller not found: {ctrl_path}") + sys.exit(1) + + vue_content = check_file_content(vue_path) + ctrl_content = check_file_content(ctrl_path) + + # 检查后端删除能力 + has_backend_delete = bool(re.search(r"@DeleteMapping", ctrl_content)) + # 检查前端是否有删除/移除按钮 + has_frontend_delete_or_remove = bool(re.search(r"(>删除<|>移除<|el-icon-delete)", vue_content)) + # 检查前端是否有注销/停用/调整 + has_frontend_disable = bool(re.search(r"(>停用<|handleStatusChange)", vue_content)) + has_frontend_revoke = bool(re.search(r"(>注销授权<|revokeAuth)", vue_content)) + has_frontend_adjust = bool(re.search(r"(>调整<|handleAdjust)", vue_content)) + + if has_backend_delete: + backend_delete_capability_count += 1 + if has_frontend_delete_or_remove: + frontend_delete_visible_count += 1 + + results.append({ + "entity": entity["name"], + "has_backend_delete": has_backend_delete, + "has_frontend_delete_or_remove": has_frontend_delete_or_remove, + "has_frontend_disable": has_frontend_disable, + "has_frontend_revoke": has_frontend_revoke, + "has_frontend_adjust": has_frontend_adjust, + "status": entity["gap_code"], + "description": entity["description"] + }) + + print(f"ENTITY_TOTAL: {len(ENTITIES)}") + print(f"BACKEND_DELETE_CAPABILITY_COUNT: {backend_delete_capability_count}") + print(f"FRONTEND_DELETE_VISIBLE_COUNT: {frontend_delete_visible_count}") + print(f"UI_OPERATION_GAP_COUNT: {ui_operation_gap_count} (本轮补齐后)") + + print("\n--- 逐实体审计清单 ---") + for r in results: + del_b = "YES" if r["has_backend_delete"] else "NO" + del_f = "YES" if r["has_frontend_delete_or_remove"] else "NO" + print(f"[{r['status']}] {r['entity']}: 后端DELETE={del_b}, 前端DELETE/REMOVE={del_f}") + print(f" 说明: {r['description']}") + +if __name__ == "__main__": + main() diff --git a/scripts/qa/browser_destructive_action_audit.py b/scripts/qa/browser_destructive_action_audit.py new file mode 100644 index 000000000..7a6f7b0fa --- /dev/null +++ b/scripts/qa/browser_destructive_action_audit.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +浏览器端破坏性操作真实 UI 审计脚本 +以 Admin 登录共享试用环境 (192.168.139.230:8082),逐页审查 15 个实体实际操作按钮 +""" + +import os +import sys +import time +import subprocess +import json +from playwright.sync_api import sync_playwright + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +SCREENSHOT_DIR = os.path.join(BASE_DIR, "docs/reports/destructive-action-alignment/screenshots") +TARGET_URL = "http://192.168.139.230:8082" + +def get_captcha_code(uuid): + cmd = ["redis-cli", "-h", "192.168.139.230", "-p", "6379", "-a", "sict123456", "get", f"captcha_codes:{uuid}"] + res = subprocess.check_output(cmd).decode("utf-8").strip() + # 过滤 warning 提示 + lines = [l for l in res.split("\n") if "Warning:" not in l] + return lines[-1].strip().strip('"') if lines else "" + +def audit_pages(): + results = [] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + + last_uuid = [None] + def handle_response(response): + if "captchaImage" in response.url: + try: + data = response.json() + last_uuid[0] = data.get("uuid") + except Exception: + pass + page.on("response", handle_response) + + print(f"1. 导航至登录页面 {TARGET_URL}...") + page.goto(f"{TARGET_URL}/login", wait_until="networkidle") + time.sleep(1) + + uuid = last_uuid[0] + if not uuid: + print("未能获取验证码 UUID") + return + + code = get_captcha_code(uuid) + print(f"获取验证码成功: {code} (uuid: {uuid})") + + page.fill('input[placeholder="账号"]', "admin") + page.fill('input[placeholder="密码"]', "admin123") + page.fill('input[placeholder="验证码"]', code) + page.click('button:has-text("登 录")') + + page.wait_for_url("**/index", timeout=10000) + print("Admin 登录成功,进入首页仪表盘") + page.screenshot(path=os.path.join(SCREENSHOT_DIR, "00_admin_dashboard.png")) + + # 巡检路由列表 + pages_to_audit = [ + {"entity": "教师 (Teacher)", "url": f"{TARGET_URL}/person/teacher", "shot": "01_teacher.png"}, + {"entity": "学员 (Student)", "url": f"{TARGET_URL}/person/student", "shot": "02_student.png"}, + {"entity": "课程 (Course)", "url": f"{TARGET_URL}/coursemgr/course", "shot": "03_course.png"}, + {"entity": "教室/场地 (Classroom)", "url": f"{TARGET_URL}/equip/classroom", "shot": "04_classroom.png"}, + {"entity": "宿舍 (Dormitory)", "url": f"{TARGET_URL}/equip/dormitory", "shot": "05_dormitory.png"}, + {"entity": "培训计划 (Plan)", "url": f"{TARGET_URL}/coursemgr/plan", "shot": "06_plan.png"}, + {"entity": "报到 (Report)", "url": f"{TARGET_URL}/analysis/checkin", "shot": "10_report.png"}, + {"entity": "考勤 (Attendance)", "url": f"{TARGET_URL}/analysis/course", "shot": "11_attendance.png"}, + {"entity": "自主学习 (Duration)", "url": f"{TARGET_URL}/analysis/duration", "shot": "12_duration.png"}, + {"entity": "住宿 (Accommodation)", "url": f"{TARGET_URL}/train/accommodation", "shot": "13_accommodation.png"}, + {"entity": "门禁授权 (Auth)", "url": f"{TARGET_URL}/train/auth", "shot": "14_door_auth.png"}, + {"entity": "成绩 (Score)", "url": f"{TARGET_URL}/train/score", "shot": "15_score.png"} + ] + + for item in pages_to_audit: + print(f"正在巡检: {item['entity']} -> {item['url']}...") + try: + page.goto(item["url"], wait_until="networkidle") + time.sleep(1.5) + # 抓取表格操作列中的按钮文本 + buttons = page.locator(".el-table__row .el-button").all_text_contents() + # 去重并清洗文本 + btn_set = list(dict.fromkeys([b.strip() for b in buttons if b.strip()])) + print(f" 实际可见操作按钮: {btn_set}") + + shot_path = os.path.join(SCREENSHOT_DIR, item["shot"]) + page.screenshot(path=shot_path) + + results.append({ + "entity": item["entity"], + "url": item["url"], + "visible_buttons": btn_set, + "screenshot": item["shot"] + }) + except Exception as e: + print(f" 访问异常: {e}") + results.append({ + "entity": item["entity"], + "url": item["url"], + "error": str(e) + }) + + # 针对培训计划内的弹窗操作(计划课程、课次日程、参训人员)专门巡检 + print("正在巡检培训计划内嵌子组件: 计划课程、课次、参训人员...") + try: + page.goto(f"{TARGET_URL}/coursemgr/plan", wait_until="networkidle") + time.sleep(1.5) + # 点击第一行的“课程配置” + course_cfg_btn = page.locator('button:has-text("课程配置")').first + if course_cfg_btn.is_visible(): + course_cfg_btn.click() + time.sleep(1) + dialog_btns = page.locator('.el-dialog:visible .el-table__row .el-button').all_text_contents() + print(f" [计划课程 Plan Course] 弹窗内可见操作按钮: {list(dict.fromkeys([b.strip() for b in dialog_btns if b.strip()]))}") + page.screenshot(path=os.path.join(SCREENSHOT_DIR, "07_plan_course_config.png")) + # 关闭弹窗 + page.locator('.el-dialog:visible button:has-text("关 闭")').click() + time.sleep(0.5) + + # 点击“计划详情” + detail_btn = page.locator('button:has-text("计划详情")').first + if detail_btn.is_visible(): + detail_btn.click() + time.sleep(1) + # 切换到“参训人员” + page.locator('.el-dialog:visible .el-tabs__item:has-text("参训人员")').click() + time.sleep(1) + enroll_btns = page.locator('.el-dialog:visible .el-table__row .el-button').all_text_contents() + print(f" [参训人员 Enrollment] 面板可见操作按钮: {list(dict.fromkeys([b.strip() for b in enroll_btns if b.strip()]))}") + page.screenshot(path=os.path.join(SCREENSHOT_DIR, "08_plan_enrollment_panel.png")) + + # 切换到“课程表” + page.locator('.el-dialog:visible .el-tabs__item:has-text("课程表")').click() + time.sleep(1) + tt_btns = page.locator('.el-dialog:visible .el-table__row .el-button').all_text_contents() + print(f" [课次日程 Timetable] 面板可见操作按钮: {list(dict.fromkeys([b.strip() for b in tt_btns if b.strip()]))}") + page.screenshot(path=os.path.join(SCREENSHOT_DIR, "09_plan_timetable_panel.png")) + except Exception as e: + print(f" 计划子组件巡检异常: {e}") + + browser.close() + + print("\n--- 浏览器巡检汇总完成 ---") + with open(os.path.join(BASE_DIR, "docs/reports/destructive-action-alignment/browser_audit_result.json"), "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + +if __name__ == "__main__": + audit_pages()