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 new file mode 100644 index 000000000..442c356d8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java @@ -0,0 +1,233 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PutMapping; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.dto.AttendanceAdjustDTO; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormAuthMapper; +import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl; +import com.ruoyi.train.service.impl.TrainDormAuthServiceImpl; + +/** + * 试用前最终内部缺陷修复 (PRE-CUSTOMER FINAL INTERNAL FIXES) 契约与防护测试: + * 1. 考勤人工修正 Controller 契约与权限一致性 (train:attendance:edit) + * 2. 住宿历史记录物理删除拦截 (ACCOMMODATION_HISTORY_PHYSICAL_DELETE_BLOCKED) + * 3. 门禁注销授权与操作流水物理删除拦截 (DOOR_AUTH_HISTORY_PHYSICAL_DELETE_BLOCKED) + */ +public class PreCustomerStabilizationFinalFixesContractTest +{ + private TrainAccommodationServiceImpl accommodationService; + private TrainDormAuthServiceImpl dormAuthService; + private Map accommodationStore; + private Map authStore; + + @Before + public void setUp() throws Exception + { + accommodationService = new TrainAccommodationServiceImpl(); + dormAuthService = new TrainDormAuthServiceImpl(); + accommodationStore = new HashMap<>(); + authStore = new HashMap<>(); + + TrainAccommodationMapper mockAccMapper = (TrainAccommodationMapper) java.lang.reflect.Proxy.newProxyInstance( + TrainAccommodationMapper.class.getClassLoader(), + new Class[]{TrainAccommodationMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainAccommodationByAccommodationId".equals(name)) + { + return accommodationStore.get(args[0]); + } + if ("deleteTrainAccommodationByAccommodationId".equals(name)) + { + return accommodationStore.remove(args[0]) != null ? 1 : 0; + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + TrainDormAuthMapper mockAuthMapper = (TrainDormAuthMapper) java.lang.reflect.Proxy.newProxyInstance( + TrainDormAuthMapper.class.getClassLoader(), + new Class[]{TrainDormAuthMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainDormAuthByAuthId".equals(name)) + { + return authStore.get(args[0]); + } + if ("deleteTrainDormAuthByAuthId".equals(name)) + { + return authStore.remove(args[0]) != null ? 1 : 0; + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + Field accField = TrainAccommodationServiceImpl.class.getDeclaredField("trainAccommodationMapper"); + accField.setAccessible(true); + accField.set(accommodationService, mockAccMapper); + + Field authField = TrainDormAuthServiceImpl.class.getDeclaredField("trainDormAuthMapper"); + authField.setAccessible(true); + authField.set(dormAuthService, mockAuthMapper); + } + + /** + * 1. 验证 AttendanceController 的人工调整契约与权限注解严格为 train:attendance:edit + */ + @Test + public void testAttendanceAdjustControllerContract() throws Exception + { + Method adjustMethod = AttendanceController.class.getMethod("adjust", Long.class, AttendanceAdjustDTO.class); + assertNotNull("adjust 方法必须存在", adjustMethod); + + PutMapping putMapping = adjustMethod.getAnnotation(PutMapping.class); + assertNotNull("必须声明 @PutMapping", putMapping); + assertEquals("/{checkinId}", putMapping.value()[0]); + + PreAuthorize preAuth = adjustMethod.getAnnotation(PreAuthorize.class); + assertNotNull("必须声明 @PreAuthorize 权限校验", preAuth); + assertEquals("@ss.hasPermi('train:attendance:edit')", preAuth.value()); + } + + /** + * 2. 住宿历史删除防护: + * A. 预分配错误/尚未真实入住:允许物理清理 + * B. 活跃在住或已退宿真实历史:强行拦截并保护历史事实 + */ + @Test + public void testAccommodationHistoricalDeleteGuard() + { + // Case A: 预分配错误 (STATUS_PREALLOCATED = 0) + TrainAccommodation draft = new TrainAccommodation(); + draft.setAccommodationId(9001L); + draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + accommodationStore.put(draft.getAccommodationId(), draft); + + int draftDeleted = accommodationService.deleteTrainAccommodationByAccommodationId(9001L); + assertEquals("未实际入住的预分配记录允许纠错删除", 1, draftDeleted); + + // Case B1: 活跃在住 (STATUS_CHECKED_IN = 1) + TrainAccommodation active = new TrainAccommodation(); + active.setAccommodationId(9002L); + active.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + accommodationStore.put(active.getAccommodationId(), active); + + try + { + accommodationService.deleteTrainAccommodationByAccommodationId(9002L); + fail("在住记录禁止物理删除"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("当前学员正处于已入住状态")); + } + + // Case B2: 已退宿历史记录 (STATUS_CHECKED_OUT = 2) - 核心历史履历防护 + TrainAccommodation historical = new TrainAccommodation(); + historical.setAccommodationId(9003L); + historical.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT); + accommodationStore.put(historical.getAccommodationId(), historical); + + try + { + accommodationService.deleteTrainAccommodationByAccommodationId(9003L); + fail("已退宿真实历史记录禁止物理删除,必须保留历史履历事实"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("禁止物理删除历史履历") + || e.getMessage().contains("真实历史入住与退宿事实")); + } + assertNotNull("历史退宿记录在库中依然完好保存", accommodationStore.get(9003L)); + } + + /** + * 3. 门禁注销授权与操作流水物理删除拦截防护: + * A. 初始草稿尚未产生操作:允许删除 + * B. 生效中/授权中:必须先注销 + * C. 已注销/已关联外部流水:严禁物理删除,保全审计溯源链 + */ + @Test + public void testDoorAuthHistoryPhysicalDeleteGuard() + { + // Case A: 初始草稿待下发 (STATUS_PENDING / AUTH_STATUS_PENDING_GRANT) + TrainDormAuth draft = new TrainDormAuth(); + draft.setAuthId(8001L); + draft.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING); + draft.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT); + authStore.put(draft.getAuthId(), draft); + + int draftDeleted = dormAuthService.deleteTrainDormAuthByAuthId(8001L); + assertEquals("无外部流水草稿允许清理", 1, draftDeleted); + + // Case B: 生效中授权 (ACTIVE) + TrainDormAuth active = new TrainDormAuth(); + active.setAuthId(8002L); + active.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE); + active.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + authStore.put(active.getAuthId(), active); + + try + { + dormAuthService.deleteTrainDormAuthByAuthId(8002L); + fail("生效中门禁授权禁止直接删除"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("当前门禁授权处于生效或授权中状态")); + } + + // Case C: 已注销授权 (REVOKED) - 核心安全审计链防护 + TrainDormAuth revoked = new TrainDormAuth(); + revoked.setAuthId(8003L); + revoked.setFaceSyncStatus(TrainDormAuth.STATUS_REVOKED); + revoked.setAuthStatus(TrainDormAuth.AUTH_STATUS_REVOKED); + authStore.put(revoked.getAuthId(), revoked); + + try + { + dormAuthService.deleteTrainDormAuthByAuthId(8003L); + fail("已注销门禁授权严禁物理删除,以保障审计链完整"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("禁止物理删除历史凭证") + || e.getMessage().contains("已进入授权生命周期")); + } + assertNotNull("已注销门禁授权依然完好存在", authStore.get(8003L)); + + // Case D: 带有外部操作流水关联 (latestOperationId != null) + TrainDormAuth opLinked = new TrainDormAuth(); + opLinked.setAuthId(8004L); + opLinked.setLatestOperationId(6666L); + authStore.put(opLinked.getAuthId(), opLinked); + + try + { + dormAuthService.deleteTrainDormAuthByAuthId(8004L); + fail("关联外部流水的门禁授权严禁物理删除"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("产生外部审计流水")); + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java index e85f9d5f4..6a47ba084 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java @@ -364,10 +364,20 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService { return 0; } - // 业务历史保护:若为活跃已入住状态,应先退宿而不是直接物理删除 + // 业务活跃保护:若为活跃已入住状态,禁止物理删除住宿安排 if (TrainAccommodation.STATUS_CHECKED_IN.equals(exist.getAccommodationStatus())) { - throw new ServiceException("当前学员正处于已入住状态,请先办理退宿后再执行清理"); + throw new ServiceException("当前学员正处于已入住状态,禁止物理删除住宿安排"); + } + // 历史履历保护:若为已退宿状态,说明曾产生真实入住与退宿事实,禁止物理删除历史履历 + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) + { + throw new ServiceException("该住宿记录已产生真实历史入住与退宿事实,禁止物理删除历史履历"); + } + // 安全策略:仅允许清理尚未实际入住的预分配草稿/错误记录 (STATUS_PREALLOCATED) + if (!TrainAccommodation.STATUS_PREALLOCATED.equals(exist.getAccommodationStatus())) + { + throw new ServiceException("当前住宿记录非预分配状态,禁止物理删除"); } return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java index da81a9820..f6a420457 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java @@ -184,9 +184,19 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int deleteTrainDormAuthByAuthIds(Long[] authIds) { - return trainDormAuthMapper.deleteTrainDormAuthByAuthIds(authIds); + if (authIds == null || authIds.length == 0) + { + return 0; + } + int count = 0; + for (Long authId : authIds) + { + count += deleteTrainDormAuthByAuthId(authId); + } + return count; } /** @@ -198,6 +208,34 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService @Override public int deleteTrainDormAuthByAuthId(Long authId) { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + return 0; + } + // 门禁授权生效保护:生效中或授权中状态,禁止直接清理,必须走标准注销流程 + if (TrainDormAuth.STATUS_ACTIVE.equals(exist.getFaceSyncStatus()) + || TrainDormAuth.AUTH_STATUS_ACTIVE.equals(exist.getAuthStatus()) + || TrainDormAuth.AUTH_STATUS_GRANTING.equals(exist.getAuthStatus())) + { + throw new ServiceException("当前门禁授权处于生效或授权中状态,请先执行注销后再处理"); + } + // 门禁审计链完整性保护:已注销、注销流转中或已关联外部操作流水的历史授权,严禁物理删除 + if (TrainDormAuth.STATUS_REVOKED.equals(exist.getFaceSyncStatus()) + || TrainDormAuth.AUTH_STATUS_REVOKED.equals(exist.getAuthStatus()) + || TrainDormAuth.AUTH_STATUS_PENDING_REVOKE.equals(exist.getAuthStatus()) + || TrainDormAuth.AUTH_STATUS_REVOKING.equals(exist.getAuthStatus()) + || exist.getLatestOperationId() != null + || exist.getExternalAuthId() != null) + { + throw new ServiceException("该门禁授权已进入授权生命周期或产生外部审计流水,禁止物理删除历史凭证"); + } + // 安全底线:仅允许清理尚未执行过任何下发动作的初始待授权草稿 (STATUS_PENDING / AUTH_STATUS_PENDING_GRANT) + if ((exist.getFaceSyncStatus() != null && !TrainDormAuth.STATUS_PENDING.equals(exist.getFaceSyncStatus())) + || (exist.getAuthStatus() != null && !TrainDormAuth.AUTH_STATUS_PENDING_GRANT.equals(exist.getAuthStatus()))) + { + throw new ServiceException("当前门禁授权非待下发草稿状态,禁止物理删除"); + } return trainDormAuthMapper.deleteTrainDormAuthByAuthId(authId); } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java index 23a8e51f3..d11f6c250 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java @@ -447,6 +447,79 @@ public class AccommodationServiceTest assertEquals(1, activeCount); } + /** + * 验证错误预分配记录允许物理删除纠错 + */ + @Test + public void testDeleteAccommodation_PreallocatedCanBeDeleted() + { + TrainAccommodation draft = new TrainAccommodation(); + draft.setEnrollmentId(2001L); + draft.setDormId("DORM_101"); + draft.setBedNo("3号床"); + draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + mockAccommodationMapper.insertTrainAccommodation(draft); + Long id = draft.getAccommodationId(); + + int deleted = accommodationService.deleteTrainAccommodationByAccommodationId(id); + assertEquals(1, deleted); + assertNull(accommodationService.selectTrainAccommodationByAccommodationId(id)); + } + + /** + * 验证活跃在住记录禁止物理删除 + */ + @Test + public void testDeleteAccommodation_CheckedInIsBlocked() + { + TrainAccommodation active = new TrainAccommodation(); + active.setEnrollmentId(2002L); + active.setDormId("DORM_101"); + active.setBedNo("4号床"); + active.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.insertTrainAccommodation(active); + Long id = active.getAccommodationId(); + + try + { + accommodationService.deleteTrainAccommodationByAccommodationId(id); + fail("在住记录禁止物理删除,必须抛出 ServiceException"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("当前学员正处于已入住状态")); + } + assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id)); + } + + /** + * 验证已退宿的历史记录禁止物理删除,保护历史住宿事实履历完整 + */ + @Test + public void testDeleteAccommodation_CheckedOutIsBlockedForHistoricalIntegrity() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(2003L); + acc.setDormId("DORM_101"); + acc.setBedNo("5号床"); + acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT); + mockAccommodationMapper.insertTrainAccommodation(acc); + Long id = acc.getAccommodationId(); + + try + { + accommodationService.deleteTrainAccommodationByAccommodationId(id); + fail("已退宿历史记录禁止物理删除以保护历史履历,必须抛出 ServiceException"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("禁止物理删除历史履历") + || e.getMessage().contains("真实历史入住与退宿事实")); + } + // 确认历史记录依然存在于库中 + assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id)); + } + // ================================= 内部 Mock 类 ================================= private static class MockAccommodationMapper implements TrainAccommodationMapper diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java index be70640f5..362304b9e 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java @@ -297,6 +297,92 @@ public class DormAuthAlignmentTest assertEquals(newAccId, queriedNew.getAccommodationId()); } + /** + * 验证活跃生效中(ACTIVE)门禁授权禁止直接物理删除,必须先注销 + */ + @Test + public void testDeleteTrainDormAuth_ActiveStatusBlocked() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(5003L); + acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.save(acc); + + TrainDormAuth activeAuth = new TrainDormAuth(); + activeAuth.setAccommodationId(5003L); + dormAuthService.insertTrainDormAuth(activeAuth); + Long authId = activeAuth.getAuthId(); + + // 模拟外部设备同步成功,更新状态为 ACTIVE + mockDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_ACTIVE, null, "admin", new Date()); + + try + { + dormAuthService.deleteTrainDormAuthByAuthId(authId); + fail("应拦截活跃门禁授权删除并抛出 ServiceException"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("当前门禁授权处于生效或授权中状态")); + } + } + + /** + * 验证已注销(REVOKED)门禁授权禁止物理删除,以保全设备操作与安全审计追溯链 + */ + @Test + public void testDeleteTrainDormAuth_RevokedIsBlockedForAuditIntegrity() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(5004L); + acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.save(acc); + + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5004L); + dormAuthService.insertTrainDormAuth(auth); + Long authId = auth.getAuthId(); + + // 办理注销 + dormAuthService.revokeAuth(authId, "admin"); + + try + { + dormAuthService.deleteTrainDormAuthByAuthId(authId); + fail("已注销门禁授权应禁止物理删除以保护审计追溯链,抛出 ServiceException"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("禁止物理删除历史凭证") + || e.getMessage().contains("已进入授权生命周期")); + } + // 确认记录依然完整保留在库中 + assertNotNull(dormAuthService.selectTrainDormAuthByAuthId(authId)); + } + + /** + * 验证尚未触发外部操作且处于初始待授权状态的草稿记录允许被清理 + */ + @Test + public void testDeleteTrainDormAuth_DraftWithoutOperationsCanBeDeleted() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(5005L); + acc.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + mockAccommodationMapper.save(acc); + + TrainDormAuth draft = new TrainDormAuth(); + draft.setAccommodationId(5005L); + draft.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING); + draft.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT); + dormAuthService.insertTrainDormAuth(draft); + Long authId = draft.getAuthId(); + + int deleted = dormAuthService.deleteTrainDormAuthByAuthId(authId); + assertEquals(1, deleted); + assertNull(dormAuthService.selectTrainDormAuthByAuthId(authId)); + } + // ================================= 内部 Mock 类 ================================= private static class MockDormAuthMapper implements TrainDormAuthMapper diff --git a/build_ui/src/layout/components/Sidebar/SidebarItem.vue b/build_ui/src/layout/components/Sidebar/SidebarItem.vue index 4853fbb70..4b5674a05 100644 --- a/build_ui/src/layout/components/Sidebar/SidebarItem.vue +++ b/build_ui/src/layout/components/Sidebar/SidebarItem.vue @@ -1,16 +1,16 @@