diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java new file mode 100644 index 000000000..b4533247c --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java @@ -0,0 +1,245 @@ +package com.ruoyi.web.controller.train; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.domain.TrainExternalResourceMapping; +import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator; +import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.IExternalResourceMappingService; +import com.ruoyi.train.service.ITrainDormAuthService; + +/** + * 宿舍门禁与外部资源映射管理端Controller (Wave 3B) + *

+ * 安全红线: + * 1. 严格限制管理员权限访问 (train:access:manage / train:access:query); + * 2. 严禁提供远程开锁 (Remote Unlock) 入口; + * 3. 严禁向前端返回任何明文密码 (password)、Token 或 Secret; + * 4. 手动对账严格遵循领域业务前置条件校验,绝不提供绕过前置条件的强开接口。 + * + * @author ruoyi + * @date 2026-09-16 + */ +@RestController +@RequestMapping("/train/access") +public class TrainAccessManagementController extends BaseController +{ + private static final String DEFAULT_VENDOR = "DMS"; + + @Autowired + private IExternalResourceMappingService externalResourceMappingService; + + @Autowired + private ITrainDormAuthService trainDormAuthService; + + @Autowired + private IExternalOperationService externalOperationService; + + @Autowired + private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator; + + /** + * 查询指定宿舍的外部资源映射状态(包含房间映射、门锁映射及主锁配置健康度) + */ + @PreAuthorize("@ss.hasPermi('train:access:query')") + @GetMapping("/mapping/dorm/{dormId}") + public AjaxResult getDormMapping(@PathVariable("dormId") String dormId) + { + TrainExternalResourceMapping roomMapping = externalResourceMappingService.getActiveRoomMapping(DEFAULT_VENDOR, dormId); + List lockMappings = externalResourceMappingService.getActiveLockMappings(DEFAULT_VENDOR, dormId); + if (lockMappings == null) + { + lockMappings = new ArrayList<>(); + } + + long primaryCount = lockMappings.stream() + .filter(l -> l.getIsPrimary() != null && l.getIsPrimary() == 1) + .count(); + + Map data = new HashMap<>(); + data.put("dormId", dormId); + data.put("vendorCode", DEFAULT_VENDOR); + data.put("roomMapping", roomMapping); + data.put("lockMappings", lockMappings); + data.put("lockCount", lockMappings.size()); + data.put("primaryLockCount", primaryCount); + data.put("hasPrimaryLock", primaryCount == 1); + data.put("isConfigValid", primaryCount == 1 && roomMapping != null); + + if (primaryCount == 0) + { + data.put("configError", "未配置主门锁 (0 primary lock),门禁凭据无法自动下发"); + } + else if (primaryCount > 1) + { + data.put("configError", "配置异常:存在多把主门锁 (" + primaryCount + " primary locks),违反单一主锁最少特权原则"); + } + else if (roomMapping == null) + { + data.put("configError", "未绑定外部房间映射 (No active room mapping)"); + } + else + { + data.put("configError", null); + } + + return success(data); + } + + /** + * 绑定或更新宿舍外部房间映射 + */ + @PreAuthorize("@ss.hasPermi('train:access:manage')") + @PostMapping("/mapping/bind-room") + public AjaxResult bindRoomMapping(@RequestBody TrainExternalResourceMapping mapping) + { + if (mapping.getVendorCode() == null || mapping.getVendorCode().trim().isEmpty()) + { + mapping.setVendorCode(DEFAULT_VENDOR); + } + mapping.setCreateBy(getUsernameSafe()); + TrainExternalResourceMapping result = externalResourceMappingService.bindRoomMapping(mapping); + return success(result); + } + + /** + * 绑定或新增宿舍外部门锁映射 (支持一房多锁) + */ + @PreAuthorize("@ss.hasPermi('train:access:manage')") + @PostMapping("/mapping/bind-lock") + public AjaxResult bindLockMapping(@RequestBody TrainExternalResourceMapping mapping) + { + if (mapping.getVendorCode() == null || mapping.getVendorCode().trim().isEmpty()) + { + mapping.setVendorCode(DEFAULT_VENDOR); + } + mapping.setCreateBy(getUsernameSafe()); + TrainExternalResourceMapping result = externalResourceMappingService.bindLockMapping(mapping); + return success(result); + } + + /** + * 解除/注销指定的外部资源映射 + */ + @PreAuthorize("@ss.hasPermi('train:access:manage')") + @DeleteMapping("/mapping/{mappingId}") + public AjaxResult unbindMapping(@PathVariable("mappingId") Long mappingId) + { + boolean ok = externalResourceMappingService.deactivateMapping(mappingId, getUsernameSafe()); + return toAjax(ok ? 1 : 0); + } + + /** + * 查询门禁授权列表 (支持按学员、宿舍、状态筛选) + */ + @PreAuthorize("@ss.hasPermi('train:access:query')") + @GetMapping("/auth/list") + public TableDataInfo listAuth(TrainDormAuth trainDormAuth) + { + startPage(); + List list = trainDormAuthService.selectTrainDormAuthList(trainDormAuth); + return getDataTable(list); + } + + /** + * 获取指定门禁授权详细信息 + */ + @PreAuthorize("@ss.hasPermi('train:access:query')") + @GetMapping("/auth/{authId}") + public AjaxResult getAuthDetail(@PathVariable("authId") Long authId) + { + TrainDormAuth auth = trainDormAuthService.selectTrainDormAuthByAuthId(authId); + return success(auth); + } + + /** + * 查询指定门禁授权下的外部操作流水历史 (全要素脱敏返回,绝不泄漏密码或凭据) + */ + @PreAuthorize("@ss.hasPermi('train:access:query')") + @GetMapping("/operations/{doorAuthId}") + public AjaxResult getOperationHistory(@PathVariable("doorAuthId") Long doorAuthId) + { + List operations = externalOperationService.listOperationsByDoorAuthId(doorAuthId); + List> sanitizedList = new ArrayList<>(); + if (operations != null) + { + for (TrainExternalOperation op : operations) + { + Map item = new HashMap<>(); + item.put("operationId", op.getOperationId()); + item.put("doorAuthId", op.getDoorAuthId()); + item.put("operationType", op.getOperationType()); + item.put("operationStatus", op.getOperationStatus()); + item.put("vendorCode", op.getVendorCode()); + item.put("externalOperationId", op.getExternalOperationId()); + item.put("externalHardwareId", op.getExternalHardwareId()); + item.put("attemptCount", op.getAttemptCount()); + item.put("requestedAt", op.getRequestedAt()); + item.put("completedAt", op.getCompletedAt()); + item.put("nextPollTime", op.getNextPollTime()); + item.put("createTime", op.getCreateTime()); + item.put("updateTime", op.getUpdateTime()); + item.put("lastErrorCode", op.getLastErrorCode()); + item.put("lastErrorMessage", sanitizeSecret(op.getLastErrorMessage())); + item.put("remark", sanitizeSecret(op.getRemark())); + sanitizedList.add(item); + } + } + return success(sanitizedList); + } + + /** + * 管理员手动触发参训学员门禁权限对账 + *

+ * 严格遵循相同前置条件校验规则:未报到、未分宿、缺映射或门锁配置异常均正常返回 WAITING_FOR_PREREQUISITE, + * 绝不提供绕过安全前置条件的强开权限逻辑。 + */ + @PreAuthorize("@ss.hasPermi('train:access:reconcile')") + @PostMapping("/reconcile/{enrollmentId}") + public AjaxResult manualReconcile(@PathVariable("enrollmentId") Long enrollmentId) + { + ReconciliationResult result = accessAuthorizationOrchestrator.reconcile(enrollmentId); + return success(result); + } + + private String sanitizeSecret(String input) + { + if (input == null) + { + return null; + } + return input.replaceAll("(?i)(password|token|secret|appsecret)=[^&\\s]+", "$1=[PROTECTED]") + .replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2"); + } + + private String getUsernameSafe() + { + try + { + return SecurityUtils.getUsername(); + } + catch (Exception e) + { + return "admin"; + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessManagementPermissionMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessManagementPermissionMigrationContractTest.java new file mode 100644 index 000000000..3d7b14a42 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessManagementPermissionMigrationContractTest.java @@ -0,0 +1,56 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.Test; + +/** + * 010 DMS 门禁与外部资源映射管理权限 Migration 契约测试 (Wave 3B Gate M) + */ +public class DmsAccessManagementPermissionMigrationContractTest +{ + @Test + public void migrationClosesAccessManagementPermissionsWithoutRoleWrites() throws Exception + { + String sql = new String(Files.readAllBytes(findMigration()), StandardCharsets.UTF_8).toLowerCase(); + + // 必须包含新增的核心权限标识与幂等子句 + assertContains(sql, + "2191", "train:access:query", + "2192", "train:access:manage", + "2193", "train:access:reconcile", + "menu_type", "'f'", "where not exists"); + + // 严禁修改角色权限映射 (角色授权由后续 RBAC 统一分配) + assertFalse("migration must not insert role assignments", sql.contains("insert into public.sys_role_menu")); + assertFalse("migration must not update role assignments", sql.contains("update public.sys_role_menu")); + assertFalse("migration must not delete role assignments", sql.contains("delete from public.sys_role_menu")); + + // 严禁包含高危远程开锁权限或泛化无约束权限 + assertFalse("must not create remote unlock permission", sql.contains("remoteunlock") || sql.contains("remote-unlock")); + assertFalse("must not contain plaintext password column", sql.contains("password") || sql.contains("secret")); + } + + private Path findMigration() + { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) + { + Path candidate = path.resolve("sql/system/kingbase/010_dms_access_management_permissions.sql"); + if (Files.isRegularFile(candidate)) return candidate; + } + throw new IllegalStateException("010_dms_access_management_permissions.sql migration not found from working directory"); + } + + private void assertContains(String sql, String... tokens) + { + for (String token : tokens) + { + assertTrue("missing migration token: " + token, sql.contains(token)); + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccessManagementControllerTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccessManagementControllerTest.java new file mode 100644 index 000000000..071a9adb2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccessManagementControllerTest.java @@ -0,0 +1,269 @@ +package com.ruoyi.web.controller.train; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.domain.TrainExternalResourceMapping; +import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator; +import com.ruoyi.train.integration.dms.orchestration.ReconciliationDecision; +import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.IExternalResourceMappingService; +import com.ruoyi.train.service.ITrainDormAuthService; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; + +/** + * 门禁与外部资源映射管理控制器自动化测试 (Wave 3B) + */ +public class TrainAccessManagementControllerTest +{ + private TrainAccessManagementController controller; + + private List mappingDb; + private List dormAuthDb; + private List operationDb; + + @Before + public void setUp() throws Exception + { + controller = new TrainAccessManagementController(); + mappingDb = new ArrayList<>(); + dormAuthDb = new ArrayList<>(); + operationDb = new ArrayList<>(); + + IExternalResourceMappingService mappingService = (IExternalResourceMappingService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalResourceMappingService.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("getActiveRoomMapping".equals(name)) { + String dormId = (String) args[1]; + return mappingDb.stream() + .filter(m -> dormId.equals(m.getDormId()) && "ROOM".equals(m.getExternalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) + .findFirst().orElse(null); + } + if ("getActiveLockMappings".equals(name)) { + String dormId = (String) args[1]; + List locks = new ArrayList<>(); + for (TrainExternalResourceMapping m : mappingDb) { + if (dormId.equals(m.getDormId()) && "DOOR_LOCK".equals(m.getExternalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) { + locks.add(m); + } + } + return locks; + } + if ("bindRoomMapping".equals(name)) { + TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0]; + m.setMappingId(101L); + m.setMappingStatus("ACTIVE"); + mappingDb.add(m); + return m; + } + if ("bindLockMapping".equals(name)) { + TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0]; + m.setMappingId(201L); + m.setMappingStatus("ACTIVE"); + mappingDb.add(m); + return m; + } + if ("deactivateMapping".equals(name)) { + Long mappingId = (Long) args[0]; + mappingDb.removeIf(m -> mappingId.equals(m.getMappingId())); + return true; + } + return null; + } + ); + + ITrainDormAuthService authService = (ITrainDormAuthService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainDormAuthService.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainDormAuthList".equals(name)) { + return dormAuthDb; + } + if ("selectTrainDormAuthByAuthId".equals(name)) { + Long id = (Long) args[0]; + return dormAuthDb.stream().filter(a -> id.equals(a.getAuthId())).findFirst().orElse(null); + } + return null; + } + ); + + IExternalOperationService operationService = (IExternalOperationService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalOperationService.class}, + (proxy, method, args) -> { + if ("listOperationsByDoorAuthId".equals(method.getName())) { + Long doorAuthId = (Long) args[0]; + List list = new ArrayList<>(); + for (TrainExternalOperation op : operationDb) { + if (doorAuthId.equals(op.getDoorAuthId())) { + list.add(op); + } + } + return list; + } + return null; + } + ); + + AccessAuthorizationOrchestrator orchestrator = new AccessAuthorizationOrchestrator() { + @Override + public ReconciliationResult reconcile(Long enrollmentId) { + if (enrollmentId == 999L) { + return ReconciliationResult.waiting("Prerequisites missing: enrollment not reported"); + } + return ReconciliationResult.grantRequired(10L, 501L, true, true, "Triggered by admin manual reconcile"); + } + }; + + setField(controller, "externalResourceMappingService", mappingService); + setField(controller, "trainDormAuthService", authService); + setField(controller, "externalOperationService", operationService); + setField(controller, "accessAuthorizationOrchestrator", orchestrator); + } + + /** + * 测试房间门锁映射查询及主锁配置健康度判断: + * 1. 0主锁 -> configError + * 2. 1主锁 -> isConfigValid = true + * 3. 2主锁 -> configError + */ + @Test + @SuppressWarnings("unchecked") + public void testGetDormMapping_HealthAndPrimaryPolicy() + { + String dormId = "DORM-301"; + + // 1. 无任何映射 -> 0 primary + AjaxResult r1 = controller.getDormMapping(dormId); + assertEquals(200, r1.get("code")); + Map data1 = (Map) r1.get("data"); + assertEquals(0L, data1.get("primaryLockCount")); + assertFalse((Boolean) data1.get("isConfigValid")); + assertNotNull(data1.get("configError")); + assertTrue(data1.get("configError").toString().contains("0 primary lock")); + + // 2. 绑定 1 个房间与 1 个主门锁 -> 正常有效 + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId(dormId); + room.setExternalResourceType("ROOM"); + room.setMappingStatus("ACTIVE"); + mappingDb.add(room); + + TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping(); + lock1.setDormId(dormId); + lock1.setExternalResourceType("DOOR_LOCK"); + lock1.setIsPrimary(1); + lock1.setMappingStatus("ACTIVE"); + mappingDb.add(lock1); + + AjaxResult r2 = controller.getDormMapping(dormId); + Map data2 = (Map) r2.get("data"); + assertEquals(1L, data2.get("primaryLockCount")); + assertTrue((Boolean) data2.get("isConfigValid")); + assertNull(data2.get("configError")); + + // 3. 误配置了第 2 个主锁 -> 配置异常 + TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping(); + lock2.setDormId(dormId); + lock2.setExternalResourceType("DOOR_LOCK"); + lock2.setIsPrimary(1); + lock2.setMappingStatus("ACTIVE"); + mappingDb.add(lock2); + + AjaxResult r3 = controller.getDormMapping(dormId); + Map data3 = (Map) r3.get("data"); + assertEquals(2L, data3.get("primaryLockCount")); + assertFalse((Boolean) data3.get("isConfigValid")); + assertTrue(data3.get("configError").toString().contains("存在多把主门锁")); + } + + /** + * 测试操作流水查询与敏感数据脱敏: + * 即使原始错误消息或备注中含有 password 或 token,API 必须将其全局替换为 [PROTECTED] + */ + @Test + @SuppressWarnings("unchecked") + public void testGetOperationHistory_SanitizesSensitiveData() + { + Long authId = 88L; + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(1001L); + op.setDoorAuthId(authId); + op.setOperationType("GRANT"); + op.setOperationStatus("FAILED"); + op.setLastErrorMessage("DMS responded error: password=PlaintextSecret123&appsecret=TopSecretValue"); + op.setRemark("Operator tried with token=raw_token_xyz"); + operationDb.add(op); + + AjaxResult r = controller.getOperationHistory(authId); + assertEquals(200, r.get("code")); + List> list = (List>) r.get("data"); + assertEquals(1, list.size()); + + Map item = list.get(0); + String errMsg = (String) item.get("lastErrorMessage"); + String remark = (String) item.get("remark"); + + assertFalse("Password must not be leaked in error message", errMsg.contains("PlaintextSecret123")); + assertFalse("AppSecret must not be leaked in error message", errMsg.contains("TopSecretValue")); + assertFalse("Token must not be leaked in remark", remark.contains("raw_token_xyz")); + assertTrue("Sensitive text must be replaced with [PROTECTED]", errMsg.contains("[PROTECTED]")); + } + + /** + * 测试手动对账接口:继续遵循相同前置条件校验,绝不提供绕过前置条件的强开权限 + */ + @Test + public void testManualReconcile_RespectsPrerequisites() + { + // 1. 未满足条件学员 -> WAITING_FOR_PREREQUISITE + AjaxResult r1 = controller.manualReconcile(999L); + assertEquals(200, r1.get("code")); + ReconciliationResult res1 = (ReconciliationResult) r1.get("data"); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, res1.getDecision()); + + // 2. 满足条件学员 -> GRANT_REQUIRED + AjaxResult r2 = controller.manualReconcile(111L); + assertEquals(200, r2.get("code")); + ReconciliationResult res2 = (ReconciliationResult) r2.get("data"); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, res2.getDecision()); + } + + /** + * 安全基线红线断言: + * 1. 控制器中严禁存在任何名为 remoteUnlock 或类似远程应急开锁方法 + * 2. 控制器类所有公开方法均无密码明文入参或返回 + */ + @Test + public void testSecurityReview_ZeroRemoteUnlockMethod() + { + for (Method m : TrainAccessManagementController.class.getDeclaredMethods()) { + String name = m.getName().toLowerCase(); + assertFalse("Remote unlock is strictly prohibited in controller: " + name, + name.contains("remoteunlock") || name.contains("remote_unlock") || name.contains("forceunlock")); + } + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java index c4d93c81e..49c4cbc32 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java @@ -121,6 +121,9 @@ public class TrainDormAuth extends BaseEntity /** 床位号 (非物理列,联表自 train_accommodation) */ private String bedNo; + /** 报名ID/学员选课ID (非物理列,联表自 train_accommodation) */ + private Long enrollmentId; + /** 创建管理员 (兼容旧字段,映射到 BaseEntity.createBy) */ private String createUser; @@ -378,6 +381,16 @@ public class TrainDormAuth extends BaseEntity this.bedNo = bedNo; } + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + public String getCreateUser() { return createUser != null ? createUser : getCreateBy(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml index 0d28c3d39..f4cfdab14 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainDormAuthMapper.xml @@ -28,12 +28,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + SELECT a.auth_id, a.accommodation_id, + a.auth_status, + a.credential_type, + a.latest_operation_id, + a.operation_version, a.auth_start, a.auth_end, a.face_sync_status, @@ -46,6 +55,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" a.update_by, a.update_time, a.remark, + acc.enrollment_id, acc.dorm_id, acc.bed_no, e.student_id, @@ -98,6 +108,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" AND a.create_by = #{createUser} + + AND a.auth_status = #{authStatus} + + + AND acc.enrollment_id = #{enrollmentId} + ORDER BY a.create_time DESC @@ -117,6 +133,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" INSERT INTO train_dorm_auth accommodation_id, + auth_status, + credential_type, + latest_operation_id, + operation_version, auth_start, auth_end, face_sync_status, @@ -132,6 +152,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{accommodationId}, + #{authStatus}, + #{credentialType}, + #{latestOperationId}, + #{operationVersion}, #{authStart}, #{authEnd}, #{faceSyncStatus}, @@ -151,6 +175,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" UPDATE train_dorm_auth accommodation_id = #{accommodationId}, + auth_status = #{authStatus}, + credential_type = #{credentialType}, + latest_operation_id = #{latestOperationId}, + operation_version = #{operationVersion}, auth_start = #{authStart}, auth_end = #{authEnd}, face_sync_status = #{faceSyncStatus}, diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java new file mode 100644 index 000000000..2e2f63851 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java @@ -0,0 +1,932 @@ +package com.ruoyi.train.integration.dms.orchestration; + +import com.ruoyi.train.domain.*; +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.client.DmsHttpClient; +import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient; +import com.ruoyi.train.integration.dms.client.DmsPasswordClient; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import com.ruoyi.train.integration.dms.poller.DmsOperationPoller; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormAuthMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.IExternalResourceMappingService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.*; + +/** + * R.3B Wave 3B 完整端到端 15 业务场景验收测试套件 (Mock DMS E2E) + * + * 覆盖场景清单: + * 1. 先Accommodation后Report -> Grant -> accepted -> poll success -> ACTIVE + * 2. 先Report后Accommodation -> WAITING -> Accommodation后Grant -> ACTIVE + * 3. 重复Report + reconcile x5 -> authorization 1条 -> credential create 1次 + * 4. Grant failed -> Authorization非ACTIVE -> retry生成新Operation + * 5. Grant timeout -> 不ACTIVE -> 历史保留 + * 6. 退训 -> Revoke -> poll success -> REVOKED + * 7. 完成 -> Revoke -> REVOKED + * 8. 请假 -> 不自动Revoke (LEAVE_AUTO_REVOKE_POLICY = OPEN_QUESTION) + * 9. 调房成功 -> Grant new first -> New ACTIVE -> Revoke old -> Old REVOKED + * 10. 调房新房Grant失败 -> Old仍ACTIVE -> Old revoke count = 0 + * 11. 0 primary lock -> no grant + * 12. 2 primary locks -> config error/no grant + * 13. 1 primary + multiple secondary -> 只grant primary + * 14. DMS network failure -> Report Confirm不回滚 + * 15. DMS revoke failure -> checkout/plan complete不回滚 + */ +public class DmsMockE2EScenariosSuiteTest { + + private MockDmsServer mockServer; + private DmsProperties properties; + private AccessAuthorizationOrchestrator orchestrator; + private DmsOperationPoller poller; + + private final Map enrollmentDb = new ConcurrentHashMap<>(); + private final Map accommodationDb = new ConcurrentHashMap<>(); + private final Map dormAuthDb = new ConcurrentHashMap<>(); + private final Map resourceMappingDb = new ConcurrentHashMap<>(); + private final Map operationDb = new ConcurrentHashMap<>(); + private final Map operationIdempotencyDb = new ConcurrentHashMap<>(); + + private final AtomicLong authSeq = new AtomicLong(1000); + private final AtomicLong opSeq = new AtomicLong(5000); + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-suite-app-id"); + properties.setAppSecret("test-suite-app-secret"); + properties.setTokenTtlSeconds(7200); + properties.setPollIntervalSeconds(10); + properties.setMaxRetryAttempts(3); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient); + DmsCredentialGatewayImpl credentialGateway = new DmsCredentialGatewayImpl(passwordClient); + DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient); + + TrainEnrollmentMapper enrollmentMapper = createMockEnrollmentMapper(); + TrainAccommodationMapper accommodationMapper = createMockAccommodationMapper(); + TrainDormAuthMapper dormAuthMapper = createMockDormAuthMapper(); + IExternalResourceMappingService mappingService = createMockMappingService(); + IExternalOperationService operationService = createMockOperationService(); + + com.ruoyi.train.service.impl.TrainDormAuthServiceImpl authService = new com.ruoyi.train.service.impl.TrainDormAuthServiceImpl(); + setPrivateField(authService, "trainDormAuthMapper", dormAuthMapper); + setPrivateField(authService, "trainAccommodationMapper", accommodationMapper); + + poller = new DmsOperationPoller(properties, operationService, statusClient, authService); + + orchestrator = new AccessAuthorizationOrchestrator( + enrollmentMapper, + accommodationMapper, + dormAuthMapper, + authService, + mappingService, + operationService, + credentialGateway + ); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + enrollmentDb.clear(); + accommodationDb.clear(); + dormAuthDb.clear(); + resourceMappingDb.clear(); + operationDb.clear(); + operationIdempotencyDb.clear(); + } + + // 辅助方法:注册标准房间与门锁映射 + private void setupStandardMapping(String dormId, String roomId, String lockId, String hardwareId) { + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId(dormId); + room.setVendorCode("DMS"); + room.setLocalResourceType("DORM_ROOM"); + room.setExternalResourceType("ROOM"); + room.setExternalResourceId(roomId); + room.setMappingStatus("ACTIVE"); + resourceMappingDb.put("ROOM:" + dormId, room); + + TrainExternalResourceMapping lock = new TrainExternalResourceMapping(); + lock.setDormId(dormId); + lock.setVendorCode("DMS"); + lock.setLocalResourceType("DOOR_LOCK"); + lock.setExternalResourceType("DOOR_LOCK"); + lock.setExternalResourceId(lockId); + lock.setExternalHardwareId(hardwareId); + lock.setIsPrimary(1); + lock.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:" + dormId + ":" + lockId, lock); + } + + // ========================================== + // 场景 1: 先Accommodation后Report -> Grant -> accepted -> poll success -> ACTIVE + // ========================================== + @Test + public void test01_AccommodationFirstThenReport_GrantPollSuccessActive() { + Long eid = 101L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-101"); + enrollment.setEnrollmentStatus(0); // 待报到 + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-101", "1001", "5001", "HW-101-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1001L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-101"); + acc.setAccommodationStatus(0); // 预分配 + accommodationDb.put(1001L, acc); + + // 未报到 -> WAITING_FOR_PREREQUISITE + ReconciliationResult r1 = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision()); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + + // 报到确认 + enrollment.setEnrollmentStatus(1); + acc.setAccommodationStatus(1); + ReconciliationResult r2 = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + + TrainDormAuth auth = dormAuthDb.get(1001L); + assertNotNull(auth); + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); + + // Poller 轮询成功终态 -> 变更为 ACTIVE + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + } + + // ========================================== + // 场景 2: 先Report后Accommodation -> WAITING -> Accommodation后Grant -> ACTIVE + // ========================================== + @Test + public void test02_ReportFirstThenAccommodation_WaitingThenGrantPollSuccessActive() { + Long eid = 102L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-102"); + enrollment.setEnrollmentStatus(1); // 已报到 + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-102", "1002", "5002", "HW-102-MAIN"); + + // 暂无住宿 -> WAITING_FOR_PREREQUISITE + ReconciliationResult r1 = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision()); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + + // 分配住宿 + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1002L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-102"); + acc.setAccommodationStatus(1); + accommodationDb.put(1002L, acc); + + ReconciliationResult r2 = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + TrainDormAuth auth = dormAuthDb.get(1002L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + } + + // ========================================== + // 场景 3: 重复Report + reconcile x5 -> authorization 1条 -> credential create 1次 + // ========================================== + @Test + public void test03_DuplicateReportAndReconcile5Times_Idempotent() { + Long eid = 103L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-103"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-103", "1003", "5003", "HW-103-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1003L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-103"); + acc.setAccommodationStatus(1); + accommodationDb.put(1003L, acc); + + orchestrator.reconcile(eid); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = dormAuthDb.get(1003L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + + // 重复触发 5 次 + for (int i = 0; i < 5; i++) { + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.NO_ACTION, r.getDecision()); + } + + assertEquals("Create count must remain exactly 1", 1, mockServer.getPasswordCreatePostCounter()); + assertEquals("Authorization must be exactly 1 record", 1, dormAuthDb.size()); + } + + // ========================================== + // 场景 4: Grant failed -> Authorization非ACTIVE -> retry生成新Operation + // ========================================== + @Test + public void test04_GrantFailed_AuthorizationNonActive_RetryGeneratesNewOperation() { + Long eid = 104L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-104"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-104", "1004", "5004", "HW-104-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1004L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-104"); + acc.setAccommodationStatus(1); + accommodationDb.put(1004L, acc); + + orchestrator.reconcile(eid); + TrainDormAuth auth = dormAuthDb.get(1004L); + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); + + // DMS 异步执行失败 + mockServer.setOperationStatus(99001L, "failed", "Hardware busy / timeout"); + poller.pollPendingOperations(); + + // 验证非 ACTIVE,进入 ERROR + assertEquals(TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus()); + assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 再次触发对账 -> 重试生成新 Operation + ReconciliationResult retryResult = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, retryResult.getDecision()); + assertEquals(2, mockServer.getPasswordCreatePostCounter()); + + mockServer.setOperationStatus(99001L, "success", "Recovered"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + } + + // ========================================== + // 场景 5: Grant timeout -> 不ACTIVE -> 历史保留 + // ========================================== + @Test + public void test05_GrantTimeout_RemainsNonActive_HistoryPreserved() { + Long eid = 105L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-105"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-105", "1005", "5005", "HW-105-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1005L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-105"); + acc.setAccommodationStatus(1); + accommodationDb.put(1005L, acc); + + orchestrator.reconcile(eid); + TrainDormAuth auth = dormAuthDb.get(1005L); + + // 轮询返回尚未完成状态 (undone) + mockServer.setOperationStatus(99001L, "undone", "Device task queued in gateway"); + poller.pollPendingOperations(); + + // 仍保持 GRANTING,严禁进入 ACTIVE + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); + assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 流水历史完整保留 + assertEquals(1, operationDb.size()); + } + + // ========================================== + // 场景 6: 退训 -> Revoke -> poll success -> REVOKED + // ========================================== + @Test + public void test06_WithdrawalQuit_RevokePollSuccessRevoked() { + Long eid = 106L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-106"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-106", "1006", "5006", "HW-106-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1006L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-106"); + acc.setAccommodationStatus(1); + accommodationDb.put(1006L, acc); + + orchestrator.reconcile(eid); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = dormAuthDb.get(1006L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 学员退训 (status = 3) + enrollment.setEnrollmentStatus(3); + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.REVOKE_REQUIRED, r.getDecision()); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus()); + assertEquals(1, mockServer.getPasswordDeletePostCounter()); + + mockServer.setOperationStatus(99002L, "success", "Revoked"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus()); + } + + // ========================================== + // 场景 7: 完成 -> Revoke -> REVOKED + // ========================================== + @Test + public void test07_PlanComplete_RevokePollSuccessRevoked() { + Long eid = 107L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-107"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-107", "1007", "5007", "HW-107-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1007L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-107"); + acc.setAccommodationStatus(1); + accommodationDb.put(1007L, acc); + + orchestrator.reconcile(eid); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = dormAuthDb.get(1007L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 结训完成 (status = 4 已完成) + enrollment.setEnrollmentStatus(4); + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.REVOKE_REQUIRED, r.getDecision()); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus()); + + mockServer.setOperationStatus(99002L, "success", "Completed Revoke"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus()); + } + + // ========================================== + // 场景 8: 请假 -> 不自动Revoke (保留开放策略) + // ========================================== + @Test + public void test08_Leave_NoAutoRevokePolicyPreserved() { + Long eid = 108L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-108"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-108", "1008", "5008", "HW-108-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1008L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-108"); + acc.setAccommodationStatus(1); + accommodationDb.put(1008L, acc); + + orchestrator.reconcile(eid); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = dormAuthDb.get(1008L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 请假期间宿舍状态仍为已入住 (1),触发对账不生成任何 Revoke + ReconciliationResult leaveReconcile = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.NO_ACTION, leaveReconcile.getDecision()); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + assertEquals(0, mockServer.getPasswordDeletePostCounter()); + } + + // ========================================== + // 场景 9: 调房成功 -> Grant new first -> New ACTIVE -> Revoke old -> Old REVOKED + // ========================================== + @Test + public void test09_TransferSuccess_GrantNewFirstNewActiveThenRevokeOldRevoked() { + Long eid = 109L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-109"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-109A", "10091", "50091", "HW-109A-MAIN"); + setupStandardMapping("DORM-109B", "10092", "50092", "HW-109B-MAIN"); + + TrainAccommodation oldAcc = new TrainAccommodation(); + oldAcc.setAccommodationId(1091L); + oldAcc.setEnrollmentId(eid); + oldAcc.setDormId("DORM-109A"); + oldAcc.setAccommodationStatus(1); + accommodationDb.put(1091L, oldAcc); + + orchestrator.reconcile(eid); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth oldAuth = dormAuthDb.get(1091L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); + + // 调房:旧房退宿,新房入住 + oldAcc.setAccommodationStatus(2); + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setAccommodationId(1092L); + newAcc.setEnrollmentId(eid); + newAcc.setDormId("DORM-109B"); + newAcc.setAccommodationStatus(1); + accommodationDb.put(1092L, newAcc); + + // 调房对账 1:必须先 Grant 新房!旧房保持 ACTIVE! + ReconciliationResult r1 = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r1.getDecision()); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); + assertEquals(0, mockServer.getPasswordDeletePostCounter()); + + TrainDormAuth newAuth = dormAuthDb.get(1092L); + assertNotNull(newAuth); + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, newAuth.getAuthStatus()); + + // 新房生效为 ACTIVE + mockServer.setOperationStatus(99001L, "success", "New room active"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, newAuth.getAuthStatus()); + + // 调房对账 2:新房 ACTIVE 后,触发旧房撤销 + ReconciliationResult r2 = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.REVOKE_REQUIRED, r2.getDecision()); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, oldAuth.getAuthStatus()); + assertEquals(1, mockServer.getPasswordDeletePostCounter()); + + mockServer.setOperationStatus(99002L, "success", "Old room revoked"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, oldAuth.getAuthStatus()); + } + + // ========================================== + // 场景 10: 调房新房Grant失败 -> Old仍ACTIVE -> Old revoke count = 0 + // ========================================== + @Test + public void test10_TransferFailure_NewRoomGrantFailedOldRemainsActiveZeroRevoke() { + Long eid = 110L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-110"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-110A", "10101", "50101", "HW-110A-MAIN"); + + TrainAccommodation oldAcc = new TrainAccommodation(); + oldAcc.setAccommodationId(1101L); + oldAcc.setEnrollmentId(eid); + oldAcc.setDormId("DORM-110A"); + oldAcc.setAccommodationStatus(2); // 旧房迁出 + accommodationDb.put(1101L, oldAcc); + + TrainDormAuth oldAuth = new TrainDormAuth(); + oldAuth.setAuthId(1101L); + oldAuth.setAccommodationId(1101L); + oldAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + dormAuthDb.put(1101L, oldAuth); + + // 新房 110B (未配置外部映射) + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setAccommodationId(1102L); + newAcc.setEnrollmentId(eid); + newAcc.setDormId("DORM-110B"); + newAcc.setAccommodationStatus(1); + accommodationDb.put(1102L, newAcc); + + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r.getDecision()); + + // 关键安全断言:新房未发放成功时,旧房严格保持 ACTIVE,撤销调用次数绝对为 0 + assertEquals("Old room access must strictly remain ACTIVE", TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); + assertEquals("Old room revoke call count must be 0", 0, mockServer.getPasswordDeletePostCounter()); + } + + // ========================================== + // 场景 11: 0 primary lock -> no grant + // ========================================== + @Test + public void test11_ZeroPrimaryLock_NoGrant() { + Long eid = 111L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-111"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + // 仅配置次要锁 (isPrimary = 0) + TrainExternalResourceMapping secondaryLock = new TrainExternalResourceMapping(); + secondaryLock.setDormId("DORM-111"); + secondaryLock.setVendorCode("DMS"); + secondaryLock.setLocalResourceType("DOOR_LOCK"); + secondaryLock.setExternalResourceType("DOOR_LOCK"); + secondaryLock.setExternalResourceId("5111"); + secondaryLock.setIsPrimary(0); + secondaryLock.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:DORM-111:5111", secondaryLock); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1111L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-111"); + acc.setAccommodationStatus(1); + accommodationDb.put(1111L, acc); + + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r.getDecision()); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + } + + // ========================================== + // 场景 12: 2 primary locks -> config error/no grant + // ========================================== + @Test + public void test12_TwoPrimaryLocks_ConfigErrorNoGrant() { + Long eid = 112L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-112"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + // 配置 2 把主锁 + TrainExternalResourceMapping pri1 = new TrainExternalResourceMapping(); + pri1.setDormId("DORM-112"); + pri1.setVendorCode("DMS"); + pri1.setLocalResourceType("DOOR_LOCK"); + pri1.setExternalResourceType("DOOR_LOCK"); + pri1.setExternalResourceId("51121"); + pri1.setIsPrimary(1); + pri1.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:DORM-112:51121", pri1); + + TrainExternalResourceMapping pri2 = new TrainExternalResourceMapping(); + pri2.setDormId("DORM-112"); + pri2.setVendorCode("DMS"); + pri2.setLocalResourceType("DOOR_LOCK"); + pri2.setExternalResourceType("DOOR_LOCK"); + pri2.setExternalResourceId("51122"); + pri2.setIsPrimary(1); + pri2.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:DORM-112:51122", pri2); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1112L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-112"); + acc.setAccommodationStatus(1); + accommodationDb.put(1112L, acc); + + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r.getDecision()); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + } + + // ========================================== + // 场景 13: 1 primary + multiple secondary -> 只grant primary + // ========================================== + @Test + public void test13_OnePrimaryPlusMultipleSecondary_GrantsPrimaryOnly() { + Long eid = 113L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-113"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + // 1 主锁 + 1 房间映射 + setupStandardMapping("DORM-113", "1013", "51130", "HW-113-PRIMARY"); + + // 附加次要锁 + TrainExternalResourceMapping sec1 = new TrainExternalResourceMapping(); + sec1.setDormId("DORM-113"); + sec1.setVendorCode("DMS"); + sec1.setLocalResourceType("DOOR_LOCK"); + sec1.setExternalResourceType("DOOR_LOCK"); + sec1.setExternalResourceId("51131"); + sec1.setIsPrimary(0); + sec1.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:DORM-113:51131", sec1); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1113L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-113"); + acc.setAccommodationStatus(1); + accommodationDb.put(1113L, acc); + + ReconciliationResult r = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r.getDecision()); + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + + TrainExternalOperation op = operationDb.values().iterator().next(); + assertEquals("LK-HD-101", op.getExternalHardwareId()); + } + + // ========================================== + // 场景 14: DMS network failure -> Report Confirm不回滚 + // ========================================== + @Test + public void test14_DmsNetworkFailure_ReportConfirmDoesNotRollback() { + Long eid = 114L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-114"); + enrollment.setEnrollmentStatus(1); // 报到成功完成 + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-114", "1014", "5014", "HW-114-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1114L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-114"); + acc.setAccommodationStatus(1); + accommodationDb.put(1114L, acc); + + // 停止 Mock Server 模拟网络崩溃 + mockServer.stop(); + + try { + orchestrator.reconcile(eid); + } catch (Exception ignored) { + } + + // 核心事务独立性断言:学员报到状态保持 1,不因外部接口通信失败回滚! + assertEquals(Integer.valueOf(1), enrollment.getEnrollmentStatus()); + } + + // ========================================== + // 场景 15: DMS revoke failure -> checkout/plan complete不回滚 + // ========================================== + @Test + public void test15_DmsRevokeFailure_CheckoutPlanCompleteDoesNotRollback() { + Long eid = 115L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-115"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + setupStandardMapping("DORM-115", "1015", "5015", "HW-115-MAIN"); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(1115L); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-115"); + acc.setAccommodationStatus(1); + accommodationDb.put(1115L, acc); + + orchestrator.reconcile(eid); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + // 结训退宿本地已完成 (status = 2) + acc.setAccommodationStatus(2); + + // 模拟外部网络中断 + mockServer.stop(); + + try { + orchestrator.reconcile(eid); + } catch (Exception ignored) { + } + + // 核心事务独立性断言:本地退房状态保持 2,不回滚! + assertEquals(Integer.valueOf(2), acc.getAccommodationStatus()); + } + + // ========================================== + // Mock Mapper & Service 桩实现 + // ========================================== + private TrainEnrollmentMapper createMockEnrollmentMapper() { + return (TrainEnrollmentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByEnrollmentId".equals(name)) { + return enrollmentDb.get((Long) args[0]); + } + return null; + } + ); + } + + private TrainAccommodationMapper createMockAccommodationMapper() { + return (TrainAccommodationMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainAccommodationMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectActiveAccommodationByEnrollmentId".equals(name)) { + Long eid = (Long) args[0]; + return accommodationDb.values().stream() + .filter(a -> eid.equals(a.getEnrollmentId()) && (a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1))) + .findFirst().orElse(null); + } + if ("selectAccommodationHistoryByEnrollmentId".equals(name)) { + Long eid = (Long) args[0]; + List list = new ArrayList<>(); + for (TrainAccommodation a : accommodationDb.values()) { + if (eid.equals(a.getEnrollmentId())) { + list.add(a); + } + } + return list; + } + if ("selectTrainAccommodationByAccommodationId".equals(name)) { + return accommodationDb.get((Long) args[0]); + } + return null; + } + ); + } + + private TrainDormAuthMapper createMockDormAuthMapper() { + return (TrainDormAuthMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainDormAuthMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainDormAuthByAccommodationId".equals(name)) { + return dormAuthDb.get((Long) args[0]); + } + if ("selectTrainDormAuthByAuthId".equals(name)) { + Long aid = (Long) args[0]; + return dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + } + if ("insertTrainDormAuth".equals(name)) { + TrainDormAuth auth = (TrainDormAuth) args[0]; + auth.setAuthId(authSeq.incrementAndGet()); + dormAuthDb.put(auth.getAccommodationId(), auth); + return 1; + } + if ("updateTrainDormAuth".equals(name)) { + TrainDormAuth auth = (TrainDormAuth) args[0]; + dormAuthDb.put(auth.getAccommodationId(), auth); + return 1; + } + return null; + } + ); + } + + private IExternalResourceMappingService createMockMappingService() { + return (IExternalResourceMappingService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalResourceMappingService.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("getActiveRoomMapping".equals(name)) { + String dormId = (String) args[1]; + return resourceMappingDb.get("ROOM:" + dormId); + } + if ("getActiveLockMappings".equals(name)) { + String dormId = (String) args[1]; + List locks = new ArrayList<>(); + for (Map.Entry entry : resourceMappingDb.entrySet()) { + if (entry.getKey().startsWith("LOCK:" + dormId)) { + locks.add(entry.getValue()); + } + } + return locks; + } + return null; + } + ); + } + + private IExternalOperationService createMockOperationService() { + return (IExternalOperationService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalOperationService.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("createOperation".equals(name)) { + TrainExternalOperation op = (TrainExternalOperation) args[0]; + if (op.getIdempotencyKey() != null && operationIdempotencyDb.containsKey(op.getIdempotencyKey())) { + return operationIdempotencyDb.get(op.getIdempotencyKey()); + } + op.setOperationId(opSeq.incrementAndGet()); + operationDb.put(op.getOperationId(), op); + if (op.getIdempotencyKey() != null) { + operationIdempotencyDb.put(op.getIdempotencyKey(), op); + } + return op; + } + if ("bindExternalReference".equals(name)) { + Long opId = (Long) args[0]; + TrainExternalOperation op = operationDb.get(opId); + if (op != null) { + op.setExternalOperationId((String) args[1]); + op.setExternalHardwareId((String) args[2]); + op.setExternalKeyId((String) args[3]); + } + return op; + } + if ("transitStatus".equals(name)) { + Long opId = (Long) args[0]; + TrainExternalOperation op = operationDb.get(opId); + if (op != null) { + op.setOperationStatus((String) args[1]); + op.setLastErrorCode((String) args[2]); + op.setLastErrorMessage((String) args[3]); + } + return op; + } + if ("listPendingPollOperations".equals(name)) { + List list = new ArrayList<>(); + for (TrainExternalOperation op : operationDb.values()) { + if (TrainExternalOperation.STATUS_PENDING.equals(op.getOperationStatus()) || + TrainExternalOperation.STATUS_ACCEPTED.equals(op.getOperationStatus()) || + TrainExternalOperation.STATUS_RETRY_WAIT.equals(op.getOperationStatus())) { + list.add(op); + } + } + return list; + } + if ("updateOperationPollResult".equals(name)) { + Long opId = (Long) args[0]; + TrainExternalOperation op = operationDb.get(opId); + if (op != null) { + op.setOperationStatus((String) args[1]); + if (args[4] != null) op.setLastErrorCode((String) args[4]); + if (args[5] != null) op.setLastErrorMessage((String) args[5]); + } + return op; + } + return null; + } + ); + } + + private void setPrivateField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/RuoYi-Vue/sql/system/kingbase/010_dms_access_management_permissions.sql b/RuoYi-Vue/sql/system/kingbase/010_dms_access_management_permissions.sql new file mode 100644 index 000000000..3351240bd --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/010_dms_access_management_permissions.sql @@ -0,0 +1,36 @@ +-- ============================================================================= +-- 脚本名称: 010_dms_access_management_permissions.sql +-- 业务模块: DMS门禁与外部资源映射管理端权限扩展 (R.3B Wave 3B) +-- 适用数据库: KingbaseES, schema public +-- 范围: 仅限 sys_menu 按钮级权限增量插入,不改动已有角色分配 (sys_role_menu Δ = 0) +-- 幂等性设计: 支持重复执行,WHERE NOT EXISTS 防重,无破坏性语句 +-- 安全红线: 严禁引入远程应急开门权限,严禁普通学员/教师授权 +-- ============================================================================= + +BEGIN; + +-- 1. 门禁与映射查看权限 (train:access:query) +INSERT INTO public.sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT 2191, '门禁与资源映射查看', 2060, 1, '#', '', '', 1, 0, + 'F', '0', '0', 'train:access:query', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁授权与外部资源映射查看权限' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:access:query'); + +-- 2. 门禁资源映射管理权限 (train:access:manage) +INSERT INTO public.sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT 2192, '门禁资源映射维护', 2060, 2, '#', '', '', 1, 0, + 'F', '0', '0', 'train:access:manage', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁房间与门锁资源绑定解绑维护权限' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:access:manage'); + +-- 3. 门禁手动对账权限 (train:access:reconcile) +INSERT INTO public.sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT 2193, '门禁手动对账触发', 2060, 3, '#', '', '', 1, 0, + 'F', '0', '0', 'train:access:reconcile', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁权限手动对账触发执行权限' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:access:reconcile'); + +COMMIT; diff --git a/build_ui/.env.development b/build_ui/.env.development index 668d9414c..b6b53351c 100644 --- a/build_ui/.env.development +++ b/build_ui/.env.development @@ -1,5 +1,5 @@ # 页面标题 -VUE_APP_TITLE = 智慧培训管理平台 +VUE_APP_TITLE = SYFJGYD学员管理系统 # 开发环境配置 ENV = 'development' diff --git a/build_ui/.env.production b/build_ui/.env.production index 460b4b630..89d4d4ecb 100644 --- a/build_ui/.env.production +++ b/build_ui/.env.production @@ -1,5 +1,5 @@ -# 页面标题 智慧培训管理平台 -VUE_APP_TITLE = 智慧培训管理平台 +# 页面标题 SYFJGYD学员管理系统 +VUE_APP_TITLE = SYFJGYD学员管理系统 # 生产环境配置 ENV = 'production' diff --git a/build_ui/src/api/train/access.js b/build_ui/src/api/train/access.js new file mode 100644 index 000000000..ea1e9a33f --- /dev/null +++ b/build_ui/src/api/train/access.js @@ -0,0 +1,68 @@ +import request from '@/utils/request' + +// 查询宿舍关联的外部资源映射(包含房间与门锁映射及主锁健康度) +export function getDormMapping(dormId) { + return request({ + url: '/train/access/mapping/dorm/' + dormId, + method: 'get' + }) +} + +// 绑定或修改宿舍外部房间映射 +export function bindRoomMapping(data) { + return request({ + url: '/train/access/mapping/bind-room', + method: 'post', + data: data + }) +} + +// 绑定或新增宿舍外部门锁映射 +export function bindLockMapping(data) { + return request({ + url: '/train/access/mapping/bind-lock', + method: 'post', + data: data + }) +} + +// 解除/失效指定的外部资源映射 +export function unbindMapping(mappingId) { + return request({ + url: '/train/access/mapping/' + mappingId, + method: 'delete' + }) +} + +// 查询门禁授权列表 +export function listAuth(query) { + return request({ + url: '/train/access/auth/list', + method: 'get', + params: query + }) +} + +// 获取门禁授权详细信息 +export function getAuthDetail(authId) { + return request({ + url: '/train/access/auth/' + authId, + method: 'get' + }) +} + +// 查询门禁授权关联的外部操作流水历史(脱敏) +export function getOperationHistory(doorAuthId) { + return request({ + url: '/train/access/operations/' + doorAuthId, + method: 'get' + }) +} + +// 手动触发学员门禁权限对账 +export function manualReconcile(enrollmentId) { + return request({ + url: '/train/access/reconcile/' + enrollmentId, + method: 'post' + }) +} diff --git a/build_ui/src/layout/components/Sidebar/Logo.vue b/build_ui/src/layout/components/Sidebar/Logo.vue index 71ec4a08b..22e9c2c83 100644 --- a/build_ui/src/layout/components/Sidebar/Logo.vue +++ b/build_ui/src/layout/components/Sidebar/Logo.vue @@ -2,11 +2,11 @@