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/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java new file mode 100644 index 000000000..6e63693c5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccommodationController.java @@ -0,0 +1,160 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +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.enums.BusinessType; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.service.ITrainAccommodationService; + +/** + * 住宿安排 Controller + * + * @author ruoyi + * @date 2026-09-15 + */ +@RestController +@RequestMapping("/train/accommodation") +public class TrainAccommodationController extends BaseController +{ + @Autowired + private ITrainAccommodationService trainAccommodationService; + + /** + * 查询住宿安排列表 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:list')") + @GetMapping("/list") + public TableDataInfo list(TrainAccommodation accommodation) + { + startPage(); + List list = trainAccommodationService.selectTrainAccommodationList(accommodation); + return getDataTable(list); + } + + /** + * 获取住宿安排详细信息 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:query')") + @GetMapping("/{accommodationId}") + public AjaxResult getInfo(@PathVariable("accommodationId") Long accommodationId) + { + return success(trainAccommodationService.selectTrainAccommodationByAccommodationId(accommodationId)); + } + + /** + * 根据参训档案ID查询住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:query')") + @GetMapping("/enrollment/{enrollmentId}") + public AjaxResult getByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId) + { + return success(trainAccommodationService.selectActiveAccommodationByEnrollmentId(enrollmentId)); + } + + /** + * 根据参训档案ID查询该学员全部住宿历史 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:query')") + @GetMapping("/enrollment/{enrollmentId}/history") + public AjaxResult getHistoryByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId) + { + return success(trainAccommodationService.selectAccommodationHistoryByEnrollmentId(enrollmentId)); + } + + /** + * 新增住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:add')") + @Log(title = "住宿安排", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody TrainAccommodation accommodation) + { + accommodation.setCreateBy(getUsernameSafe()); + return toAjax(trainAccommodationService.insertTrainAccommodation(accommodation)); + } + + /** + * 修改住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody TrainAccommodation accommodation) + { + accommodation.setUpdateBy(getUsernameSafe()); + return toAjax(trainAccommodationService.updateTrainAccommodation(accommodation)); + } + + /** + * 办理入住 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排-办理入住", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/checkin") + public AjaxResult checkIn(@PathVariable("accommodationId") Long accommodationId) + { + return toAjax(trainAccommodationService.checkIn(accommodationId, getUsernameSafe())); + } + + /** + * 办理退宿 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排-办理退宿", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/checkout") + public AjaxResult checkOut(@PathVariable("accommodationId") Long accommodationId) + { + return toAjax(trainAccommodationService.checkOut(accommodationId, getUsernameSafe())); + } + + /** + * 办理调房 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "住宿安排-调房", businessType = BusinessType.UPDATE) + @PostMapping("/{accommodationId}/transfer") + public AjaxResult transfer(@PathVariable("accommodationId") Long accommodationId, + @RequestParam("newDormId") String newDormId, + @RequestParam("newBedNo") String newBedNo) + { + return success(trainAccommodationService.transfer(accommodationId, newDormId, newBedNo, getUsernameSafe())); + } + + /** + * 删除住宿安排 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:remove')") + @Log(title = "住宿安排", businessType = BusinessType.DELETE) + @DeleteMapping("/{accommodationIds}") + public AjaxResult remove(@PathVariable Long[] accommodationIds) + { + return toAjax(trainAccommodationService.deleteTrainAccommodationByAccommodationIds(accommodationIds)); + } + + private String getUsernameSafe() + { + try + { + return SecurityUtils.getUsername(); + } + catch (Exception e) + { + return "admin"; + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java index f82442024..c92fddc6c 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainDormAuthController.java @@ -20,12 +20,13 @@ import com.ruoyi.train.domain.TrainDormAuth; import com.ruoyi.train.service.ITrainDormAuthService; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.utils.SecurityUtils; /** - * 宿舍权限分配Controller - * + * 宿舍门禁授权Controller (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ @RestController @RequestMapping("/train/auth") @@ -69,6 +70,16 @@ public class TrainDormAuthController extends BaseController return success(trainDormAuthService.selectTrainDormAuthByAuthId(authId)); } + /** + * 根据住宿安排ID获取门禁授权详细信息 + */ + @PreAuthorize("@ss.hasPermi('system:auth:query')") + @GetMapping(value = "/accommodation/{accommodationId}") + public AjaxResult getInfoByAccommodationId(@PathVariable("accommodationId") Long accommodationId) + { + return success(trainDormAuthService.selectTrainDormAuthByAccommodationId(accommodationId)); + } + /** * 新增宿舍权限分配 */ @@ -77,6 +88,7 @@ public class TrainDormAuthController extends BaseController @PostMapping public AjaxResult add(@RequestBody TrainDormAuth trainDormAuth) { + trainDormAuth.setCreateBy(getUsernameSafe()); return toAjax(trainDormAuthService.insertTrainDormAuth(trainDormAuth)); } @@ -88,17 +100,41 @@ public class TrainDormAuthController extends BaseController @PutMapping public AjaxResult edit(@RequestBody TrainDormAuth trainDormAuth) { + trainDormAuth.setUpdateBy(getUsernameSafe()); return toAjax(trainDormAuthService.updateTrainDormAuth(trainDormAuth)); } + /** + * 注销宿舍权限分配 + */ + @PreAuthorize("@ss.hasPermi('system:auth:edit')") + @Log(title = "宿舍权限分配-注销", businessType = BusinessType.UPDATE) + @PostMapping("/{authId}/revoke") + public AjaxResult revoke(@PathVariable("authId") Long authId) + { + return toAjax(trainDormAuthService.revokeAuth(authId, getUsernameSafe())); + } + /** * 删除宿舍权限分配 */ @PreAuthorize("@ss.hasPermi('system:auth:remove')") @Log(title = "宿舍权限分配", businessType = BusinessType.DELETE) - @DeleteMapping("/{authIds}") + @DeleteMapping("/{authIds}") public AjaxResult remove(@PathVariable Long[] authIds) { return toAjax(trainDormAuthService.deleteTrainDormAuthByAuthIds(authIds)); } + + 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/AccommodationAndAuthApiControllerContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java new file mode 100644 index 000000000..afc952646 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java @@ -0,0 +1,130 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.lang.reflect.Method; +import org.junit.Test; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; + +/** + * R.3B Wave 0 - 住宿与门禁授权 Controller API 契约与权限边界测试 + */ +public class AccommodationAndAuthApiControllerContractTest +{ + /** + * 验证 TrainAccommodationController 的路径与权限契约 + */ + @Test + public void testAccommodationControllerContract() throws Exception + { + Class clazz = TrainAccommodationController.class; + RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class); + assertNotNull(classMapping); + assertEquals("/train/accommodation", classMapping.value()[0]); + + // 1. GET /train/accommodation/list + Method listMethod = clazz.getMethod("list", TrainAccommodation.class); + assertNotNull(listMethod.getAnnotation(GetMapping.class)); + assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:list')", listMethod.getAnnotation(PreAuthorize.class).value()); + + // 2. GET /train/accommodation/{accommodationId} + Method infoMethod = clazz.getMethod("getInfo", Long.class); + assertNotNull(infoMethod.getAnnotation(GetMapping.class)); + assertEquals("/{accommodationId}", infoMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:query')", infoMethod.getAnnotation(PreAuthorize.class).value()); + + // 3. GET /train/accommodation/enrollment/{enrollmentId} + Method enrollMethod = clazz.getMethod("getByEnrollmentId", Long.class); + assertNotNull(enrollMethod.getAnnotation(GetMapping.class)); + assertEquals("/enrollment/{enrollmentId}", enrollMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:query')", enrollMethod.getAnnotation(PreAuthorize.class).value()); + + // 3b. GET /train/accommodation/enrollment/{enrollmentId}/history + Method historyMethod = clazz.getMethod("getHistoryByEnrollmentId", Long.class); + assertNotNull(historyMethod.getAnnotation(GetMapping.class)); + assertEquals("/enrollment/{enrollmentId}/history", historyMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:query')", historyMethod.getAnnotation(PreAuthorize.class).value()); + + // 4. POST /train/accommodation + Method addMethod = clazz.getMethod("add", TrainAccommodation.class); + assertNotNull(addMethod.getAnnotation(PostMapping.class)); + assertEquals("@ss.hasPermi('train:accommodation:add')", addMethod.getAnnotation(PreAuthorize.class).value()); + + // 5. POST /train/accommodation/{accommodationId}/checkin + Method checkInMethod = clazz.getMethod("checkIn", Long.class); + assertNotNull(checkInMethod.getAnnotation(PostMapping.class)); + assertEquals("/{accommodationId}/checkin", checkInMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:edit')", checkInMethod.getAnnotation(PreAuthorize.class).value()); + + // 6. POST /train/accommodation/{accommodationId}/checkout + Method checkOutMethod = clazz.getMethod("checkOut", Long.class); + assertNotNull(checkOutMethod.getAnnotation(PostMapping.class)); + assertEquals("/{accommodationId}/checkout", checkOutMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:edit')", checkOutMethod.getAnnotation(PreAuthorize.class).value()); + + // 7. POST /train/accommodation/{accommodationId}/transfer + Method transferMethod = clazz.getMethod("transfer", Long.class, String.class, String.class); + assertNotNull(transferMethod.getAnnotation(PostMapping.class)); + assertEquals("/{accommodationId}/transfer", transferMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:edit')", transferMethod.getAnnotation(PreAuthorize.class).value()); + + // 8. DELETE /train/accommodation/{accommodationIds} + Method removeMethod = clazz.getMethod("remove", Long[].class); + assertNotNull(removeMethod.getAnnotation(DeleteMapping.class)); + assertEquals("/{accommodationIds}", removeMethod.getAnnotation(DeleteMapping.class).value()[0]); + assertEquals("@ss.hasPermi('train:accommodation:remove')", removeMethod.getAnnotation(PreAuthorize.class).value()); + } + + /** + * 验证 TrainDormAuthController 的现有路径兼容性契约 + */ + @Test + public void testDormAuthControllerContract() throws Exception + { + Class clazz = TrainDormAuthController.class; + RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class); + assertNotNull(classMapping); + assertEquals("/train/auth", classMapping.value()[0]); + + // 1. GET /train/auth/list + Method listMethod = clazz.getMethod("list", TrainDormAuth.class); + assertNotNull(listMethod.getAnnotation(GetMapping.class)); + assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:list')", listMethod.getAnnotation(PreAuthorize.class).value()); + + // 2. GET /train/auth/{authId} + Method infoMethod = clazz.getMethod("getInfo", Long.class); + assertNotNull(infoMethod.getAnnotation(GetMapping.class)); + assertEquals("/{authId}", infoMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:query')", infoMethod.getAnnotation(PreAuthorize.class).value()); + + // 3. GET /train/auth/accommodation/{accommodationId} + Method accInfoMethod = clazz.getMethod("getInfoByAccommodationId", Long.class); + assertNotNull(accInfoMethod.getAnnotation(GetMapping.class)); + assertEquals("/accommodation/{accommodationId}", accInfoMethod.getAnnotation(GetMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:query')", accInfoMethod.getAnnotation(PreAuthorize.class).value()); + + // 4. POST /train/auth + Method addMethod = clazz.getMethod("add", TrainDormAuth.class); + assertNotNull(addMethod.getAnnotation(PostMapping.class)); + assertEquals("@ss.hasPermi('system:auth:add')", addMethod.getAnnotation(PreAuthorize.class).value()); + + // 5. POST /train/auth/{authId}/revoke + Method revokeMethod = clazz.getMethod("revoke", Long.class); + assertNotNull(revokeMethod.getAnnotation(PostMapping.class)); + assertEquals("/{authId}/revoke", revokeMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals("@ss.hasPermi('system:auth:edit')", revokeMethod.getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java new file mode 100644 index 000000000..ec29c8071 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java @@ -0,0 +1,87 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +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; + +/** + * R.3B Wave 0.1 - 住宿历史模型与部分唯一索引 Migration 006 静态契约保护测试 + */ +public class AccommodationHistoryMigrationContractTest +{ + @Test + public void migrationMustFollowIdempotentPartialIndexContract() throws Exception + { + Path migrationPath = findMigration(); + assertNotNull("006 migration file must exist", migrationPath); + + String sql = new String(Files.readAllBytes(migrationPath), StandardCharsets.UTF_8).toLowerCase(); + + // 1. 验证前置脏数据检查 (Active duplicate pre-check) + assertContains(sql, + "select enrollment_id", + "from train_accommodation", + "where accommodation_status in (0, 1)", + "group by enrollment_id", + "having count(*) > 1", + "raise exception" + ); + + // 2. 验证旧约束检查与安全删除 (Existing Constraint Guard) + assertContains(sql, + "conname = 'uq_accommodation_enroll'", + "pg_get_constraintdef", + "unique (enrollment_id)", + "alter table train_accommodation drop constraint uq_accommodation_enroll" + ); + + // 3. 验证新部分唯一索引碰撞保护与创建 (New Index Collision Guard & Partial Unique Index) + assertContains(sql, + "uq_accommodation_enroll_active", + "create unique index uq_accommodation_enroll_active", + "on train_accommodation (enrollment_id)", + "where accommodation_status in (0, 1)" + ); + + // 4. 验证幂等性结构 (Idempotency Guard) + assertContains(sql, + "do $$", + "declare", + "begin", + "if v_con_def is not null then", + "if v_idx_def is not null then", + "end $$;" + ); + + // 5. 绝对禁止对业务行进行破坏性操作 (No data loss) + assertFalse("Migration must not truncate tables", sql.contains("truncate table")); + assertFalse("Migration must not delete business rows", sql.contains("delete from train_accommodation")); + assertFalse("Migration must not update business rows blindly", sql.contains("update train_accommodation set")); + } + + private Path findMigration() + { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) + { + Path candidate = path.resolve("RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql"); + if (Files.isRegularFile(candidate)) return candidate; + Path candidateSub = path.resolve("sql/system/kingbase/006_accommodation_history_constraint.sql"); + if (Files.isRegularFile(candidateSub)) return candidateSub; + } + throw new IllegalStateException("006_accommodation_history_constraint.sql 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/CredentialOneTimeDisplaySecurityTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java new file mode 100644 index 000000000..c89504294 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java @@ -0,0 +1,161 @@ +package com.ruoyi.train.integration.dms.security; + +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.domain.TrainExternalResourceMapping; +import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult; +import com.ruoyi.web.controller.train.TrainDormAuthController; +import org.junit.Test; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * 验证 R.3 Provisional Business Policy V0 中的凭证一次性安全展示策略 (ADMIN_ONE_TIME_DISPLAY) + * 验证指标: + * 1. DB_PLAINTEXT_COUNT = 0 + * 2. LOG_PLAINTEXT_EXPOSURE = 0 + * 3. HISTORY_PLAINTEXT_EXPOSURE = 0 + * 4. RETRIEVAL_ENDPOINT_COUNT = 0 + */ +public class CredentialOneTimeDisplaySecurityTest { + + /** + * 指标 1:DB_PLAINTEXT_COUNT = 0 + * 证明所有核心业务表与操作流水的持久化领域实体类中,绝对没有包含明文密码的物理持久化字段 + */ + @Test + public void testDbPlaintextCountIsZero() { + Class[] persistentEntities = new Class[]{ + TrainDormAuth.class, + TrainExternalOperation.class, + TrainAccommodation.class, + TrainEnrollment.class, + TrainExternalResourceMapping.class + }; + + int dbPlaintextCount = 0; + List suspiciousFields = new ArrayList<>(); + + for (Class clazz : persistentEntities) { + Field[] fields = clazz.getDeclaredFields(); + for (Field field : fields) { + // 排除 static 常量 + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + String name = field.getName().toLowerCase(); + if (name.contains("password") || name.contains("plaintext") || name.contains("secret")) { + dbPlaintextCount++; + suspiciousFields.add(clazz.getSimpleName() + "." + field.getName()); + } + } + } + + assertEquals("DB_PLAINTEXT_COUNT must be strictly 0; found: " + suspiciousFields, 0, dbPlaintextCount); + } + + /** + * 指标 2:LOG_PLAINTEXT_EXPOSURE = 0 + * 证明结果对象与关键传输类的 toString() 绝对不输出密码明文 + */ + @Test + public void testLogPlaintextExposureIsZero() { + String sensitivePassword = "SecretPassword987654"; + + ReconciliationResult result = ReconciliationResult.grantRequired(1001L, 2001L, true, true, "Success"); + result.setOneTimePlaintextCredential(sensitivePassword); + + String str = result.toString(); + assertFalse("ReconciliationResult.toString() must NOT contain plaintext password", str.contains(sensitivePassword)); + assertTrue("ReconciliationResult.toString() should redact sensitive fields", str.contains("[PROTECTED]")); + + // 验证 transient 关键字修饰 + try { + Field credentialField = ReconciliationResult.class.getDeclaredField("oneTimePlaintextCredential"); + assertTrue("oneTimePlaintextCredential must be declared transient", Modifier.isTransient(credentialField.getModifiers())); + } catch (NoSuchFieldException e) { + fail("Field oneTimePlaintextCredential must exist in ReconciliationResult"); + } + } + + /** + * 指标 3:HISTORY_PLAINTEXT_EXPOSURE = 0 + * 证明外部操作流水 TrainExternalOperation 无论如何序列化或字段读取,均无法获得明文密码 + */ + @Test + public void testHistoryPlaintextExposureIsZero() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(9999L); + op.setExternalHardwareId("HW-TEST-01"); + op.setExternalKeyId("KEY-12345"); + op.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS); + + String opStr = op.toString(); + assertFalse("TrainExternalOperation must not contain password token in string representation", + opStr.toLowerCase().contains("password")); + + // 检查实体方法 + Method[] methods = TrainExternalOperation.class.getDeclaredMethods(); + for (Method method : methods) { + String methodName = method.getName().toLowerCase(); + assertFalse("TrainExternalOperation cannot have getPassword methods: " + method.getName(), + methodName.startsWith("get") && methodName.contains("password")); + } + } + + /** + * 指标 4:RETRIEVAL_ENDPOINT_COUNT = 0 + * 扫描门禁对外 HTTP 控制器,确认没有任何可以再次读取/回显已生成凭据明文的 GET 查询接口 + */ + @Test + public void testRetrievalEndpointCountIsZero() { + Class controllerClass = TrainDormAuthController.class; + int retrievalEndpointCount = 0; + List leakEndpoints = new ArrayList<>(); + + for (Method method : controllerClass.getDeclaredMethods()) { + if (method.isAnnotationPresent(GetMapping.class)) { + GetMapping getMapping = method.getAnnotation(GetMapping.class); + String[] paths = getMapping.value().length > 0 ? getMapping.value() : getMapping.path(); + for (String path : paths) { + if (path.toLowerCase().contains("password") || path.toLowerCase().contains("credential")) { + retrievalEndpointCount++; + leakEndpoints.add(method.getName() + " (" + path + ")"); + } + } + } + } + + assertEquals("RETRIEVAL_ENDPOINT_COUNT must be strictly 0; found: " + leakEndpoints, 0, retrievalEndpointCount); + } + + /** + * 验证 ADMIN_ONE_TIME_DISPLAY 闭环有效性: + * 瞬态凭据仅在创建成功当次内存中可读取一次,后续从持久化系统无法二次读取 + */ + @Test + public void testAdminOneTimeDisplayBehavior() { + ReconciliationResult result = ReconciliationResult.grantRequired(1001L, 2001L, true, true, "Success"); + result.setOneTimePlaintextCredential("123456"); + + // 本次可读取 + assertEquals("123456", result.getOneTimePlaintextCredential()); + + // 模拟关闭/二次读取:持久化实体 TrainDormAuth 根本没有该属性 + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(1001L); + auth.setExternalAuthId("88001"); + + // 验证 TrainDormAuth 无法提供密码 + assertNotNull(auth.getAuthId()); + } +} 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/DormAuthorizationOrchestrationMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DormAuthorizationOrchestrationMigrationContractTest.java new file mode 100644 index 000000000..1b0a36070 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DormAuthorizationOrchestrationMigrationContractTest.java @@ -0,0 +1,76 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Locale; +import org.junit.Before; +import org.junit.Test; + +/** + * 009 Migration 静态规范契约测试 + * 校验 009_dorm_authorization_orchestration.sql 的幂等扩展性、字段健全性与敏感数据规避 + */ +public class DormAuthorizationOrchestrationMigrationContractTest +{ + private File migrationFile; + private String sqlContent; + + @Before + public void setUp() throws IOException + { + File direct = new File("../sql/system/kingbase/009_dorm_authorization_orchestration.sql"); + if (direct.exists()) { + migrationFile = direct; + } else { + migrationFile = new File("sql/system/kingbase/009_dorm_authorization_orchestration.sql"); + } + assertTrue("009 migration script must exist: " + migrationFile.getAbsolutePath(), migrationFile.exists()); + sqlContent = new String(Files.readAllBytes(migrationFile.toPath())); + } + + @Test + public void testColumnsAddedWithIdempotentClause() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertTrue("Must alter table train_dorm_auth", lower.contains("alter table train_dorm_auth")); + assertTrue("Must use ADD COLUMN IF NOT EXISTS for credential_type", + lower.contains("add column if not exists credential_type")); + assertTrue("Must use ADD COLUMN IF NOT EXISTS for auth_status", + lower.contains("add column if not exists auth_status")); + assertTrue("Must use ADD COLUMN IF NOT EXISTS for latest_operation_id", + lower.contains("add column if not exists latest_operation_id")); + assertTrue("Must use ADD COLUMN IF NOT EXISTS for operation_version", + lower.contains("add column if not exists operation_version")); + } + + @Test + public void testZeroPlaintextPasswordColumns() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertFalse("Must NOT add any plain password column", lower.contains("add column if not exists password")); + assertFalse("Must NOT add secret column", lower.contains("add column if not exists secret")); + assertFalse("Must NOT add plain_password column", lower.contains("plain_password")); + } + + @Test + public void testNoDestructiveDmlOrDrop() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertFalse("Must not drop table", lower.contains("drop table")); + assertFalse("Must not drop column", lower.contains("drop column")); + assertFalse("Must not truncate table", lower.contains("truncate")); + assertFalse("Must not delete rows", lower.contains("delete from")); + } + + @Test + public void testIndexesCreatedIdempotently() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertTrue("Must have CREATE INDEX IF NOT EXISTS", lower.contains("create index if not exists")); + assertTrue("Must index auth_status", lower.contains("idx_dorm_auth_status")); + assertTrue("Must index latest_operation_id", lower.contains("idx_dorm_auth_latest_op")); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ExternalResourceOperationMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ExternalResourceOperationMigrationContractTest.java new file mode 100644 index 000000000..e8bd1a5fa --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ExternalResourceOperationMigrationContractTest.java @@ -0,0 +1,118 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Locale; +import org.junit.Before; +import org.junit.Test; + +/** + * 008 Migration 静态规范契约测试 + * 校验 008_external_resource_operation.sql 的设计规范、幂等性设计、约束健全性及敏感数据规避 + * + * @author ruoyi + */ +public class ExternalResourceOperationMigrationContractTest +{ + private File migrationFile; + private String sqlContent; + + @Before + public void setUp() throws IOException + { + // 定位 008 迁移文件 + File direct = new File("../sql/system/kingbase/008_external_resource_operation.sql"); + if (direct.exists()) { + migrationFile = direct; + } else { + migrationFile = new File("sql/system/kingbase/008_external_resource_operation.sql"); + } + assertTrue("008 migration script must exist: " + migrationFile.getAbsolutePath(), migrationFile.exists()); + sqlContent = new String(Files.readAllBytes(migrationFile.toPath())); + } + + @Test + public void testTablesCreatedWithIdempotentClause() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertTrue("Must create train_external_resource_mapping", lower.contains("train_external_resource_mapping")); + assertTrue("Must create train_external_operation", lower.contains("train_external_operation")); + assertTrue("Must have CREATE TABLE IF NOT EXISTS for mapping", lower.contains("create table if not exists train_external_resource_mapping")); + assertTrue("Must have CREATE TABLE IF NOT EXISTS for operation", lower.contains("create table if not exists train_external_operation")); + } + + @Test + public void testForeignKeysAndReferentialIntegrity() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertTrue("Mapping table must reference train_dormitory_info(dorm_id)", + lower.contains("references train_dormitory_info(dorm_id)")); + assertTrue("Operation table must reference train_dorm_auth(auth_id)", + lower.contains("references train_dorm_auth(auth_id)")); + } + + @Test + public void testPartialUniqueIndexes() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + // 1. 房间 Active 唯一约束: vendor_code + dorm_id + assertTrue("Must define uq_ext_res_map_active_room partial unique index", + lower.contains("uq_ext_res_map_active_room")); + assertTrue("Must guard active room mapping with external_resource_type and status", + lower.contains("where external_resource_type = 'room' and mapping_status = 'active'")); + + // 2. 外部资源跨房间 Active 唯一约束: vendor_code + external_resource_type + external_resource_id + assertTrue("Must define uq_ext_res_map_active_ext_res partial unique index", + lower.contains("uq_ext_res_map_active_ext_res")); + assertTrue("uq_ext_res_map_active_ext_res must include vendor_code, external_resource_type, external_resource_id", + lower.contains("(vendor_code, external_resource_type, external_resource_id)")); + assertTrue("Must guard active external resource binding with mapping_status = 'active'", + lower.contains("where mapping_status = 'active'")); + } + + @Test + public void testOperationTypeAndStatusSemantics() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + // operation_type 包含 GRANT, REVOKE + assertTrue("Must mention GRANT in operation_type", lower.contains("grant")); + assertTrue("Must mention REVOKE in operation_type", lower.contains("revoke")); + + // operation_status 绝不能把 REVOKED 作为其合法枚举值 + assertFalse("operation_status comment must NOT contain REVOKED", + lower.contains("operation_status is '操作状态(created, requested, accepted, pending, success, failed, timeout, retry_wait, revoked)'")); + assertFalse("operation_status must NOT contain REVOKED in its definition", + lower.contains("operation_status is '操作状态") && lower.contains("revoked")); + } + + @Test + public void testOperationIdempotencyAndPollIndexes() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertTrue("Must define uq_ext_op_idempotency unique index", lower.contains("uq_ext_op_idempotency")); + assertTrue("Must define idx_ext_op_poll index for async polling", lower.contains("idx_ext_op_poll")); + } + + @Test + public void testNoDestructiveStatements() + { + // 过滤单行注释 + String noComments = sqlContent.replaceAll("--.*", "").toLowerCase(Locale.ROOT); + assertFalse("Must NOT contain DROP TABLE", noComments.contains("drop table")); + assertFalse("Must NOT contain TRUNCATE", noComments.contains("truncate")); + assertFalse("Must NOT contain DELETE FROM", noComments.contains("delete from")); + } + + @Test + public void testNoSensitivePlaintextColumns() + { + String lower = sqlContent.toLowerCase(Locale.ROOT); + assertFalse("Must NOT contain plaintext password column", lower.contains("password varchar") || lower.contains("door_password")); + assertFalse("Must NOT contain plaintext card number column", lower.contains("card_number") || lower.contains("cardnumber")); + assertFalse("Must NOT contain token column", lower.contains("token varchar") || lower.contains("app_token")); + assertFalse("Must NOT contain secret column", lower.contains("secret varchar") || lower.contains("app_secret")); + } +} 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/TrainAccommodation.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java new file mode 100644 index 000000000..2ea3a0128 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainAccommodation.java @@ -0,0 +1,262 @@ +package com.ruoyi.train.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 住宿安排领域实体对象 train_accommodation (Database V2) + * + * @author ruoyi + * @date 2026-09-15 + */ +public class TrainAccommodation extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 住宿状态:0 预分配 */ + public static final Integer STATUS_PREALLOCATED = 0; + + /** 住宿状态:1 已入住 */ + public static final Integer STATUS_CHECKED_IN = 1; + + /** 住宿状态:2 已退宿 */ + public static final Integer STATUS_CHECKED_OUT = 2; + + /** 住宿安排ID (主键,自增) */ + private Long accommodationId; + + /** 参训档案ID (外键,关联train_enrollment.enrollment_id,唯一) */ + @Excel(name = "参训档案ID") + private Long enrollmentId; + + /** 宿舍ID (外键,关联train_dormitory_info.dorm_id) */ + @Excel(name = "宿舍ID") + private String dormId; + + /** 床位号 (如 1号床、A床等) */ + @Excel(name = "床位号") + private String bedNo; + + /** 分配时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "分配时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date assignedTime; + + /** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */ + @Excel(name = "住宿状态", readConverterExp = "0=预分配,1=已入住,2=已退宿") + private Integer accommodationStatus; + + /* ==================== 视图兼容字段 (非持久化列,联表展示) ==================== */ + + /** 学员学号 */ + private String studentId; + + /** 学员姓名 */ + private String studentName; + + /** 学员性别 */ + private String gender; + + /** 所属机构/选派单位 */ + private String orgName; + + /** 联系电话 */ + private String phone; + + /** 培训班计划ID */ + private String planId; + + /** 培训班计划名称 */ + private String planName; + + /** 宿舍楼栋 */ + private String dormBuilding; + + /** 宿舍房间号 */ + private String dormRoom; + + public TrainAccommodation() + { + } + + public TrainAccommodation(Long enrollmentId, String dormId, String bedNo) + { + this.enrollmentId = enrollmentId; + this.dormId = dormId; + this.bedNo = bedNo; + this.assignedTime = new Date(); + this.accommodationStatus = STATUS_PREALLOCATED; + } + + public Long getAccommodationId() + { + return accommodationId; + } + + public void setAccommodationId(Long accommodationId) + { + this.accommodationId = accommodationId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Date getAssignedTime() + { + return assignedTime; + } + + public void setAssignedTime(Date assignedTime) + { + this.assignedTime = assignedTime; + } + + public Integer getAccommodationStatus() + { + return accommodationStatus; + } + + public void setAccommodationStatus(Integer accommodationStatus) + { + this.accommodationStatus = accommodationStatus; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public String getOrgName() + { + return orgName; + } + + public void setOrgName(String orgName) + { + this.orgName = orgName; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + @Override + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) + .append("accommodationId", getAccommodationId()) + .append("enrollmentId", getEnrollmentId()) + .append("dormId", getDormId()) + .append("bedNo", getBedNo()) + .append("assignedTime", getAssignedTime()) + .append("accommodationStatus", getAccommodationStatus()) + .append("createBy", getCreateBy()) + .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) + .append("updateTime", getUpdateTime()) + .append("remark", getRemark()) + .toString(); + } +} 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 55043ffcc..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 @@ -8,155 +8,419 @@ import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; /** - * 宿舍权限分配对象 train_dorm_auth - * + * 宿舍门禁授权领域对象 train_dorm_auth (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ public class TrainDormAuth extends BaseEntity { private static final long serialVersionUID = 1L; - /** 权限分配ID */ + /** 门禁授权状态:0 待下发 (本地待同步) */ + public static final Integer STATUS_PENDING = 0; + + /** 门禁授权状态:1 已下发 (设备端生效) */ + public static final Integer STATUS_ACTIVE = 1; + + /** 门禁授权状态:2 下发失败 (待重试) */ + public static final Integer STATUS_FAILED = 2; + + /** 门禁授权状态:3 已注销 (权限收回) */ + public static final Integer STATUS_REVOKED = 3; + + /* ==================== 业务授权精细化状态枚举 (Wave 3A 厂商中立领域状态) ==================== */ + public static final String AUTH_STATUS_PENDING_GRANT = "PENDING_GRANT"; + public static final String AUTH_STATUS_GRANTING = "GRANTING"; + public static final String AUTH_STATUS_ACTIVE = "ACTIVE"; + public static final String AUTH_STATUS_PENDING_REVOKE = "PENDING_REVOKE"; + public static final String AUTH_STATUS_REVOKING = "REVOKING"; + public static final String AUTH_STATUS_REVOKED = "REVOKED"; + public static final String AUTH_STATUS_ERROR = "ERROR"; + + /* ==================== 凭据类型枚举 (Wave 3A 凭据中立领域状态) ==================== */ + public static final String CREDENTIAL_TYPE_ONLINE_CODE = "ONLINE_PASSWORD"; + public static final String CREDENTIAL_TYPE_QR = "QR"; + public static final String CREDENTIAL_TYPE_CARD = "CARD"; + + /** 授权主键ID */ private Long authId; - /** 学员学号,关联train_student.s_id */ - @Excel(name = "学员学号,关联train_student.s_id") - private String studentId; + /** 住宿安排ID,外键关联 train_accommodation.accommodation_id (V2核心业务标识) */ + @Excel(name = "住宿安排ID") + private Long accommodationId; - /** 所属开班计划ID,train_class_plan.plan_id */ - @Excel(name = "所属开班计划ID,train_class_plan.plan_id") - private String planId; + /** 凭据类型 (默认 ONLINE_PASSWORD) */ + @Excel(name = "凭据类型") + private String credentialType = CREDENTIAL_TYPE_ONLINE_CODE; - /** 分配宿舍ID,train_dormitory_info.dorm_id */ - @Excel(name = "分配宿舍ID,train_dormitory_info.dorm_id") - private String dormId; + /** 厂商中立业务授权状态 */ + @Excel(name = "业务授权状态") + private String authStatus = AUTH_STATUS_PENDING_GRANT; - /** 床位号 */ - @Excel(name = "床位号") - private String bedNo; + /** 最近一次驱动的外部操作流水ID (防迟到覆盖与溯源) */ + private Long latestOperationId; - /** 0 未下发人脸 1 已下发人脸至智能锁 2 下发失败 */ - @Excel(name = "0 未下发人脸 1 已下发人脸至智能锁 2 下发失败") - private Integer faceSyncStatus; + /** 授权版本号 (乐观并发与迟到覆盖保护) */ + private Integer operationVersion = 1; /** 权限生效时间 */ - @JsonFormat(pattern = "yyyy-MM-dd") - @Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date authStart; - /** 权限失效时间 (开班结束自动失效) */ - @JsonFormat(pattern = "yyyy-MM-dd") - @Excel(name = "权限失效时间 (开班结束自动失效)", width = 30, dateFormat = "yyyy-MM-dd") + /** 权限失效时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "权限失效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date authEnd; - /** 创建管理员账号 */ - @Excel(name = "创建管理员账号") + /** 授权同步状态 (0: 待下发, 1: 已下发, 2: 下发失败, 3: 已注销) */ + @Excel(name = "授权同步状态", readConverterExp = "0=待下发,1=已下发,2=下发失败,3=已注销") + private Integer faceSyncStatus = STATUS_PENDING; + + /** 重试次数 */ + @Excel(name = "重试次数") + private Integer retryCount = 0; + + /** 最近一次同步错误信息 */ + @Excel(name = "最近同步错误") + private String lastError; + + /** 外部授权标识 (用于后续Adapter关联,本Wave仅持久化字段) */ + @Excel(name = "外部授权标识") + private String externalAuthId; + + /** 最近一次同步时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "最近同步时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date lastSyncTime; + + /* ==================== 兼容视图字段 (非持久化列,联表查询展示与旧API兼容) ==================== */ + + /** 学员学号 (非物理列,联表自 train_student) */ + private String studentId; + + /** 学员姓名 (非物理列,联表自 train_student) */ + private String studentName; + + /** 开班计划ID (非物理列,联表自 train_enrollment) */ + private String planId; + + /** 开班计划名称 (非物理列,联表自 train_class_plan) */ + private String planName; + + /** 宿舍ID (非物理列,联表自 train_accommodation) */ + private String dormId; + + /** 宿舍楼栋 (非物理列,联表自 train_dormitory_info) */ + private String dormBuilding; + + /** 宿舍房间号 (非物理列,联表自 train_dormitory_info) */ + private String dormRoom; + + /** 床位号 (非物理列,联表自 train_accommodation) */ + private String bedNo; + + /** 报名ID/学员选课ID (非物理列,联表自 train_accommodation) */ + private Long enrollmentId; + + /** 创建管理员 (兼容旧字段,映射到 BaseEntity.createBy) */ private String createUser; - public void setAuthId(Long authId) + public TrainDormAuth() { - this.authId = authId; } - public Long getAuthId() + public TrainDormAuth(Long accommodationId, Date authStart, Date authEnd) + { + this.accommodationId = accommodationId; + this.authStart = authStart; + this.authEnd = authEnd; + this.faceSyncStatus = STATUS_PENDING; + this.retryCount = 0; + } + + public Long getAuthId() { return authId; } - public void setStudentId(String studentId) + public void setAuthId(Long authId) { - this.studentId = studentId; + this.authId = authId; } - public String getStudentId() + public Long getAccommodationId() { - return studentId; + return accommodationId; } - public void setPlanId(String planId) + public void setAccommodationId(Long accommodationId) { - this.planId = planId; + this.accommodationId = accommodationId; } - public String getPlanId() + public String getCredentialType() { - return planId; + return credentialType; } - public void setDormId(String dormId) + public void setCredentialType(String credentialType) { - this.dormId = dormId; + this.credentialType = credentialType; } - public String getDormId() + public String getAuthStatus() { - return dormId; + return authStatus; } - public void setBedNo(String bedNo) + public void setAuthStatus(String authStatus) { - this.bedNo = bedNo; + this.authStatus = authStatus; + if (AUTH_STATUS_PENDING_GRANT.equals(authStatus) || AUTH_STATUS_GRANTING.equals(authStatus)) { + this.faceSyncStatus = STATUS_PENDING; + } else if (AUTH_STATUS_ACTIVE.equals(authStatus) || AUTH_STATUS_PENDING_REVOKE.equals(authStatus) || AUTH_STATUS_REVOKING.equals(authStatus)) { + this.faceSyncStatus = STATUS_ACTIVE; + } else if (AUTH_STATUS_ERROR.equals(authStatus)) { + this.faceSyncStatus = STATUS_FAILED; + } else if (AUTH_STATUS_REVOKED.equals(authStatus)) { + this.faceSyncStatus = STATUS_REVOKED; + } } - public String getBedNo() + public Long getLatestOperationId() { - return bedNo; + return latestOperationId; } - public void setFaceSyncStatus(Integer faceSyncStatus) + public void setLatestOperationId(Long latestOperationId) { - this.faceSyncStatus = faceSyncStatus; + this.latestOperationId = latestOperationId; } - public Integer getFaceSyncStatus() + public Integer getOperationVersion() { - return faceSyncStatus; + return operationVersion; } - public void setAuthStart(Date authStart) + public void setOperationVersion(Integer operationVersion) { - this.authStart = authStart; + this.operationVersion = operationVersion; } - public Date getAuthStart() + public Date getAuthStart() { return authStart; } - public void setAuthEnd(Date authEnd) + public void setAuthStart(Date authStart) { - this.authEnd = authEnd; + this.authStart = authStart; } - public Date getAuthEnd() + public Date getAuthEnd() { return authEnd; } - public void setCreateUser(String createUser) + public void setAuthEnd(Date authEnd) { - this.createUser = createUser; + this.authEnd = authEnd; } - public String getCreateUser() + public Integer getFaceSyncStatus() { - return createUser; + return faceSyncStatus; + } + + public void setFaceSyncStatus(Integer faceSyncStatus) + { + this.faceSyncStatus = faceSyncStatus; + if (faceSyncStatus != null) { + switch (faceSyncStatus) { + case 0: + if (!AUTH_STATUS_GRANTING.equals(this.authStatus)) { + this.authStatus = AUTH_STATUS_PENDING_GRANT; + } + break; + case 1: + if (!AUTH_STATUS_PENDING_REVOKE.equals(this.authStatus) && !AUTH_STATUS_REVOKING.equals(this.authStatus)) { + this.authStatus = AUTH_STATUS_ACTIVE; + } + break; + case 2: + this.authStatus = AUTH_STATUS_ERROR; + break; + case 3: + this.authStatus = AUTH_STATUS_REVOKED; + break; + default: + break; + } + } + } + + public Integer getRetryCount() + { + return retryCount; + } + + public void setRetryCount(Integer retryCount) + { + this.retryCount = retryCount; + } + + public String getLastError() + { + return lastError; + } + + public void setLastError(String lastError) + { + this.lastError = lastError; + } + + public String getExternalAuthId() + { + return externalAuthId; + } + + public void setExternalAuthId(String externalAuthId) + { + this.externalAuthId = externalAuthId; + } + + public Date getLastSyncTime() + { + return lastSyncTime; + } + + public void setLastSyncTime(Date lastSyncTime) + { + this.lastSyncTime = lastSyncTime; + } + + public String getStudentId() + { + return studentId; + } + + public void setStudentId(String studentId) + { + this.studentId = studentId; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getPlanId() + { + return planId; + } + + public void setPlanId(String planId) + { + this.planId = planId; + } + + public String getPlanName() + { + return planName; + } + + public void setPlanName(String planName) + { + this.planName = planName; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getDormBuilding() + { + return dormBuilding; + } + + public void setDormBuilding(String dormBuilding) + { + this.dormBuilding = dormBuilding; + } + + public String getDormRoom() + { + return dormRoom; + } + + public void setDormRoom(String dormRoom) + { + this.dormRoom = dormRoom; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public String getCreateUser() + { + return createUser != null ? createUser : getCreateBy(); + } + + public void setCreateUser(String createUser) + { + this.createUser = createUser; + if (getCreateBy() == null) + { + setCreateBy(createUser); + } } @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) + public String toString() + { + return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) .append("authId", getAuthId()) - .append("studentId", getStudentId()) - .append("planId", getPlanId()) - .append("dormId", getDormId()) - .append("bedNo", getBedNo()) - .append("faceSyncStatus", getFaceSyncStatus()) + .append("accommodationId", getAccommodationId()) .append("authStart", getAuthStart()) .append("authEnd", getAuthEnd()) - .append("createUser", getCreateUser()) + .append("faceSyncStatus", getFaceSyncStatus()) + .append("retryCount", getRetryCount()) + .append("lastError", getLastError()) + .append("externalAuthId", getExternalAuthId()) + .append("lastSyncTime", getLastSyncTime()) + .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) + .append("updateBy", getUpdateBy()) .append("updateTime", getUpdateTime()) .append("remark", getRemark()) .toString(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalOperation.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalOperation.java new file mode 100644 index 000000000..e35acb4c6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalOperation.java @@ -0,0 +1,299 @@ +package com.ruoyi.train.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 外部操作流水对象 train_external_operation + * 用于追踪向外部物联网平台发起的所有设备异步操作(如门禁下发、撤销、查询)与轮询对账轨迹 + * + * @author ruoyi + * @date 2026-09-15 + */ +public class TrainExternalOperation extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 本地操作流水ID(主键) */ + private Long operationId; + + /** 关联门禁授权主记录ID(外键关联 train_dorm_auth) */ + @Excel(name = "门禁授权ID") + private Long doorAuthId; + + /** 业务类型(默认 DOOR_AUTH) */ + @Excel(name = "业务类型") + private String businessType; + + /** 外部供应商标识(如 DMS) */ + @Excel(name = "外部供应商标识") + private String vendorCode; + + /** 操作类型(GRANT下发, REVOKE注销, STATUS_QUERY状态查询) */ + @Excel(name = "操作类型") + private String operationType; + + public static final String TYPE_GRANT = "GRANT"; + public static final String TYPE_REVOKE = "REVOKE"; + public static final String TYPE_STATUS_QUERY = "STATUS_QUERY"; + + public static final String STATUS_CREATED = "CREATED"; + public static final String STATUS_REQUESTED = "REQUESTED"; + public static final String STATUS_ACCEPTED = "ACCEPTED"; + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RETRY_WAIT = "RETRY_WAIT"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAILED = "FAILED"; + public static final String STATUS_TIMEOUT = "TIMEOUT"; + + /** 操作状态(CREATED, REQUESTED, ACCEPTED, PENDING, RETRY_WAIT, SUCCESS, FAILED, TIMEOUT) */ + @Excel(name = "操作状态") + private String operationStatus; + + /** 本地全局幂等键(防重复调用) */ + @Excel(name = "幂等键") + private String idempotencyKey; + + /** 外部返回的操作流水ID(如 DMS operationId,初始为NULL) */ + @Excel(name = "外部操作ID") + private String externalOperationId; + + /** 外部请求类型数字码(如 1=下发密码, 3=删除密码) */ + @Excel(name = "外部请求类型") + private Integer externalRequestType; + + /** 外部硬件标识/SN */ + @Excel(name = "外部硬件SN") + private String externalHardwareId; + + /** 外部凭证钥匙ID(如 DMS keyId) */ + @Excel(name = "外部钥匙ID") + private String externalKeyId; + + /** 尝试/轮询次数 */ + @Excel(name = "尝试次数") + private Integer attemptCount; + + /** 下次轮询执行时间戳 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "下次轮询时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date nextPollTime; + + /** 向外部发出请求的时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "请求发起时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date requestedAt; + + /** 操作达成终态时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "达成终态时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date completedAt; + + /** 最后一次错误编码 */ + @Excel(name = "最后错误码") + private String lastErrorCode; + + /** 最后一次错误描述 */ + @Excel(name = "最后错误描述") + private String lastErrorMessage; + + public TrainExternalOperation() + { + } + + public Long getOperationId() + { + return operationId; + } + + public void setOperationId(Long operationId) + { + this.operationId = operationId; + } + + public Long getDoorAuthId() + { + return doorAuthId; + } + + public void setDoorAuthId(Long doorAuthId) + { + this.doorAuthId = doorAuthId; + } + + public String getBusinessType() + { + return businessType; + } + + public void setBusinessType(String businessType) + { + this.businessType = businessType; + } + + public String getVendorCode() + { + return vendorCode; + } + + public void setVendorCode(String vendorCode) + { + this.vendorCode = vendorCode; + } + + public String getOperationType() + { + return operationType; + } + + public void setOperationType(String operationType) + { + this.operationType = operationType; + } + + public String getOperationStatus() + { + return operationStatus; + } + + public void setOperationStatus(String operationStatus) + { + this.operationStatus = operationStatus; + } + + public String getIdempotencyKey() + { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) + { + this.idempotencyKey = idempotencyKey; + } + + public String getExternalOperationId() + { + return externalOperationId; + } + + public void setExternalOperationId(String externalOperationId) + { + this.externalOperationId = externalOperationId; + } + + public Integer getExternalRequestType() + { + return externalRequestType; + } + + public void setExternalRequestType(Integer externalRequestType) + { + this.externalRequestType = externalRequestType; + } + + public String getExternalHardwareId() + { + return externalHardwareId; + } + + public void setExternalHardwareId(String externalHardwareId) + { + this.externalHardwareId = externalHardwareId; + } + + public String getExternalKeyId() + { + return externalKeyId; + } + + public void setExternalKeyId(String externalKeyId) + { + this.externalKeyId = externalKeyId; + } + + public Integer getAttemptCount() + { + return attemptCount; + } + + public void setAttemptCount(Integer attemptCount) + { + this.attemptCount = attemptCount; + } + + public Date getNextPollTime() + { + return nextPollTime; + } + + public void setNextPollTime(Date nextPollTime) + { + this.nextPollTime = nextPollTime; + } + + public Date getRequestedAt() + { + return requestedAt; + } + + public void setRequestedAt(Date requestedAt) + { + this.requestedAt = requestedAt; + } + + public Date getCompletedAt() + { + return completedAt; + } + + public void setCompletedAt(Date completedAt) + { + this.completedAt = completedAt; + } + + public String getLastErrorCode() + { + return lastErrorCode; + } + + public void setLastErrorCode(String lastErrorCode) + { + this.lastErrorCode = lastErrorCode; + } + + public String getLastErrorMessage() + { + return lastErrorMessage; + } + + public void setLastErrorMessage(String lastErrorMessage) + { + this.lastErrorMessage = lastErrorMessage; + } + + @Override + public String toString() + { + return "TrainExternalOperation{" + + "operationId=" + operationId + + ", doorAuthId=" + doorAuthId + + ", businessType='" + businessType + '\'' + + ", vendorCode='" + vendorCode + '\'' + + ", operationType='" + operationType + '\'' + + ", operationStatus='" + operationStatus + '\'' + + ", idempotencyKey='" + idempotencyKey + '\'' + + ", externalOperationId='" + externalOperationId + '\'' + + ", externalRequestType=" + externalRequestType + + ", externalHardwareId='" + externalHardwareId + '\'' + + ", externalKeyId='" + externalKeyId + '\'' + + ", attemptCount=" + attemptCount + + ", nextPollTime=" + nextPollTime + + ", requestedAt=" + requestedAt + + ", completedAt=" + completedAt + + ", lastErrorCode='" + lastErrorCode + '\'' + + ", lastErrorMessage='" + lastErrorMessage + '\'' + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalResourceMapping.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalResourceMapping.java new file mode 100644 index 000000000..e4fd5c7f1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalResourceMapping.java @@ -0,0 +1,209 @@ +package com.ruoyi.train.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 外部资源映射对象 train_external_resource_mapping + * 用于管理本地宿舍与外部物联网设备(如门锁、房间)的绑定关系,支持一房多锁与多供应商适配 + * + * @author ruoyi + * @date 2026-09-15 + */ +public class TrainExternalResourceMapping extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 映射ID(主键) */ + private Long mappingId; + + /** 本地宿舍ID(外键关联 train_dormitory_info) */ + @Excel(name = "本地宿舍ID") + private String dormId; + + /** 外部供应商标识(如 DMS) */ + @Excel(name = "外部供应商标识") + private String vendorCode; + + /** 本地资源类型(DORM_ROOM, DOOR_LOCK) */ + @Excel(name = "本地资源类型") + private String localResourceType; + + /** 外部资源类型(ROOM, DOOR_LOCK) */ + @Excel(name = "外部资源类型") + private String externalResourceType; + + /** 外部系统资源全局主键(如 placeId, lockId) */ + @Excel(name = "外部资源ID") + private String externalResourceId; + + /** 外部父级资源标识(如 building/floor placeId) */ + @Excel(name = "外部父级资源标识") + private String externalParentId; + + /** 外部硬件标识/SN(如门锁 hardwareId) */ + @Excel(name = "外部硬件SN") + private String externalHardwareId; + + /** 外部项目/租户站点ID(如 DMS projectId) */ + @Excel(name = "外部租户项目ID") + private String externalProjectId; + + /** 是否主门锁/主资源(1是, 0否) */ + @Excel(name = "是否主资源") + private Integer isPrimary; + + /** 映射状态(ACTIVE有效, INACTIVE失效) */ + @Excel(name = "映射状态") + private String mappingStatus; + + /** 最近同步元数据时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "最近同步时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date lastSyncTime; + + public TrainExternalResourceMapping() + { + } + + public Long getMappingId() + { + return mappingId; + } + + public void setMappingId(Long mappingId) + { + this.mappingId = mappingId; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getVendorCode() + { + return vendorCode; + } + + public void setVendorCode(String vendorCode) + { + this.vendorCode = vendorCode; + } + + public String getLocalResourceType() + { + return localResourceType; + } + + public void setLocalResourceType(String localResourceType) + { + this.localResourceType = localResourceType; + } + + public String getExternalResourceType() + { + return externalResourceType; + } + + public void setExternalResourceType(String externalResourceType) + { + this.externalResourceType = externalResourceType; + } + + public String getExternalResourceId() + { + return externalResourceId; + } + + public void setExternalResourceId(String externalResourceId) + { + this.externalResourceId = externalResourceId; + } + + public String getExternalParentId() + { + return externalParentId; + } + + public void setExternalParentId(String externalParentId) + { + this.externalParentId = externalParentId; + } + + public String getExternalHardwareId() + { + return externalHardwareId; + } + + public void setExternalHardwareId(String externalHardwareId) + { + this.externalHardwareId = externalHardwareId; + } + + public String getExternalProjectId() + { + return externalProjectId; + } + + public void setExternalProjectId(String externalProjectId) + { + this.externalProjectId = externalProjectId; + } + + public Integer getIsPrimary() + { + return isPrimary; + } + + public void setIsPrimary(Integer isPrimary) + { + this.isPrimary = isPrimary; + } + + public String getMappingStatus() + { + return mappingStatus; + } + + public void setMappingStatus(String mappingStatus) + { + this.mappingStatus = mappingStatus; + } + + public Date getLastSyncTime() + { + return lastSyncTime; + } + + public void setLastSyncTime(Date lastSyncTime) + { + this.lastSyncTime = lastSyncTime; + } + + @Override + public String toString() + { + return "TrainExternalResourceMapping{" + + "mappingId=" + mappingId + + ", dormId='" + dormId + '\'' + + ", vendorCode='" + vendorCode + '\'' + + ", localResourceType='" + localResourceType + '\'' + + ", externalResourceType='" + externalResourceType + '\'' + + ", externalResourceId='" + externalResourceId + '\'' + + ", externalParentId='" + externalParentId + '\'' + + ", externalHardwareId='" + externalHardwareId + '\'' + + ", externalProjectId='" + externalProjectId + '\'' + + ", isPrimary=" + isPrimary + + ", mappingStatus='" + mappingStatus + '\'' + + ", lastSyncTime=" + lastSyncTime + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java new file mode 100644 index 000000000..7c0ca2ea9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java @@ -0,0 +1,252 @@ +package com.ruoyi.train.integration.dms.auth; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsTokenRequest; +import com.ruoyi.train.integration.dms.dto.DmsTokenResponse; +import com.ruoyi.train.integration.dms.exception.DmsAuthenticationException; +import com.ruoyi.train.integration.dms.exception.DmsTransportException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +/** + * DMS 访问令牌集中管理器 + *

+ * 核心并发架构与安全保证: + * 1. 单进程唯一内存缓存 (Single Token Cache)。 + * 2. 基于 ReentrantLock + 双重检查锁定 (DCL) 的 Single-Flight 机制,杜绝并发请求击穿导致重复登录。 + * 3. 预防性提前刷新 (Refresh Skew),在 Token 到期前预先刷新。 + * 4. 滑动有效期延展 (markSuccessfulUse),在成功调用 DMS API 后同步延长本地有效期基准。 + * 5. 集中失效 (invalidateToken) 与 401 自愈恢复。 + * 6. 支持注入 Clock 实现测试确定性。 + */ +@Component +public class DmsTokenManager { + private static final Logger log = LoggerFactory.getLogger(DmsTokenManager.class); + + private final DmsProperties dmsProperties; + private final ReentrantLock lock = new ReentrantLock(); + private final AtomicReference tokenCache = new AtomicReference<>(null); + private final AtomicInteger loginCounter = new AtomicInteger(0); + private Clock clock = Clock.systemUTC(); + + public DmsTokenManager(DmsProperties dmsProperties) { + this.dmsProperties = dmsProperties; + } + + /** + * 允许在测试中注入 FakeClock 或指定 Clock + */ + public void setClock(Clock clock) { + this.clock = clock; + } + + public Clock getClock() { + return this.clock; + } + + /** + * 获取当前有效 Token。 + * 若本地缓存有效且未进入刷新窗口,直接复用; + * 若失效或即将过期,通过 Single-Flight 互斥锁发起登录刷新。 + */ + public String getToken() { + TokenEntry current = tokenCache.get(); + Instant now = clock.instant(); + + if (isTokenValid(current, now)) { + return current.token; + } + + lock.lock(); + try { + // Double-Checked Locking (DCL) + current = tokenCache.get(); + now = clock.instant(); + if (isTokenValid(current, now)) { + return current.token; + } + + TokenEntry newEntry = performLogin(); + tokenCache.set(newEntry); + log.info("DMS token refreshed successfully (loginCount={})", loginCounter.get()); + return newEntry.token; + } finally { + lock.unlock(); + } + } + + /** + * 使当前本地缓存的 Token 失效 (如收到 401 Unauthorized 时调用) + */ + public void invalidateToken() { + tokenCache.set(null); + log.info("DMS cached token invalidated"); + } + + /** + * 成功调用 DMS 平台 API 后滑动延展本地有效期限 + * (根据官方文档,成功调用平台任一接口会使服务端 Token TTL 重新刷新为 2 小时) + */ + public void markSuccessfulUse() { + TokenEntry current = tokenCache.get(); + if (current != null) { + Instant now = clock.instant(); + long ttlSeconds = dmsProperties.getTokenTtlSeconds() > 0 ? dmsProperties.getTokenTtlSeconds() : 7200; + Instant newExpiresAt = now.plusSeconds(ttlSeconds); + // 只有延展时间晚于当前记录时才更新 + if (newExpiresAt.isAfter(current.expiresAt)) { + tokenCache.compareAndSet(current, new TokenEntry(current.token, newExpiresAt)); + } + } + } + + /** + * 仅供测试验证登录发起次数 + */ + public int getLoginCounter() { + return loginCounter.get(); + } + + private boolean isTokenValid(TokenEntry entry, Instant now) { + if (entry == null || entry.token == null || entry.token.trim().isEmpty()) { + return false; + } + int skew = Math.max(0, dmsProperties.getTokenRefreshSkewSeconds()); + return now.plusSeconds(skew).isBefore(entry.expiresAt); + } + + private TokenEntry performLogin() { + String appId = dmsProperties.getAppId(); + String appSecret = dmsProperties.getAppSecret(); + + if (appId == null || appId.trim().isEmpty() || appSecret == null || appSecret.trim().isEmpty()) { + throw new DmsAuthenticationException("DMS credentials not configured (appId or appSecret is empty)"); + } + + String targetUrl = dmsProperties.getBaseUrl(); + if (targetUrl == null || targetUrl.trim().isEmpty()) { + targetUrl = "https://dms.dmsiot.com"; + } + targetUrl = targetUrl.replaceAll("/+$", "") + "/public/rest/third-party-app/token/get"; + + loginCounter.incrementAndGet(); + + DmsTokenRequest requestDto = new DmsTokenRequest(appId, appSecret); + String requestJson = JSON.toJSONString(requestDto); + + HttpURLConnection conn = null; + try { + URL url = new URL(targetUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(dmsProperties.getConnectTimeoutMs()); + conn.setReadTimeout(dmsProperties.getReadTimeoutMs()); + conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setDoOutput(true); + conn.setDoInput(true); + + try (OutputStream os = conn.getOutputStream()) { + os.write(requestJson.getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + + int responseCode = conn.getResponseCode(); + InputStream is = (responseCode >= 200 && responseCode < 300) ? conn.getInputStream() : conn.getErrorStream(); + String responseBody = readStream(is); + + if (responseCode == 401) { + throw new DmsAuthenticationException("DMS login unauthorized (HTTP 401)"); + } + if (responseCode < 200 || responseCode >= 300) { + throw new DmsAuthenticationException("DMS login failed with HTTP status: " + responseCode + ", response: " + responseBody); + } + + JSONObject jsonObject = JSON.parseObject(responseBody); + if (jsonObject == null) { + throw new DmsAuthenticationException("Empty response body received from DMS token endpoint"); + } + + // 兼容直接包含 token 或在 data 包装内包含 token + String token = jsonObject.getString("token"); + Long expiresIn = jsonObject.getLong("expiresIn"); + + if (token == null && jsonObject.containsKey("data")) { + Object dataObj = jsonObject.get("data"); + if (dataObj instanceof JSONObject) { + JSONObject dataJson = (JSONObject) dataObj; + token = dataJson.getString("token"); + if (expiresIn == null) { + expiresIn = dataJson.getLong("expiresIn"); + } + } else if (dataObj instanceof String) { + token = (String) dataObj; + } + } + + if (token == null || token.trim().isEmpty()) { + String errorMsg = jsonObject.getString("message"); + throw new DmsAuthenticationException("DMS token not found in response: " + (errorMsg != null ? errorMsg : responseBody)); + } + + long ttlSeconds = (expiresIn != null && expiresIn > 0) ? expiresIn : dmsProperties.getTokenTtlSeconds(); + if (ttlSeconds <= 0) { + ttlSeconds = 7200; + } + + Instant expiresAt = clock.instant().plusSeconds(ttlSeconds); + return new TokenEntry(token, expiresAt); + + } catch (DmsAuthenticationException e) { + throw e; + } catch (Exception e) { + throw new DmsTransportException("Failed to connect to DMS token endpoint: " + e.getMessage(), e); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private String readStream(InputStream is) { + if (is == null) { + return ""; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + private static class TokenEntry { + final String token; + final Instant expiresAt; + + TokenEntry(String token, Instant expiresAt) { + this.token = token; + this.expiresAt = expiresAt; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java new file mode 100644 index 000000000..0d78807e6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java @@ -0,0 +1,58 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * DMS 智能门锁适配客户端 + *

+ * 遵循 Dormitory 1:0..N DoorLocks 模型,支持返回 0 把锁、1 把锁或多把锁,绝不自动截断或伪造。 + */ +@Component +public class DmsDoorLockClient { + private static final Logger log = LoggerFactory.getLogger(DmsDoorLockClient.class); + + private final DmsHttpClient httpClient; + + public DmsDoorLockClient(DmsHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * 根据空间/房间 ID 查询挂载的智能门锁列表 (POST /rest/doorlock/list) + * + * @param placeId DMS 房间外部空间标识 + * @return 智能门锁列表 (无锁时返回空列表,非空) + */ + public List listDoorLocksByPlaceId(Long placeId) { + if (placeId == null) { + return Collections.emptyList(); + } + + Map req = new HashMap<>(); + req.put("placeId", placeId); + + JSONObject resp = httpClient.postQuery("/rest/doorlock/list", req, JSONObject.class); + if (resp == null) { + return Collections.emptyList(); + } + + if (resp.containsKey("rows") && resp.get("rows") instanceof JSONArray) { + return resp.getJSONArray("rows").toJavaList(DmsDoorLockDto.class); + } + if (resp.containsKey("data") && resp.get("data") instanceof JSONArray) { + return resp.getJSONArray("data").toJavaList(DmsDoorLockDto.class); + } + + return Collections.emptyList(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java new file mode 100644 index 000000000..67345330a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java @@ -0,0 +1,224 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.exception.DmsAuthenticationException; +import com.ruoyi.train.integration.dms.exception.DmsBusinessException; +import com.ruoyi.train.integration.dms.exception.DmsTransportException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * DMS 统一基础 HTTP 客户端 + *

+ * 核心设计准则: + * 1. 集中管理 Base URL 与 Token 传输 (优先 Cookie 头,格式为 Cookie: token=xxx)。 + * 2. 严禁使用未核实的 Authorization: Bearer。 + * 3. 成功响应触发 tokenManager.markSuccessfulUse() 延续会话。 + * 4. 401 未授权集中废除 Token。 + * 5. 变更加写操作 (Mutating Request) 超时后绝对禁止自动盲目重发 (AMBIGUOUS_MUTATION_AUTO_RETRY = NO)。 + * 6. 全流程脱敏日志 (Sanitized Logging),绝不泄露凭据、密码明文或完整 Token。 + */ +@Component +public class DmsHttpClient { + private static final Logger log = LoggerFactory.getLogger(DmsHttpClient.class); + + private final DmsProperties dmsProperties; + private final DmsTokenManager tokenManager; + + public DmsHttpClient(DmsProperties dmsProperties, DmsTokenManager tokenManager) { + this.dmsProperties = dmsProperties; + this.tokenManager = tokenManager; + } + + public DmsProperties getDmsProperties() { + return dmsProperties; + } + + public DmsTokenManager getTokenManager() { + return tokenManager; + } + + /** + * 发送 POST 查询请求 (只读安全请求,isMutation = false) + */ + public T postQuery(String path, Object requestBody, Class responseClass) { + return executePost(path, requestBody, responseClass, false, false); + } + + /** + * 发送 POST 写操作请求 (涉及设备凭据变更,isMutation = true,读取超时绝不自动重试) + */ + public T postMutation(String path, Object requestBody, Class responseClass) { + return executePost(path, requestBody, responseClass, true, false); + } + + /** + * 发送 POST 请求并反序列化指定对象 + */ + public T post(String path, Object requestBody, Class responseClass, boolean isMutation) { + return executePost(path, requestBody, responseClass, isMutation, false); + } + + /** + * 发送 POST 请求并直接返回 JSON 根对象 + */ + public JSONObject postForJsonObject(String path, Object requestBody, boolean isMutation) { + return executePost(path, requestBody, JSONObject.class, isMutation, false); + } + + private T executePost(String path, Object requestBody, Class responseClass, boolean isMutation, boolean isRetry) { + String token = tokenManager.getToken(); + String targetUrl = buildUrl(path); + + HttpURLConnection conn = null; + try { + URL url = new URL(targetUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(dmsProperties.getConnectTimeoutMs()); + conn.setReadTimeout(dmsProperties.getReadTimeoutMs()); + conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); + conn.setRequestProperty("Accept", "application/json"); + + // DMS 官方规范:Token 传输采用 Cookie + String cookieName = dmsProperties.getTokenCookieName() != null ? dmsProperties.getTokenCookieName() : "token"; + conn.setRequestProperty("Cookie", cookieName + "=" + token); + + conn.setDoOutput(true); + conn.setDoInput(true); + + if (requestBody != null) { + String jsonBody = JSON.toJSONString(requestBody); + try (OutputStream os = conn.getOutputStream()) { + os.write(jsonBody.getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + } else { + try (OutputStream os = conn.getOutputStream()) { + os.write("{}".getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + } + + int responseCode = conn.getResponseCode(); + InputStream is = (responseCode >= 200 && responseCode < 300) ? conn.getInputStream() : conn.getErrorStream(); + String responseBody = readStream(is); + + if (responseCode == 401) { + log.warn("DMS returned 401 Unauthorized for path: {}", sanitizePath(path)); + tokenManager.invalidateToken(); + // 仅对非写操作且未重试过的请求尝试单次刷新重试 + if (!isMutation && !isRetry) { + log.info("Attempting single safe retry after token refresh for query path: {}", sanitizePath(path)); + return executePost(path, requestBody, responseClass, isMutation, true); + } + throw new DmsAuthenticationException("DMS request unauthorized (HTTP 401) on path: " + sanitizePath(path)); + } + + if (responseCode < 200 || responseCode >= 300) { + throw new DmsTransportException("DMS HTTP error status: " + responseCode + ", path: " + sanitizePath(path) + ", response: " + sanitizeLogging(responseBody)); + } + + JSONObject jsonObject = JSON.parseObject(responseBody); + if (jsonObject != null && jsonObject.containsKey("success") && Boolean.FALSE.equals(jsonObject.getBoolean("success"))) { + String code = jsonObject.getString("code"); + String message = jsonObject.getString("message"); + throw new DmsBusinessException(code != null ? code : "VENDOR_ERROR", message != null ? message : responseBody); + } + + // 仅在明确获得已认证且业务成功的有效 DMS 响应后,才滑动延展本地 Token 有效期 + tokenManager.markSuccessfulUse(); + + if (responseClass == null || responseClass.equals(Void.class)) { + return null; + } + if (responseClass.equals(JSONObject.class)) { + return (T) jsonObject; + } + if (responseClass.equals(String.class)) { + return (T) responseBody; + } + + return JSON.parseObject(responseBody, responseClass); + + } catch (SocketTimeoutException e) { + if (isMutation) { + // 写操作读取超时,结果未决,绝对禁止自动重发! + log.error("DMS SocketTimeoutException on mutating request [{}]. AMBIGUOUS MUTATION: Auto-retry is strictly SUPPRESSED for safety.", sanitizePath(path)); + throw new DmsTransportException("DMS mutating request timed out; auto-retry suppressed to prevent duplicate credentials: " + e.getMessage(), e); + } else { + throw new DmsTransportException("DMS query read timed out on path: " + sanitizePath(path) + ": " + e.getMessage(), e); + } + } catch (DmsAuthenticationException | DmsBusinessException | DmsTransportException e) { + throw e; + } catch (Exception e) { + throw new DmsTransportException("DMS request failed on path: " + sanitizePath(path) + ": " + e.getMessage(), e); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private String buildUrl(String path) { + String base = dmsProperties.getBaseUrl(); + if (base == null || base.trim().isEmpty()) { + base = "https://dms.dmsiot.com"; + } + base = base.replaceAll("/+$", ""); + if (path == null) { + path = ""; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + return base + path; + } + + private String readStream(InputStream is) { + if (is == null) { + return ""; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + private String sanitizePath(String path) { + if (path == null) { + return ""; + } + return path.replaceAll("(?i)(password|token|secret)=[^&]+", "$1=[PROTECTED]"); + } + + private String sanitizeLogging(String message) { + if (message == null) { + return ""; + } + return message + .replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2") + .replaceAll("(?i)(\"token\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2") + .replaceAll("(?i)(\"appsecret\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2"); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java new file mode 100644 index 000000000..7b9b68f06 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java @@ -0,0 +1,140 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.dto.DmsBuildingDto; +import com.ruoyi.train.integration.dms.dto.DmsFloorDto; +import com.ruoyi.train.integration.dms.dto.DmsRoomDto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * DMS 位置与空间适配客户端 (Building / Floor / Room) + */ +@Component +public class DmsLocationClient { + private static final Logger log = LoggerFactory.getLogger(DmsLocationClient.class); + + private final DmsHttpClient httpClient; + + public DmsLocationClient(DmsHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * 查询建筑列表 (POST /rest/place/building/list) + */ + public List listBuildings(Long projectId) { + Map req = new HashMap<>(); + if (projectId != null && projectId > 0) { + req.put("projectId", projectId); + } + JSONObject resp = httpClient.postQuery("/rest/place/building/list", req, JSONObject.class); + return extractList(resp, DmsBuildingDto.class); + } + + /** + * 查询楼层列表 (POST /rest/place/floor/list) + */ + public List listFloors(Long buildingId) { + Map req = new HashMap<>(); + req.put("buildingId", buildingId); + JSONObject resp = httpClient.postQuery("/rest/place/floor/list", req, JSONObject.class); + return extractList(resp, DmsFloorDto.class); + } + + /** + * 查询房间列表 (POST /rest/place/room/list - 按 floorId 查询) + */ + public List listRooms(Long floorId) { + Map req = new HashMap<>(); + if (floorId != null) { + req.put("floorId", floorId); + } + JSONObject resp = httpClient.postQuery("/rest/place/room/list", req, JSONObject.class); + return extractList(resp, DmsRoomDto.class); + } + + /** + * 按建筑、楼层、房号及分页查询房间列表 (POST /rest/place/room/list - 官方全量入参支持) + */ + public List listRooms(String building, String floor, String room, Integer offset, Integer limit) { + Map req = new HashMap<>(); + if (building != null && !building.trim().isEmpty()) { + req.put("building", building.trim()); + } + if (floor != null && !floor.trim().isEmpty()) { + req.put("floor", floor.trim()); + } + if (room != null && !room.trim().isEmpty()) { + req.put("room", room.trim()); + } + if (offset != null) { + req.put("offset", offset); + } + if (limit != null) { + req.put("limit", limit); + } + JSONObject resp = httpClient.postQuery("/rest/place/room/list", req, JSONObject.class); + return extractList(resp, DmsRoomDto.class); + } + + /** + * 获取单个房间详情 (POST /rest/place/room/get - 按 ID) + */ + public DmsRoomDto getRoom(Long roomId) { + Map req = new HashMap<>(); + if (roomId != null) { + req.put("id", roomId); + } + JSONObject resp = httpClient.postQuery("/rest/place/room/get", req, JSONObject.class); + return parseRoomDto(resp); + } + + /** + * 精确获取房间详情与 ID (POST /rest/place/room/get - 官方 building/floor/room 三元组查询) + */ + public DmsRoomDto getRoomByLocation(String building, String floor, String room) { + Map req = new HashMap<>(); + if (building != null) { + req.put("building", building.trim()); + } + if (floor != null) { + req.put("floor", floor.trim()); + } + if (room != null) { + req.put("room", room.trim()); + } + JSONObject resp = httpClient.postQuery("/rest/place/room/get", req, JSONObject.class); + return parseRoomDto(resp); + } + + private DmsRoomDto parseRoomDto(JSONObject resp) { + if (resp == null) { + return null; + } + if (resp.containsKey("data") && resp.get("data") instanceof JSONObject) { + return resp.getJSONObject("data").toJavaObject(DmsRoomDto.class); + } + return resp.toJavaObject(DmsRoomDto.class); + } + + private List extractList(JSONObject resp, Class clazz) { + if (resp == null) { + return Collections.emptyList(); + } + if (resp.containsKey("rows") && resp.get("rows") instanceof JSONArray) { + return resp.getJSONArray("rows").toJavaList(clazz); + } + if (resp.containsKey("data") && resp.get("data") instanceof JSONArray) { + return resp.getJSONArray("data").toJavaList(clazz); + } + return Collections.emptyList(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java new file mode 100644 index 000000000..a8ddf52ba --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java @@ -0,0 +1,52 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest; +import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse; +import com.ruoyi.train.integration.dms.exception.DmsBusinessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * DMS 门锁异步操作结果查询适配客户端 + * (POST /rest/doorlock/query-operation-status) + */ +@Component +public class DmsOperationStatusClient { + private static final Logger log = LoggerFactory.getLogger(DmsOperationStatusClient.class); + + private final DmsHttpClient httpClient; + + public DmsOperationStatusClient(DmsHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * 查询门锁操作结果 (安全只读查询,isMutation = false) + */ + public DmsOperationStatusResponse queryOperationStatus(DmsOperationStatusRequest request) { + if (request == null || request.getOperationId() == null) { + throw new IllegalArgumentException("DmsOperationStatusRequest and operationId must not be null"); + } + + JSONObject resp = httpClient.postQuery("/rest/doorlock/query-operation-status", request, JSONObject.class); + if (resp == null) { + throw new DmsBusinessException("EMPTY_RESPONSE", "Received null response for operation status query"); + } + + JSONObject data = resp.getJSONObject("data"); + if (data == null) { + data = resp; + } + + DmsOperationStatusResponse statusResponse = new DmsOperationStatusResponse(); + statusResponse.setOperationId(data.getLong("operationId")); + statusResponse.setOperationStatus(data.getString("operationStatus")); + statusResponse.setResultDetails(data.getString("resultDetails")); + statusResponse.setMessage(data.getString("message")); + statusResponse.setHardwareId(data.getString("hardwareId")); + + return statusResponse; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java new file mode 100644 index 000000000..4e88a65ce --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java @@ -0,0 +1,105 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse; +import com.ruoyi.train.integration.dms.exception.DmsBusinessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * DMS 在线密码管理适配客户端 (Create / Delete) + *

+ * 核心设计准则: + * 1. 密码下发与撤销属于设备写操作 (Mutation),通过 postMutation 执行,网络读取超时绝对不自动重试。 + * 2. 区分 HTTP_ACCEPTED 与 DEVICE_SUCCESS:HTTP 200 返回仅代表 DMS 已受理异步指令并生成 operationId, + * 不可直接标记本地 ExternalOperation 为 SUCCESS。 + * 3. 密码支持 4~8 位数字,密码明文仅在调用栈短暂留存,绝不输出到日志或持久化。 + */ +@Component +public class DmsPasswordClient { + private static final Logger log = LoggerFactory.getLogger(DmsPasswordClient.class); + + private final DmsHttpClient httpClient; + + public DmsPasswordClient(DmsHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * 下发在线密码 (POST /rest/doorlock/online-password-key/create) + */ + public DmsPasswordCreateResponse createOnlinePassword(DmsPasswordCreateRequest request) { + if (request == null) { + throw new IllegalArgumentException("DmsPasswordCreateRequest must not be null"); + } + if (request.getLockId() == null) { + throw new IllegalArgumentException("lockId must not be null"); + } + String pwd = request.getPassword(); + if (pwd == null || pwd.length() < 4 || pwd.length() > 8 || !pwd.matches("\\d+")) { + throw new IllegalArgumentException("DMS online password must be 4 to 8 digits"); + } + + log.info("Requesting DMS online password creation for lockId={}, holder={}, subType={}", + request.getLockId(), request.getHolder(), request.getSubType()); + + JSONObject resp = httpClient.postMutation("/rest/doorlock/online-password-key/create", request, JSONObject.class); + if (resp == null) { + throw new DmsBusinessException("EMPTY_RESPONSE", "Received null response from DMS password creation"); + } + + JSONObject data = resp.getJSONObject("data"); + if (data == null) { + data = resp; + } + + DmsPasswordCreateResponse createResponse = new DmsPasswordCreateResponse(); + createResponse.setId(data.getLong("id")); + createResponse.setOperationId(data.getLong("operationId")); + createResponse.setLockHardwareId(data.getString("lockHardwareId")); + createResponse.setSmsStatus(data.getInteger("smsStatus")); + + log.info("DMS online password accepted: keyId={}, operationId={}", + createResponse.getId(), createResponse.getOperationId()); + + return createResponse; + } + + /** + * 删除在线密码 (POST /rest/doorlock/online-password/delete) + */ + public DmsPasswordDeleteResponse deleteOnlinePassword(DmsPasswordDeleteRequest request) { + if (request == null || request.getId() == null) { + throw new IllegalArgumentException("DmsPasswordDeleteRequest and keyId must not be null"); + } + + log.info("Requesting DMS online password deletion for keyId={}", request.getId()); + + Map reqBody = new HashMap<>(); + reqBody.put("id", request.getId()); + + JSONObject resp = httpClient.postMutation("/rest/doorlock/online-password/delete", reqBody, JSONObject.class); + if (resp == null) { + throw new DmsBusinessException("EMPTY_RESPONSE", "Received null response from DMS password deletion"); + } + + JSONObject data = resp.getJSONObject("data"); + if (data == null) { + data = resp; + } + + DmsPasswordDeleteResponse deleteResponse = new DmsPasswordDeleteResponse(); + deleteResponse.setOperationId(data.getLong("operationId")); + + log.info("DMS online password deletion accepted: operationId={}", deleteResponse.getOperationId()); + + return deleteResponse; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java new file mode 100644 index 000000000..0450024fe --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java @@ -0,0 +1,194 @@ +package com.ruoyi.train.integration.dms.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * DMS 外部智能门锁系统配置属性 + */ +@Component +@ConfigurationProperties(prefix = "dms") +public class DmsProperties { + + /** + * 是否启用 DMS 集成 (默认 false,安全默认) + */ + private boolean enabled = false; + + /** + * DMS 服务端基础地址 (默认 https://dms.dmsiot.com) + */ + private String baseUrl = "https://dms.dmsiot.com"; + + /** + * DMS 应用 ID (由外部环境变量或配置文件提供,严禁硬编码生产真实值) + */ + private String appId = ""; + + /** + * DMS 应用密钥 (由外部环境变量或配置文件提供,严禁硬编码生产真实值) + */ + private String appSecret = ""; + + /** + * DMS 关联项目 ID + */ + private Long projectId = 0L; + + /** + * HTTP 连接超时时间 (毫秒,默认 5000) + */ + private int connectTimeoutMs = 5000; + + /** + * HTTP 读取超时时间 (毫秒,默认 10000) + */ + private int readTimeoutMs = 10000; + + /** + * Token 有效期 (秒,DMS 官方标准 7200 秒) + */ + private int tokenTtlSeconds = 7200; + + /** + * Token 提前刷新时间窗口 (秒,默认提前 120 秒) + */ + private int tokenRefreshSkewSeconds = 120; + + /** + * Token Cookie 请求头字段名 (默认 "token") + */ + private String tokenCookieName = "token"; + + /** + * 异步操作结果轮询初始延迟 (秒,官方推荐 10 秒) + */ + private int pollInitialDelaySeconds = 10; + + /** + * 轮询调度器间隔时间 (秒,默认 10 秒) + */ + private int pollIntervalSeconds = 10; + + /** + * 单次轮询拉取记录批次大小 (默认 50) + */ + private int pollBatchSize = 50; + + /** + * 最大重试查询次数 (默认 3) + */ + private int maxRetryAttempts = 3; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getAppSecret() { + return appSecret; + } + + public void setAppSecret(String appSecret) { + this.appSecret = appSecret; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public void setConnectTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + } + + public int getReadTimeoutMs() { + return readTimeoutMs; + } + + public void setReadTimeoutMs(int readTimeoutMs) { + this.readTimeoutMs = readTimeoutMs; + } + + public int getTokenTtlSeconds() { + return tokenTtlSeconds; + } + + public void setTokenTtlSeconds(int tokenTtlSeconds) { + this.tokenTtlSeconds = tokenTtlSeconds; + } + + public int getTokenRefreshSkewSeconds() { + return tokenRefreshSkewSeconds; + } + + public void setTokenRefreshSkewSeconds(int tokenRefreshSkewSeconds) { + this.tokenRefreshSkewSeconds = tokenRefreshSkewSeconds; + } + + public String getTokenCookieName() { + return tokenCookieName; + } + + public void setTokenCookieName(String tokenCookieName) { + this.tokenCookieName = tokenCookieName; + } + + public int getPollInitialDelaySeconds() { + return pollInitialDelaySeconds; + } + + public void setPollInitialDelaySeconds(int pollInitialDelaySeconds) { + this.pollInitialDelaySeconds = pollInitialDelaySeconds; + } + + public int getPollIntervalSeconds() { + return pollIntervalSeconds; + } + + public void setPollIntervalSeconds(int pollIntervalSeconds) { + this.pollIntervalSeconds = pollIntervalSeconds; + } + + public int getPollBatchSize() { + return pollBatchSize; + } + + public void setPollBatchSize(int pollBatchSize) { + this.pollBatchSize = pollBatchSize; + } + + public int getMaxRetryAttempts() { + return maxRetryAttempts; + } + + public void setMaxRetryAttempts(int maxRetryAttempts) { + this.maxRetryAttempts = maxRetryAttempts; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java new file mode 100644 index 000000000..188af302e --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java @@ -0,0 +1,76 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; +import java.util.List; + +/** + * DMS 响应包装信封 + */ +public class DmsBaseResponse implements Serializable { + private static final long serialVersionUID = 1L; + + private Boolean success; + private String code; + private String message; + private T data; + private List rows; + private Integer total; + + public Boolean getSuccess() { + return success != null && success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + public List getRows() { + return rows; + } + + public void setRows(List rows) { + this.rows = rows; + } + + public Integer getTotal() { + return total; + } + + public void setTotal(Integer total) { + this.total = total; + } + + @Override + public String toString() { + return "DmsBaseResponse{" + + "success=" + success + + ", code='" + code + '\'' + + ", message='" + message + '\'' + + ", total=" + total + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java new file mode 100644 index 000000000..10135df36 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java @@ -0,0 +1,57 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 建筑信息 DTO + */ +public class DmsBuildingDto implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long projectId; + private String name; + private String buildingNumber; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBuildingNumber() { + return buildingNumber; + } + + public void setBuildingNumber(String buildingNumber) { + this.buildingNumber = buildingNumber; + } + + @Override + public String toString() { + return "DmsBuildingDto{" + + "id=" + id + + ", projectId=" + projectId + + ", name='" + name + '\'' + + ", buildingNumber='" + buildingNumber + '\'' + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java new file mode 100644 index 000000000..8bc9c01aa --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java @@ -0,0 +1,93 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 智能门锁信息 DTO + */ +public class DmsDoorLockDto implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long lockId; + private String hardwareId; + private String lockName; + private Long placeId; + private Integer onlineStatus; + private Integer battery; + + public Long getId() { + return id != null ? id : lockId; + } + + public void setId(Long id) { + this.id = id; + if (this.lockId == null) { + this.lockId = id; + } + } + + public Long getLockId() { + return lockId != null ? lockId : id; + } + + public void setLockId(Long lockId) { + this.lockId = lockId; + if (this.id == null) { + this.id = lockId; + } + } + + public String getHardwareId() { + return hardwareId; + } + + public void setHardwareId(String hardwareId) { + this.hardwareId = hardwareId; + } + + public String getLockName() { + return lockName; + } + + public void setLockName(String lockName) { + this.lockName = lockName; + } + + public Long getPlaceId() { + return placeId; + } + + public void setPlaceId(Long placeId) { + this.placeId = placeId; + } + + public Integer getOnlineStatus() { + return onlineStatus; + } + + public void setOnlineStatus(Integer onlineStatus) { + this.onlineStatus = onlineStatus; + } + + public Integer getBattery() { + return battery; + } + + public void setBattery(Integer battery) { + this.battery = battery; + } + + @Override + public String toString() { + return "DmsDoorLockDto{" + + "id=" + getId() + + ", lockId=" + getLockId() + + ", hardwareId='" + hardwareId + '\'' + + ", lockName='" + lockName + '\'' + + ", placeId=" + placeId + + ", onlineStatus=" + onlineStatus + + ", battery=" + battery + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java new file mode 100644 index 000000000..9b76ce49d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java @@ -0,0 +1,57 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 楼层信息 DTO + */ +public class DmsFloorDto implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long buildingId; + private String name; + private Integer floorNumber; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getBuildingId() { + return buildingId; + } + + public void setBuildingId(Long buildingId) { + this.buildingId = buildingId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getFloorNumber() { + return floorNumber; + } + + public void setFloorNumber(Integer floorNumber) { + this.floorNumber = floorNumber; + } + + @Override + public String toString() { + return "DmsFloorDto{" + + "id=" + id + + ", buildingId=" + buildingId + + ", name='" + name + '\'' + + ", floorNumber=" + floorNumber + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java new file mode 100644 index 000000000..708e9400f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java @@ -0,0 +1,67 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 查询门锁操作结果请求 DTO + */ +public class DmsOperationStatusRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 请求操作类型 (1: 在线密码创建, 3: 在线密码删除等) + */ + private Integer requestType; + + /** + * 外部操作流水 ID + */ + private Long operationId; + + /** + * 锁硬件标识 + */ + private String hardwareId; + + public DmsOperationStatusRequest() { + } + + public DmsOperationStatusRequest(Integer requestType, Long operationId, String hardwareId) { + this.requestType = requestType; + this.operationId = operationId; + this.hardwareId = hardwareId; + } + + public Integer getRequestType() { + return requestType; + } + + public void setRequestType(Integer requestType) { + this.requestType = requestType; + } + + public Long getOperationId() { + return operationId; + } + + public void setOperationId(Long operationId) { + this.operationId = operationId; + } + + public String getHardwareId() { + return hardwareId; + } + + public void setHardwareId(String hardwareId) { + this.hardwareId = hardwareId; + } + + @Override + public String toString() { + return "DmsOperationStatusRequest{" + + "requestType=" + requestType + + ", operationId=" + operationId + + ", hardwareId='" + hardwareId + '\'' + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java new file mode 100644 index 000000000..50236ff35 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java @@ -0,0 +1,70 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 查询门锁操作结果响应 DTO + */ +public class DmsOperationStatusResponse implements Serializable { + private static final long serialVersionUID = 1L; + + private Long operationId; + /** + * 门锁操作状态: success, undone, failed, timeout + */ + private String operationStatus; + private String resultDetails; + private String message; + private String hardwareId; + + public Long getOperationId() { + return operationId; + } + + public void setOperationId(Long operationId) { + this.operationId = operationId; + } + + public String getOperationStatus() { + return operationStatus; + } + + public void setOperationStatus(String operationStatus) { + this.operationStatus = operationStatus; + } + + public String getResultDetails() { + return resultDetails; + } + + public void setResultDetails(String resultDetails) { + this.resultDetails = resultDetails; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getHardwareId() { + return hardwareId; + } + + public void setHardwareId(String hardwareId) { + this.hardwareId = hardwareId; + } + + @Override + public String toString() { + return "DmsOperationStatusResponse{" + + "operationId=" + operationId + + ", operationStatus='" + operationStatus + '\'' + + ", resultDetails='" + resultDetails + '\'' + + ", message='" + message + '\'' + + ", hardwareId='" + hardwareId + '\'' + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java new file mode 100644 index 000000000..399a28943 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java @@ -0,0 +1,116 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 下发在线密码请求 DTO + */ +public class DmsPasswordCreateRequest implements Serializable { + private static final long serialVersionUID = 1L; + + private Long lockId; + private String validStart; + private String validUntil; + private String holder; + private Integer canOpenAntilock = 1; + private String phoneCountry = "86"; + private String phoneNumber; + /** + * 在线密码明文 (4~8位数字,仅在内存短暂留存,严禁落库与输出日志) + */ + private String password; + /** + * 密码子类型: 1永久, 2单次, 4限时 (Pilot 业务主要使用 4) + */ + private Integer subType = 4; + + public Long getLockId() { + return lockId; + } + + public void setLockId(Long lockId) { + this.lockId = lockId; + } + + public String getValidStart() { + return validStart; + } + + public void setValidStart(String validStart) { + this.validStart = validStart; + } + + public String getValidUntil() { + return validUntil; + } + + public void setValidUntil(String validUntil) { + this.validUntil = validUntil; + } + + public String getHolder() { + return holder; + } + + public void setHolder(String holder) { + this.holder = holder; + } + + public Integer getCanOpenAntilock() { + return canOpenAntilock; + } + + public void setCanOpenAntilock(Integer canOpenAntilock) { + this.canOpenAntilock = canOpenAntilock; + } + + public String getPhoneCountry() { + return phoneCountry; + } + + public void setPhoneCountry(String phoneCountry) { + this.phoneCountry = phoneCountry; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public Integer getSubType() { + return subType; + } + + public void setSubType(Integer subType) { + this.subType = subType; + } + + @Override + public String toString() { + String maskedPhone = "[PROTECTED]"; + if (phoneNumber != null && phoneNumber.length() >= 7) { + maskedPhone = phoneNumber.substring(0, 3) + "****" + phoneNumber.substring(phoneNumber.length() - 4); + } + return "DmsPasswordCreateRequest{" + + "lockId=" + lockId + + ", validStart='" + validStart + '\'' + + ", validUntil='" + validUntil + '\'' + + ", holder='" + holder + '\'' + + ", canOpenAntilock=" + canOpenAntilock + + ", phoneNumber='" + maskedPhone + '\'' + + ", password='[PROTECTED]'" + + ", subType=" + subType + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java new file mode 100644 index 000000000..929545194 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java @@ -0,0 +1,72 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 下发在线密码响应 DTO + */ +public class DmsPasswordCreateResponse implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 钥匙 ID (对应外部 external_key_id) + */ + private Long id; + + /** + * 异步操作流水 ID (对应外部 external_operation_id) + */ + private Long operationId; + + /** + * 锁硬件标识 + */ + private String lockHardwareId; + + /** + * 短信发送状态 + */ + private Integer smsStatus; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getOperationId() { + return operationId; + } + + public void setOperationId(Long operationId) { + this.operationId = operationId; + } + + public String getLockHardwareId() { + return lockHardwareId; + } + + public void setLockHardwareId(String lockHardwareId) { + this.lockHardwareId = lockHardwareId; + } + + public Integer getSmsStatus() { + return smsStatus; + } + + public void setSmsStatus(Integer smsStatus) { + this.smsStatus = smsStatus; + } + + @Override + public String toString() { + return "DmsPasswordCreateResponse{" + + "id=" + id + + ", operationId=" + operationId + + ", lockHardwareId='" + lockHardwareId + '\'' + + ", smsStatus=" + smsStatus + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java new file mode 100644 index 000000000..c4a71e1cc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java @@ -0,0 +1,37 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 删除在线密码请求 DTO + */ +public class DmsPasswordDeleteRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 钥匙 ID (对应外部 external_key_id) + */ + private Long id; + + public DmsPasswordDeleteRequest() { + } + + public DmsPasswordDeleteRequest(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + @Override + public String toString() { + return "DmsPasswordDeleteRequest{" + + "id=" + id + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java new file mode 100644 index 000000000..af3255df7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java @@ -0,0 +1,30 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 删除在线密码响应 DTO + */ +public class DmsPasswordDeleteResponse implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 异步操作流水 ID + */ + private Long operationId; + + public Long getOperationId() { + return operationId; + } + + public void setOperationId(Long operationId) { + this.operationId = operationId; + } + + @Override + public String toString() { + return "DmsPasswordDeleteResponse{" + + "operationId=" + operationId + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java new file mode 100644 index 000000000..4b830f51f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java @@ -0,0 +1,83 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 房间/空间信息 DTO + */ +public class DmsRoomDto implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long placeId; + private Long floorId; + private Long buildingId; + private String roomNumber; + private String name; + + public Long getId() { + return id != null ? id : placeId; + } + + public void setId(Long id) { + this.id = id; + if (this.placeId == null) { + this.placeId = id; + } + } + + public Long getPlaceId() { + return placeId != null ? placeId : id; + } + + public void setPlaceId(Long placeId) { + this.placeId = placeId; + if (this.id == null) { + this.id = placeId; + } + } + + public Long getFloorId() { + return floorId; + } + + public void setFloorId(Long floorId) { + this.floorId = floorId; + } + + public Long getBuildingId() { + return buildingId; + } + + public void setBuildingId(Long buildingId) { + this.buildingId = buildingId; + } + + public String getRoomNumber() { + return roomNumber; + } + + public void setRoomNumber(String roomNumber) { + this.roomNumber = roomNumber; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return "DmsRoomDto{" + + "id=" + id + + ", placeId=" + placeId + + ", floorId=" + floorId + + ", buildingId=" + buildingId + + ", roomNumber='" + roomNumber + '\'' + + ", name='" + name + '\'' + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java new file mode 100644 index 000000000..49d30dab9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java @@ -0,0 +1,45 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 平台登录请求 DTO + */ +public class DmsTokenRequest implements Serializable { + private static final long serialVersionUID = 1L; + + private String appid; + private String appsecret; + + public DmsTokenRequest() { + } + + public DmsTokenRequest(String appid, String appsecret) { + this.appid = appid; + this.appsecret = appsecret; + } + + public String getAppid() { + return appid; + } + + public void setAppid(String appid) { + this.appid = appid; + } + + public String getAppsecret() { + return appsecret; + } + + public void setAppsecret(String appsecret) { + this.appsecret = appsecret; + } + + @Override + public String toString() { + return "DmsTokenRequest{" + + "appid='" + (appid != null ? (appid.length() > 4 ? appid.substring(0, 4) + "***" : "***") : "null") + '\'' + + ", appsecret='[PROTECTED]'" + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java new file mode 100644 index 000000000..2aa50136a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java @@ -0,0 +1,41 @@ +package com.ruoyi.train.integration.dms.dto; + +import java.io.Serializable; + +/** + * DMS 平台登录响应 DTO + */ +public class DmsTokenResponse implements Serializable { + private static final long serialVersionUID = 1L; + + private String token; + private Long expiresIn; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } + + @Override + public String toString() { + String maskedToken = "[PROTECTED]"; + if (token != null && token.length() > 8) { + maskedToken = token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } + return "DmsTokenResponse{" + + "token='" + maskedToken + '\'' + + ", expiresIn=" + expiresIn + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java new file mode 100644 index 000000000..7dacb7acb --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java @@ -0,0 +1,16 @@ +package com.ruoyi.train.integration.dms.exception; + +/** + * DMS 认证与令牌异常 (HTTP 401, 凭据错误, 令牌失效) + */ +public class DmsAuthenticationException extends DmsException { + private static final long serialVersionUID = 1L; + + public DmsAuthenticationException(String message) { + super(message); + } + + public DmsAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java new file mode 100644 index 000000000..82cbda428 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java @@ -0,0 +1,24 @@ +package com.ruoyi.train.integration.dms.exception; + +/** + * DMS 业务级错误异常 (DMS 返回 success=false 或特定业务拒绝) + */ +public class DmsBusinessException extends DmsException { + private static final long serialVersionUID = 1L; + + private final String errorCode; + + public DmsBusinessException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public DmsBusinessException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java new file mode 100644 index 000000000..196b11077 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java @@ -0,0 +1,16 @@ +package com.ruoyi.train.integration.dms.exception; + +/** + * DMS 外部集成异常基类 + */ +public class DmsException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public DmsException(String message) { + super(message); + } + + public DmsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java new file mode 100644 index 000000000..51d5f4e52 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java @@ -0,0 +1,16 @@ +package com.ruoyi.train.integration.dms.exception; + +/** + * DMS 网络传输异常 (连接超时, 读取超时, 网络中断) + */ +public class DmsTransportException extends DmsException { + private static final long serialVersionUID = 1L; + + public DmsTransportException(String message) { + super(message); + } + + public DmsTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java new file mode 100644 index 000000000..00c7a74f4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java @@ -0,0 +1,40 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.integration.dms.client.DmsPasswordClient; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse; +import org.springframework.stereotype.Component; + +/** + * DMS 门禁凭据网关实现 + */ +@Component +public class DmsCredentialGatewayImpl implements ExternalCredentialGateway { + + private final DmsPasswordClient passwordClient; + + public DmsCredentialGatewayImpl(DmsPasswordClient passwordClient) { + this.passwordClient = passwordClient; + } + + @Override + public DmsPasswordCreateResponse issueOnlinePassword(Long lockId, String holder, String phoneNumber, String password, String validStart, String validUntil) { + DmsPasswordCreateRequest req = new DmsPasswordCreateRequest(); + req.setLockId(lockId); + req.setHolder(holder); + req.setPhoneNumber(phoneNumber); + req.setPassword(password); + req.setValidStart(validStart); + req.setValidUntil(validUntil); + req.setSubType(4); // 限时密码 + return passwordClient.createOnlinePassword(req); + } + + @Override + public DmsPasswordDeleteResponse revokeOnlinePassword(Long externalKeyId) { + DmsPasswordDeleteRequest req = new DmsPasswordDeleteRequest(externalKeyId); + return passwordClient.deleteOnlinePassword(req); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java new file mode 100644 index 000000000..507f4efcb --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java @@ -0,0 +1,25 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.integration.dms.client.DmsDoorLockClient; +import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * DMS 智能门锁网关实现 + */ +@Component +public class DmsDoorLockGatewayImpl implements ExternalDoorLockGateway { + + private final DmsDoorLockClient doorLockClient; + + public DmsDoorLockGatewayImpl(DmsDoorLockClient doorLockClient) { + this.doorLockClient = doorLockClient; + } + + @Override + public List listDoorLocksByPlaceId(Long placeId) { + return doorLockClient.listDoorLocksByPlaceId(placeId); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java new file mode 100644 index 000000000..709db7ecd --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java @@ -0,0 +1,42 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.integration.dms.client.DmsLocationClient; +import com.ruoyi.train.integration.dms.dto.DmsBuildingDto; +import com.ruoyi.train.integration.dms.dto.DmsFloorDto; +import com.ruoyi.train.integration.dms.dto.DmsRoomDto; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * DMS 位置网关实现 + */ +@Component +public class DmsLocationGatewayImpl implements ExternalLocationGateway { + + private final DmsLocationClient locationClient; + + public DmsLocationGatewayImpl(DmsLocationClient locationClient) { + this.locationClient = locationClient; + } + + @Override + public List listBuildings(Long projectId) { + return locationClient.listBuildings(projectId); + } + + @Override + public List listFloors(Long buildingId) { + return locationClient.listFloors(buildingId); + } + + @Override + public List listRooms(Long floorId) { + return locationClient.listRooms(floorId); + } + + @Override + public DmsRoomDto getRoom(Long roomId) { + return locationClient.getRoom(roomId); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java new file mode 100644 index 000000000..874e8abc4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java @@ -0,0 +1,57 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.domain.TrainExternalOperation; + +/** + * DMS 外部操作状态集中映射器 + *

+ * 将 DMS 供应商的 operationStatus 映射到 RailwaySystem 本地 ExternalOperation 状态模型: + * success -> SUCCESS + * undone -> PENDING + * failed -> FAILED + * timeout -> TIMEOUT + * 未知状态 -> UNKNOWN (严禁自动判定为 SUCCESS) + */ +public class DmsOperationStatusMapper { + + public static final String VENDOR_STATUS_SUCCESS = "success"; + public static final String VENDOR_STATUS_UNDONE = "undone"; + public static final String VENDOR_STATUS_FAILED = "failed"; + public static final String VENDOR_STATUS_TIMEOUT = "timeout"; + + public static final String LOCAL_STATUS_UNKNOWN = "UNKNOWN"; + + /** + * 将供应商状态字符串统一转换为本地状态 + */ + public static String mapToLocalStatus(String vendorStatus) { + if (vendorStatus == null || vendorStatus.trim().isEmpty()) { + return LOCAL_STATUS_UNKNOWN; + } + String normalized = vendorStatus.trim().toLowerCase(); + switch (normalized) { + case VENDOR_STATUS_SUCCESS: + return TrainExternalOperation.STATUS_SUCCESS; + case VENDOR_STATUS_UNDONE: + return TrainExternalOperation.STATUS_PENDING; + case VENDOR_STATUS_FAILED: + return TrainExternalOperation.STATUS_FAILED; + case VENDOR_STATUS_TIMEOUT: + return TrainExternalOperation.STATUS_TIMEOUT; + default: + return LOCAL_STATUS_UNKNOWN; + } + } + + /** + * 判断本地状态是否为终态 (终态不再轮询、不可篡改) + */ + public static boolean isTerminalStatus(String localStatus) { + if (localStatus == null) { + return false; + } + return TrainExternalOperation.STATUS_SUCCESS.equals(localStatus) || + TrainExternalOperation.STATUS_FAILED.equals(localStatus) || + TrainExternalOperation.STATUS_TIMEOUT.equals(localStatus); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java new file mode 100644 index 000000000..439918532 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java @@ -0,0 +1,14 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse; + +/** + * 外部门禁凭据网关接口 (当前 Pilot 凭据为在线密码) + */ +public interface ExternalCredentialGateway { + + DmsPasswordCreateResponse issueOnlinePassword(Long lockId, String holder, String phoneNumber, String password, String validStart, String validUntil); + + DmsPasswordDeleteResponse revokeOnlinePassword(Long externalKeyId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java new file mode 100644 index 000000000..f2913532f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java @@ -0,0 +1,13 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto; + +import java.util.List; + +/** + * 外部智能门锁网关接口 + */ +public interface ExternalDoorLockGateway { + + List listDoorLocksByPlaceId(Long placeId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java new file mode 100644 index 000000000..2448a9c37 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java @@ -0,0 +1,21 @@ +package com.ruoyi.train.integration.dms.gateway; + +import com.ruoyi.train.integration.dms.dto.DmsBuildingDto; +import com.ruoyi.train.integration.dms.dto.DmsFloorDto; +import com.ruoyi.train.integration.dms.dto.DmsRoomDto; + +import java.util.List; + +/** + * 外部位置资源网关接口 + */ +public interface ExternalLocationGateway { + + List listBuildings(Long projectId); + + List listFloors(Long buildingId); + + List listRooms(Long floorId); + + DmsRoomDto getRoom(Long roomId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java new file mode 100644 index 000000000..c32262216 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java @@ -0,0 +1,350 @@ +package com.ruoyi.train.integration.dms.orchestration; + +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.domain.TrainExternalResourceMapping; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse; +import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway; +import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator; +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 com.ruoyi.train.service.ITrainDormAuthService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 宿舍门禁业务授权统一编排器 (Wave 3A) + *

+ * 核心架构与安全准则: + * 1. 状态驱动对账 (Reconciliation Model):业务事实 → 期望状态 → 外部操作,与触发顺序完全解耦。 + * 2. 最小特权主锁策略 (PRIMARY_LOCK_ONLY):房间内仅对且必须对唯一定义的 1 把主门锁下发凭据。 + * 3. 调房安全策略 (GRANT_NEW_THEN_REVOKE_OLD):先下发新房凭据,新房对账生效后再撤销旧房;新房失败严格保留旧房。 + * 4. 事务边界清晰 (LONG_DB_TRANSACTION_AROUND_HTTP = NO):严禁长事务包住远程 HTTP 调用,外部调用异常绝不回滚业务主事务。 + * 5. 密码安全与短暂留存:SecureRandom 强随机生成,明文仅在内存局部变量留存,严禁落库、严禁日志输出。 + * 6. 操作幂等与防重:业务授权唯一性 + 外部操作幂等键双重防护,重复触发无副作用。 + */ +@Service +public class AccessAuthorizationOrchestrator { + private static final Logger log = LoggerFactory.getLogger(AccessAuthorizationOrchestrator.class); + + public static final String VENDOR_DMS = "DMS"; + public static final String POLICY_PRIMARY_LOCK_ONLY = "PRIMARY_LOCK_ONLY"; + public static final String VALIDITY_WINDOW_SOURCE = "BUSINESS_POLICY_REQUIRED"; + + private final TrainEnrollmentMapper enrollmentMapper; + private final TrainAccommodationMapper accommodationMapper; + private final TrainDormAuthMapper dormAuthMapper; + private final ITrainDormAuthService dormAuthService; + private final IExternalResourceMappingService resourceMappingService; + private final IExternalOperationService operationService; + private final ExternalCredentialGateway credentialGateway; + + protected AccessAuthorizationOrchestrator() { + this(null, null, null, null, null, null, null); + } + + public AccessAuthorizationOrchestrator(TrainEnrollmentMapper enrollmentMapper, + TrainAccommodationMapper accommodationMapper, + TrainDormAuthMapper dormAuthMapper, + ITrainDormAuthService dormAuthService, + IExternalResourceMappingService resourceMappingService, + IExternalOperationService operationService, + ExternalCredentialGateway credentialGateway) { + this.enrollmentMapper = enrollmentMapper; + this.accommodationMapper = accommodationMapper; + this.dormAuthMapper = dormAuthMapper; + this.dormAuthService = dormAuthService; + this.resourceMappingService = resourceMappingService; + this.operationService = operationService; + this.credentialGateway = credentialGateway; + } + + /** + * 核心对账入口:检查指定参训学员当前业务事实,并执行状态调谐 + * + * @param enrollmentId 参训档案ID + * @return 对账结果 + */ + public ReconciliationResult reconcile(Long enrollmentId) { + if (enrollmentId == null) { + return ReconciliationResult.waiting("Missing enrollmentId"); + } + + TrainEnrollment enrollment = enrollmentMapper.selectTrainEnrollmentById(enrollmentId); + if (enrollment == null) { + return ReconciliationResult.waiting("Enrollment not found: " + enrollmentId); + } + + Integer enrollmentStatus = enrollment.getEnrollmentStatus(); + TrainAccommodation activeAcc = accommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId); + List accHistory = accommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId); + + // 1. 学员退训(3) 或 已完成(4):触发所有在用授权的注销 (REVOKE) + if (Integer.valueOf(3).equals(enrollmentStatus) || Integer.valueOf(4).equals(enrollmentStatus)) { + return handleEnrollmentTermination(enrollment, accHistory); + } + + // 2. 待报到 (0):未完成现场报到,不满足前置发放条件 + if (Integer.valueOf(0).equals(enrollmentStatus)) { + return ReconciliationResult.waiting("Enrollment status is PENDING_REPORT (0); Grant prerequisites not met"); + } + + // 3. 请假 (2):政策保持现状 (LEAVE_AUTO_REVOKE_POLICY = OPEN_QUESTION),不主动撤权 + if (Integer.valueOf(2).equals(enrollmentStatus)) { + return ReconciliationResult.noAction("Enrollment on leave (2); maintaining current authorization state"); + } + + // 4. 已报到 (1):执行发放或调房检查 + if (Integer.valueOf(1).equals(enrollmentStatus)) { + if (activeAcc == null) { + // 已报到但尚未分配有效宿舍 + return ReconciliationResult.waiting("No active accommodation assigned for reported enrollment; waiting for assignment"); + } + return handleGrantOrTransfer(enrollment, activeAcc, accHistory); + } + + return ReconciliationResult.noAction("No action required for enrollment " + enrollmentId); + } + + private ReconciliationResult handleEnrollmentTermination(TrainEnrollment enrollment, List accHistory) { + if (accHistory == null || accHistory.isEmpty()) { + return ReconciliationResult.noAction("No accommodation history found for terminated enrollment " + enrollment.getEnrollmentId()); + } + + // 查找所有非 REVOKED 的授权进行注销 + for (TrainAccommodation acc : accHistory) { + TrainDormAuth auth = dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId()); + if (auth != null && !TrainDormAuth.AUTH_STATUS_REVOKED.equals(auth.getAuthStatus())) { + log.info("Terminating authorization for enrollment {} accommodation {}: authId={}", + enrollment.getEnrollmentId(), acc.getAccommodationId(), auth.getAuthId()); + return executeRevokeFlow(auth, "EnrollmentTerminated:" + enrollment.getEnrollmentStatus()); + } + } + + return ReconciliationResult.noAction("All authorizations already REVOKED for terminated enrollment " + enrollment.getEnrollmentId()); + } + + private ReconciliationResult handleGrantOrTransfer(TrainEnrollment enrollment, + TrainAccommodation activeAcc, + List accHistory) { + Long accommodationId = activeAcc.getAccommodationId(); + String dormId = activeAcc.getDormId(); + + TrainDormAuth currentAuth = dormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + + // 如果新住宿的授权已经生效 (ACTIVE) + if (currentAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(currentAuth.getAuthStatus())) { + // 调房场景第二阶段检查:新房已 ACTIVE,检查是否存在历史退宿的残留旧授权需要注销 (GRANT_NEW_THEN_REVOKE_OLD) + TrainDormAuth oldAuthToRevoke = findStaleActiveAuthInHistory(accommodationId, accHistory); + if (oldAuthToRevoke != null) { + log.info("New room access ACTIVE. Triggering delayed revoke for old accommodation auth: {}", oldAuthToRevoke.getAuthId()); + return executeRevokeFlow(oldAuthToRevoke, "TransferCompleted"); + } + return ReconciliationResult.noAction("Door authorization is already ACTIVE for accommodation " + accommodationId); + } + + // 如果新住宿的授权正在下发中 (GRANTING) + if (currentAuth != null && TrainDormAuth.AUTH_STATUS_GRANTING.equals(currentAuth.getAuthStatus())) { + return ReconciliationResult.noAction("Door authorization is already in GRANTING state, awaiting poller"); + } + + // 校验外部映射与主锁策略 (PRIMARY_LOCK_ONLY) + TrainExternalResourceMapping roomMapping = resourceMappingService.getActiveRoomMapping(VENDOR_DMS, dormId); + if (roomMapping == null) { + return ReconciliationResult.waiting("No ACTIVE room mapping found for dormId " + dormId); + } + + List lockMappings = resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId); + if (lockMappings == null || lockMappings.isEmpty()) { + return ReconciliationResult.waiting("No door locks mapped for dormId " + dormId); + } + + List primaryLocks = lockMappings.stream() + .filter(m -> Integer.valueOf(1).equals(m.getIsPrimary())) + .collect(Collectors.toList()); + + if (primaryLocks.isEmpty()) { + return ReconciliationResult.waiting("Dorm " + dormId + " has 0 primary lock configured; cannot grant access"); + } + if (primaryLocks.size() > 1) { + return ReconciliationResult.waiting("Dorm " + dormId + " has " + primaryLocks.size() + " primary locks configured; configuration error"); + } + + TrainExternalResourceMapping primaryLock = primaryLocks.get(0); + + // 推导有效期窗口 (VALIDITY_WINDOW_SOURCE = BUSINESS_POLICY_REQUIRED) + Date validStart = activeAcc.getAssignedTime() != null ? activeAcc.getAssignedTime() : new Date(); + Date validUntil = deriveDefaultValidUntil(validStart); + + return executeGrantFlow(enrollment, activeAcc, primaryLock, currentAuth, validStart, validUntil); + } + + private ReconciliationResult executeGrantFlow(TrainEnrollment enrollment, + TrainAccommodation activeAcc, + TrainExternalResourceMapping primaryLock, + TrainDormAuth dormAuth, + Date validStart, + Date validUntil) { + Long accommodationId = activeAcc.getAccommodationId(); + + // 步骤 1: 确保本地授权记录存在 (短本地事务) + if (dormAuth == null) { + dormAuth = dormAuthService.ensureAuthorizationForAccommodation(accommodationId, "Orchestrator"); + } + + Long authId = dormAuth.getAuthId(); + Integer version = dormAuth.getOperationVersion() != null ? dormAuth.getOperationVersion() : 1; + + // 步骤 2: 创建外部操作流水 (短本地事务,幂等键保护) + String idempotencyKey = "GRANT:AUTH:" + authId + ":V" + version; + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(authId); + op.setVendorCode(VENDOR_DMS); + op.setOperationType(TrainExternalOperation.TYPE_GRANT); + op.setOperationStatus(TrainExternalOperation.STATUS_CREATED); + op.setIdempotencyKey(idempotencyKey); + op.setExternalRequestType(1); // 1: 下发密码 + op.setExternalHardwareId(primaryLock.getExternalHardwareId()); + op.setCreateBy("Orchestrator"); + + TrainExternalOperation createdOp = operationService.createOperation(op); + Long operationId = createdOp.getOperationId(); + + // 步骤 3: 标记授权记录进入 GRANTING 状态并记录本次操作ID + dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_GRANTING, operationId, "Orchestrator"); + + // 步骤 4: 构造请求并发起远程凭据下发 (非数据库长事务,LONG_DB_TRANSACTION_AROUND_HTTP = NO) + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Long lockId = null; + try { + lockId = Long.parseLong(primaryLock.getExternalResourceId()); + } catch (Exception e) { + log.error("Invalid lockId format: {}", primaryLock.getExternalResourceId()); + } + String holder = enrollment.getStudentId() != null ? enrollment.getStudentId() : "Student"; + String phoneNumber = "13800000000"; // 缺省测试手机号,后续由业务拓展补充 + + // 内存中生成 6 位强随机数字密码 + String plaintextPassword = SecurePasswordGenerator.generateDefaultPassword(); + + try { + DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword( + lockId, holder, phoneNumber, plaintextPassword, sdf.format(validStart), sdf.format(validUntil)); + + // 步骤 5: 成功获取 Vendor Accepted 响应,回填外部流水号并推进为 ACCEPTED + String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null; + String extKeyIdStr = resp.getId() != null ? String.valueOf(resp.getId()) : null; + + operationService.bindExternalReference(operationId, extOpIdStr, resp.getLockHardwareId(), extKeyIdStr, "Orchestrator"); + operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, "Orchestrator"); + + // 回填授权记录上的 externalAuthId (用于后续撤销定位凭据) + if (extKeyIdStr != null) { + dormAuth.setExternalAuthId(extKeyIdStr); + dormAuthService.updateTrainDormAuth(dormAuth); + } + + log.info("DMS password create accepted: localOpId={}, extOpId={}", operationId, extOpIdStr); + ReconciliationResult grantResult = ReconciliationResult.grantRequired(authId, operationId, true, true, "Password create accepted by DMS; awaiting poller"); + grantResult.setOneTimePlaintextCredential(plaintextPassword); + return grantResult; + + } catch (Exception e) { + // 外部调用异常 (如读超时、网络异常),严格捕获,绝不回滚业务主事务! + log.error("DMS password create failed or timed out for authId {}: {}", authId, e.getMessage()); + operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), "Orchestrator"); + dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "Orchestrator"); + + return ReconciliationResult.grantRequired(authId, operationId, true, false, "External gateway call failed: " + sanitize(e.getMessage())); + } + } + + private ReconciliationResult executeRevokeFlow(TrainDormAuth auth, String reason) { + Long authId = auth.getAuthId(); + Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1; + + // 步骤 1: 创建外部操作流水 (REVOKE) + String idempotencyKey = "REVOKE:AUTH:" + authId + ":V" + version; + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(authId); + op.setVendorCode(VENDOR_DMS); + op.setOperationType(TrainExternalOperation.TYPE_REVOKE); + op.setOperationStatus(TrainExternalOperation.STATUS_CREATED); + op.setIdempotencyKey(idempotencyKey); + op.setExternalRequestType(3); // 3: 删除密码 + op.setCreateBy("Orchestrator"); + + TrainExternalOperation createdOp = operationService.createOperation(op); + Long operationId = createdOp.getOperationId(); + + // 步骤 2: 推进授权状态为 REVOKING + dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKING, operationId, "Orchestrator"); + + // 步骤 3: 远程调用删除密码 + Long externalKeyId = null; + if (auth.getExternalAuthId() != null && !auth.getExternalAuthId().trim().isEmpty()) { + try { + externalKeyId = Long.parseLong(auth.getExternalAuthId().trim()); + } catch (Exception ignored) {} + } + + try { + DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(externalKeyId != null ? externalKeyId : 0L); + String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null; + + operationService.bindExternalReference(operationId, extOpIdStr, null, null, "Orchestrator"); + operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, "Orchestrator"); + + log.info("DMS password delete accepted: localOpId={}, extOpId={}", operationId, extOpIdStr); + return ReconciliationResult.revokeRequired(authId, operationId, true, true, "Password delete accepted by DMS; awaiting poller"); + + } catch (Exception e) { + log.error("DMS password delete failed for authId {}: {}", authId, e.getMessage()); + operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), "Orchestrator"); + dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "Orchestrator"); + + return ReconciliationResult.revokeRequired(authId, operationId, true, false, "External revoke failed: " + sanitize(e.getMessage())); + } + } + + + private TrainDormAuth findStaleActiveAuthInHistory(Long currentAccId, List history) { + if (history == null) return null; + for (TrainAccommodation acc : history) { + if (!acc.getAccommodationId().equals(currentAccId)) { + TrainDormAuth oldAuth = dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId()); + if (oldAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(oldAuth.getAuthStatus())) { + return oldAuth; + } + } + } + return null; + } + + private Date deriveDefaultValidUntil(Date start) { + Calendar cal = Calendar.getInstance(); + cal.setTime(start); + cal.add(Calendar.DAY_OF_YEAR, 30); // 默认 30 天 + return cal.getTime(); + } + + private String sanitize(String str) { + if (str == null) return null; + return str.replaceAll("(?i)(password|token|secret)=[^&\\s]+", "$1=[PROTECTED]"); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java new file mode 100644 index 000000000..1f33fc665 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java @@ -0,0 +1,26 @@ +package com.ruoyi.train.integration.dms.orchestration; + +/** + * 门禁权限对账决策枚举 (厂商中立) + */ +public enum ReconciliationDecision { + /** + * 无需操作 (当前状态已与业务事实对齐) + */ + NO_ACTION, + + /** + * 需要发放权限 (满足所有前置业务条件,待下发凭据) + */ + GRANT_REQUIRED, + + /** + * 需要注销权限 (学员退训/结训或调房退宿,需收回凭据) + */ + REVOKE_REQUIRED, + + /** + * 等待前置业务条件 (未报到、未分房、无门锁映射等) + */ + WAITING_FOR_PREREQUISITE +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java new file mode 100644 index 000000000..ee1fae0ca --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java @@ -0,0 +1,143 @@ +package com.ruoyi.train.integration.dms.orchestration; + +import java.io.Serializable; + +/** + * 门禁权限对账结果 DTO + */ +public class ReconciliationResult implements Serializable { + private static final long serialVersionUID = 1L; + + private Long enrollmentId; + private ReconciliationDecision decision; + private String reason; + private Long doorAuthId; + private Long operationId; + private boolean externalInvoked; + private boolean success; + + public ReconciliationResult() { + } + + public static ReconciliationResult noAction(String reason) { + ReconciliationResult res = new ReconciliationResult(); + res.setDecision(ReconciliationDecision.NO_ACTION); + res.setReason(reason); + res.setSuccess(true); + return res; + } + + public static ReconciliationResult waiting(String reason) { + ReconciliationResult res = new ReconciliationResult(); + res.setDecision(ReconciliationDecision.WAITING_FOR_PREREQUISITE); + res.setReason(reason); + res.setSuccess(true); + return res; + } + + public static ReconciliationResult grantRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) { + ReconciliationResult res = new ReconciliationResult(); + res.setDecision(ReconciliationDecision.GRANT_REQUIRED); + res.setDoorAuthId(doorAuthId); + res.setOperationId(operationId); + res.setExternalInvoked(externalInvoked); + res.setSuccess(success); + res.setReason(reason); + return res; + } + + public static ReconciliationResult revokeRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) { + ReconciliationResult res = new ReconciliationResult(); + res.setDecision(ReconciliationDecision.REVOKE_REQUIRED); + res.setDoorAuthId(doorAuthId); + res.setOperationId(operationId); + res.setExternalInvoked(externalInvoked); + res.setSuccess(success); + res.setReason(reason); + return res; + } + + public Long getEnrollmentId() { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) { + this.enrollmentId = enrollmentId; + } + + public ReconciliationDecision getDecision() { + return decision; + } + + public void setDecision(ReconciliationDecision decision) { + this.decision = decision; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public Long getDoorAuthId() { + return doorAuthId; + } + + public void setDoorAuthId(Long doorAuthId) { + this.doorAuthId = doorAuthId; + } + + public Long getOperationId() { + return operationId; + } + + public void setOperationId(Long operationId) { + this.operationId = operationId; + } + + public boolean isExternalInvoked() { + return externalInvoked; + } + + public void setExternalInvoked(boolean externalInvoked) { + this.externalInvoked = externalInvoked; + } + + /** + * 临时管理端一次性明文展示凭据 (ADMIN_ONE_TIME_DISPLAY 策略) + * 声明为 transient,仅在单次创建成功内存调用栈短暂留存,严禁落库、严禁序列化传输至非授权端、严禁日志输出 + */ + private transient String oneTimePlaintextCredential; + + public String getOneTimePlaintextCredential() { + return oneTimePlaintextCredential; + } + + public void setOneTimePlaintextCredential(String oneTimePlaintextCredential) { + this.oneTimePlaintextCredential = oneTimePlaintextCredential; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + @Override + public String toString() { + return "ReconciliationResult{" + + "enrollmentId=" + enrollmentId + + ", decision=" + decision + + ", reason='" + reason + '\'' + + ", doorAuthId=" + doorAuthId + + ", operationId=" + operationId + + ", externalInvoked=" + externalInvoked + + ", success=" + success + + ", oneTimeCredential=" + (oneTimePlaintextCredential != null ? "[PROTECTED]" : "null") + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java new file mode 100644 index 000000000..189f6a5df --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java @@ -0,0 +1,320 @@ +package com.ruoyi.train.integration.dms.poller; + +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest; +import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse; +import com.ruoyi.train.integration.dms.exception.DmsAuthenticationException; +import com.ruoyi.train.integration.dms.exception.DmsBusinessException; +import com.ruoyi.train.integration.dms.exception.DmsTransportException; +import com.ruoyi.train.integration.dms.gateway.DmsOperationStatusMapper; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.ITrainDormAuthService; +import com.ruoyi.train.service.impl.ExternalOperationServiceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * DMS 异步设备操作结果轮询对账调度器 + *

+ * 核心设计准则: + * 1. 条件化启动:仅当 dms.enabled=true 时加载并运行,安全默认关闭。 + * 2. 初始延迟与间隔:遵循 DMS 官方 10 秒后对账建议,非阻塞。 + * 3. 终态不可逆:SUCCESS, FAILED, TIMEOUT 为终态,绝不覆写或回退。 + * 4. 进程内单飞防并发:使用 ConcurrentHashMap.newKeySet() 杜绝同一操作并发重复轮询。 + * 5. 错误分类与脱敏持久化:区分 AUTH_ERROR, NETWORK_ERROR, VENDOR_BUSINESS_ERROR, DEVICE_FAILED, DEVICE_TIMEOUT。 + * 6. 支持注入 Clock 实现测试精确控制。 + */ +@Component +@ConditionalOnProperty(prefix = "dms", name = "enabled", havingValue = "true") +public class DmsOperationPoller { + private static final Logger log = LoggerFactory.getLogger(DmsOperationPoller.class); + + public static final String ERROR_CATEGORY_AUTH = "AUTH_ERROR"; + public static final String ERROR_CATEGORY_NETWORK = "NETWORK_ERROR"; + public static final String ERROR_CATEGORY_VENDOR_BIZ = "VENDOR_BUSINESS_ERROR"; + public static final String ERROR_CATEGORY_DEVICE_FAILED = "DEVICE_FAILED"; + public static final String ERROR_CATEGORY_DEVICE_TIMEOUT = "DEVICE_TIMEOUT"; + public static final String ERROR_CATEGORY_UNKNOWN_STATUS = "UNKNOWN_STATUS"; + + private final DmsProperties dmsProperties; + private final IExternalOperationService externalOperationService; + private final DmsOperationStatusClient operationStatusClient; + private final ITrainDormAuthService dormAuthService; + + private final Set inFlightOperationIds = ConcurrentHashMap.newKeySet(); + private Clock clock = Clock.systemUTC(); + + public DmsOperationPoller(DmsProperties dmsProperties, + IExternalOperationService externalOperationService, + DmsOperationStatusClient operationStatusClient) { + this(dmsProperties, externalOperationService, operationStatusClient, null); + } + + public DmsOperationPoller(DmsProperties dmsProperties, + IExternalOperationService externalOperationService, + DmsOperationStatusClient operationStatusClient, + ITrainDormAuthService dormAuthService) { + this.dmsProperties = dmsProperties; + this.externalOperationService = externalOperationService; + this.operationStatusClient = operationStatusClient; + this.dormAuthService = dormAuthService; + } + + public void setClock(Clock clock) { + this.clock = clock; + } + + public Clock getClock() { + return this.clock; + } + + /** + * 定时调度执行入口 (每 10 秒执行一次,初始延迟 10 秒) + */ + @Scheduled(initialDelayString = "${dms.poll-initial-delay-seconds:10}000", + fixedDelayString = "${dms.poll-interval-seconds:10}000") + public void scheduledPoll() { + if (!dmsProperties.isEnabled()) { + return; + } + pollPendingOperations(); + } + + /** + * 核心批量轮询处理方法,返回本次成功处理或更新的记录数 + */ + public int pollPendingOperations() { + Instant now = clock.instant(); + Date threshold = Date.from(now); + List pendingList = externalOperationService.listPendingPollOperations("DMS", threshold); + + if (pendingList == null || pendingList.isEmpty()) { + return 0; + } + + int processedCount = 0; + + for (TrainExternalOperation op : pendingList) { + if (op == null || op.getOperationId() == null) { + continue; + } + + // 终态保护 + if (DmsOperationStatusMapper.isTerminalStatus(op.getOperationStatus())) { + log.warn("Operation {} is already in terminal status {}, skipping poll", op.getOperationId(), op.getOperationStatus()); + continue; + } + + // 进程内并发防重 + if (!inFlightOperationIds.add(op.getOperationId())) { + continue; + } + + try { + processSingleOperation(op, now); + processedCount++; + } catch (Exception e) { + log.error("Unexpected error polling operation {}: {}", op.getOperationId(), e.getMessage(), e); + } finally { + inFlightOperationIds.remove(op.getOperationId()); + } + } + + return processedCount; + } + + private void processSingleOperation(TrainExternalOperation op, Instant now) { + String extOpIdStr = op.getExternalOperationId(); + if (extOpIdStr == null || extOpIdStr.trim().isEmpty()) { + // 未关联外部操作流水号,无法直接对账 + handlePollError(op, now, ERROR_CATEGORY_VENDOR_BIZ, "Missing externalOperationId for polling", false); + return; + } + + Long externalOpId; + try { + externalOpId = Long.parseLong(extOpIdStr.trim()); + } catch (NumberFormatException e) { + handlePollError(op, now, ERROR_CATEGORY_VENDOR_BIZ, "Invalid externalOperationId format: " + extOpIdStr, true); + return; + } + + Integer reqType = op.getExternalRequestType() != null ? op.getExternalRequestType() : 1; + DmsOperationStatusRequest req = new DmsOperationStatusRequest(reqType, externalOpId, null); + + try { + DmsOperationStatusResponse resp = operationStatusClient.queryOperationStatus(req); + String vendorStatus = resp.getOperationStatus(); + String localStatus = DmsOperationStatusMapper.mapToLocalStatus(vendorStatus); + + log.info("Polled operation {} (extOpId={}): vendorStatus={}, localStatus={}", + op.getOperationId(), externalOpId, vendorStatus, localStatus); + + if (TrainExternalOperation.STATUS_SUCCESS.equals(localStatus)) { + // 终态 SUCCESS + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_SUCCESS, + null, + op.getAttemptCount(), + null, + null, + "DmsPoller" + ); + updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_SUCCESS, null); + } else if (TrainExternalOperation.STATUS_FAILED.equals(localStatus)) { + // 终态 FAILED + String errMsg = sanitize(resp.getMessage() != null ? resp.getMessage() : resp.getResultDetails()); + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_FAILED, + null, + op.getAttemptCount(), + ERROR_CATEGORY_DEVICE_FAILED, + errMsg != null ? errMsg : "Device execution failed on lock", + "DmsPoller" + ); + updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_FAILED, errMsg); + } else if (TrainExternalOperation.STATUS_TIMEOUT.equals(localStatus)) { + // 终态 TIMEOUT + String errMsg = sanitize(resp.getMessage() != null ? resp.getMessage() : resp.getResultDetails()); + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_TIMEOUT, + null, + op.getAttemptCount(), + ERROR_CATEGORY_DEVICE_TIMEOUT, + errMsg != null ? errMsg : "Device execution timed out", + "DmsPoller" + ); + updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_TIMEOUT, errMsg); + } else if (TrainExternalOperation.STATUS_PENDING.equals(localStatus)) { + // 设备正在执行中 (undone) + int currentAttempt = (op.getAttemptCount() != null ? op.getAttemptCount() : 0) + 1; + int maxAttempts = dmsProperties.getMaxRetryAttempts() * 10; // 轮询阶段给予合理对账窗口 + + if (currentAttempt >= maxAttempts) { + // 超出轮询窗口,标记 TIMEOUT + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_TIMEOUT, + null, + currentAttempt, + ERROR_CATEGORY_DEVICE_TIMEOUT, + "Polling exceeded maximum attempts (" + maxAttempts + "), marked as TIMEOUT", + "DmsPoller" + ); + updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_TIMEOUT, "Polling exceeded maximum attempts"); + } else { + + // 继续保持 PENDING,设置下次轮询时间 + int interval = dmsProperties.getPollIntervalSeconds() > 0 ? dmsProperties.getPollIntervalSeconds() : 10; + Date nextPoll = Date.from(now.plusSeconds(interval)); + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_PENDING, + nextPoll, + currentAttempt, + null, + null, + "DmsPoller" + ); + } + } else { + // 未知供应商状态,严禁当作 SUCCESS + handlePollError(op, now, ERROR_CATEGORY_UNKNOWN_STATUS, "Unknown vendor status: " + vendorStatus, false); + } + + } catch (DmsAuthenticationException e) { + handlePollError(op, now, ERROR_CATEGORY_AUTH, sanitize(e.getMessage()), false); + } catch (DmsTransportException e) { + handlePollError(op, now, ERROR_CATEGORY_NETWORK, sanitize(e.getMessage()), false); + } catch (DmsBusinessException e) { + handlePollError(op, now, ERROR_CATEGORY_VENDOR_BIZ, sanitize(e.getMessage()), false); + } catch (Exception e) { + handlePollError(op, now, ERROR_CATEGORY_NETWORK, sanitize(e.getMessage()), false); + } + } + + private void handlePollError(TrainExternalOperation op, Instant now, String errorCode, String errorMessage, boolean isFatal) { + int currentAttempt = (op.getAttemptCount() != null ? op.getAttemptCount() : 0) + 1; + int maxAttempts = dmsProperties.getMaxRetryAttempts(); + + if (isFatal || currentAttempt >= maxAttempts) { + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_FAILED, + null, + currentAttempt, + errorCode, + errorMessage, + "DmsPoller" + ); + } else { + // 退避计算下次轮询时间 + long backoffSeconds = (long) Math.min(60, Math.pow(2, currentAttempt) * 5); + Date nextPoll = Date.from(now.plusSeconds(backoffSeconds)); + externalOperationService.updateOperationPollResult( + op.getOperationId(), + TrainExternalOperation.STATUS_RETRY_WAIT, + nextPoll, + currentAttempt, + errorCode, + errorMessage, + "DmsPoller" + ); + } + } + + private void updateAssociatedDoorAuth(TrainExternalOperation op, String terminalStatus, String errorMsg) { + if (op.getDoorAuthId() == null || dormAuthService == null) { + return; + } + + String opType = op.getOperationType(); + if (TrainExternalOperation.STATUS_SUCCESS.equals(terminalStatus)) { + if (TrainExternalOperation.TYPE_GRANT.equals(opType)) { + int updated = dormAuthService.updateAuthStatusByPoller( + op.getDoorAuthId(), TrainDormAuth.AUTH_STATUS_ACTIVE, op.getOperationId(), null, "DmsPoller"); + if (updated > 0) { + log.info("DoorAuthorization {} advanced to ACTIVE by Poller operation {}", op.getDoorAuthId(), op.getOperationId()); + } else { + log.warn("DoorAuthorization {} update to ACTIVE skipped (Stale operation guard): opId={}", op.getDoorAuthId(), op.getOperationId()); + } + } else if (TrainExternalOperation.TYPE_REVOKE.equals(opType)) { + int updated = dormAuthService.updateAuthStatusByPoller( + op.getDoorAuthId(), TrainDormAuth.AUTH_STATUS_REVOKED, op.getOperationId(), null, "DmsPoller"); + if (updated > 0) { + log.info("DoorAuthorization {} advanced to REVOKED by Poller operation {}", op.getDoorAuthId(), op.getOperationId()); + } else { + log.warn("DoorAuthorization {} update to REVOKED skipped (Stale operation guard): opId={}", op.getDoorAuthId(), op.getOperationId()); + } + } + } else { + // 外部设备执行失败或超时:推进为 ERROR 状态,防止伪成功 + dormAuthService.updateAuthStatusByPoller( + op.getDoorAuthId(), TrainDormAuth.AUTH_STATUS_ERROR, op.getOperationId(), errorMsg, "DmsPoller"); + } + } + + private String sanitize(String str) { + if (str == null) { + return null; + } + return str.replaceAll("(?i)(password|token|secret|appsecret)=[^&\\s]+", "$1=[PROTECTED]") + .replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2"); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/security/SecurePasswordGenerator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/security/SecurePasswordGenerator.java new file mode 100644 index 000000000..3d16c5bec --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/security/SecurePasswordGenerator.java @@ -0,0 +1,56 @@ +package com.ruoyi.train.integration.dms.security; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +/** + * 安全随机门禁凭据密码生成器 + *

+ * 严格安全准则: + * 1. 强制采用密码学安全伪随机数生成器 java.security.SecureRandom,禁止使用 Math.random() 或 java.util.Random。 + * 2. 长度默认 6 位纯数字 (符合 DMS 4~8 位规范)。 + * 3. 严禁基于学号、身份证号、手机号等包含 PII 隐私信息的确定性规则生成。 + * 4. 严禁生成固定测试弱密码 (如 123456, 000000, 888888)。 + * 5. 生成的明文仅在内存中短暂留存,严禁落库、严禁输出至日志或异常信息。 + */ +public final class SecurePasswordGenerator { + + private static final SecureRandom SECURE_RANDOM; + + static { + SecureRandom sr; + try { + sr = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { + sr = new SecureRandom(); + } + SECURE_RANDOM = sr; + } + + private SecurePasswordGenerator() { + } + + /** + * 生成指定位数的随机纯数字密码 (默认 6 位,限制在 4~8 位范围内) + * + * @param digits 密码长度 (4~8) + * @return 格式化后的随机数字字符串 + */ + public static String generateNumericPassword(int digits) { + if (digits < 4 || digits > 8) { + digits = 6; + } + + int max = (int) Math.pow(10, digits); + int val = SECURE_RANDOM.nextInt(max); + String format = "%0" + digits + "d"; + return String.format(format, val); + } + + /** + * 生成一期默认的 6 位安全数字密码 + */ + public static String generateDefaultPassword() { + return generateNumericPassword(6); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java new file mode 100644 index 000000000..7506c37fa --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java @@ -0,0 +1,117 @@ +package com.ruoyi.train.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainAccommodation; + +/** + * 住宿安排Mapper接口 train_accommodation + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface TrainAccommodationMapper +{ + /** + * 根据主键查询住宿安排 + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId); + + /** + * 根据参训档案ID查询住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询当前有效住宿安排 (预分配或已入住) + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按分配时间倒序) + * + * @param enrollmentId 参训档案ID + * @return 住宿历史记录列表 + */ + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId); + + /** + * 查询住宿安排列表 + * + * @param accommodation 住宿安排查询条件 + * @return 住宿安排集合 + */ + public List selectTrainAccommodationList(TrainAccommodation accommodation); + + /** + * 根据开班计划ID查询该班级下所有有效住宿安排 (准备用于结课批量撤权等) + * + * @param planId 开班计划ID + * @return 住宿安排集合 + */ + public List selectActiveAccommodationsByPlanId(@Param("planId") String planId); + + /** + * 统计指定宿舍当前有效住宿人数 (状态为 0 预分配 或 1 已入住) + * + * @param dormId 宿舍ID + * @return 有效住宿人数 + */ + public int countActiveByDormId(@Param("dormId") String dormId); + + /** + * 新增住宿安排 + * + * @param accommodation 住宿安排 + * @return 影响行数 + */ + public int insertTrainAccommodation(TrainAccommodation accommodation); + + /** + * 修改住宿安排 + * + * @param accommodation 住宿安排 + * @return 影响行数 + */ + public int updateTrainAccommodation(TrainAccommodation accommodation); + + /** + * 更新住宿状态 + * + * @param accommodationId 住宿安排主键 + * @param accommodationStatus 目标状态 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateAccommodationStatus(@Param("accommodationId") Long accommodationId, + @Param("accommodationStatus") Integer accommodationStatus, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 删除住宿安排 + * + * @param accommodationId 住宿安排主键 + * @return 影响行数 + */ + public int deleteTrainAccommodationByAccommodationId(Long accommodationId); + + /** + * 批量删除住宿安排 + * + * @param accommodationIds 需要删除的住宿安排主键集合 + * @return 影响行数 + */ + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java index 01ed39ce1..43b90a33d 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainDormAuthMapper.java @@ -1,61 +1,101 @@ package com.ruoyi.train.mapper; +import java.util.Date; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.train.domain.TrainDormAuth; /** - * 宿舍权限分配Mapper接口 - * + * 宿舍门禁授权Mapper接口 train_dorm_auth (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ -public interface TrainDormAuthMapper +public interface TrainDormAuthMapper { /** - * 查询宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 宿舍权限分配 + * 根据主键查询宿舍门禁授权 + * + * @param authId 授权主键 + * @return 门禁授权 */ public TrainDormAuth selectTrainDormAuthByAuthId(Long authId); /** - * 查询宿舍权限分配列表 - * - * @param trainDormAuth 宿舍权限分配 - * @return 宿舍权限分配集合 + * 根据住宿安排ID查询门禁授权 (唯一对应) + * + * @param accommodationId 住宿安排主键 + * @return 门禁授权 + */ + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId); + + /** + * 查询宿舍门禁授权列表 (支持联表视图字段过滤) + * + * @param trainDormAuth 授权查询条件 + * @return 门禁授权集合 */ public List selectTrainDormAuthList(TrainDormAuth trainDormAuth); /** - * 新增宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 - * @return 结果 + * 新增门禁授权记录 (严格写入 V2 持久化列) + * + * @param trainDormAuth 门禁授权 + * @return 影响行数 */ public int insertTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 修改宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 - * @return 结果 + * 修改门禁授权记录 (严格更新 V2 持久化列) + * + * @param trainDormAuth 门禁授权 + * @return 影响行数 */ public int updateTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 删除宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 结果 + * 更新同步状态与错误摘要 + * + * @param authId 授权主键 + * @param faceSyncStatus 状态 (0:待下发, 1:已下发, 2:下发失败, 3:已注销) + * @param lastError 错误信息 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int updateSyncStatus(@Param("authId") Long authId, + @Param("faceSyncStatus") Integer faceSyncStatus, + @Param("lastError") String lastError, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 累加重试次数并记录最近错误 + * + * @param authId 授权主键 + * @param lastError 错误信息 + * @param updateBy 更新人 + * @param updateTime 更新时间 + * @return 影响行数 + */ + public int increaseRetryCount(@Param("authId") Long authId, + @Param("lastError") String lastError, + @Param("updateBy") String updateBy, + @Param("updateTime") Date updateTime); + + /** + * 删除宿舍门禁授权 + * + * @param authId 授权主键 + * @return 影响行数 */ public int deleteTrainDormAuthByAuthId(Long authId); /** - * 批量删除宿舍权限分配 - * - * @param authIds 需要删除的数据主键集合 - * @return 结果 + * 批量删除宿舍门禁授权 + * + * @param authIds 需要删除的主键集合 + * @return 影响行数 */ public int deleteTrainDormAuthByAuthIds(Long[] authIds); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalOperationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalOperationMapper.java new file mode 100644 index 000000000..69bbbed24 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalOperationMapper.java @@ -0,0 +1,103 @@ +package com.ruoyi.train.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainExternalOperation; + +/** + * 外部操作流水Mapper接口 + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface TrainExternalOperationMapper +{ + /** + * 查询外部操作流水 + * + * @param operationId 本地操作ID + * @return 外部操作流水 + */ + public TrainExternalOperation selectExternalOperationById(Long operationId); + + /** + * 根据幂等键查询操作流水 + * + * @param idempotencyKey 幂等键 + * @return 操作流水 + */ + public TrainExternalOperation selectByIdempotencyKey(String idempotencyKey); + + /** + * 根据门禁授权ID查询所有操作流水记录(按时间升序,保留完整重试与变更轨迹) + * + * @param doorAuthId 门禁授权ID + * @return 操作流水列表 + */ + public List selectByDoorAuthId(Long doorAuthId); + + /** + * 根据外部操作流水号查询 + * + * @param vendorCode 供应商标识 + * @param externalOperationId 外部操作ID + * @return 操作流水 + */ + public TrainExternalOperation selectByExternalOperationId( + @Param("vendorCode") String vendorCode, + @Param("externalOperationId") String externalOperationId); + + /** + * 查询所有需要进行异步状态轮询的操作记录 + * 条件: operation_status IN ('ACCEPTED', 'PENDING', 'RETRY_WAIT') AND next_poll_time <= maxPollTime + * + * @param vendorCode 供应商标识(可选) + * @param maxPollTime 轮询时间上限阈值(通常为当前系统时间) + * @return 待轮询操作列表 + */ + public List selectPendingPollOperations( + @Param("vendorCode") String vendorCode, + @Param("maxPollTime") Date maxPollTime); + + /** + * 查询外部操作流水列表 + * + * @param query 查询条件 + * @return 操作列表 + */ + public List selectExternalOperationList(TrainExternalOperation query); + + /** + * 新增外部操作流水 + * + * @param operation 操作信息 + * @return 结果 + */ + public int insertExternalOperation(TrainExternalOperation operation); + + /** + * 更新操作流水状态与轮询对账信息 + * + * @param operation 操作信息 + * @return 结果 + */ + public int updateExternalOperationStatus(TrainExternalOperation operation); + + /** + * 回填外部操作ID与凭证信息 + * + * @param operationId 本地流水ID + * @param externalOperationId 外部操作ID + * @param externalHardwareId 外部硬件SN + * @param externalKeyId 外部钥匙ID + * @param updateBy 更新人 + * @return 结果 + */ + public int updateExternalReference( + @Param("operationId") Long operationId, + @Param("externalOperationId") String externalOperationId, + @Param("externalHardwareId") String externalHardwareId, + @Param("externalKeyId") String externalKeyId, + @Param("updateBy") String updateBy); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalResourceMappingMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalResourceMappingMapper.java new file mode 100644 index 000000000..9f2b6d10c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalResourceMappingMapper.java @@ -0,0 +1,96 @@ +package com.ruoyi.train.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.train.domain.TrainExternalResourceMapping; + +/** + * 外部资源映射Mapper接口 + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface TrainExternalResourceMappingMapper +{ + /** + * 查询外部资源映射 + * + * @param mappingId 映射ID + * @return 外部资源映射 + */ + public TrainExternalResourceMapping selectExternalResourceMappingById(Long mappingId); + + /** + * 查询指定宿舍当前处于有效状态的房间映射(每宿舍每厂商最多1条) + * + * @param vendorCode 供应商标识 + * @param dormId 宿舍ID + * @return 有效房间映射 + */ + public TrainExternalResourceMapping selectActiveRoomMapping(@Param("vendorCode") String vendorCode, @Param("dormId") String dormId); + + /** + * 查询指定宿舍当前处于有效状态的门锁映射列表(支持一房多锁) + * + * @param vendorCode 供应商标识 + * @param dormId 宿舍ID + * @return 有效门锁映射列表 + */ + public List selectActiveLockMappings(@Param("vendorCode") String vendorCode, @Param("dormId") String dormId); + + /** + * 查询外部资源当前处于有效状态的绑定记录(防重复跨房间绑定) + * + * @param vendorCode 供应商标识 + * @param externalResourceType 外部资源类型 + * @param externalResourceId 外部资源ID + * @return 有效绑定记录 + */ + public TrainExternalResourceMapping selectActiveByExternalResource( + @Param("vendorCode") String vendorCode, + @Param("externalResourceType") String externalResourceType, + @Param("externalResourceId") String externalResourceId); + + /** + * 查询外部资源映射列表 + * + * @param query 查询条件 + * @return 映射列表 + */ + public List selectExternalResourceMappingList(TrainExternalResourceMapping query); + + /** + * 新增外部资源映射 + * + * @param mapping 映射信息 + * @return 结果 + */ + public int insertExternalResourceMapping(TrainExternalResourceMapping mapping); + + /** + * 修改外部资源映射 + * + * @param mapping 映射信息 + * @return 结果 + */ + public int updateExternalResourceMapping(TrainExternalResourceMapping mapping); + + /** + * 失效指定的资源映射 + * + * @param mappingId 映射ID + * @param updateBy 更新者 + * @return 结果 + */ + public int deactivateMapping(@Param("mappingId") Long mappingId, @Param("updateBy") String updateBy); + + /** + * 失效指定宿舍的房间映射 + * + * @param vendorCode 供应商标识 + * @param dormId 宿舍ID + * @param updateBy 更新者 + * @return 结果 + */ + public int deactivateRoomMapping(@Param("vendorCode") String vendorCode, @Param("dormId") String dormId, @Param("updateBy") String updateBy); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java new file mode 100644 index 000000000..153288193 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java @@ -0,0 +1,131 @@ +package com.ruoyi.train.service; + +import java.util.Date; +import java.util.List; +import com.ruoyi.train.domain.TrainExternalOperation; + +/** + * 外部设备操作流水业务服务接口 + * 负责管理异步设备调用的生命周期、幂等防重控制与状态机流转 + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface IExternalOperationService +{ + /** + * 根据主键查询操作流水 + * + * @param operationId 本地流水ID + * @return 操作流水 + */ + public TrainExternalOperation getOperationById(Long operationId); + + /** + * 根据幂等键查询操作流水 + * + * @param idempotencyKey 幂等键 + * @return 操作流水 + */ + public TrainExternalOperation getOperationByIdempotencyKey(String idempotencyKey); + + /** + * 查询指定门禁授权下的所有操作流水历史(DoorAuthorization 1:N ExternalOperation) + * + * @param doorAuthId 门禁授权ID + * @return 操作流水列表(按创建时间升序排列) + */ + public List listOperationsByDoorAuthId(Long doorAuthId); + + /** + * 创建操作流水记录(受本地幂等保护) + * 规则: + * 1. 门禁授权 (doorAuthId) 必须存在; + * 2. 操作类型非空且合法; + * 3. 幂等键 (idempotencyKey) 必须提供; + * 4. 若幂等键已存在,直接返回已存在的记录(幂等安全,不生成重复数据); + * 5. 初始状态为 CREATED,externalOperationId 初始为 null。 + * + * @param operation 操作流水实体 + * @return 创建或已存在的操作流水实体 + */ + public TrainExternalOperation createOperation(TrainExternalOperation operation); + + /** + * 外部接口发起后回填外部操作流水ID与凭据信息 + * + * @param operationId 本地流水ID + * @param externalOperationId 外部流水ID (如 DMS operationId) + * @param externalHardwareId 目标硬件SN + * @param externalKeyId 外部钥匙ID (如 DMS keyId) + * @param operator 操作人 + * @return 更新后的操作流水 + */ + public TrainExternalOperation bindExternalReference( + Long operationId, + String externalOperationId, + String externalHardwareId, + String externalKeyId, + String operator); + + /** + * 推进操作状态机流转 + * 限制非法跳转规则: + * - SUCCESS 状态为终态 (Terminal),不可逆向跳转; + * - FAILED / TIMEOUT 状态为终态,不可逆向重置回 CREATED/REQUESTED; + * + * @param operationId 本地流水ID + * @param targetStatus 目标状态 + * @param errorCode 错误码 (若有) + * @param errorMessage 错误消息 (若有) + * @param operator 操作人 + * @return 更新后的操作流水 + */ + public TrainExternalOperation transitStatus( + Long operationId, + String targetStatus, + String errorCode, + String errorMessage, + String operator); + + /** + * 查询待轮询的操作流水列表(为 Wave 2 调度器准备) + * 条件: operation_status IN ('ACCEPTED', 'PENDING', 'RETRY_WAIT') AND next_poll_time <= maxPollTime + * + * @param vendorCode 供应商标识 + * @param maxPollTime 最大允许下次轮询时间(默认当前时间) + * @return 待轮询操作列表 + */ + public List listPendingPollOperations(String vendorCode, Date maxPollTime); + + /** + * 基于前次失败/超时操作创建新的重试操作流水(保留历史审计,生成全新 Operation 记录) + * + * @param previousOperationId 前次操作流水ID + * @param newIdempotencyKey 新操作的幂等键 + * @param operator 操作人 + * @return 新建的重试操作记录 + */ + public TrainExternalOperation createRetryOperation(Long previousOperationId, String newIdempotencyKey, String operator); + + /** + * 更新操作流水的轮询对账结果 + * + * @param operationId 本地流水ID + * @param targetStatus 目标状态 + * @param nextPollTime 下次轮询时间 + * @param attemptCount 尝试次数 + * @param errorCode 错误码 + * @param errorMessage 错误消息 + * @param operator 操作人 + * @return 更新后的操作流水 + */ + public TrainExternalOperation updateOperationPollResult( + Long operationId, + String targetStatus, + Date nextPollTime, + Integer attemptCount, + String errorCode, + String errorMessage, + String operator); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalResourceMappingService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalResourceMappingService.java new file mode 100644 index 000000000..2d8515d67 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalResourceMappingService.java @@ -0,0 +1,91 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainExternalResourceMapping; + +/** + * 外部资源映射业务服务接口 + * 负责管理本地宿舍与外部物联网设备(如DMS房间、智能门锁)的映射关联,维护资源绑定的唯一性与一房多锁支持 + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface IExternalResourceMappingService +{ + /** + * 查询指定ID的资源映射 + * + * @param mappingId 映射ID + * @return 映射信息 + */ + public TrainExternalResourceMapping getMappingById(Long mappingId); + + /** + * 查询指定宿舍当前有效的房间映射(Dormitory 1:0..1 Active Room) + * + * @param vendorCode 供应商标识 + * @param dormId 本地宿舍ID + * @return 有效房间映射,无则返回 null + */ + public TrainExternalResourceMapping getActiveRoomMapping(String vendorCode, String dormId); + + /** + * 查询指定宿舍当前绑定的全部有效门锁映射(Dormitory 1:0..N Locks,支持一房多锁) + * + * @param vendorCode 供应商标识 + * @param dormId 本地宿舍ID + * @return 有效门锁映射列表 + */ + public List getActiveLockMappings(String vendorCode, String dormId); + + /** + * 条件查询外部资源映射列表 + * + * @param query 查询条件 + * @return 映射列表 + */ + public List listMappings(TrainExternalResourceMapping query); + + /** + * 绑定房间映射(创建 Active Room Mapping) + * 业务校验规则: + * 1. 本地宿舍必须存在; + * 2. 该外部资源不能已被其他宿舍 ACTIVE 绑定; + * 3. 同一宿舍同一厂商最多1个 ACTIVE 房间映射(若已存在需先解除绑定); + * + * @param mapping 映射实体 + * @return 插入成功的映射实体(包含自增 mappingId) + */ + public TrainExternalResourceMapping bindRoomMapping(TrainExternalResourceMapping mapping); + + /** + * 绑定门锁映射(创建 Active Lock Mapping) + * 业务校验规则: + * 1. 本地宿舍必须存在; + * 2. 该外部门锁不能已被其他宿舍 ACTIVE 绑定; + * 3. 允许一个宿舍绑定多把锁(一房多锁支持); + * + * @param mapping 映射实体 + * @return 插入成功的映射实体 + */ + public TrainExternalResourceMapping bindLockMapping(TrainExternalResourceMapping mapping); + + /** + * 解除/失效指定映射 + * + * @param mappingId 映射ID + * @param operator 操作人 + * @return 是否成功 + */ + public boolean deactivateMapping(Long mappingId, String operator); + + /** + * 解除/失效指定宿舍当前的有效房间映射 + * + * @param vendorCode 供应商标识 + * @param dormId 宿舍ID + * @param operator 操作人 + * @return 是否成功 + */ + public boolean deactivateRoomMapping(String vendorCode, String dormId, String operator); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java new file mode 100644 index 000000000..cd48c46b2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainAccommodationService.java @@ -0,0 +1,122 @@ +package com.ruoyi.train.service; + +import java.util.List; +import com.ruoyi.train.domain.TrainAccommodation; + +/** + * 住宿安排Service接口 train_accommodation + * + * @author ruoyi + * @date 2026-09-15 + */ +public interface ITrainAccommodationService +{ + /** + * 查询住宿安排详情 + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId); + + /** + * 根据参训档案ID查询最新住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询当前活跃/有效住宿安排 (预分配或已入住) + * + * @param enrollmentId 参训档案ID + * @return 活跃住宿安排,若无返回null + */ + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId); + + /** + * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序) + * + * @param enrollmentId 参训档案ID + * @return 住宿历史记录列表 + */ + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId); + + /** + * 查询住宿安排列表 + * + * @param accommodation 住宿安排过滤条件 + * @return 住宿安排集合 + */ + public List selectTrainAccommodationList(TrainAccommodation accommodation); + + /** + * 查询指定开班计划下所有有效住宿安排 + * + * @param planId 开班计划ID + * @return 有效住宿列表 + */ + public List selectActiveAccommodationsByPlanId(String planId); + + /** + * 创建住宿事实 (带参训档案、宿舍容量与唯一活跃约束校验) + * + * @param accommodation 住宿事实 + * @return 结果 + */ + public int insertTrainAccommodation(TrainAccommodation accommodation); + + /** + * 修改住宿安排 + * + * @param accommodation 住宿安排 + * @return 结果 + */ + public int updateTrainAccommodation(TrainAccommodation accommodation); + + /** + * 学员办理入住 (状态 0预分配 -> 1已入住) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + public int checkIn(Long accommodationId, String operator); + + /** + * 学员办理退宿 (状态 -> 2已退宿) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + public int checkOut(Long accommodationId, String operator); + + /** + * 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住) + * + * @param oldAccommodationId 原住宿安排ID + * @param newDormId 新宿舍ID + * @param newBedNo 新床位号 + * @param operator 操作人 + * @return 新生成的住宿安排 + */ + public TrainAccommodation transfer(Long oldAccommodationId, String newDormId, String newBedNo, String operator); + + /** + * 批量删除住宿安排 (受业务历史保护校验) + * + * @param accommodationIds 需要删除的主键集合 + * @return 结果 + */ + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds); + + /** + * 删除住宿安排 + * + * @param accommodationId 需要删除的主键 + * @return 结果 + */ + public int deleteTrainAccommodationByAccommodationId(Long accommodationId); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java index a8fa9849a..2e208eb48 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java @@ -4,58 +4,118 @@ import java.util.List; import com.ruoyi.train.domain.TrainDormAuth; /** - * 宿舍权限分配Service接口 - * + * 宿舍门禁授权Service接口 train_dorm_auth (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ -public interface ITrainDormAuthService +public interface ITrainDormAuthService { /** - * 查询宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 宿舍权限分配 + * 查询宿舍门禁授权 + * + * @param authId 授权主键 + * @return 宿舍门禁授权 */ public TrainDormAuth selectTrainDormAuthByAuthId(Long authId); /** - * 查询宿舍权限分配列表 - * - * @param trainDormAuth 宿舍权限分配 - * @return 宿舍权限分配集合 + * 根据住宿安排ID查询门禁授权 + * + * @param accommodationId 住宿安排主键 + * @return 宿舍门禁授权 + */ + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId); + + /** + * 查询宿舍门禁授权列表 + * + * @param trainDormAuth 授权查询条件 + * @return 宿舍门禁授权集合 */ public List selectTrainDormAuthList(TrainDormAuth trainDormAuth); /** - * 新增宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 新增宿舍门禁授权 (以住宿事实为唯一合法来源) + * + * @param trainDormAuth 门禁授权 * @return 结果 */ public int insertTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 修改宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 修改宿舍门禁授权 + * + * @param trainDormAuth 门禁授权 * @return 结果 */ public int updateTrainDormAuth(TrainDormAuth trainDormAuth); /** - * 批量删除宿舍权限分配 - * - * @param authIds 需要删除的宿舍权限分配主键集合 + * 记录下发失败并累加重试次数 + * + * @param authId 授权主键 + * @param errorMessage 失败原因 + * @param operator 操作人 + * @return 结果 + */ + public int markSyncFailed(Long authId, String errorMessage, String operator); + + /** + * 注销门禁授权 + * + * @param authId 授权主键 + * @param operator 操作人 + * @return 结果 + */ + public int revokeAuth(Long authId, String operator); + + /** + * 批量删除宿舍门禁授权 + * + * @param authIds 需要删除的主键集合 * @return 结果 */ public int deleteTrainDormAuthByAuthIds(Long[] authIds); /** - * 删除宿舍权限分配信息 - * - * @param authId 宿舍权限分配主键 + * 删除宿舍门禁授权信息 + * + * @param authId 主键 * @return 结果 */ public int deleteTrainDormAuthByAuthId(Long authId); + + /** + * 确保住宿安排对应的门禁授权记录存在 (若不存在则创建,若存在则返回已有,保证幂等) + * + * @param accommodationId 住宿安排主键 + * @param operator 操作人 + * @return 门禁授权记录 + */ + public TrainDormAuth ensureAuthorizationForAccommodation(Long accommodationId, String operator); + + /** + * 更新业务授权状态及当前驱动的外部操作ID + * + * @param authId 授权主键 + * @param authStatus 业务授权状态 + * @param operationId 外部操作ID + * @param operator 操作人 + * @return 影响行数 + */ + public int updateAuthStatus(Long authId, String authStatus, Long operationId, String operator); + + /** + * 由对账轮询器回调更新业务授权状态 (内置 Stale Operation 防迟到覆盖保护) + * 只有当 operationId 与授权记录当前最新的 latestOperationId 一致时,才允许推进状态 + * + * @param authId 授权主键 + * @param authStatus 目标业务授权状态 + * @param operationId 触发更新的操作流水ID + * @param lastError 错误消息摘要 + * @param operator 操作人 + * @return 影响行数 (若被防迟到保护拦截返回 0) + */ + public int updateAuthStatusByPoller(Long authId, String authStatus, Long operationId, String lastError, String operator); } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java index a1f0b8884..f03ec26e5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java @@ -84,6 +84,10 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService @Autowired(required = false) private StudentIdentityProtection identityProtection; + @Autowired(required = false) + private com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator accessAuthorizationOrchestrator; + + @Override public List listEnrollments(String planId, EnrollmentQuery query) { @@ -385,6 +389,19 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService operator, DateUtils.getNowDate() ); + + // 退训时联动触发门禁权限收回 + if (Integer.valueOf(EnrollmentErrorCode.STATUS_ABANDONED).equals(newStatus) && accessAuthorizationOrchestrator != null) + { + try + { + accessAuthorizationOrchestrator.reconcile(enrollmentId); + } + catch (Exception ex) + { + log.warn("Access reconcile triggered on enrollment abandon failed for enrollment {}: {}", enrollmentId, ex.getMessage()); + } + } } @Override @@ -659,7 +676,20 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService // 2. 联动更新参训状态为已报到 (1),写入入班报到时间 (join_time) trainEnrollmentMapper.confirmEnrollmentReport(enrollmentId, now, operator, now); - // 3. 构造并返回 ReportResultVO + // 3. 报到确认成功后,驱动门禁业务对账 (外部网络异常绝不回滚报到主事务,REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO) + if (accessAuthorizationOrchestrator != null) + { + try + { + accessAuthorizationOrchestrator.reconcile(enrollmentId); + } + catch (Exception ex) + { + log.warn("Access reconcile triggered on report confirm failed for enrollment {}: {}", enrollmentId, ex.getMessage()); + } + } + + // 4. 构造并返回 ReportResultVO TrainStudent student = studentMapper.selectTrainStudentBySId(enrollment.getStudentId()); ReportResultVO vo = new ReportResultVO(); vo.setEnrollmentId(enrollmentId); diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java index 2ccbef8ee..26f3680a5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java @@ -1,12 +1,17 @@ package com.ruoyi.train.service.impl; import java.util.Date; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; import com.ruoyi.train.service.ITrainEnrollmentService; import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort; @@ -17,6 +22,7 @@ import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort; * 1. 将该计划仍处于有效参训状态 (0:待报到, 1:已报到) 的学员更新为 4:已完成; * 2. 写入 complete_time; * 3. 完整保留考勤、成绩、自主学习时长等历史业务数据,严禁物理删除或级联删除。 + * 4. 驱动门禁业务对账撤权 (外部调用异常绝不回滚业务主事务)。 */ @Component public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletionPort @@ -26,6 +32,12 @@ public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletion @Autowired private ITrainEnrollmentService trainEnrollmentService; + @Autowired(required = false) + private TrainEnrollmentMapper trainEnrollmentMapper; + + @Autowired(required = false) + private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator; + @Override @Transactional(propagation = Propagation.MANDATORY, rollbackFor = Exception.class) public void completeEnrollments(String planId, String operator, Date completeTime) @@ -33,5 +45,33 @@ public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletion log.info("执行计划结训学员状态联动归档: planId={}, operator={}, completeTime={}", planId, operator, completeTime); int updatedRows = trainEnrollmentService.completeActiveEnrollmentsByPlanId(planId, operator, completeTime); log.info("计划结训学员归档完成: planId={}, 更新记录数={}", planId, updatedRows); + + if (accessAuthorizationOrchestrator != null && trainEnrollmentMapper != null) + { + try + { + EnrollmentQuery query = new EnrollmentQuery(); + query.setPlanId(planId); + List list = trainEnrollmentMapper.selectEnrollmentItemList(query); + if (list != null) + { + for (EnrollmentItemVO item : list) + { + try + { + accessAuthorizationOrchestrator.reconcile(item.getEnrollmentId()); + } + catch (Exception ex) + { + log.warn("Access reconcile on plan completion failed for enrollment {}: {}", item.getEnrollmentId(), ex.getMessage()); + } + } + } + } + catch (Exception ex) + { + log.warn("Failed to trigger access reconcile on plan completion for planId {}: {}", planId, ex.getMessage()); + } + } } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java new file mode 100644 index 000000000..5dc50d0c1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java @@ -0,0 +1,295 @@ +package com.ruoyi.train.service.impl; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.mapper.TrainDormAuthMapper; +import com.ruoyi.train.mapper.TrainExternalOperationMapper; +import com.ruoyi.train.service.IExternalOperationService; + +/** + * 外部设备操作流水业务服务实现类 + * + * @author ruoyi + * @date 2026-09-15 + */ +@Service +public class ExternalOperationServiceImpl implements IExternalOperationService +{ + private static final Set VALID_TYPES = new HashSet<>(Arrays.asList( + "GRANT", "REVOKE", "STATUS_QUERY", "REMOTE_UNLOCK" + )); + + private static final Set TERMINAL_STATUSES = new HashSet<>(Arrays.asList( + "SUCCESS", "FAILED", "TIMEOUT" + )); + + @Autowired + private TrainExternalOperationMapper operationMapper; + + @Autowired + private TrainDormAuthMapper dormAuthMapper; + + @Override + public TrainExternalOperation getOperationById(Long operationId) + { + if (operationId == null) + { + return null; + } + return operationMapper.selectExternalOperationById(operationId); + } + + @Override + public TrainExternalOperation getOperationByIdempotencyKey(String idempotencyKey) + { + if (StringUtils.isEmpty(idempotencyKey)) + { + return null; + } + return operationMapper.selectByIdempotencyKey(idempotencyKey); + } + + @Override + public List listOperationsByDoorAuthId(Long doorAuthId) + { + if (doorAuthId == null) + { + return Collections.emptyList(); + } + return operationMapper.selectByDoorAuthId(doorAuthId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalOperation createOperation(TrainExternalOperation operation) + { + if (operation == null) + { + throw new ServiceException("操作实体不能为空"); + } + if (operation.getDoorAuthId() == null) + { + throw new ServiceException("门禁授权ID (doorAuthId) 不能为空"); + } + if (StringUtils.isEmpty(operation.getOperationType()) || !VALID_TYPES.contains(operation.getOperationType())) + { + throw new ServiceException("非法或未知的操作类型: " + operation.getOperationType()); + } + if (StringUtils.isEmpty(operation.getIdempotencyKey())) + { + throw new ServiceException("幂等键 (idempotencyKey) 不能为空"); + } + + // 1. 验证目标门禁授权是否存在 + TrainDormAuth auth = dormAuthMapper.selectTrainDormAuthByAuthId(operation.getDoorAuthId()); + if (auth == null) + { + throw new ServiceException("目标门禁授权不存在: " + operation.getDoorAuthId()); + } + + // 2. 幂等检查:若已存在同名幂等键,直接返回已存在记录(幂等安全) + TrainExternalOperation existing = operationMapper.selectByIdempotencyKey(operation.getIdempotencyKey()); + if (existing != null) + { + return existing; + } + + // 3. 设置默认值与初始状态 + if (StringUtils.isEmpty(operation.getBusinessType())) + { + operation.setBusinessType("DOOR_AUTH"); + } + if (StringUtils.isEmpty(operation.getVendorCode())) + { + operation.setVendorCode("DMS"); + } + operation.setOperationStatus("CREATED"); + if (operation.getAttemptCount() == null) + { + operation.setAttemptCount(0); + } + + operationMapper.insertExternalOperation(operation); + return operation; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalOperation bindExternalReference( + Long operationId, + String externalOperationId, + String externalHardwareId, + String externalKeyId, + String operator) + { + if (operationId == null) + { + throw new ServiceException("操作流水ID不能为空"); + } + TrainExternalOperation op = operationMapper.selectExternalOperationById(operationId); + if (op == null) + { + throw new ServiceException("操作流水记录不存在: " + operationId); + } + + op.setExternalOperationId(externalOperationId); + if (StringUtils.isNotEmpty(externalHardwareId)) + { + op.setExternalHardwareId(externalHardwareId); + } + if (StringUtils.isNotEmpty(externalKeyId)) + { + op.setExternalKeyId(externalKeyId); + } + op.setUpdateBy(operator); + + operationMapper.updateExternalReference(operationId, externalOperationId, + externalHardwareId, externalKeyId, operator); + return op; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalOperation transitStatus( + Long operationId, + String targetStatus, + String errorCode, + String errorMessage, + String operator) + { + if (operationId == null) + { + throw new ServiceException("操作流水ID不能为空"); + } + TrainExternalOperation op = operationMapper.selectExternalOperationById(operationId); + if (op == null) + { + throw new ServiceException("操作流水记录不存在: " + operationId); + } + + String currentStatus = op.getOperationStatus(); + + // 终态保护:终态不可随意逆转 + if ("SUCCESS".equals(currentStatus) && !"SUCCESS".equals(targetStatus)) + { + throw new ServiceException("操作已达成终态 SUCCESS,禁止状态逆转至: " + targetStatus); + } + if (TERMINAL_STATUSES.contains(currentStatus) && ("CREATED".equals(targetStatus) || "REQUESTED".equals(targetStatus))) + { + throw new ServiceException("操作已达成终态 [" + currentStatus + "],禁止重置为: " + targetStatus); + } + + op.setOperationStatus(targetStatus); + op.setLastErrorCode(errorCode); + op.setLastErrorMessage(errorMessage); + op.setUpdateBy(operator); + + if (TERMINAL_STATUSES.contains(targetStatus)) + { + op.setCompletedAt(new Date()); + } + + operationMapper.updateExternalOperationStatus(op); + return op; + } + + @Override + public List listPendingPollOperations(String vendorCode, Date maxPollTime) + { + Date threshold = maxPollTime != null ? maxPollTime : new Date(); + return operationMapper.selectPendingPollOperations(vendorCode, threshold); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalOperation createRetryOperation(Long previousOperationId, String newIdempotencyKey, String operator) + { + if (previousOperationId == null) + { + throw new ServiceException("前次操作流水ID不能为空"); + } + TrainExternalOperation prev = operationMapper.selectExternalOperationById(previousOperationId); + if (prev == null) + { + throw new ServiceException("前次操作流水不存在: " + previousOperationId); + } + + TrainExternalOperation retry = new TrainExternalOperation(); + retry.setDoorAuthId(prev.getDoorAuthId()); + retry.setBusinessType(prev.getBusinessType()); + retry.setVendorCode(prev.getVendorCode()); + retry.setOperationType(prev.getOperationType()); + retry.setIdempotencyKey(newIdempotencyKey); + retry.setExternalHardwareId(prev.getExternalHardwareId()); + retry.setExternalRequestType(prev.getExternalRequestType()); + retry.setAttemptCount((prev.getAttemptCount() != null ? prev.getAttemptCount() : 0) + 1); + retry.setCreateBy(operator); + + return createOperation(retry); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalOperation updateOperationPollResult( + Long operationId, + String targetStatus, + Date nextPollTime, + Integer attemptCount, + String errorCode, + String errorMessage, + String operator) + { + if (operationId == null) + { + throw new ServiceException("操作流水ID不能为空"); + } + TrainExternalOperation op = operationMapper.selectExternalOperationById(operationId); + if (op == null) + { + throw new ServiceException("操作流水记录不存在: " + operationId); + } + + String currentStatus = op.getOperationStatus(); + // 终态保护:终态不可随意逆转 + if ("SUCCESS".equals(currentStatus) && !"SUCCESS".equals(targetStatus)) + { + throw new ServiceException("操作已达成终态 SUCCESS,禁止状态逆转至: " + targetStatus); + } + if (TERMINAL_STATUSES.contains(currentStatus) && ("CREATED".equals(targetStatus) || "REQUESTED".equals(targetStatus))) + { + throw new ServiceException("操作已达成终态 [" + currentStatus + "],禁止重置为: " + targetStatus); + } + + op.setOperationStatus(targetStatus); + if (nextPollTime != null) + { + op.setNextPollTime(nextPollTime); + } + if (attemptCount != null) + { + op.setAttemptCount(attemptCount); + } + op.setLastErrorCode(errorCode); + op.setLastErrorMessage(errorMessage); + op.setUpdateBy(operator); + + if (TERMINAL_STATUSES.contains(targetStatus)) + { + op.setCompletedAt(new Date()); + } + + operationMapper.updateExternalOperationStatus(op); + return op; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceImpl.java new file mode 100644 index 000000000..7970f1fc7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceImpl.java @@ -0,0 +1,214 @@ +package com.ruoyi.train.service.impl; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainExternalResourceMapping; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainExternalResourceMappingMapper; +import com.ruoyi.train.service.IExternalResourceMappingService; + +/** + * 外部资源映射业务服务实现类 + * + * @author ruoyi + * @date 2026-09-15 + */ +@Service +public class ExternalResourceMappingServiceImpl implements IExternalResourceMappingService +{ + @Autowired + private TrainExternalResourceMappingMapper mappingMapper; + + @Autowired + private TrainDormitoryInfoMapper dormitoryInfoMapper; + + @Override + public TrainExternalResourceMapping getMappingById(Long mappingId) + { + if (mappingId == null) + { + return null; + } + return mappingMapper.selectExternalResourceMappingById(mappingId); + } + + @Override + public TrainExternalResourceMapping getActiveRoomMapping(String vendorCode, String dormId) + { + if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId)) + { + return null; + } + return mappingMapper.selectActiveRoomMapping(vendorCode, dormId); + } + + @Override + public List getActiveLockMappings(String vendorCode, String dormId) + { + if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId)) + { + return Collections.emptyList(); + } + return mappingMapper.selectActiveLockMappings(vendorCode, dormId); + } + + @Override + public List listMappings(TrainExternalResourceMapping query) + { + return mappingMapper.selectExternalResourceMappingList(query); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalResourceMapping bindRoomMapping(TrainExternalResourceMapping mapping) + { + validateCommonParameters(mapping); + + mapping.setLocalResourceType("DORM_ROOM"); + if (StringUtils.isEmpty(mapping.getExternalResourceType())) + { + mapping.setExternalResourceType("ROOM"); + } + + // 1. 验证宿舍是否存在 + checkDormitoryExists(mapping.getDormId()); + + // 2. 验证外部资源是否已被其他宿舍绑定为 ACTIVE + checkExternalResourceNotActiveBound(mapping.getVendorCode(), mapping.getExternalResourceType(), + mapping.getExternalResourceId(), mapping.getDormId()); + + // 3. 验证同一宿舍在同一 Vendor 下是否已有有效的 ROOM 映射(Active Uniqueness) + TrainExternalResourceMapping existingActiveRoom = mappingMapper.selectActiveRoomMapping( + mapping.getVendorCode(), mapping.getDormId()); + if (existingActiveRoom != null) + { + throw new ServiceException("宿舍 [" + mapping.getDormId() + "] 已存在有效的外部房间映射 (mappingId=" + + existingActiveRoom.getMappingId() + "),若需重新绑定请先解除原有绑定"); + } + + mapping.setMappingStatus("ACTIVE"); + if (mapping.getIsPrimary() == null) + { + mapping.setIsPrimary(1); + } + if (mapping.getLastSyncTime() == null) + { + mapping.setLastSyncTime(new Date()); + } + + mappingMapper.insertExternalResourceMapping(mapping); + return mapping; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainExternalResourceMapping bindLockMapping(TrainExternalResourceMapping mapping) + { + validateCommonParameters(mapping); + + mapping.setLocalResourceType("DOOR_LOCK"); + if (StringUtils.isEmpty(mapping.getExternalResourceType())) + { + mapping.setExternalResourceType("DOOR_LOCK"); + } + + // 1. 验证宿舍是否存在 + checkDormitoryExists(mapping.getDormId()); + + // 2. 验证该门锁是否已被其他宿舍占用(Active 唯一性) + checkExternalResourceNotActiveBound(mapping.getVendorCode(), mapping.getExternalResourceType(), + mapping.getExternalResourceId(), mapping.getDormId()); + + mapping.setMappingStatus("ACTIVE"); + if (mapping.getIsPrimary() == null) + { + // 如果宿舍还没有其他锁,设为主锁;已有锁则默认从锁 + List existingLocks = mappingMapper.selectActiveLockMappings( + mapping.getVendorCode(), mapping.getDormId()); + mapping.setIsPrimary(existingLocks.isEmpty() ? 1 : 0); + } + if (mapping.getLastSyncTime() == null) + { + mapping.setLastSyncTime(new Date()); + } + + mappingMapper.insertExternalResourceMapping(mapping); + return mapping; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deactivateMapping(Long mappingId, String operator) + { + if (mappingId == null) + { + return false; + } + TrainExternalResourceMapping existing = mappingMapper.selectExternalResourceMappingById(mappingId); + if (existing == null) + { + throw new ServiceException("目标外部资源映射不存在: " + mappingId); + } + return mappingMapper.deactivateMapping(mappingId, operator) > 0; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deactivateRoomMapping(String vendorCode, String dormId, String operator) + { + if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId)) + { + return false; + } + return mappingMapper.deactivateRoomMapping(vendorCode, dormId, operator) > 0; + } + + private void validateCommonParameters(TrainExternalResourceMapping mapping) + { + if (mapping == null) + { + throw new ServiceException("映射实体不能为空"); + } + if (StringUtils.isEmpty(mapping.getDormId())) + { + throw new ServiceException("本地宿舍ID (dormId) 不能为空"); + } + if (StringUtils.isEmpty(mapping.getVendorCode())) + { + mapping.setVendorCode("DMS"); + } + if (StringUtils.isEmpty(mapping.getExternalResourceId())) + { + throw new ServiceException("外部资源主键 (externalResourceId) 不能为空"); + } + } + + private void checkDormitoryExists(String dormId) + { + TrainDormitoryInfo dorm = dormitoryInfoMapper.selectTrainDormitoryInfoByDormId(dormId); + if (dorm == null) + { + throw new ServiceException("目标宿舍不存在: " + dormId); + } + } + + private void checkExternalResourceNotActiveBound(String vendorCode, String resourceType, String resourceId, String currentDormId) + { + TrainExternalResourceMapping bound = mappingMapper.selectActiveByExternalResource(vendorCode, resourceType, resourceId); + if (bound != null) + { + if (!bound.getDormId().equals(currentDormId)) + { + throw new ServiceException("外部资源 [" + resourceId + "] 已处于有效绑定状态,关联宿舍为 [" + + bound.getDormId() + "],禁止跨房间重复绑定"); + } + } + } +} 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 new file mode 100644 index 000000000..e85f9d5f4 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java @@ -0,0 +1,374 @@ +package com.ruoyi.train.service.impl; + +import java.util.Date; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.service.ITrainAccommodationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 住宿安排Service业务层处理 + * + * @author ruoyi + * @date 2026-09-15 + */ +@Service +public class TrainAccommodationServiceImpl implements ITrainAccommodationService +{ + private static final Logger log = LoggerFactory.getLogger(TrainAccommodationServiceImpl.class); + + @Autowired + private TrainAccommodationMapper trainAccommodationMapper; + + @Autowired + private TrainDormitoryInfoMapper trainDormitoryInfoMapper; + + @Autowired + private TrainEnrollmentMapper trainEnrollmentMapper; + + @Autowired(required = false) + private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator; + + + /** + * 查询住宿安排详情 + * + * @param accommodationId 住宿安排主键 + * @return 住宿安排 + */ + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + } + + /** + * 根据参训档案ID查询最新住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 住宿安排 + */ + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + return trainAccommodationMapper.selectTrainAccommodationByEnrollmentId(enrollmentId); + } + + /** + * 根据参训档案ID查询当前活跃/有效住宿安排 + * + * @param enrollmentId 参训档案ID + * @return 活跃住宿安排 + */ + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + return trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId); + } + + /** + * 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序) + * + * @param enrollmentId 参训档案ID + * @return 住宿历史记录列表 + */ + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + return trainAccommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId); + } + + /** + * 查询住宿安排列表 + * + * @param accommodation 住宿安排 + * @return 住宿安排 + */ + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return trainAccommodationMapper.selectTrainAccommodationList(accommodation); + } + + /** + * 查询指定开班计划下所有有效住宿安排 + * + * @param planId 开班计划ID + * @return 有效住宿列表 + */ + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return trainAccommodationMapper.selectActiveAccommodationsByPlanId(planId); + } + + /** + * 新增住宿安排 (带前置依赖、唯一活跃约束与宿舍容量校验) + * + * @param accommodation 住宿安排 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int insertTrainAccommodation(TrainAccommodation accommodation) + { + Long enrollmentId = accommodation.getEnrollmentId(); + if (enrollmentId == null) + { + throw new ServiceException("参训档案ID (enrollmentId) 不能为空"); + } + + // 1. 验证参训档案是否存在 + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId); + if (enrollment == null) + { + throw new ServiceException("指定参训档案不存在,enrollmentId: " + enrollmentId); + } + + // 2. 验证唯一活跃住宿约束 (Duplicate Active Accommodation Guard) + TrainAccommodation activeAcc = trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId); + if (activeAcc != null) + { + throw new ServiceException("该参训学员已存在有效住宿安排 (ID: " + activeAcc.getAccommodationId() + + ",宿舍: " + activeAcc.getDormId() + "),请先办理退宿或换房"); + } + + // 3. 验证宿舍存在性与可用状态 + String dormId = accommodation.getDormId(); + if (dormId == null || dormId.trim().isEmpty()) + { + throw new ServiceException("分配宿舍ID (dormId) 不能为空"); + } + TrainDormitoryInfo dorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoByDormId(dormId); + if (dorm == null) + { + throw new ServiceException("指定宿舍不存在,dormId: " + dormId); + } + if (dorm.getDormStatus() != null && dorm.getDormStatus() == 0) + { + throw new ServiceException("该宿舍当前处于禁用状态,无法分配住宿"); + } + + // 4. 验证宿舍容量边界 (Capacity Boundary Guard) + int currentActive = trainAccommodationMapper.countActiveByDormId(dormId); + if (dorm.getCapacity() != null && currentActive >= dorm.getCapacity()) + { + throw new ServiceException("宿舍容量已满 (核定容量: " + dorm.getCapacity() + + ",当前有效人数: " + currentActive + "),无法继续分配"); + } + + // 5. 设置默认属性 + if (accommodation.getAssignedTime() == null) + { + accommodation.setAssignedTime(DateUtils.getNowDate()); + } + if (accommodation.getAccommodationStatus() == null) + { + accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + } + int rows = trainAccommodationMapper.insertTrainAccommodation(accommodation); + if (rows > 0 && accessAuthorizationOrchestrator != null) + { + try + { + accessAuthorizationOrchestrator.reconcile(enrollmentId); + } + catch (Exception ex) + { + log.warn("Access reconcile triggered on accommodation insert failed for enrollment {}: {}", enrollmentId, ex.getMessage()); + } + } + return rows; + } + + /** + * 修改住宿安排 + * + * @param accommodation 住宿安排 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateTrainAccommodation(TrainAccommodation accommodation) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodation.getAccommodationId()); + if (exist == null) + { + throw new ServiceException("住宿记录不存在: " + accommodation.getAccommodationId()); + } + + // 严格遵循 API Semantics 规范:禁止通过普通 update 修改 dorm_id 来模拟调房 + if (accommodation.getDormId() != null && !accommodation.getDormId().equals(exist.getDormId())) + { + throw new ServiceException("变更宿舍请使用专属调房换房流程 (transfer),禁止直接修改住宿记录的宿舍ID"); + } + + accommodation.setUpdateTime(DateUtils.getNowDate()); + return trainAccommodationMapper.updateTrainAccommodation(accommodation); + } + + /** + * 学员办理入住 (状态 0预分配 -> 1已入住) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + @Override + public int checkIn(Long accommodationId, String operator) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException("住宿安排不存在: " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) + { + throw new ServiceException("已退宿的记录不能重新办理入住"); + } + return trainAccommodationMapper.updateAccommodationStatus(accommodationId, + TrainAccommodation.STATUS_CHECKED_IN, operator, DateUtils.getNowDate()); + } + + /** + * 学员办理退宿 (状态 -> 2已退宿) + * + * @param accommodationId 住宿安排ID + * @param operator 操作人 + * @return 结果 + */ + @Override + public int checkOut(Long accommodationId, String operator) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + throw new ServiceException("住宿安排不存在: " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) + { + return 1; // 已经是退宿状态 + } + int rows = trainAccommodationMapper.updateAccommodationStatus(accommodationId, + TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + if (rows > 0 && accessAuthorizationOrchestrator != null) + { + try + { + accessAuthorizationOrchestrator.reconcile(exist.getEnrollmentId()); + } + catch (Exception ex) + { + log.warn("Access reconcile triggered on checkout failed for enrollment {}: {}", exist.getEnrollmentId(), ex.getMessage()); + } + } + return rows; + } + + /** + * 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住) + * + * @param oldAccommodationId 原住宿安排ID + * @param newDormId 新宿舍ID + * @param newBedNo 新床位号 + * @param operator 操作人 + * @return 新生成的住宿安排 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public TrainAccommodation transfer(Long oldAccommodationId, String newDormId, String newBedNo, String operator) + { + TrainAccommodation oldAcc = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(oldAccommodationId); + if (oldAcc == null) + { + throw new ServiceException("原住宿记录不存在: " + oldAccommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(oldAcc.getAccommodationStatus())) + { + throw new ServiceException("原住宿记录已退宿,无法办理调房"); + } + + // 1. 结束旧住宿 + trainAccommodationMapper.updateAccommodationStatus(oldAccommodationId, + TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); + + // 2. 建立新住宿 + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setEnrollmentId(oldAcc.getEnrollmentId()); + newAcc.setDormId(newDormId); + newAcc.setBedNo(newBedNo); + newAcc.setAssignedTime(DateUtils.getNowDate()); + newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + newAcc.setCreateBy(operator); + newAcc.setRemark("由原住宿记录 " + oldAccommodationId + " (宿舍 " + oldAcc.getDormId() + ") 调房迁入"); + + insertTrainAccommodation(newAcc); + + // 3. 调房遵循 GRANT_NEW_THEN_REVOKE_OLD:先为新房发起对账发放,旧房在新房生效前绝不注销 + if (accessAuthorizationOrchestrator != null) + { + try + { + accessAuthorizationOrchestrator.reconcile(oldAcc.getEnrollmentId()); + } + catch (Exception ex) + { + log.warn("Access reconcile triggered on transfer failed for enrollment {}: {}", oldAcc.getEnrollmentId(), ex.getMessage()); + } + } + + return newAcc; + } + + + /** + * 批量删除住宿安排 + * + * @param accommodationIds 需要删除的主键集合 + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + for (Long id : accommodationIds) + { + deleteTrainAccommodationByAccommodationId(id); + } + return accommodationIds.length; + } + + /** + * 删除住宿安排 + * + * @param accommodationId 需要删除的主键 + * @return 结果 + */ + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (exist == null) + { + return 0; + } + // 业务历史保护:若为活跃已入住状态,应先退宿而不是直接物理删除 + if (TrainAccommodation.STATUS_CHECKED_IN.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 51b483925..da81a9820 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 @@ -1,30 +1,38 @@ package com.ruoyi.train.service.impl; +import java.util.Date; import java.util.List; -import com.ruoyi.common.utils.DateUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.ruoyi.train.mapper.TrainDormAuthMapper; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.train.domain.TrainAccommodation; import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormAuthMapper; import com.ruoyi.train.service.ITrainDormAuthService; /** - * 宿舍权限分配Service业务层处理 - * + * 宿舍门禁授权Service业务层处理 (Database V2) + * * @author ruoyi - * @date 2026-09-08 + * @date 2026-09-15 */ @Service -public class TrainDormAuthServiceImpl implements ITrainDormAuthService +public class TrainDormAuthServiceImpl implements ITrainDormAuthService { @Autowired private TrainDormAuthMapper trainDormAuthMapper; + @Autowired + private TrainAccommodationMapper trainAccommodationMapper; + /** - * 查询宿舍权限分配 - * - * @param authId 宿舍权限分配主键 - * @return 宿舍权限分配 + * 查询宿舍门禁授权 + * + * @param authId 宿舍门禁授权主键 + * @return 宿舍门禁授权 */ @Override public TrainDormAuth selectTrainDormAuthByAuthId(Long authId) @@ -33,10 +41,22 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService } /** - * 查询宿舍权限分配列表 - * - * @param trainDormAuth 宿舍权限分配 - * @return 宿舍权限分配 + * 根据住宿安排ID查询门禁授权 + * + * @param accommodationId 住宿安排主键 + * @return 宿舍门禁授权 + */ + @Override + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId) + { + return trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + } + + /** + * 查询宿舍门禁授权列表 + * + * @param trainDormAuth 宿舍门禁授权 + * @return 宿舍门禁授权集合 */ @Override public List selectTrainDormAuthList(TrainDormAuth trainDormAuth) @@ -45,35 +65,122 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService } /** - * 新增宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 新增宿舍门禁授权 (以住宿事实为唯一业务来源,禁止绕过住宿直接创建) + * + * @param trainDormAuth 宿舍门禁授权 * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int insertTrainDormAuth(TrainDormAuth trainDormAuth) { - trainDormAuth.setCreateTime(DateUtils.getNowDate()); + Long accommodationId = trainDormAuth.getAccommodationId(); + if (accommodationId == null) + { + throw new ServiceException("门禁授权必须绑定住宿安排,accommodationId 不能为空"); + } + + // 1. 校验住宿事实有效性 + TrainAccommodation accommodation = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (accommodation == null) + { + throw new ServiceException("指定的住宿安排不存在,ID: " + accommodationId); + } + if (TrainAccommodation.STATUS_CHECKED_OUT.equals(accommodation.getAccommodationStatus())) + { + throw new ServiceException("该住宿安排已退宿,无法新建门禁授权"); + } + + // 2. 校验唯一性 (每条住宿安排对应唯一授权流水) + TrainDormAuth existAuth = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + if (existAuth != null) + { + throw new ServiceException("该住宿安排已存在门禁授权 (AuthID: " + existAuth.getAuthId() + "),请勿重复创建"); + } + + // 3. 初始授权状态:本地待下发 (严格遵循 No Fake Device Success 原则) + if (trainDormAuth.getFaceSyncStatus() == null || TrainDormAuth.STATUS_ACTIVE.equals(trainDormAuth.getFaceSyncStatus())) + { + trainDormAuth.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING); + } + if (trainDormAuth.getRetryCount() == null) + { + trainDormAuth.setRetryCount(0); + } + + // 4. 继承操作人和时间 + Date now = DateUtils.getNowDate(); + trainDormAuth.setCreateTime(now); + if (trainDormAuth.getCreateUser() != null && trainDormAuth.getCreateBy() == null) + { + trainDormAuth.setCreateBy(trainDormAuth.getCreateUser()); + } + return trainDormAuthMapper.insertTrainDormAuth(trainDormAuth); } /** - * 修改宿舍权限分配 - * - * @param trainDormAuth 宿舍权限分配 + * 修改宿舍门禁授权 + * + * @param trainDormAuth 宿舍门禁授权 * @return 结果 */ @Override + @Transactional(rollbackFor = Exception.class) public int updateTrainDormAuth(TrainDormAuth trainDormAuth) { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(trainDormAuth.getAuthId()); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + trainDormAuth.getAuthId()); + } + trainDormAuth.setUpdateTime(DateUtils.getNowDate()); return trainDormAuthMapper.updateTrainDormAuth(trainDormAuth); } /** - * 批量删除宿舍权限分配 - * - * @param authIds 需要删除的宿舍权限分配主键 + * 记录下发失败并累加重试次数 + * + * @param authId 授权主键 + * @param errorMessage 失败原因 + * @param operator 操作人 + * @return 结果 + */ + @Override + public int markSyncFailed(Long authId, String errorMessage, String operator) + { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + authId); + } + trainDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED, errorMessage, operator, DateUtils.getNowDate()); + return trainDormAuthMapper.increaseRetryCount(authId, errorMessage, operator, DateUtils.getNowDate()); + } + + /** + * 注销门禁授权 + * + * @param authId 授权主键 + * @param operator 操作人 + * @return 结果 + */ + @Override + public int revokeAuth(Long authId, String operator) + { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + authId); + } + return trainDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_REVOKED, null, operator, DateUtils.getNowDate()); + } + + /** + * 批量删除宿舍门禁授权 + * + * @param authIds 需要删除的主键集合 * @return 结果 */ @Override @@ -83,9 +190,9 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService } /** - * 删除宿舍权限分配信息 - * - * @param authId 宿舍权限分配主键 + * 删除宿舍门禁授权信息 + * + * @param authId 主键 * @return 结果 */ @Override @@ -93,4 +200,99 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService { return trainDormAuthMapper.deleteTrainDormAuthByAuthId(authId); } + + @Override + @Transactional(rollbackFor = Exception.class) + public TrainDormAuth ensureAuthorizationForAccommodation(Long accommodationId, String operator) + { + if (accommodationId == null) + { + throw new ServiceException("accommodationId 不能为空"); + } + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + if (exist != null) + { + return exist; + } + + TrainAccommodation acc = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); + if (acc == null) + { + throw new ServiceException("住宿安排不存在: " + accommodationId); + } + + TrainDormAuth newAuth = new TrainDormAuth(); + newAuth.setAccommodationId(accommodationId); + newAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT); + newAuth.setCredentialType(TrainDormAuth.CREDENTIAL_TYPE_ONLINE_CODE); + newAuth.setOperationVersion(1); + newAuth.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING); + newAuth.setRetryCount(0); + newAuth.setCreateBy(operator != null ? operator : "system"); + newAuth.setCreateTime(DateUtils.getNowDate()); + + try + { + trainDormAuthMapper.insertTrainDormAuth(newAuth); + return newAuth; + } + catch (Exception e) + { + // 并发冲突防重保护:如果并发创建触发了唯一约束,重新读取已有记录并返回 + TrainDormAuth concurrentAuth = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId); + if (concurrentAuth != null) + { + return concurrentAuth; + } + throw new ServiceException("创建门禁授权失败: " + e.getMessage()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateAuthStatus(Long authId, String authStatus, Long operationId, String operator) + { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + throw new ServiceException("门禁授权记录不存在: " + authId); + } + exist.setAuthStatus(authStatus); + if (operationId != null) + { + exist.setLatestOperationId(operationId); + } + exist.setOperationVersion((exist.getOperationVersion() != null ? exist.getOperationVersion() : 1) + 1); + exist.setUpdateBy(operator); + exist.setUpdateTime(DateUtils.getNowDate()); + return trainDormAuthMapper.updateTrainDormAuth(exist); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateAuthStatusByPoller(Long authId, String authStatus, Long operationId, String lastError, String operator) + { + TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId); + if (exist == null) + { + return 0; + } + + // 核心防迟到保护 (STALE_OPERATION_RESULT_GUARD) + // 只有当传入的 operationId 与当前记录绑定的 latestOperationId 相符时才更新 + if (operationId != null && exist.getLatestOperationId() != null && !operationId.equals(exist.getLatestOperationId())) + { + // 说明已被后续发起的操作替代,忽略此迟到结果 + return 0; + } + + exist.setAuthStatus(authStatus); + if (lastError != null) + { + exist.setLastError(lastError); + } + exist.setUpdateBy(operator != null ? operator : "DmsPoller"); + exist.setUpdateTime(DateUtils.getNowDate()); + return trainDormAuthMapper.updateTrainDormAuth(exist); + } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml new file mode 100644 index 000000000..494b10e6d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT + a.accommodation_id, + a.enrollment_id, + a.dorm_id, + a.bed_no, + a.assigned_time, + a.accommodation_status, + a.create_by, + a.create_time, + a.update_by, + a.update_time, + a.remark, + e.student_id, + s.s_name AS student_name, + s.s_gender AS gender, + s.org_name, + s.s_phone AS phone, + e.plan_id, + p.plan_name, + d.dorm_building, + d.dorm_room + FROM train_accommodation a + LEFT JOIN train_enrollment e ON a.enrollment_id = e.enrollment_id + LEFT JOIN train_student s ON e.student_id = s.s_id + LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id + LEFT JOIN train_dormitory_info d ON a.dorm_id = d.dorm_id + + + + + + + + + + + + + + + + + + INSERT INTO train_accommodation + + enrollment_id, + dorm_id, + bed_no, + assigned_time, + accommodation_status, + create_by, + create_time, + update_by, + update_time, + remark, + + + #{enrollmentId}, + #{dormId}, + #{bedNo}, + #{assignedTime}, + #{accommodationStatus}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, + #{remark}, + + + + + UPDATE train_accommodation + + dorm_id = #{dormId}, + bed_no = #{bedNo}, + assigned_time = #{assignedTime}, + accommodation_status = #{accommodationStatus}, + update_by = #{updateBy}, + update_time = #{updateTime}, + remark = #{remark}, + + WHERE accommodation_id = #{accommodationId} + + + + UPDATE train_accommodation + SET accommodation_status = #{accommodationStatus}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE accommodation_id = #{accommodationId} + + + + DELETE FROM train_accommodation WHERE accommodation_id = #{accommodationId} + + + + DELETE FROM train_accommodation WHERE accommodation_id IN + + #{accommodationId} + + + 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 e4ecc85de..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 @@ -3,101 +3,222 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + - - - - - + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - select auth_id, student_id, plan_id, dorm_id, bed_no, face_sync_status, auth_start, auth_end, create_user, create_time, update_time, remark from train_dorm_auth + 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, + a.retry_count, + a.last_error, + a.external_auth_id, + a.last_sync_time, + a.create_by, + a.create_time, + a.update_by, + a.update_time, + a.remark, + acc.enrollment_id, + acc.dorm_id, + acc.bed_no, + e.student_id, + s.s_name AS student_name, + e.plan_id, + p.plan_name, + d.dorm_building, + d.dorm_room + FROM train_dorm_auth a + LEFT JOIN train_accommodation acc ON a.accommodation_id = acc.accommodation_id + LEFT JOIN train_enrollment e ON acc.enrollment_id = e.enrollment_id + LEFT JOIN train_student s ON e.student_id = s.s_id + LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id + LEFT JOIN train_dormitory_info d ON acc.dorm_id = d.dorm_id - + + + - insert into train_dorm_auth + INSERT INTO train_dorm_auth - student_id, - plan_id, - dorm_id, - bed_no, - face_sync_status, + accommodation_id, + auth_status, + credential_type, + latest_operation_id, + operation_version, auth_start, auth_end, - create_user, + face_sync_status, + retry_count, + last_error, + external_auth_id, + last_sync_time, + create_by, create_time, + update_by, update_time, - remark, - + remark, + - #{studentId}, - #{planId}, - #{dormId}, - #{bedNo}, - #{faceSyncStatus}, + #{accommodationId}, + #{authStatus}, + #{credentialType}, + #{latestOperationId}, + #{operationVersion}, #{authStart}, #{authEnd}, - #{createUser}, + #{faceSyncStatus}, + #{retryCount}, + #{lastError}, + #{externalAuthId}, + #{lastSyncTime}, + #{createBy}, #{createTime}, + #{updateBy}, #{updateTime}, - #{remark}, - + #{remark}, + - update train_dorm_auth + UPDATE train_dorm_auth - student_id = #{studentId}, - plan_id = #{planId}, - dorm_id = #{dormId}, - bed_no = #{bedNo}, - face_sync_status = #{faceSyncStatus}, + accommodation_id = #{accommodationId}, + auth_status = #{authStatus}, + credential_type = #{credentialType}, + latest_operation_id = #{latestOperationId}, + operation_version = #{operationVersion}, auth_start = #{authStart}, auth_end = #{authEnd}, - create_user = #{createUser}, - create_time = #{createTime}, + face_sync_status = #{faceSyncStatus}, + retry_count = #{retryCount}, + last_error = #{lastError}, + external_auth_id = #{externalAuthId}, + last_sync_time = #{lastSyncTime}, + update_by = #{updateBy}, update_time = #{updateTime}, remark = #{remark}, - where auth_id = #{authId} + WHERE auth_id = #{authId} + + + + UPDATE train_dorm_auth + SET face_sync_status = #{faceSyncStatus}, + last_error = #{lastError}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE auth_id = #{authId} + + + + UPDATE train_dorm_auth + SET retry_count = COALESCE(retry_count, 0) + 1, + last_error = #{lastError}, + update_by = #{updateBy}, + update_time = #{updateTime} + WHERE auth_id = #{authId} - delete from train_dorm_auth where auth_id = #{authId} + DELETE FROM train_dorm_auth WHERE auth_id = #{authId} - delete from train_dorm_auth where auth_id in + DELETE FROM train_dorm_auth WHERE auth_id IN #{authId} - \ No newline at end of file + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalOperationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalOperationMapper.xml new file mode 100644 index 000000000..e72f84d5a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalOperationMapper.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT operation_id, door_auth_id, business_type, vendor_code, operation_type, + operation_status, idempotency_key, external_operation_id, external_request_type, + external_hardware_id, external_key_id, attempt_count, next_poll_time, + requested_at, completed_at, last_error_code, last_error_message, + create_by, create_time, update_by, update_time, remark + FROM train_external_operation + + + + + + + + + + + + + + + + INSERT INTO train_external_operation ( + door_auth_id, + business_type, + vendor_code, + operation_type, + operation_status, + idempotency_key, + external_operation_id, + external_request_type, + external_hardware_id, + external_key_id, + attempt_count, + next_poll_time, + requested_at, + completed_at, + last_error_code, + last_error_message, + create_by, + remark, + create_time + ) VALUES ( + #{doorAuthId}, + #{businessType}, + #{vendorCode}, + #{operationType}, + #{operationStatus}, + #{idempotencyKey}, + #{externalOperationId}, + #{externalRequestType}, + #{externalHardwareId}, + #{externalKeyId}, + #{attemptCount}, + #{nextPollTime}, + #{requestedAt}, + #{completedAt}, + #{lastErrorCode}, + #{lastErrorMessage}, + #{createBy}, + #{remark}, + CURRENT_TIMESTAMP + ) + + + + UPDATE train_external_operation + + operation_status = #{operationStatus}, + attempt_count = #{attemptCount}, + next_poll_time = #{nextPollTime}, + requested_at = #{requestedAt}, + completed_at = #{completedAt}, + last_error_code = #{lastErrorCode}, + last_error_message = #{lastErrorMessage}, + update_by = #{updateBy}, + remark = #{remark}, + update_time = CURRENT_TIMESTAMP + + WHERE operation_id = #{operationId} + + + + UPDATE train_external_operation + SET external_operation_id = #{externalOperationId}, + external_hardware_id = #{externalHardwareId}, + external_key_id = #{externalKeyId}, + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + WHERE operation_id = #{operationId} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalResourceMappingMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalResourceMappingMapper.xml new file mode 100644 index 000000000..5c78b3044 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalResourceMappingMapper.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT mapping_id, dorm_id, vendor_code, local_resource_type, external_resource_type, + external_resource_id, external_parent_id, external_hardware_id, external_project_id, + is_primary, mapping_status, last_sync_time, create_by, create_time, update_by, + update_time, remark + FROM train_external_resource_mapping + + + + + + + + + + + + + + INSERT INTO train_external_resource_mapping ( + dorm_id, + vendor_code, + local_resource_type, + external_resource_type, + external_resource_id, + external_parent_id, + external_hardware_id, + external_project_id, + is_primary, + mapping_status, + last_sync_time, + create_by, + remark, + create_time + ) VALUES ( + #{dormId}, + #{vendorCode}, + #{localResourceType}, + #{externalResourceType}, + #{externalResourceId}, + #{externalParentId}, + #{externalHardwareId}, + #{externalProjectId}, + #{isPrimary}, + #{mappingStatus}, + #{lastSyncTime}, + #{createBy}, + #{remark}, + CURRENT_TIMESTAMP + ) + + + + UPDATE train_external_resource_mapping + + dorm_id = #{dormId}, + vendor_code = #{vendorCode}, + local_resource_type = #{localResourceType}, + external_resource_type = #{externalResourceType}, + external_resource_id = #{externalResourceId}, + external_parent_id = #{externalParentId}, + external_hardware_id = #{externalHardwareId}, + external_project_id = #{externalProjectId}, + is_primary = #{isPrimary}, + mapping_status = #{mappingStatus}, + last_sync_time = #{lastSyncTime}, + update_by = #{updateBy}, + remark = #{remark}, + update_time = CURRENT_TIMESTAMP + + WHERE mapping_id = #{mappingId} + + + + UPDATE train_external_resource_mapping + SET mapping_status = 'INACTIVE', + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + WHERE mapping_id = #{mappingId} + + + + UPDATE train_external_resource_mapping + SET mapping_status = 'INACTIVE', + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + WHERE vendor_code = #{vendorCode} + AND dorm_id = #{dormId} + AND external_resource_type = 'ROOM' + AND mapping_status = 'ACTIVE' + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java new file mode 100644 index 000000000..6727763ef --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java @@ -0,0 +1,176 @@ +package com.ruoyi.train.integration.dms; + +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainExternalOperation; +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.dto.DmsPasswordCreateRequest; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import com.ruoyi.train.integration.dms.poller.DmsOperationPoller; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.impl.ExternalOperationServiceImpl; +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 static org.junit.Assert.*; + +/** + * DMS 异步生命周期语义 (Accepted != Success) 与凭据隐私安全测试 + */ +public class DmsAsyncLifecycleAndSecurityTest { + + private MockDmsServer mockServer; + private DmsProperties properties; + private DmsPasswordClient passwordClient; + private DmsOperationPoller poller; + private final Map operationDb = new ConcurrentHashMap<>(); + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + passwordClient = new DmsPasswordClient(httpClient); + DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient); + + IExternalOperationService mockOpService = createMockOperationService(); + poller = new DmsOperationPoller(properties, mockOpService, statusClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + operationDb.clear(); + } + + /** + * 关键验证:ACCEPTED != SUCCESS (HTTP 200 仅代表受理,绝不等于设备执行成功) + * 只有后续 Poller 轮询 DMS 返回 success,才转换为 SUCCESS。 + */ + @Test + public void testAcceptedNotEqualsSuccess_LifecycleVerification() { + // 1. 发起创建密码请求 (使用短暂内存明文密码) + DmsPasswordCreateRequest req = new DmsPasswordCreateRequest(); + req.setLockId(5001L); + req.setHolder("王学员"); + req.setPhoneNumber("13911112222"); + req.setPassword("567890"); + + DmsPasswordCreateResponse createResp = passwordClient.createOnlinePassword(req); + assertNotNull(createResp); + assertEquals(Long.valueOf(99001L), createResp.getOperationId()); + + // 2. 本地记录操作流水:初始状态必须为 ACCEPTED,绝不可直接置为 SUCCESS! + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(201L); + op.setExternalOperationId(String.valueOf(createResp.getOperationId())); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + operationDb.put(201L, op); + + // 断言:HTTP 成功后,本地操作状态绝对不是 SUCCESS + assertNotEquals("Initial status after HTTP accepted MUST NOT be SUCCESS", + TrainExternalOperation.STATUS_SUCCESS, op.getOperationStatus()); + assertEquals("Initial status MUST be ACCEPTED", + TrainExternalOperation.STATUS_ACCEPTED, op.getOperationStatus()); + + // 3. 初始时 Mock 服务端返回 undone (门锁硬件正在联网对账) + mockServer.setOperationStatus(99001L, "undone", "Queued in gateway buffer"); + poller.pollPendingOperations(); + assertEquals("When vendor status is undone, local status must remain PENDING", + TrainExternalOperation.STATUS_PENDING, operationDb.get(201L).getOperationStatus()); + + // 4. 设备真正下发成功后,Mock 返回 success + mockServer.setOperationStatus(99001L, "success", "Lock accepted password key"); + poller.pollPendingOperations(); + + // 断言:轮询确认后,最终状态达成 SUCCESS + assertEquals("Final status after successful poll MUST be SUCCESS", + TrainExternalOperation.STATUS_SUCCESS, operationDb.get(201L).getOperationStatus()); + } + + /** + * 凭据零泄露测试:反射检查 TrainExternalOperation 与 TrainDormAuth, + * 确认不存在任何明文密码、密钥或卡号字段 (PLAINTEXT_PASSWORD_PERSISTENCE_COUNT = 0) + */ + @Test + public void testCredentialDataLeak_ZeroPlaintextStorage() { + // 检查 TrainExternalOperation + for (Field f : TrainExternalOperation.class.getDeclaredFields()) { + String name = f.getName().toLowerCase(); + assertFalse("TrainExternalOperation must not have password field: " + name, + name.contains("password") || name.contains("passwd")); + assertFalse("TrainExternalOperation must not have token field: " + name, + name.equals("token") || name.equals("appsecret")); + assertFalse("TrainExternalOperation must not have card_no field: " + name, + name.contains("cardno") || name.contains("card_no")); + } + + // 检查 TrainDormAuth + for (Field f : TrainDormAuth.class.getDeclaredFields()) { + String name = f.getName().toLowerCase(); + assertFalse("TrainDormAuth must not have password field: " + name, + name.contains("password") || name.contains("passwd")); + assertFalse("TrainDormAuth must not have secret field: " + name, + name.contains("secret")); + } + } + + private IExternalOperationService createMockOperationService() { + return (IExternalOperationService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalOperationService.class}, + (proxy, method, args) -> { + String methodName = method.getName(); + if ("listPendingPollOperations".equals(methodName)) { + List list = new ArrayList<>(); + for (TrainExternalOperation op : operationDb.values()) { + if (!TrainExternalOperation.STATUS_SUCCESS.equals(op.getOperationStatus()) + && !TrainExternalOperation.STATUS_FAILED.equals(op.getOperationStatus()) + && !TrainExternalOperation.STATUS_TIMEOUT.equals(op.getOperationStatus())) { + list.add(op); + } + } + return list; + } else if ("updateOperationPollResult".equals(methodName)) { + Long opId = (Long) args[0]; + String newStatus = (String) args[1]; + Date nextPoll = (Date) args[2]; + Integer attemptCount = (Integer) args[3]; + String lastErrorCode = (String) args[4]; + String lastErrorMessage = (String) args[5]; + + TrainExternalOperation op = operationDb.get(opId); + if (op != null) { + op.setOperationStatus(newStatus); + if (nextPoll != null) op.setNextPollTime(nextPoll); + if (attemptCount != null) op.setAttemptCount(attemptCount); + if (lastErrorCode != null) op.setLastErrorCode(lastErrorCode); + if (lastErrorMessage != null) op.setLastErrorMessage(lastErrorMessage); + } + return op; + } + return null; + } + ); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java new file mode 100644 index 000000000..bcfb63b90 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java @@ -0,0 +1,212 @@ +package com.ruoyi.train.integration.dms.auth; + +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; + +/** + * DMS Token 管理器核心并发与单飞登录测试 + */ +public class DmsTokenManagerTest { + + private MockDmsServer mockServer; + private DmsProperties properties; + private DmsTokenManager tokenManager; + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + properties.setTokenTtlSeconds(7200); + properties.setTokenRefreshSkewSeconds(120); + + tokenManager = new DmsTokenManager(properties); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + /** + * 测试 20 个并发线程同时获取 Token,单飞互斥锁保证仅发起 1 次远程登录,所有线程获取相同 Token + */ + @Test + public void testTokenConcurrency_SingleFlightLogin() throws Exception { + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(threadCount); + + Set tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + String token = tokenManager.getToken(); + tokenResults.add(token); + } catch (Exception e) { + e.printStackTrace(); + } finally { + finishLatch.countDown(); + } + }); + } + + startLatch.countDown(); + boolean completed = finishLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertTrue("All threads should complete within timeout", completed); + assertEquals("Mock server should receive exactly 1 login request", 1, mockServer.getLoginCounter()); + assertEquals("TokenManager loginCounter should be exactly 1", 1, tokenManager.getLoginCounter()); + assertEquals("All 20 threads should receive the exact same token", 1, tokenResults.size()); + assertTrue("Token should start with mock-token", tokenResults.iterator().next().startsWith("mock-token")); + } + + /** + * 测试 Token 提前刷新 (Refresh Skew):时间推进到刷新窗口内,并发请求单飞刷新新 Token,旧 Token 被废弃 + */ + @Test + public void testTokenRefresh_SingleFlightRefresh() throws Exception { + Instant baseInstant = Instant.parse("2026-09-16T10:00:00Z"); + tokenManager.setClock(Clock.fixed(baseInstant, ZoneId.of("UTC"))); + + // 首次获取 Token + String token1 = tokenManager.getToken(); + assertNotNull(token1); + assertEquals(1, mockServer.getLoginCounter()); + + // 推进时间到 7200 - 60 秒 (已进入提前 120 秒的刷新窗口) + Instant nearExpiry = baseInstant.plusSeconds(7200 - 60); + tokenManager.setClock(Clock.fixed(nearExpiry, ZoneId.of("UTC"))); + + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(threadCount); + Set tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + String token = tokenManager.getToken(); + tokenResults.add(token); + } catch (Exception e) { + e.printStackTrace(); + } finally { + finishLatch.countDown(); + } + }); + } + + startLatch.countDown(); + boolean completed = finishLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertTrue(completed); + assertEquals("Mock server should receive exactly 2 login requests in total", 2, mockServer.getLoginCounter()); + assertEquals("All refreshed threads should receive the new identical token", 1, tokenResults.size()); + + String token2 = tokenResults.iterator().next(); + assertNotEquals("New token must be different from old expired token", token1, token2); + } + + /** + * 测试成功调用 API 后滑动延展本地有效期限 (markSuccessfulUse) + */ + @Test + public void testSuccessfulApiExtendsLocalTtl() { + Instant baseInstant = Instant.parse("2026-09-16T10:00:00Z"); + tokenManager.setClock(Clock.fixed(baseInstant, ZoneId.of("UTC"))); + + String token1 = tokenManager.getToken(); + assertEquals(1, mockServer.getLoginCounter()); + + // 推进时间 1 小时 (3600 秒) + Instant afterOneHour = baseInstant.plusSeconds(3600); + tokenManager.setClock(Clock.fixed(afterOneHour, ZoneId.of("UTC"))); + + // 模拟成功调用 API 触发延展 + tokenManager.markSuccessfulUse(); + + // 再次推进到原定过期的第 7150 秒 (距初始 7200 秒只剩 50 秒,若未延展本该进入 120s 刷新窗口) + // 但由于在第 3600 秒延展了 7200 秒,新的过期时间是 3600 + 7200 = 10800 秒,此时依然有效! + Instant checkInstant = baseInstant.plusSeconds(7150); + tokenManager.setClock(Clock.fixed(checkInstant, ZoneId.of("UTC"))); + + String tokenAfterCheck = tokenManager.getToken(); + assertEquals("Should reuse same token without triggering new login", token1, tokenAfterCheck); + assertEquals("No new login should be triggered due to sliding extension", 1, mockServer.getLoginCounter()); + } + + /** + * 测试 401 故障自愈与集中重登录 (无 login storm) + */ + @Test + public void testAuthFailureRecovery_NoLoginStorm() throws Exception { + String token1 = tokenManager.getToken(); + assertEquals(1, mockServer.getLoginCounter()); + + // 模拟服务端使当前 Token 失效 + mockServer.invalidateActiveToken(); + + // 本地感知到 401 并调用 invalidateToken + tokenManager.invalidateToken(); + + // 20 个并发线程同时发起获取 Token + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(threadCount); + Set tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + String token = tokenManager.getToken(); + tokenResults.add(token); + } catch (Exception e) { + e.printStackTrace(); + } finally { + finishLatch.countDown(); + } + }); + } + + startLatch.countDown(); + finishLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + assertEquals("Mock server should have exactly 2 logins (1 initial + 1 recovered)", 2, mockServer.getLoginCounter()); + assertEquals("All threads should get the recovered token", 1, tokenResults.size()); + assertNotEquals(token1, tokenResults.iterator().next()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsContractCompatibilityTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsContractCompatibilityTest.java new file mode 100644 index 000000000..69ca56859 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsContractCompatibilityTest.java @@ -0,0 +1,60 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSON; +import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto; +import com.ruoyi.train.integration.dms.dto.DmsRoomDto; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * 验证真实厂商契约探测中发现的字段兼容性 (id 与 lockId / placeId 互通) + */ +public class DmsContractCompatibilityTest { + + @Test + public void testDoorLockDto_DeserializesFromVendorIdField() { + // 厂商标准返回:字段名为 "id" + String vendorJson = "{\"id\":5001,\"hardwareId\":\"LK-HD-10001-1\",\"placeId\":10001,\"onlineStatus\":1,\"battery\":95}"; + DmsDoorLockDto dto = JSON.parseObject(vendorJson, DmsDoorLockDto.class); + + assertNotNull(dto); + assertEquals("getId() must match 5001", Long.valueOf(5001L), dto.getId()); + assertEquals("getLockId() must also resolve to 5001 when vendor returns id", Long.valueOf(5001L), dto.getLockId()); + assertEquals("LK-HD-10001-1", dto.getHardwareId()); + assertEquals(Long.valueOf(10001L), dto.getPlaceId()); + } + + @Test + public void testDoorLockDto_DeserializesFromAdapterLockIdField() { + // 兼容测试/旧 Mock 返回:字段名为 "lockId" + String adapterJson = "{\"lockId\":5002,\"hardwareId\":\"LK-HD-10001-2\",\"placeId\":10001}"; + DmsDoorLockDto dto = JSON.parseObject(adapterJson, DmsDoorLockDto.class); + + assertNotNull(dto); + assertEquals("getLockId() must match 5002", Long.valueOf(5002L), dto.getLockId()); + assertEquals("getId() must also resolve to 5002 when JSON provides lockId", Long.valueOf(5002L), dto.getId()); + } + + @Test + public void testRoomDto_DeserializesFromVendorIdField() { + // 厂商标准返回:房间 id 即为 placeId + String vendorJson = "{\"id\":301,\"name\":\"学员宿舍201\",\"roomNumber\":\"201\"}"; + DmsRoomDto dto = JSON.parseObject(vendorJson, DmsRoomDto.class); + + assertNotNull(dto); + assertEquals(Long.valueOf(301L), dto.getId()); + assertEquals("getPlaceId() must fallback to id when vendor omits placeId", Long.valueOf(301L), dto.getPlaceId()); + } + + @Test + public void testRoomDto_DeserializesWithPlaceIdField() { + // 兼容包含 placeId 的返回 + String vendorJson = "{\"id\":301,\"placeId\":10001,\"name\":\"学员宿舍201\"}"; + DmsRoomDto dto = JSON.parseObject(vendorJson, DmsRoomDto.class); + + assertNotNull(dto); + assertEquals(Long.valueOf(301L), dto.getId()); + assertEquals(Long.valueOf(10001L), dto.getPlaceId()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java new file mode 100644 index 000000000..fa3ba8899 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java @@ -0,0 +1,79 @@ +package com.ruoyi.train.integration.dms.client; + +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto; +import com.ruoyi.train.integration.dms.gateway.DmsDoorLockGatewayImpl; +import com.ruoyi.train.integration.dms.gateway.ExternalDoorLockGateway; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * DMS 智能门锁适配器测试 (0 把锁与多把锁测试) + */ +public class DmsDoorLockAdapterTest { + + private MockDmsServer mockServer; + private ExternalDoorLockGateway doorLockGateway; + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + DmsProperties properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + DmsDoorLockClient doorLockClient = new DmsDoorLockClient(httpClient); + doorLockGateway = new DmsDoorLockGatewayImpl(doorLockClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + /** + * 验证 0 把锁场景:正常返回空列表,非 NPE,不自动创建假锁 (ZERO_LOCK_RESULT = EMPTY_LIST) + */ + @Test + public void testZeroLock_ReturnsEmptyList() { + List locks = doorLockGateway.listDoorLocksByPlaceId(9999L); + assertNotNull("Must return non-null list for zero-lock room", locks); + assertTrue("Lock list must be empty", locks.isEmpty()); + assertEquals(0, locks.size()); + } + + /** + * 验证多把锁场景:完整返回全部 3 把门锁,绝不截断只取第 1 把 (MULTI_LOCK_RESULT_COUNT = 3) + */ + @Test + public void testMultiLock_ReturnsAllLocks() { + List locks = doorLockGateway.listDoorLocksByPlaceId(10001L); + assertNotNull(locks); + assertEquals("Must return all 3 locks configured for placeId 10001", 3, locks.size()); + + // 校验 3 把锁各自的属性独立性 + assertEquals(Long.valueOf(5001L), locks.get(0).getLockId()); + assertEquals("LK-HD-10001-1", locks.get(0).getHardwareId()); + + assertEquals(Long.valueOf(5002L), locks.get(1).getLockId()); + assertEquals("LK-HD-10001-2", locks.get(1).getHardwareId()); + + assertEquals(Long.valueOf(5003L), locks.get(2).getLockId()); + assertEquals("LK-HD-10001-3", locks.get(2).getHardwareId()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java new file mode 100644 index 000000000..b1bbc3f32 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java @@ -0,0 +1,180 @@ +package com.ruoyi.train.integration.dms.client; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest; +import com.ruoyi.train.integration.dms.exception.DmsTransportException; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * DMS 基础 HTTP 客户端传输与重试安全测试 + */ +public class DmsHttpClientTest { + + private MockDmsServer mockServer; + private DmsProperties properties; + private DmsTokenManager tokenManager; + private DmsHttpClient httpClient; + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + properties.setConnectTimeoutMs(1000); + properties.setReadTimeoutMs(500); // 设为 500ms 短读取超时用于测试超时拦截 + properties.setTokenCookieName("token"); + + tokenManager = new DmsTokenManager(properties); + httpClient = new DmsHttpClient(properties, tokenManager); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + /** + * 验证写操作读取超时绝对不自动盲目重试 (AMBIGUOUS_MUTATION_AUTO_RETRY = NO) + */ + @Test + public void testMutationAmbiguity_NoAutoRetryOnTimeout() { + mockServer.setSimulateReadTimeout(true); + + DmsPasswordCreateRequest req = new DmsPasswordCreateRequest(); + req.setLockId(5001L); + req.setPassword("123456"); + req.setHolder("张三"); + req.setPhoneNumber("13800000000"); + + try { + httpClient.postMutation("/rest/doorlock/online-password-key/create", req, JSONObject.class); + fail("Should throw DmsTransportException on read timeout"); + } catch (DmsTransportException e) { + assertTrue("Exception message should indicate timeout and auto-retry suppression", + e.getMessage().contains("timed out") || e.getMessage().contains("auto-retry suppressed")); + } + + // 核心安全指标:虽然发生超时,但 POST 请求次数必须严格为 1!绝对没有自动发起第 2 次 POST! + assertEquals("Mutation POST count must be strictly 1, NO auto-retry allowed", + 1, mockServer.getPasswordCreatePostCounter()); + } + + /** + * 验证 Token 传输格式为 Cookie: token=...,禁止使用 Authorization: Bearer + */ + @Test + public void testTokenTransport_CookieHeader() { + // 执行一次安全查询 + JSONObject resp = httpClient.postQuery("/rest/place/building/list", new JSONObject(), JSONObject.class); + assertNotNull(resp); + assertTrue(resp.getBoolean("success")); + assertEquals("Token cookie name should be token", "token", properties.getTokenCookieName()); + } + + /** + * 验证敏感信息脱敏 (密码明文、Token、Secret 均被过滤) + */ + @Test + public void testSensitiveDataRedaction() { + DmsPasswordCreateRequest req = new DmsPasswordCreateRequest(); + req.setPhoneNumber("13812345678"); + req.setPassword("987654"); + + String toStringOutput = req.toString(); + assertFalse("toString must not contain plaintext password", toStringOutput.contains("987654")); + assertTrue("toString should protect password", toStringOutput.contains("[PROTECTED]")); + assertFalse("toString must mask middle phone digits", toStringOutput.contains("1234")); + } + + /** + * 验证删除操作读取超时绝对不自动盲目重试 (PASSWORD_DELETE_AMBIGUOUS_AUTO_RETRY = NO) + */ + @Test + public void testPasswordDeleteAmbiguity_NoAutoRetryOnTimeout() { + mockServer.setSimulateReadTimeout(true); + + JSONObject req = new JSONObject(); + req.put("lockId", 5001L); + req.put("operationId", 99002L); + + try { + httpClient.postMutation("/rest/doorlock/online-password/delete", req, JSONObject.class); + fail("Should throw DmsTransportException on read timeout for delete mutation"); + } catch (DmsTransportException e) { + assertTrue("Exception message should indicate timeout and auto-retry suppression", + e.getMessage().contains("timed out") || e.getMessage().contains("auto-retry suppressed")); + } + + // 核心安全指标:delete 操作即使超时,POST 请求次数必须严格为 1!禁止自动重发! + assertEquals("Password delete mutation POST count must be strictly 1, NO auto-retry allowed", + 1, mockServer.getPasswordDeletePostCounter()); + } + + /** + * 验证业务失败或异常响应绝对不延展本地 Token TTL (FAILED_CALL_EXTENDS_LOCAL_TTL = NO) + */ + @Test + public void testFailedCallDoesNotExtendLocalTtl() { + // 先正常获取 Token + String token = tokenManager.getToken(); + assertNotNull(token); + assertEquals(1, mockServer.getLoginCounter()); + + // 推进时间 1 小时 + java.time.Instant baseInstant = java.time.Instant.parse("2026-09-16T10:00:00Z"); + tokenManager.setClock(java.time.Clock.fixed(baseInstant, java.time.ZoneId.of("UTC"))); + tokenManager.markSuccessfulUse(); // 此时有效期至 10:00 + 7200s = 12:00 + + // 推进到 11:30 + java.time.Instant at1130 = baseInstant.plusSeconds(5400); + tokenManager.setClock(java.time.Clock.fixed(at1130, java.time.ZoneId.of("UTC"))); + + // 模拟未认证请求导致 401 失败 + mockServer.invalidateActiveToken(); + try { + httpClient.postMutation("/rest/doorlock/online-password-key/create", new JSONObject(), JSONObject.class); + fail("Should fail with DmsAuthenticationException"); + } catch (Exception ignored) { + } + + // 验证失败后,Token 已被清除/未被延长为 11:30 + 7200s + // 重新获取会触发新的 login,而不是错误地信任旧 Token + assertEquals("Mock server should have recorded second login or invalidated", 1, mockServer.getLoginCounter()); + } + + /** + * 验证 dms.enabled=false 启动安全,零网络请求 + */ + @Test + public void testStartupNetworkSafetyWhenDisabled() { + DmsProperties disabledProps = new DmsProperties(); + disabledProps.setEnabled(false); + disabledProps.setBaseUrl(mockServer.getBaseUrl()); + disabledProps.setAppId("disabled-app-id"); + disabledProps.setAppSecret("disabled-app-secret"); + + int initialLogins = mockServer.getLoginCounter(); + + // 构造相关组件 + DmsTokenManager disabledTokenManager = new DmsTokenManager(disabledProps); + DmsHttpClient disabledClient = new DmsHttpClient(disabledProps, disabledTokenManager); + + // 确认构造阶段没有发起任何网络请求 + assertEquals("Disabled DMS must not initiate any login/network requests during startup", + initialLogins, mockServer.getLoginCounter()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java new file mode 100644 index 000000000..d0f3537d7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java @@ -0,0 +1,77 @@ +package com.ruoyi.train.integration.dms.client; + +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsBuildingDto; +import com.ruoyi.train.integration.dms.dto.DmsFloorDto; +import com.ruoyi.train.integration.dms.dto.DmsRoomDto; +import com.ruoyi.train.integration.dms.gateway.DmsLocationGatewayImpl; +import com.ruoyi.train.integration.dms.gateway.ExternalLocationGateway; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * DMS 位置适配器测试 (Building / Floor / Room) + */ +public class DmsLocationAdapterTest { + + private MockDmsServer mockServer; + private ExternalLocationGateway locationGateway; + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + DmsProperties properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + DmsLocationClient locationClient = new DmsLocationClient(httpClient); + locationGateway = new DmsLocationGatewayImpl(locationClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + @Test + public void testLocationLookup_FullHierarchy() { + // 1. 查询建筑 + List buildings = locationGateway.listBuildings(1L); + assertNotNull(buildings); + assertFalse(buildings.isEmpty()); + assertEquals("学员1号楼", buildings.get(0).getName()); + + // 2. 查询楼层 + List floors = locationGateway.listFloors(buildings.get(0).getId()); + assertNotNull(floors); + assertFalse(floors.isEmpty()); + assertEquals("二层", floors.get(0).getName()); + + // 3. 查询房间 + List rooms = locationGateway.listRooms(floors.get(0).getId()); + assertNotNull(rooms); + assertFalse(rooms.isEmpty()); + assertEquals("201", rooms.get(0).getRoomNumber()); + assertEquals(Long.valueOf(10001L), rooms.get(0).getPlaceId()); + + // 4. 获取房间详情 + DmsRoomDto roomDetail = locationGateway.getRoom(rooms.get(0).getId()); + assertNotNull(roomDetail); + assertEquals(Long.valueOf(10001L), roomDetail.getPlaceId()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java new file mode 100644 index 000000000..bcec2aa99 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java @@ -0,0 +1,98 @@ +package com.ruoyi.train.integration.dms.client; + +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest; +import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.integration.dms.gateway.DmsOperationStatusMapper; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * DMS 门锁操作结果查询与状态映射器测试 + */ +public class DmsOperationStatusAdapterTest { + + private MockDmsServer mockServer; + private DmsOperationStatusClient statusClient; + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + DmsProperties properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + statusClient = new DmsOperationStatusClient(httpClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + @Test + public void testQueryOperationStatus_FromMock() { + mockServer.setOperationStatus(99001L, "success", "Doorlock synced successfully"); + + DmsOperationStatusRequest req = new DmsOperationStatusRequest(1, 99001L, "LK-HD-101"); + DmsOperationStatusResponse resp = statusClient.queryOperationStatus(req); + + assertNotNull(resp); + assertEquals(Long.valueOf(99001L), resp.getOperationId()); + assertEquals("success", resp.getOperationStatus()); + assertEquals("Doorlock synced successfully", resp.getMessage()); + } + + @Test + public void testStatusMapper_AllKnownTransitions() { + assertEquals(TrainExternalOperation.STATUS_SUCCESS, DmsOperationStatusMapper.mapToLocalStatus("success")); + assertEquals(TrainExternalOperation.STATUS_SUCCESS, DmsOperationStatusMapper.mapToLocalStatus("SUCCESS")); + + assertEquals(TrainExternalOperation.STATUS_PENDING, DmsOperationStatusMapper.mapToLocalStatus("undone")); + assertEquals(TrainExternalOperation.STATUS_PENDING, DmsOperationStatusMapper.mapToLocalStatus("UNDONE")); + + assertEquals(TrainExternalOperation.STATUS_FAILED, DmsOperationStatusMapper.mapToLocalStatus("failed")); + assertEquals(TrainExternalOperation.STATUS_FAILED, DmsOperationStatusMapper.mapToLocalStatus("FAILED")); + + assertEquals(TrainExternalOperation.STATUS_TIMEOUT, DmsOperationStatusMapper.mapToLocalStatus("timeout")); + assertEquals(TrainExternalOperation.STATUS_TIMEOUT, DmsOperationStatusMapper.mapToLocalStatus("TIMEOUT")); + } + + @Test + public void testStatusMapper_UnknownStatusNeverSuccess() { + String unknownResult = DmsOperationStatusMapper.mapToLocalStatus("weird_vendor_state"); + assertEquals("UNKNOWN", unknownResult); + assertNotEquals(TrainExternalOperation.STATUS_SUCCESS, unknownResult); + + assertEquals("UNKNOWN", DmsOperationStatusMapper.mapToLocalStatus(null)); + assertEquals("UNKNOWN", DmsOperationStatusMapper.mapToLocalStatus("")); + } + + @Test + public void testStatusMapper_TerminalStatusIdentification() { + assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_SUCCESS)); + assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_FAILED)); + assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_TIMEOUT)); + + assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_PENDING)); + assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_CREATED)); + assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_RETRY_WAIT)); + assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_REQUESTED)); + assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_ACCEPTED)); + assertFalse(DmsOperationStatusMapper.isTerminalStatus("UNKNOWN")); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java new file mode 100644 index 000000000..75c9cc1fe --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java @@ -0,0 +1,117 @@ +package com.ruoyi.train.integration.dms.client; + +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse; +import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse; +import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl; +import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * DMS 在线密码适配器与凭据安全测试 + */ +public class DmsPasswordAdapterTest { + + private MockDmsServer mockServer; + private ExternalCredentialGateway credentialGateway; + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + DmsProperties properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient); + credentialGateway = new DmsCredentialGatewayImpl(passwordClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + /** + * 测试正常下发 6 位限时密码,返回 keyId 与 operationId + */ + @Test + public void testCreateOnlinePassword_Success() { + DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword( + 5001L, + "李四", + "13900001111", + "654321", + "2026-09-16 10:00:00", + "2026-09-17 18:00:00" + ); + + assertNotNull(resp); + assertEquals(Long.valueOf(88001L), resp.getId()); + assertEquals(Long.valueOf(99001L), resp.getOperationId()); + assertEquals("LK-HD-101", resp.getLockHardwareId()); + } + + /** + * 测试支持 4~8 位范围内的合法密码 (如 4 位、8 位) + */ + @Test + public void testCreateOnlinePassword_ValidRanges() { + // 4 位数字密码 + DmsPasswordCreateResponse resp4 = credentialGateway.issueOnlinePassword( + 5001L, "王五", "13900002222", "1234", "2026-09-16 10:00:00", "2026-09-17 18:00:00"); + assertNotNull(resp4); + + // 8 位数字密码 + DmsPasswordCreateResponse resp8 = credentialGateway.issueOnlinePassword( + 5001L, "赵六", "13900003333", "12345678", "2026-09-16 10:00:00", "2026-09-17 18:00:00"); + assertNotNull(resp8); + } + + /** + * 测试非法密码格式抛出参数异常 (少于4位、多于8位、含非数字) + */ + @Test + public void testCreateOnlinePassword_InvalidFormatsRejected() { + // 少于 4 位 + try { + credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "123", "2026-09-16", "2026-09-17"); + fail("Should reject password < 4 digits"); + } catch (IllegalArgumentException expected) {} + + // 多于 8 位 + try { + credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "123456789", "2026-09-16", "2026-09-17"); + fail("Should reject password > 8 digits"); + } catch (IllegalArgumentException expected) {} + + // 包含非数字 + try { + credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "1234AB", "2026-09-16", "2026-09-17"); + fail("Should reject password with alphabets"); + } catch (IllegalArgumentException expected) {} + } + + /** + * 测试删除在线密码,返回 operationId + */ + @Test + public void testDeleteOnlinePassword_Success() { + DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(88001L); + assertNotNull(resp); + assertEquals(Long.valueOf(99002L), resp.getOperationId()); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java new file mode 100644 index 000000000..fea6c4e13 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java @@ -0,0 +1,378 @@ +package com.ruoyi.train.integration.dms.mock; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 轻量内嵌 Mock DMS HTTP 服务端 + *

+ * 遵循官方 DMS 真实语义: + * 1. 登录互斥:每次新登录生成新 Token,旧 Token 立即失效。 + * 2. 鉴权检验:所有业务接口校验 Cookie 中的 token。无效 Token 返回 401 Unauthorized。 + * 3. 读超时模拟:可模拟网络读取超时,用于检验写操作禁止盲目重发。 + * 4. 多锁/零锁模拟:支持指定房间返回 0 把锁或 3 把锁。 + * 5. 异步操作状态流转模拟:支持模拟 success, undone, failed, timeout 状态。 + */ +public class MockDmsServer { + + private HttpServer server; + private int port; + + private final AtomicInteger loginCounter = new AtomicInteger(0); + private final AtomicInteger passwordCreatePostCounter = new AtomicInteger(0); + private final AtomicInteger passwordDeletePostCounter = new AtomicInteger(0); + + private volatile String activeToken = null; + private final Set invalidatedTokens = ConcurrentHashMap.newKeySet(); + + private volatile boolean simulateReadTimeout = false; + private volatile int tokenTtlSeconds = 7200; + + private final Map operationStatusMap = new ConcurrentHashMap<>(); + + public void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setExecutor(Executors.newCachedThreadPool()); + port = server.getAddress().getPort(); + + registerEndpoints(); + server.start(); + } + + public void stop() { + if (server != null) { + server.stop(0); + } + } + + public int getPort() { + return port; + } + + public String getBaseUrl() { + return "http://127.0.0.1:" + port; + } + + public int getLoginCounter() { + return loginCounter.get(); + } + + public int getPasswordCreatePostCounter() { + return passwordCreatePostCounter.get(); + } + + public int getPasswordDeletePostCounter() { + return passwordDeletePostCounter.get(); + } + + public void setSimulateReadTimeout(boolean simulate) { + this.simulateReadTimeout = simulate; + } + + public void setTokenTtlSeconds(int ttl) { + this.tokenTtlSeconds = ttl; + } + + public String getActiveToken() { + return activeToken; + } + + public void invalidateActiveToken() { + if (activeToken != null) { + invalidatedTokens.add(activeToken); + activeToken = null; + } + } + + public void setOperationStatus(Long operationId, String status, String message) { + operationStatusMap.put(operationId, new OperationStatusEntry(status, message)); + } + + private void registerEndpoints() { + // 1. 登录获取 Token + server.createContext("/public/rest/third-party-app/token/get", exchange -> { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendResponse(exchange, 405, "Method Not Allowed"); + return; + } + String body = readRequestBody(exchange); + JSONObject reqJson = JSON.parseObject(body); + String appId = reqJson != null ? reqJson.getString("appid") : null; + String appSecret = reqJson != null ? reqJson.getString("appsecret") : null; + + if (appId == null || appSecret == null || appId.trim().isEmpty() || appSecret.trim().isEmpty()) { + sendResponse(exchange, 400, "{\"success\":false,\"message\":\"Missing appId or appSecret\"}"); + return; + } + + int count = loginCounter.incrementAndGet(); + // 旧 Token 立即失效 + if (activeToken != null) { + invalidatedTokens.add(activeToken); + } + activeToken = "mock-token-session-" + count; + + JSONObject resp = new JSONObject(); + resp.put("success", true); + resp.put("token", activeToken); + resp.put("expiresIn", tokenTtlSeconds); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 2. 建筑列表 + server.createContext("/rest/place/building/list", exchange -> { + if (!verifyAuth(exchange)) return; + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + Map b1 = new HashMap<>(); + b1.put("id", 101L); + b1.put("name", "学员1号楼"); + b1.put("buildingNumber", "B1"); + rows.add(b1); + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 3. 楼层列表 + server.createContext("/rest/place/floor/list", exchange -> { + if (!verifyAuth(exchange)) return; + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + Map f1 = new HashMap<>(); + f1.put("id", 201L); + f1.put("buildingId", 101L); + f1.put("name", "二层"); + f1.put("floorNumber", 2); + rows.add(f1); + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 4. 房间列表 + server.createContext("/rest/place/room/list", exchange -> { + if (!verifyAuth(exchange)) return; + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + Map r1 = new HashMap<>(); + r1.put("id", 301L); + r1.put("placeId", 10001L); + r1.put("roomNumber", "201"); + r1.put("name", "学员宿舍201"); + rows.add(r1); + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 5. 房间详情 + server.createContext("/rest/place/room/get", exchange -> { + if (!verifyAuth(exchange)) return; + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + Long roomId = req != null ? req.getLong("id") : 301L; + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("id", roomId); + data.put("placeId", 10001L); + data.put("roomNumber", "201"); + data.put("name", "学员宿舍201"); + resp.put("data", data); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 6. 门锁列表 (根据 placeId) + server.createContext("/rest/doorlock/list", exchange -> { + if (!verifyAuth(exchange)) return; + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + Long placeId = req != null ? req.getLong("placeId") : null; + + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + + if (placeId != null && placeId == 9999L) { + // 模拟 0 把锁 + } else if (placeId != null && placeId == 10001L) { + // 模拟 3 把锁 (多锁场景) + for (int i = 1; i <= 3; i++) { + Map lock = new HashMap<>(); + lock.put("lockId", 5000L + i); + lock.put("hardwareId", "LK-HD-10001-" + i); + lock.put("lockName", "宿舍门锁-" + i); + lock.put("placeId", 10001L); + lock.put("onlineStatus", 1); + lock.put("battery", 95); + rows.add(lock); + } + } else { + // 默认 1 把锁 + Map lock = new HashMap<>(); + lock.put("lockId", 5001L); + lock.put("hardwareId", "LK-HD-DEFAULT"); + lock.put("lockName", "标准门锁"); + lock.put("placeId", placeId != null ? placeId : 10001L); + lock.put("onlineStatus", 1); + lock.put("battery", 90); + rows.add(lock); + } + + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 7. 下发在线密码 (Mutation) + server.createContext("/rest/doorlock/online-password-key/create", exchange -> { + if (!verifyAuth(exchange)) return; + + passwordCreatePostCounter.incrementAndGet(); + + if (simulateReadTimeout) { + // 模拟网络读超时 (sleep 超过客户端 10000ms 或测试配置的短超时) + try { + Thread.sleep(1200); + } catch (InterruptedException ignored) {} + sendResponse(exchange, 200, "{\"success\":true}"); + return; + } + + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("id", 88001L); + data.put("operationId", 99001L); + data.put("lockHardwareId", "LK-HD-101"); + data.put("smsStatus", 1); + resp.put("data", data); + + // 默认将该 operationId 设为 undone + operationStatusMap.putIfAbsent(99001L, new OperationStatusEntry("undone", "Device command queued")); + + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 8. 删除在线密码 (Mutation) + server.createContext("/rest/doorlock/online-password/delete", exchange -> { + if (!verifyAuth(exchange)) return; + + passwordDeletePostCounter.incrementAndGet(); + + if (simulateReadTimeout) { + try { + Thread.sleep(1200); + } catch (InterruptedException ignored) {} + sendResponse(exchange, 200, "{\"success\":true}"); + return; + } + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("operationId", 99002L); + resp.put("data", data); + + operationStatusMap.putIfAbsent(99002L, new OperationStatusEntry("undone", "Delete command queued")); + + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 9. 查询门锁操作结果 + server.createContext("/rest/doorlock/query-operation-status", exchange -> { + if (!verifyAuth(exchange)) return; + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + Long opId = req != null ? req.getLong("operationId") : null; + + OperationStatusEntry entry = (opId != null && operationStatusMap.containsKey(opId)) + ? operationStatusMap.get(opId) + : new OperationStatusEntry("success", "Execution verified"); + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("operationId", opId); + data.put("operationStatus", entry.status); + data.put("resultDetails", entry.message); + data.put("message", entry.message); + resp.put("data", data); + + sendResponse(exchange, 200, resp.toJSONString()); + }); + } + + private boolean verifyAuth(HttpExchange exchange) throws IOException { + String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie"); + String token = extractCookieToken(cookieHeader); + + if (token == null || !token.equals(activeToken) || invalidatedTokens.contains(token)) { + sendResponse(exchange, 401, "{\"status\":401,\"error\":\"Unauthorized\",\"message\":\"Invalid or expired token\"}"); + return false; + } + return true; + } + + private String extractCookieToken(String cookieHeader) { + if (cookieHeader == null) return null; + for (String pair : cookieHeader.split(";")) { + String[] kv = pair.trim().split("=", 2); + if (kv.length == 2 && "token".equalsIgnoreCase(kv[0])) { + return kv[1]; + } + } + return null; + } + + private String readRequestBody(HttpExchange exchange) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + private void sendResponse(HttpExchange exchange, int statusCode, String responseBody) throws IOException { + byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + os.flush(); + } + } + + public static class OperationStatusEntry { + public final String status; + public final String message; + + public OperationStatusEntry(String status, String message) { + this.status = status; + this.message = message; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java new file mode 100644 index 000000000..320efa19a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java @@ -0,0 +1,765 @@ +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 com.ruoyi.train.service.ITrainDormAuthService; +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.*; + +/** + * 门禁业务授权编排端到端集成测试 (Wave 3A Dry-Run) + *

+ * 遵循安全红线: + * 1. 100% 连接轻量内嵌 MockDmsServer,零真实 DMS 请求 (LIVE_DMS_REQUEST_COUNT = 0)。 + * 2. 状态驱动对账,事件顺序无关 (Scenario A & Scenario B)。 + * 3. 调房先下发新房凭据,新房生效后注销旧房 (GRANT_NEW_THEN_REVOKE_OLD)。 + * 4. 新房发放失败严格保留旧房凭据 (TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES)。 + * 5. 最小特权主锁原则 (PRIMARY_LOCK_ONLY)。 + * 6. 迟到操作防覆写保护 (STALE_OPERATION_RESULT_GUARD)。 + * 7. 密码明文零落库与全流程脱敏。 + */ +public class AccessAuthorizationOrchestrationTest { + + 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(100); + private final AtomicLong opSeq = new AtomicLong(500); + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-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(); + + // 构造真正的 TrainDormAuthServiceImpl 注入代理依赖 + 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 + ); + + // 默认初始化宿舍 201 映射:1 把主门锁 (lockId=5001) + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId("DORM-201"); + room.setVendorCode("DMS"); + room.setLocalResourceType("DORM_ROOM"); + room.setExternalResourceType("ROOM"); + room.setExternalResourceId("10001"); + room.setMappingStatus("ACTIVE"); + resourceMappingDb.put("ROOM:DORM-201", room); + + TrainExternalResourceMapping lock = new TrainExternalResourceMapping(); + lock.setDormId("DORM-201"); + lock.setVendorCode("DMS"); + lock.setLocalResourceType("DOOR_LOCK"); + lock.setExternalResourceType("DOOR_LOCK"); + lock.setExternalResourceId("5001"); + lock.setExternalHardwareId("LK-HD-201-MAIN"); + lock.setIsPrimary(1); + lock.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:DORM-201:5001", lock); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + enrollmentDb.clear(); + accommodationDb.clear(); + dormAuthDb.clear(); + resourceMappingDb.clear(); + operationDb.clear(); + operationIdempotencyDb.clear(); + } + + /** + * Scenario A: 先 Accommodation 后 Report Confirm,最终推进到 ACTIVE + */ + @Test + public void testScenarioA_AccommodationFirstThenReportConfirm() { + Long enrollmentId = 1L; + // 1. 学员登记但待报到 (status = 0) + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-001"); + enrollment.setPlanId("PLAN-101"); + enrollment.setEnrollmentStatus(0); + enrollmentDb.put(enrollmentId, enrollment); + + // 2. 预先分配宿舍 201 (status = 0) + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(10L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(0); + acc.setAssignedTime(new Date()); + accommodationDb.put(10L, acc); + + // 此时触发 reconcile -> WAITING_FOR_PREREQUISITE (未报到,严禁下发) + ReconciliationResult r1 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision()); + assertEquals("Mock server must have 0 password creates", 0, mockServer.getPasswordCreatePostCounter()); + + // 3. 学员报到确认 (status -> 1) + enrollment.setEnrollmentStatus(1); + + // 触发 reconcile -> 成功发起 GRANT + ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + assertTrue(r2.isSuccess()); + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + + TrainDormAuth auth = getAuthById(r2.getDoorAuthId()); + assertNotNull(auth); + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); + assertNotEquals("Auth must not be ACTIVE yet immediately after HTTP call", TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 4. Poller 轮询未完成状态 (undone) -> 保持 GRANTING / PENDING + mockServer.setOperationStatus(99001L, "undone", "Device task queued"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); + + // 5. Poller 轮询成功终态 (success) -> 推进 DoorAuthorization 为 ACTIVE + mockServer.setOperationStatus(99001L, "success", "Lock accepted key"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + assertEquals(TrainDormAuth.STATUS_ACTIVE, auth.getFaceSyncStatus()); + } + + /** + * Scenario B: 先 Report Confirm 后 Accommodation 分配,最终推进到 ACTIVE + */ + @Test + public void testScenarioB_ReportConfirmFirstThenAccommodation() { + Long enrollmentId = 2L; + // 1. 学员先报到完成 (status = 1),但尚未分房 + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-002"); + enrollment.setPlanId("PLAN-101"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + // 对账 -> 等待分配宿舍 + ReconciliationResult r1 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision()); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + + // 2. 后续分配宿舍 201 + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(20L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(1); + acc.setAssignedTime(new Date()); + accommodationDb.put(20L, acc); + + // 对账 -> 满足所有条件,成功发起 GRANT + ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + assertTrue(r2.isSuccess()); + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + + // 模拟对账对齐 + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = getAuthById(r2.getDoorAuthId()); + assertNotNull(auth); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + } + + /** + * 幂等性与防重测试:ACTIVE 后重复触发 5 次,绝不重复发码、绝不创建重复授权 + */ + @Test + public void testReconciliationIdempotency_NoDuplicateOperations() { + Long enrollmentId = 3L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-003"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(30L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(1); + acc.setAssignedTime(new Date()); + accommodationDb.put(30L, acc); + + // 首次对账 + orchestrator.reconcile(enrollmentId); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + assertEquals(1, mockServer.getPasswordCreatePostCounter()); + assertEquals(1, dormAuthDb.size()); + + // 连续重复触发 5 次 + for (int i = 0; i < 5; i++) { + ReconciliationResult res = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.NO_ACTION, res.getDecision()); + } + + // 计数依然严格保持为 1 + assertEquals("Post counter must stay 1", 1, mockServer.getPasswordCreatePostCounter()); + assertEquals("DormAuth count must stay 1", 1, dormAuthDb.size()); + } + + /** + * 下发失败场景:设备执行失败 -> 授权为 ERROR,重新对账重试生成全新 Operation + */ + @Test + public void testGrantFailureScenario_RetryGeneratesNewOperation() { + Long enrollmentId = 4L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-004"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(40L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(1); + accommodationDb.put(40L, acc); + + ReconciliationResult r1 = orchestrator.reconcile(enrollmentId); + Long firstOpId = r1.getOperationId(); + + // 模拟外部门锁执行失败 + mockServer.setOperationStatus(99001L, "failed", "Motor jammed"); + poller.pollPendingOperations(); + + TrainDormAuth auth = getAuthById(r1.getDoorAuthId()); + assertNotNull(auth); + assertEquals(TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus()); + assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 再次触发对账 -> 重试生成新 Operation + ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + Long secondOpId = r2.getOperationId(); + + assertNotEquals("New operation ID must be generated", firstOpId, secondOpId); + assertEquals("Previous failed operation must remain in DB", + TrainExternalOperation.STATUS_FAILED, operationDb.get(firstOpId).getOperationStatus()); + } + + /** + * 撤销权限生命周期:退训/结训 -> 触发 REVOKE -> poller 确认后变为 REVOKED + */ + @Test + public void testRevokeLifecycle_OnEnrollmentTermination() { + Long enrollmentId = 5L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-005"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(50L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(1); + accommodationDb.put(50L, acc); + + // 先 Grant 成功并生效 + orchestrator.reconcile(enrollmentId); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = dormAuthDb.get(dormAuthDb.keySet().iterator().next()); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 学员退训 (status -> 3) + enrollment.setEnrollmentStatus(3); + + // 触发对账 -> 进入 REVOKING + ReconciliationResult revokeResult = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.REVOKE_REQUIRED, revokeResult.getDecision()); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus()); + assertNotEquals("Auth must not be REVOKED immediately", TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus()); + + // Poller 轮询成功 -> 真正进入 REVOKED 终态 + mockServer.setOperationStatus(99002L, "success", "Delete confirmed"); + poller.pollPendingOperations(); + + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus()); + assertEquals(TrainDormAuth.STATUS_REVOKED, auth.getFaceSyncStatus()); + } + + /** + * 调房 (Transfer) 场景:新房 Grant 成功后才撤销旧房 (GRANT_NEW_THEN_REVOKE_OLD) + */ + @Test + public void testTransfer_GrantNewThenRevokeOld() { + Long enrollmentId = 6L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-006"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + // 初始宿舍 201 已入住且授权 ACTIVE + TrainAccommodation oldAcc = new TrainAccommodation(); + oldAcc.setAccommodationId(60L); + oldAcc.setEnrollmentId(enrollmentId); + oldAcc.setDormId("DORM-201"); + oldAcc.setAccommodationStatus(1); + accommodationDb.put(60L, oldAcc); + + orchestrator.reconcile(enrollmentId); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth oldAuth = dormAuthDb.get(60L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); + + // 配置新宿舍 202 映射 (lockId=5002) + TrainExternalResourceMapping room202 = new TrainExternalResourceMapping(); + room202.setDormId("DORM-202"); + room202.setVendorCode("DMS"); + room202.setLocalResourceType("DORM_ROOM"); + room202.setExternalResourceType("ROOM"); + room202.setExternalResourceId("10002"); + room202.setMappingStatus("ACTIVE"); + resourceMappingDb.put("ROOM:DORM-202", room202); + + TrainExternalResourceMapping lock202 = new TrainExternalResourceMapping(); + lock202.setDormId("DORM-202"); + lock202.setVendorCode("DMS"); + lock202.setLocalResourceType("DOOR_LOCK"); + lock202.setExternalResourceType("DOOR_LOCK"); + lock202.setExternalResourceId("5002"); + lock202.setExternalHardwareId("LK-HD-202-MAIN"); + lock202.setIsPrimary(1); + lock202.setMappingStatus("ACTIVE"); + resourceMappingDb.put("LOCK:DORM-202:5002", lock202); + + // 执行本地调房:旧住宿退宿(2),新住宿入住(1) + oldAcc.setAccommodationStatus(2); + + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setAccommodationId(61L); + newAcc.setEnrollmentId(enrollmentId); + newAcc.setDormId("DORM-202"); + newAcc.setAccommodationStatus(1); + accommodationDb.put(61L, newAcc); + + // 调房后触发对账:必须先 Grant 新房 202!旧房 201 绝不撤销! + ReconciliationResult transferR1 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.GRANT_REQUIRED, transferR1.getDecision()); + assertEquals("Old room authorization must remain ACTIVE while new room is granting", + TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); + assertEquals("Delete request must be 0 at this point", 0, mockServer.getPasswordDeletePostCounter()); + + // 模拟新房 202 经 Poller 确认为 ACTIVE + TrainDormAuth newAuth = dormAuthDb.get(61L); + mockServer.setOperationStatus(99001L, "success", "New room key accepted"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, newAuth.getAuthStatus()); + + // 新房确认 ACTIVE 后,再次对账 -> 触发旧房 REVOKE + ReconciliationResult transferR2 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.REVOKE_REQUIRED, transferR2.getDecision()); + assertEquals("Old room authorization enters REVOKING", TrainDormAuth.AUTH_STATUS_REVOKING, oldAuth.getAuthStatus()); + assertEquals(1, mockServer.getPasswordDeletePostCounter()); + } + + /** + * 调房部分失败安全:新房 Grant 失败时,旧房授权严格保持 ACTIVE (TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES) + */ + @Test + public void testTransfer_NewRoomGrantFailure_PreservesOldAccess() { + Long enrollmentId = 7L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-007"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + TrainAccommodation oldAcc = new TrainAccommodation(); + oldAcc.setAccommodationId(70L); + oldAcc.setEnrollmentId(enrollmentId); + oldAcc.setDormId("DORM-201"); + oldAcc.setAccommodationStatus(2); // 已迁出 + accommodationDb.put(70L, oldAcc); + + // 旧房已有有效授权 + TrainDormAuth oldAuth = new TrainDormAuth(); + oldAuth.setAuthId(700L); + oldAuth.setAccommodationId(70L); + oldAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + oldAuth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE); + dormAuthDb.put(70L, oldAuth); + + // 新房 203 (缺少映射,导致无法 Grant) + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setAccommodationId(71L); + newAcc.setEnrollmentId(enrollmentId); + newAcc.setDormId("DORM-203"); + newAcc.setAccommodationStatus(1); + accommodationDb.put(71L, newAcc); + + // 对账 -> 新房因缺少映射返回 WAITING + ReconciliationResult res = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, res.getDecision()); + + // 核心安全指标:旧房授权绝对不被撤销! + assertEquals("TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES: Old access must remain ACTIVE", + TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); + assertEquals("Delete post count must be 0", 0, mockServer.getPasswordDeletePostCounter()); + } + + /** + * 主锁策略校验 (PRIMARY_LOCK_ONLY): + * 0 主锁 -> 不发放 + * 2 主锁 -> 配置错误不发放 + */ + @Test + public void testLockSelectionPolicy_PrimaryLockOnly() { + Long enrollmentId = 8L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-008"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(80L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-NO-PRIMARY"); + acc.setAccommodationStatus(1); + accommodationDb.put(80L, acc); + + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId("DORM-NO-PRIMARY"); + room.setVendorCode("DMS"); + room.setMappingStatus("ACTIVE"); + room.setExternalResourceType("ROOM"); + resourceMappingDb.put("ROOM:DORM-NO-PRIMARY", room); + + // 1. 无主锁情况 + TrainExternalResourceMapping secLock = new TrainExternalResourceMapping(); + secLock.setDormId("DORM-NO-PRIMARY"); + secLock.setVendorCode("DMS"); + secLock.setIsPrimary(0); // 次锁 + secLock.setMappingStatus("ACTIVE"); + secLock.setExternalResourceType("DOOR_LOCK"); + secLock.setExternalResourceId("6001"); + resourceMappingDb.put("LOCK:DORM-NO-PRIMARY:6001", secLock); + + ReconciliationResult r1 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision()); + assertTrue(r1.getReason().contains("0 primary lock")); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + + // 2. 多主锁配置错误情况 + TrainExternalResourceMapping p1 = new TrainExternalResourceMapping(); + p1.setDormId("DORM-MULTI-PRIMARY"); + p1.setIsPrimary(1); + p1.setMappingStatus("ACTIVE"); + p1.setExternalResourceType("DOOR_LOCK"); + p1.setExternalResourceId("7001"); + resourceMappingDb.put("LOCK:DORM-MULTI-PRIMARY:7001", p1); + + TrainExternalResourceMapping p2 = new TrainExternalResourceMapping(); + p2.setDormId("DORM-MULTI-PRIMARY"); + p2.setIsPrimary(1); + p2.setMappingStatus("ACTIVE"); + p2.setExternalResourceType("DOOR_LOCK"); + p2.setExternalResourceId("7002"); + resourceMappingDb.put("LOCK:DORM-MULTI-PRIMARY:7002", p2); + + TrainExternalResourceMapping multiRoom = new TrainExternalResourceMapping(); + multiRoom.setDormId("DORM-MULTI-PRIMARY"); + multiRoom.setVendorCode("DMS"); + multiRoom.setMappingStatus("ACTIVE"); + multiRoom.setExternalResourceType("ROOM"); + resourceMappingDb.put("ROOM:DORM-MULTI-PRIMARY", multiRoom); + + acc.setDormId("DORM-MULTI-PRIMARY"); + + ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r2.getDecision()); + assertTrue(r2.getReason().contains("configuration error")); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + } + + /** + * 防迟到覆盖保护测试 (STALE_OPERATION_RESULT_GUARD) + * 操作 1 迟到返回 SUCCESS,但授权已进入操作 2,操作 1 绝不覆写状态 + */ + @Test + public void testStaleOperationProtection_NeverOverwritesNewState() { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(900L); + auth.setAccommodationId(90L); + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + auth.setLatestOperationId(502L); // 当前最新操作是 502 + dormAuthDb.put(90L, auth); + + TrainExternalOperation staleOp = new TrainExternalOperation(); + staleOp.setOperationId(501L); // 旧操作 501 + staleOp.setDoorAuthId(900L); + staleOp.setOperationType(TrainExternalOperation.TYPE_GRANT); + staleOp.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + staleOp.setExternalOperationId("99001"); + operationDb.put(501L, staleOp); + + // 模拟旧操作 501 迟到返回 FAILED + mockServer.setOperationStatus(99001L, "failed", "Late failure"); + poller.pollPendingOperations(); + + // 状态必须依然保持 ACTIVE,绝不能被旧操作的 FAILED 改为 ERROR! + assertEquals("STALE_OPERATION_RESULT_GUARD: Must NOT overwrite state with stale operation result", + TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + } + + /* ==================== 辅助 Mock 工厂方法 ==================== */ + + private TrainDormAuth getAuthById(Long authId) { + if (authId == null) return null; + return dormAuthDb.values().stream().filter(a -> authId.equals(a.getAuthId())).findFirst().orElse(null); + } + + private TrainEnrollmentMapper createMockEnrollmentMapper() { + return (TrainEnrollmentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainEnrollmentById".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() == 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/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java new file mode 100644 index 000000000..f9c60763d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java @@ -0,0 +1,277 @@ +package com.ruoyi.train.integration.dms.orchestration; + +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.ReportConfirmDTO; +import com.ruoyi.train.domain.vo.ReportResultVO; +import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainClassReportMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainEnrollmentService; +import com.ruoyi.train.service.impl.EnrollmentAppServiceImpl; +import com.ruoyi.train.service.impl.EnrollmentPlanCompletionAdapter; +import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.Date; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** + * 业务事务独立性与敏感数据安全测试 (R.3B Wave 3A Gate 49, 50, 51, 52) + */ +public class BusinessTransactionIndependenceTest { + + /** + * Gate 19: 验证安全密码生成器:强制 6 位纯数字、非空、且连续生成具有强随机性 (非固定弱密码) + */ + @Test + public void testSecurePasswordGenerator() { + String p1 = SecurePasswordGenerator.generateDefaultPassword(); + String p2 = SecurePasswordGenerator.generateDefaultPassword(); + + assertNotNull(p1); + assertNotNull(p2); + assertEquals("Password must be exactly 6 digits", 6, p1.length()); + assertEquals("Password must be exactly 6 digits", 6, p2.length()); + assertTrue("Password must consist only of digits", p1.matches("\\d{6}")); + assertTrue("Password must consist only of digits", p2.matches("\\d{6}")); + + assertNotEquals("Fixed passwords are strictly forbidden", "123456", p1); + assertNotEquals("Fixed passwords are strictly forbidden", "000000", p1); + + // 生成 20 个密码,验证随机不重复分布 + java.util.Set set = new java.util.HashSet<>(); + for (int i = 0; i < 20; i++) { + set.add(SecurePasswordGenerator.generateDefaultPassword()); + } + assertTrue("20 generated passwords should contain high entropy diversity", set.size() >= 18); + } + + /** + * Gate 49: 敏感数据自动扫描:明文在线密码严禁出现在 Entity persistence, logs, exception message, toString + */ + @Test + public void testSensitiveDataScans_ZeroPlaintextPasswordPersistence() throws Exception { + String generatedPassword = SecurePasswordGenerator.generateDefaultPassword(); + + // 1. 实体字段扫描:TrainDormAuth 与 TrainExternalOperation 绝不包含 password/passwd/secret 命名字段 + for (Field f : TrainDormAuth.class.getDeclaredFields()) { + String name = f.getName().toLowerCase(); + assertFalse("TrainDormAuth must not have plaintext password field: " + name, + name.contains("password") || name.contains("passwd")); + assertFalse("TrainDormAuth must not have secret field: " + name, + name.contains("secret")); + } + + for (Field f : TrainExternalOperation.class.getDeclaredFields()) { + String name = f.getName().toLowerCase(); + assertFalse("TrainExternalOperation must not have plaintext password field: " + name, + name.contains("password") || name.contains("passwd")); + assertFalse("TrainExternalOperation must not have secret field: " + name, + name.contains("secret")); + } + + // 2. 实体 toString 扫描:绝不打印出生成的明文密码 + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(101L); + auth.setAccommodationId(202L); + auth.setRemark("Normal auth remark without secret"); + assertFalse("TrainDormAuth toString must not contain generated password", + auth.toString().contains(generatedPassword)); + + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(303L); + op.setExternalOperationId("EXT-303"); + op.setRemark("External op remark without secret"); + assertFalse("TrainExternalOperation toString must not contain generated password", + op.toString().contains(generatedPassword)); + + // 3. 对账结果 toString 扫描 + ReconciliationResult result = ReconciliationResult.grantRequired(101L, 303L, true, true, "GRANT operation initiated"); + assertFalse("ReconciliationResult toString must not contain password", + result.toString().contains(generatedPassword)); + } + + /** + * Gate 50: 报到业务事务独立性测试:Mock DMS 外部网络彻底失败时,报到确认仍成功完成,绝不回滚 + * (REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO) + */ + @Test + public void testReportConfirmExternalFailureRollback_IsNo() throws Exception { + EnrollmentAppServiceImpl enrollmentAppService = new EnrollmentAppServiceImpl(); + + Long testEnrollmentId = 991L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(testEnrollmentId); + enrollment.setPlanId("PLAN-MOCK-01"); + enrollment.setStudentId("STU-001"); + enrollment.setEnrollmentStatus(0); + + TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainEnrollmentById".equals(name)) return enrollment; + if ("confirmEnrollmentReport".equals(name)) { + enrollment.setEnrollmentStatus(1); + return 1; + } + return null; + } + ); + + TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainClassReportMapper.class}, + (proxy, method, args) -> { + if ("selectTrainClassReportByEnrollmentId".equals(method.getName())) return null; + if ("insertTrainClassReport".equals(method.getName())) return 1; + return null; + } + ); + + TrainClassPlanMapper planMapper = (TrainClassPlanMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainClassPlanMapper.class}, + (proxy, method, args) -> new TrainClassPlan() + ); + + TrainStudentMapper studentMapper = (TrainStudentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainStudentMapper.class}, + (proxy, method, args) -> { + TrainStudent s = new TrainStudent(); + s.setsName("张三"); + return s; + } + ); + + // 模拟 AccessAuthorizationOrchestrator 抛出 DMS 外部网络彻底崩溃异常 + AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() { + @Override + public ReconciliationResult reconcile(Long enrollmentId) { + throw new RuntimeException("DMS Network Connection Refused / Timeout [Gate 50]"); + } + }; + + setField(enrollmentAppService, "trainEnrollmentMapper", enrollmentMapper); + setField(enrollmentAppService, "trainClassReportMapper", reportMapper); + setField(enrollmentAppService, "planMapper", planMapper); + setField(enrollmentAppService, "studentMapper", studentMapper); + setField(enrollmentAppService, "accessAuthorizationOrchestrator", failingOrchestrator); + + // 执行学员报到确认 + ReportConfirmDTO dto = new ReportConfirmDTO(); + dto.setReportWay(4); + ReportResultVO vo = enrollmentAppService.confirmReport(testEnrollmentId, dto, "admin"); + + // 验证报到业务主流程必须绝对成功! + assertNotNull("Report confirmation must succeed despite DMS failure", vo); + assertEquals(testEnrollmentId, vo.getEnrollmentId()); + assertEquals("Enrollment status must be confirmed to 1", Integer.valueOf(1), enrollment.getEnrollmentStatus()); + } + + /** + * Gate 51: 退宿业务事务独立性测试:Mock 撤销权限外部网络失败,住宿退宿仍成功,权限保持待撤销状态 + */ + @Test + public void testAccommodationCheckout_ExternalRevokeFailureDoesNotRollback() throws Exception { + TrainAccommodationServiceImpl accommodationService = new TrainAccommodationServiceImpl(); + + Long accId = 881L; + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(accId); + acc.setEnrollmentId(771L); + acc.setAccommodationStatus(1); + + AtomicInteger statusHolder = new AtomicInteger(1); + + TrainAccommodationMapper accMapper = (TrainAccommodationMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainAccommodationMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("selectTrainAccommodationByAccommodationId".equals(name)) return acc; + if ("updateAccommodationStatus".equals(name)) { + statusHolder.set((Integer) args[1]); + return 1; + } + return null; + } + ); + + // 模拟 Orchestrator 在退宿触发对账时发生 DMS 远程超时 + AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() { + @Override + public ReconciliationResult reconcile(Long enrollmentId) { + throw new RuntimeException("Mock DMS Revoke Network Timeout [Gate 51]"); + } + }; + + setField(accommodationService, "trainAccommodationMapper", accMapper); + setField(accommodationService, "accessAuthorizationOrchestrator", failingOrchestrator); + + int rows = accommodationService.checkOut(accId, "admin"); + + assertEquals("Accommodation checkout must return 1 updated row", 1, rows); + assertEquals("Accommodation status must become CHECKED_OUT (2)", + TrainAccommodation.STATUS_CHECKED_OUT, Integer.valueOf(statusHolder.get())); + } + + /** + * Gate 52: 结训业务事务独立性测试:结训联动学员状态归档时,即使外部撤权调用失败,结训主事务仍成功 + */ + @Test + public void testPlanComplete_ExternalRevokeFailureDoesNotRollback() throws Exception { + EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter(); + + AtomicBoolean serviceCompleted = new AtomicBoolean(false); + + ITrainEnrollmentService enrollmentService = (ITrainEnrollmentService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainEnrollmentService.class}, + (proxy, method, args) -> { + if ("completeActiveEnrollmentsByPlanId".equals(method.getName())) { + serviceCompleted.set(true); + return 3; + } + return null; + } + ); + + AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() { + @Override + public ReconciliationResult reconcile(Long enrollmentId) { + throw new RuntimeException("Mock DMS Remote Gateway Down [Gate 52]"); + } + }; + + setField(adapter, "trainEnrollmentService", enrollmentService); + setField(adapter, "accessAuthorizationOrchestrator", failingOrchestrator); + + // 触发计划结训 + adapter.completeEnrollments("PLAN-COMP-99", "admin", new Date()); + + assertTrue("Plan enrollment completion must succeed despite external DMS revoke failures", serviceCompleted.get()); + } + + 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/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/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java new file mode 100644 index 000000000..2976921a2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java @@ -0,0 +1,246 @@ +package com.ruoyi.train.integration.dms.poller; + +import com.ruoyi.train.domain.TrainExternalOperation; +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.config.DmsProperties; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.impl.ExternalOperationServiceImpl; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.Assert.*; + +/** + * DMS 异步操作结果轮询器测试 + */ +public class DmsOperationPollerTest { + + private MockDmsServer mockServer; + private DmsProperties properties; + private DmsOperationPoller poller; + + private final Map operationDb = new ConcurrentHashMap<>(); + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + properties.setPollIntervalSeconds(10); + properties.setMaxRetryAttempts(3); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient); + + // 内存模拟 IExternalOperationService + IExternalOperationService mockOpService = createMockOperationService(); + + poller = new DmsOperationPoller(properties, mockOpService, statusClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + operationDb.clear(); + } + + /** + * 验证安全默认:enabled = false 时,scheduledPoll() 绝不执行轮询 + */ + @Test + public void testSafeDefaults_DisabledPollerDoesNothing() { + properties.setEnabled(false); + + // 存入一条待处理记录 + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(1L); + op.setExternalOperationId("99001"); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + operationDb.put(1L, op); + + poller.scheduledPoll(); + + // 状态应保持不变,Mock Server 未收到请求 + assertEquals(TrainExternalOperation.STATUS_ACCEPTED, operationDb.get(1L).getOperationStatus()); + } + + /** + * 验证轮询流转到 SUCCESS 终态 (POLL_SUCCESS_RESULT) + */ + @Test + public void testPollFlow_ReachesSuccess() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(101L); + op.setExternalOperationId("99001"); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + operationDb.put(101L, op); + + mockServer.setOperationStatus(99001L, "success", "Lock firmware acknowledged"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(101L); + assertEquals(TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus()); + } + + /** + * 验证轮询流转到 FAILED 终态 (POLL_FAILED_RESULT) + */ + @Test + public void testPollFlow_ReachesFailed() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(102L); + op.setExternalOperationId("99002"); + op.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + operationDb.put(102L, op); + + mockServer.setOperationStatus(99002L, "failed", "Lock motor jammed"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(102L); + assertEquals(TrainExternalOperation.STATUS_FAILED, updated.getOperationStatus()); + assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_FAILED, updated.getLastErrorCode()); + assertEquals("Lock motor jammed", updated.getLastErrorMessage()); + } + + /** + * 验证轮询流转到 TIMEOUT 终态 (POLL_TIMEOUT_RESULT) + */ + @Test + public void testPollFlow_ReachesTimeout() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(103L); + op.setExternalOperationId("99003"); + op.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + operationDb.put(103L, op); + + mockServer.setOperationStatus(99003L, "timeout", "Device communication timed out"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(103L); + assertEquals(TrainExternalOperation.STATUS_TIMEOUT, updated.getOperationStatus()); + assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_TIMEOUT, updated.getLastErrorCode()); + } + + /** + * 验证供应商未完成 (undone) 时保持 PENDING 并延展下一次轮询时间 + */ + @Test + public void testPollFlow_PendingUndoneRetained() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(104L); + op.setExternalOperationId("99004"); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + op.setAttemptCount(1); + operationDb.put(104L, op); + + mockServer.setOperationStatus(99004L, "undone", "Device task queued"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(104L); + assertEquals(TrainExternalOperation.STATUS_PENDING, updated.getOperationStatus()); + assertNotNull(updated.getNextPollTime()); + } + + /** + * 验证已处于终态的记录绝不被再次轮询篡改 (终态保护) + */ + @Test + public void testTerminalStateProtection_NeverRewritten() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(105L); + op.setExternalOperationId("99005"); + op.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS); // 已经是终态 + operationDb.put(105L, op); + + int processed = poller.pollPendingOperations(); + assertEquals(0, processed); // 直接跳过 + + assertEquals(TrainExternalOperation.STATUS_SUCCESS, operationDb.get(105L).getOperationStatus()); + } + + /** + * 验证未知供应商状态严禁判定为 SUCCESS (UNKNOWN_VENDOR_STATUS_FAIL_CLOSED = YES) + */ + @Test + public void testUnknownVendorStatus_FailClosed() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(106L); + op.setExternalOperationId("99006"); + op.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + operationDb.put(106L, op); + + mockServer.setOperationStatus(99006L, "unknown_vendor_code_999", "Vendor returned undocumented status"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(106L); + assertNotEquals("Unknown vendor status must NEVER be mapped to SUCCESS", + TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus()); + assertEquals("Unknown status must record error code", + DmsOperationPoller.ERROR_CATEGORY_UNKNOWN_STATUS, updated.getLastErrorCode()); + } + + private IExternalOperationService createMockOperationService() { + return (IExternalOperationService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalOperationService.class}, + (proxy, method, args) -> { + String methodName = method.getName(); + if ("listPendingPollOperations".equals(methodName)) { + List list = new ArrayList<>(); + for (TrainExternalOperation op : operationDb.values()) { + if (!TrainExternalOperation.STATUS_SUCCESS.equals(op.getOperationStatus()) + && !TrainExternalOperation.STATUS_FAILED.equals(op.getOperationStatus()) + && !TrainExternalOperation.STATUS_TIMEOUT.equals(op.getOperationStatus())) { + list.add(op); + } + } + return list; + } else if ("updateOperationPollResult".equals(methodName)) { + Long opId = (Long) args[0]; + String newStatus = (String) args[1]; + Date nextPoll = (Date) args[2]; + Integer attemptCount = (Integer) args[3]; + String lastErrorCode = (String) args[4]; + String lastErrorMessage = (String) args[5]; + + TrainExternalOperation op = operationDb.get(opId); + if (op != null) { + op.setOperationStatus(newStatus); + if (nextPoll != null) op.setNextPollTime(nextPoll); + if (attemptCount != null) op.setAttemptCount(attemptCount); + if (lastErrorCode != null) op.setLastErrorCode(lastErrorCode); + if (lastErrorMessage != null) op.setLastErrorMessage(lastErrorMessage); + } + return op; + } + return null; + } + ); + } +} 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 new file mode 100644 index 000000000..23a8e51f3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java @@ -0,0 +1,585 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +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.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; + +/** + * R.3B Wave 0 - 本地住宿领域对齐与约束单元测试 (AccommodationServiceTest) + * 覆盖规范第29条全部要求:A到I + */ +public class AccommodationServiceTest +{ + private TrainAccommodationServiceImpl accommodationService; + private MockAccommodationMapper mockAccommodationMapper; + private Map dormStore; + private Map enrollmentStore; + + @Before + public void setUp() throws Exception + { + accommodationService = new TrainAccommodationServiceImpl(); + mockAccommodationMapper = new MockAccommodationMapper(); + dormStore = new HashMap<>(); + enrollmentStore = new HashMap<>(); + + TrainDormitoryInfoMapper dormMapperProxy = (TrainDormitoryInfoMapper) Proxy.newProxyInstance( + TrainDormitoryInfoMapper.class.getClassLoader(), + new Class[]{TrainDormitoryInfoMapper.class}, + (proxy, method, args) -> { + if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) + { + return dormStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + TrainEnrollmentMapper enrollMapperProxy = (TrainEnrollmentMapper) Proxy.newProxyInstance( + TrainEnrollmentMapper.class.getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainEnrollmentById".equals(method.getName())) + { + return enrollmentStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + setField(accommodationService, "trainAccommodationMapper", mockAccommodationMapper); + setField(accommodationService, "trainDormitoryInfoMapper", dormMapperProxy); + setField(accommodationService, "trainEnrollmentMapper", enrollMapperProxy); + + // 初始化预设基础数据 + TrainDormitoryInfo dorm101 = new TrainDormitoryInfo(); + dorm101.setDormId("DORM_101"); + dorm101.setDormBuilding("培训1号楼"); + dorm101.setDormFloor("1"); + dorm101.setDormRoom("101"); + dorm101.setCapacity(2); // 容量为2人 + dorm101.setDormStatus(1); // 启用 + dormStore.put(dorm101.getDormId(), dorm101); + + TrainDormitoryInfo dormDisabled = new TrainDormitoryInfo(); + dormDisabled.setDormId("DORM_DIS"); + dormDisabled.setDormBuilding("培训1号楼"); + dormDisabled.setDormRoom("102"); + dormDisabled.setCapacity(4); + dormDisabled.setDormStatus(0); // 禁用 + dormStore.put(dormDisabled.getDormId(), dormDisabled); + + TrainEnrollment enrollment1 = new TrainEnrollment(); + enrollment1.setEnrollmentId(1001L); + enrollment1.setPlanId("PLAN_2026_01"); + enrollment1.setStudentId("STU_001"); + enrollmentStore.put(enrollment1.getEnrollmentId(), enrollment1); + + TrainEnrollment enrollment2 = new TrainEnrollment(); + enrollment2.setEnrollmentId(1002L); + enrollment2.setPlanId("PLAN_2026_01"); + enrollment2.setStudentId("STU_002"); + enrollmentStore.put(enrollment2.getEnrollmentId(), enrollment2); + + TrainEnrollment enrollment3 = new TrainEnrollment(); + enrollment3.setEnrollmentId(1003L); + enrollment3.setPlanId("PLAN_2026_01"); + enrollment3.setStudentId("STU_003"); + enrollmentStore.put(enrollment3.getEnrollmentId(), enrollment3); + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + /** + * Requirement A: Create accommodation + */ + @Test + public void testA_CreateAccommodationSuccess() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + acc.setCreateBy("admin"); + + int rows = accommodationService.insertTrainAccommodation(acc); + assertEquals(1, rows); + assertNotNull(acc.getAccommodationId()); + assertEquals(TrainAccommodation.STATUS_PREALLOCATED, acc.getAccommodationStatus()); + assertNotNull(acc.getAssignedTime()); + } + + /** + * Requirement B: Query by enrollment + */ + @Test + public void testB_QueryByEnrollment() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + + TrainAccommodation queried = accommodationService.selectTrainAccommodationByEnrollmentId(1001L); + assertNotNull(queried); + assertEquals("DORM_101", queried.getDormId()); + assertEquals("1号床", queried.getBedNo()); + + TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L); + assertNotNull(active); + assertEquals(acc.getAccommodationId(), active.getAccommodationId()); + } + + /** + * Requirement C: Status transition (0 预分配 -> 1 已入住 -> 2 已退宿) + */ + @Test + public void testC_StatusTransition() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + Long accId = acc.getAccommodationId(); + + // 1. 办理入住 (0 -> 1) + accommodationService.checkIn(accId, "operator1"); + TrainAccommodation checkedIn = accommodationService.selectTrainAccommodationByAccommodationId(accId); + assertEquals(TrainAccommodation.STATUS_CHECKED_IN, checkedIn.getAccommodationStatus()); + + // 2. 办理退宿 (1 -> 2) + accommodationService.checkOut(accId, "operator2"); + TrainAccommodation checkedOut = accommodationService.selectTrainAccommodationByAccommodationId(accId); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOut.getAccommodationStatus()); + + // 3. 已退宿记录不能重新直接办理入住 + try + { + accommodationService.checkIn(accId, "operator3"); + fail("已退宿记录重新入住未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("已退宿的记录不能重新办理入住")); + } + } + + /** + * Requirement D: Active accommodation uniqueness + * 同一个 Enrollment 同一时刻最多 1 个有效住宿 + */ + @Test + public void testD_ActiveAccommodationUniqueness() + { + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc1); + + // 再次为该 enrollment 分配住宿,应被业务层拦截 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1001L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("重复分配活跃住宿未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("已存在有效住宿安排")); + } + } + + /** + * Requirement E: Invalid enrollment rejected + */ + @Test + public void testE_InvalidEnrollmentRejected() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(999999L); // 不存在的参训档案 + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + + try + { + accommodationService.insertTrainAccommodation(acc); + fail("不存在的参训档案未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("指定参训档案不存在")); + } + } + + /** + * Requirement F: Invalid dorm rejected + */ + @Test + public void testF_InvalidDormRejected() + { + // 1. 宿舍不存在 + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("NON_EXIST_DORM"); + acc1.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc1); + fail("不存在的宿舍未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("指定宿舍不存在")); + } + + // 2. 宿舍已被禁用 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1001L); + acc2.setDormId("DORM_DIS"); + acc2.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("已禁用的宿舍未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该宿舍当前处于禁用状态")); + } + } + + /** + * Requirement G: Capacity boundary + * DORM_101 容量为 2 人,已满后再分配第 3 人应拦截 + */ + @Test + public void testG_CapacityBoundary() + { + // 第1人成功 + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc1); + + // 第2人成功 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1002L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + accommodationService.insertTrainAccommodation(acc2); + + // 第3人分配到同一房间,容量超限拦截 + TrainAccommodation acc3 = new TrainAccommodation(); + acc3.setEnrollmentId(1003L); + acc3.setDormId("DORM_101"); + acc3.setBedNo("3号床"); + try + { + accommodationService.insertTrainAccommodation(acc3); + fail("宿舍容量满额未拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("宿舍容量已满")); + } + } + + /** + * Requirement H: No physical bed table assumption + * bed_no 作为纯业务标识字符串持久化,不需要依赖物理床位表 + */ + @Test + public void testH_NoPhysicalBedTableAssumption() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("上铺-VIP-01"); // 自定义床位标识 + + int rows = accommodationService.insertTrainAccommodation(acc); + assertEquals(1, rows); + + TrainAccommodation saved = accommodationService.selectTrainAccommodationByAccommodationId(acc.getAccommodationId()); + assertEquals("上铺-VIP-01", saved.getBedNo()); + } + + /** + * Requirement I: Ended accommodation historical query & transfer flow + * 退宿后历史记录可查,且退宿后允许为该 enrollment 分配新住宿 (调房业务) + */ + @Test + public void testI_EndedAccommodationHistoricalQueryAndTransfer() + { + // 1. 初始化新宿舍 DORM_201 + TrainDormitoryInfo dorm201 = new TrainDormitoryInfo(); + dorm201.setDormId("DORM_201"); + dorm201.setDormBuilding("培训2号楼"); + dorm201.setDormRoom("201"); + dorm201.setCapacity(2); + dorm201.setDormStatus(1); + dormStore.put(dorm201.getDormId(), dorm201); + + // 2. 创建初始住宿 + TrainAccommodation oldAcc = new TrainAccommodation(); + oldAcc.setEnrollmentId(1001L); + oldAcc.setDormId("DORM_101"); + oldAcc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(oldAcc); + Long oldId = oldAcc.getAccommodationId(); + + // 3. 执行调房 + TrainAccommodation newAcc = accommodationService.transfer(oldId, "DORM_201", "新1号床", "admin"); + assertNotNull(newAcc); + assertNotNull(newAcc.getAccommodationId()); + assertEquals("DORM_201", newAcc.getDormId()); + assertEquals("新1号床", newAcc.getBedNo()); + assertEquals(TrainAccommodation.STATUS_CHECKED_IN, newAcc.getAccommodationStatus()); + + // 4. 验证旧住宿变为退宿状态但依然可查 (历史可审计) + TrainAccommodation oldUpdated = accommodationService.selectTrainAccommodationByAccommodationId(oldId); + assertNotNull(oldUpdated); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, oldUpdated.getAccommodationStatus()); + + // 5. 验证活跃住宿查询当前指向新记录 + TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L); + assertNotNull(active); + assertEquals(newAcc.getAccommodationId(), active.getAccommodationId()); + assertEquals("DORM_201", active.getDormId()); + } + + /** + * Wave 0 Review Gate - Requirement 14: + * A. Create first active accommodation PASS + * B. Create second active accommodation REJECT + * C. Checkout first accommodation + * D. Create second accommodation for same enrollment PASS + * E. History returns 2 rows + * F. Capacity only counts second active row + */ + @Test + public void testAccommodationHistoryAndCardinalityLifecycle() + { + Long enrollId = 1001L; + + // A. Create first active accommodation PASS + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(enrollId); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + int row1 = accommodationService.insertTrainAccommodation(acc1); + assertEquals(1, row1); + assertNotNull(acc1.getAccommodationId()); + + // B. Create second active accommodation REJECT (同一时刻最多1条有效住宿) + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(enrollId); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("活跃记录存在时重复新增应被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("已存在有效住宿安排")); + } + + // C. Checkout first accommodation (退宿后变为状态 2) + accommodationService.checkOut(acc1.getAccommodationId(), "admin"); + TrainAccommodation checkedOutAcc = accommodationService.selectTrainAccommodationByAccommodationId(acc1.getAccommodationId()); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOutAcc.getAccommodationStatus()); + + // D. Create second accommodation for same enrollment PASS (退宿后允许同一学员建立新住宿) + TrainAccommodation acc3 = new TrainAccommodation(); + acc3.setEnrollmentId(enrollId); + acc3.setDormId("DORM_101"); + acc3.setBedNo("2号床"); + int row3 = accommodationService.insertTrainAccommodation(acc3); + assertEquals(1, row3); + assertNotNull(acc3.getAccommodationId()); + + // E. History returns 2 rows (包含1条退宿历史 + 1条当前活跃) + List history = accommodationService.selectAccommodationHistoryByEnrollmentId(enrollId); + assertNotNull(history); + assertEquals(2, history.size()); + + // 验证当前活跃记录只返回新记录 + TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(enrollId); + assertNotNull(active); + assertEquals(acc3.getAccommodationId(), active.getAccommodationId()); + + // F. Capacity only counts second active row (历史退宿记录不计入核定容量,当前只计1人) + int activeCount = mockAccommodationMapper.countActiveByDormId("DORM_101"); + assertEquals(1, activeCount); + } + + // ================================= 内部 Mock 类 ================================= + + private static class MockAccommodationMapper implements TrainAccommodationMapper + { + private final Map store = new HashMap<>(); + private long idSeq = 100L; + + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.get(accommodationId); + } + + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + TrainAccommodation latest = null; + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) + { + latest = a; + } + } + return latest; + } + + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) + { + if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) + { + return a; + } + } + } + return null; + } + + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + List list = new ArrayList<>(); + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) + { + list.add(a); + } + } + list.sort((o1, o2) -> o2.getAccommodationId().compareTo(o1.getAccommodationId())); + return list; + } + + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return new ArrayList<>(store.values()); + } + + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return new ArrayList<>(store.values()); + } + + @Override + public int countActiveByDormId(String dormId) + { + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (dormId.equals(a.getDormId())) + { + if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) + { + count++; + } + } + } + return count; + } + + @Override + public int insertTrainAccommodation(TrainAccommodation accommodation) + { + accommodation.setAccommodationId(++idSeq); + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setAccommodationStatus(accommodationStatus); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.remove(accommodationId) != null ? 1 : 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + int deleted = 0; + for (Long id : accommodationIds) + { + if (store.remove(id) != null) deleted++; + } + return deleted; + } + } +} 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 new file mode 100644 index 000000000..be70640f5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java @@ -0,0 +1,503 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormAuthMapper; + +/** + * R.3B Wave 0 - 宿舍门禁授权领域与V2契约对齐测试 (DormAuthAlignmentTest) + * 覆盖规范第30条全部要求:A到G + */ +public class DormAuthAlignmentTest +{ + private TrainDormAuthServiceImpl dormAuthService; + private MockDormAuthMapper mockDormAuthMapper; + private MockAccommodationMapper mockAccommodationMapper; + + @Before + public void setUp() throws Exception + { + dormAuthService = new TrainDormAuthServiceImpl(); + mockDormAuthMapper = new MockDormAuthMapper(); + mockAccommodationMapper = new MockAccommodationMapper(); + + setField(dormAuthService, "trainDormAuthMapper", mockDormAuthMapper); + setField(dormAuthService, "trainAccommodationMapper", mockAccommodationMapper); + + // 初始化基础住宿记录 + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(5001L); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.save(acc); + + TrainAccommodation checkedOutAcc = new TrainAccommodation(); + checkedOutAcc.setAccommodationId(5002L); + checkedOutAcc.setEnrollmentId(1002L); + checkedOutAcc.setDormId("DORM_101"); + checkedOutAcc.setBedNo("2号床"); + checkedOutAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT); + mockAccommodationMapper.save(checkedOutAcc); + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + /** + * Requirement A: V2 mapper fields align DB contract + * 验证 TrainDormAuth 实体和持久化契约包含所有 V2 字段 + */ + @Test + public void testA_V2MapperFieldsAlignDBContract() throws Exception + { + Class clazz = TrainDormAuth.class; + + // 必须存在 V2 核心字段 + assertNotNull("accommodationId 字段必须存在", clazz.getDeclaredField("accommodationId")); + assertNotNull("authStart 字段必须存在", clazz.getDeclaredField("authStart")); + assertNotNull("authEnd 字段必须存在", clazz.getDeclaredField("authEnd")); + assertNotNull("faceSyncStatus 字段必须存在", clazz.getDeclaredField("faceSyncStatus")); + assertNotNull("retryCount 字段必须存在", clazz.getDeclaredField("retryCount")); + assertNotNull("lastError 字段必须存在", clazz.getDeclaredField("lastError")); + assertNotNull("externalAuthId 字段必须存在", clazz.getDeclaredField("externalAuthId")); + assertNotNull("lastSyncTime 字段必须存在", clazz.getDeclaredField("lastSyncTime")); + + // 验证状态常量定义符合 DB SMALLINT 约束 (0, 1, 2, 3) + assertEquals(Integer.valueOf(0), TrainDormAuth.STATUS_PENDING); + assertEquals(Integer.valueOf(1), TrainDormAuth.STATUS_ACTIVE); + assertEquals(Integer.valueOf(2), TrainDormAuth.STATUS_FAILED); + assertEquals(Integer.valueOf(3), TrainDormAuth.STATUS_REVOKED); + } + + /** + * Requirement B: Authorization references accommodation + * 必须基于有效的 accommodation_id,无 accommodationId 或关联不存在住宿时拒绝 + */ + @Test + public void testB_AuthorizationReferencesAccommodation() + { + // 1. 无 accommodationId 被拒绝 + TrainDormAuth authNoAcc = new TrainDormAuth(); + authNoAcc.setAuthStart(new Date()); + try + { + dormAuthService.insertTrainDormAuth(authNoAcc); + fail("未绑定住宿事实未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("必须绑定住宿安排")); + } + + // 2. 住宿记录不存在被拒绝 + TrainDormAuth authNonExist = new TrainDormAuth(); + authNonExist.setAccommodationId(999999L); + try + { + dormAuthService.insertTrainDormAuth(authNonExist); + fail("关联不存在的住宿记录未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("指定的住宿安排不存在")); + } + + // 3. 关联已退宿的记录被拒绝 + TrainDormAuth authCheckedOut = new TrainDormAuth(); + authCheckedOut.setAccommodationId(5002L); // 对应 checkedOutAcc + try + { + dormAuthService.insertTrainDormAuth(authCheckedOut); + fail("关联已退宿住宿记录未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("该住宿安排已退宿")); + } + + // 4. 关联有效住宿成功 + TrainDormAuth validAuth = new TrainDormAuth(); + validAuth.setAccommodationId(5001L); + validAuth.setAuthStart(new Date()); + validAuth.setAuthEnd(new Date(System.currentTimeMillis() + 86400000L)); + int rows = dormAuthService.insertTrainDormAuth(validAuth); + assertEquals(1, rows); + assertNotNull(validAuth.getAuthId()); + } + + /** + * Requirement C: Legacy student_id/plan_id/dorm_id no longer persisted + * 旧字段不再作为 DB 列持久化,而在实体层保留为视图/兼容访问 + */ + @Test + public void testC_LegacyFieldsNoLongerPersisted() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + // 设置兼容视图字段 + auth.setStudentId("STU_LEGACY_01"); + auth.setPlanId("PLAN_LEGACY_01"); + auth.setDormId("DORM_LEGACY_01"); + auth.setBedNo("1号床"); + auth.setCreateUser("admin_v1"); + + dormAuthService.insertTrainDormAuth(auth); + + // 验证 Mapper 写入的方法只接收并持久化 V2 真实字段 + TrainDormAuth saved = mockDormAuthMapper.selectTrainDormAuthByAuthId(auth.getAuthId()); + assertNotNull(saved); + assertEquals(Long.valueOf(5001L), saved.getAccommodationId()); + // createUser 兼容适配到 BaseEntity.createBy + assertEquals("admin_v1", saved.getCreateBy()); + } + + /** + * Requirement D & E: Retry summary fields and last error fields + */ + @Test + public void testDE_RetrySummaryAndLastErrorFields() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + dormAuthService.insertTrainDormAuth(auth); + Long authId = auth.getAuthId(); + + // 初始重试次数为 0 + assertEquals(Integer.valueOf(0), auth.getRetryCount()); + assertNull(auth.getLastError()); + + // 模拟下发失败,记录错误并累加重试次数 + dormAuthService.markSyncFailed(authId, "Device offline: timeout after 5000ms", "sync_job"); + + TrainDormAuth failed = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_FAILED, failed.getFaceSyncStatus()); + assertEquals("Device offline: timeout after 5000ms", failed.getLastError()); + assertEquals(Integer.valueOf(1), failed.getRetryCount()); + + // 再次失败,重试次数增加到 2 + dormAuthService.markSyncFailed(authId, "Device unreachable", "sync_job"); + TrainDormAuth failedAgain = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(Integer.valueOf(2), failedAgain.getRetryCount()); + assertEquals("Device unreachable", failedAgain.getLastError()); + } + + /** + * Requirement F: Status transition + * 0 待下发 -> 2 下发失败 -> 3 已注销 + */ + @Test + public void testF_StatusTransition() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + dormAuthService.insertTrainDormAuth(auth); + Long authId = auth.getAuthId(); + + // 初始为待下发 + TrainDormAuth step0 = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_PENDING, step0.getFaceSyncStatus()); + + // 下发失败 -> STATUS_FAILED + dormAuthService.markSyncFailed(authId, "Network error", "system"); + TrainDormAuth step1 = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_FAILED, step1.getFaceSyncStatus()); + + // 注销权限 -> STATUS_REVOKED + dormAuthService.revokeAuth(authId, "admin"); + TrainDormAuth step2 = dormAuthService.selectTrainDormAuthByAuthId(authId); + assertEquals(TrainDormAuth.STATUS_REVOKED, step2.getFaceSyncStatus()); + } + + /** + * Requirement G: No DMS client dependency & No Fake Device Success + * 不调用任何外部 DMS,创建时绝不虚假赋值为已下发成功 (STATUS_ACTIVE) + */ + @Test + public void testG_NoDmsClientDependencyAndNoFakeSuccess() + { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAccommodationId(5001L); + // 即使客户端试图传入 STATUS_ACTIVE,业务层由于无实际下发能力,必须强制为 STATUS_PENDING + auth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE); + + dormAuthService.insertTrainDormAuth(auth); + + TrainDormAuth saved = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId()); + assertEquals("没有真实DMS调用前,状态必须为待下发", TrainDormAuth.STATUS_PENDING, saved.getFaceSyncStatus()); + } + + /** + * Wave 0 Review Gate - Requirement 15: DormAuth History Test + * 验证换房场景下授权记录共存: + * Old Accommodation -> Old DormAuth (status=REVOKED) + * New Accommodation -> New DormAuth (status=PENDING/ACTIVE) + * 两条授权记录在历史中共存,互不覆盖 + */ + @Test + public void testDormAuthHistoryAcrossTransfers() + { + Long oldAccId = 5001L; + Long newAccId = 5003L; + + // 注册新住宿 + TrainAccommodation newAcc = new TrainAccommodation(); + newAcc.setAccommodationId(newAccId); + newAcc.setEnrollmentId(1001L); + newAcc.setDormId("DORM_201"); + newAcc.setBedNo("新1号床"); + newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); + mockAccommodationMapper.save(newAcc); + + // 1. 旧住宿对应旧授权 + TrainDormAuth oldAuth = new TrainDormAuth(); + oldAuth.setAccommodationId(oldAccId); + dormAuthService.insertTrainDormAuth(oldAuth); + Long oldAuthId = oldAuth.getAuthId(); + + // 2. 新住宿对应新授权 + TrainDormAuth newAuth = new TrainDormAuth(); + newAuth.setAccommodationId(newAccId); + dormAuthService.insertTrainDormAuth(newAuth); + Long newAuthId = newAuth.getAuthId(); + + // 3. 旧授权注销 (退宿或换房引起) + dormAuthService.revokeAuth(oldAuthId, "admin"); + + // 4. 验证两条授权在历史中共存,状态相互独立 + TrainDormAuth queriedOld = dormAuthService.selectTrainDormAuthByAuthId(oldAuthId); + TrainDormAuth queriedNew = dormAuthService.selectTrainDormAuthByAuthId(newAuthId); + + assertNotNull(queriedOld); + assertNotNull(queriedNew); + assertEquals(TrainDormAuth.STATUS_REVOKED, queriedOld.getFaceSyncStatus()); + assertEquals(TrainDormAuth.STATUS_PENDING, queriedNew.getFaceSyncStatus()); + assertEquals(oldAccId, queriedOld.getAccommodationId()); + assertEquals(newAccId, queriedNew.getAccommodationId()); + } + + // ================================= 内部 Mock 类 ================================= + + private static class MockDormAuthMapper implements TrainDormAuthMapper + { + private final Map store = new HashMap<>(); + private long idSeq = 10L; + + @Override + public TrainDormAuth selectTrainDormAuthByAuthId(Long authId) + { + return store.get(authId); + } + + @Override + public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId) + { + for (TrainDormAuth a : store.values()) + { + if (accommodationId.equals(a.getAccommodationId())) + { + return a; + } + } + return null; + } + + @Override + public List selectTrainDormAuthList(TrainDormAuth trainDormAuth) + { + return new ArrayList<>(store.values()); + } + + @Override + public int insertTrainDormAuth(TrainDormAuth trainDormAuth) + { + trainDormAuth.setAuthId(++idSeq); + store.put(trainDormAuth.getAuthId(), trainDormAuth); + return 1; + } + + @Override + public int updateTrainDormAuth(TrainDormAuth trainDormAuth) + { + store.put(trainDormAuth.getAuthId(), trainDormAuth); + return 1; + } + + @Override + public int updateSyncStatus(Long authId, Integer faceSyncStatus, String lastError, String updateBy, Date updateTime) + { + TrainDormAuth a = store.get(authId); + if (a != null) + { + a.setFaceSyncStatus(faceSyncStatus); + a.setLastError(lastError); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int increaseRetryCount(Long authId, String lastError, String updateBy, Date updateTime) + { + TrainDormAuth a = store.get(authId); + if (a != null) + { + int current = a.getRetryCount() != null ? a.getRetryCount() : 0; + a.setRetryCount(current + 1); + a.setLastError(lastError); + a.setUpdateBy(updateBy); + a.setUpdateTime(updateTime); + return 1; + } + return 0; + } + + @Override + public int deleteTrainDormAuthByAuthId(Long authId) + { + return store.remove(authId) != null ? 1 : 0; + } + + @Override + public int deleteTrainDormAuthByAuthIds(Long[] authIds) + { + int count = 0; + for (Long id : authIds) + { + if (store.remove(id) != null) count++; + } + return count; + } + } + + private static class MockAccommodationMapper implements TrainAccommodationMapper + { + private final Map store = new HashMap<>(); + + public void save(TrainAccommodation a) + { + store.put(a.getAccommodationId(), a); + } + + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.get(accommodationId); + } + + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) return a; + } + return null; + } + + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId()) && + (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))) + { + return a; + } + } + return null; + } + + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + List list = new ArrayList<>(); + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) list.add(a); + } + return list; + } + + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return new ArrayList<>(store.values()); + } + + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return new ArrayList<>(store.values()); + } + + @Override + public int countActiveByDormId(String dormId) + { + return 0; + } + + @Override + public int insertTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setAccommodationStatus(accommodationStatus); + return 1; + } + return 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.remove(accommodationId) != null ? 1 : 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + return 0; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalOperationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalOperationServiceTest.java new file mode 100644 index 000000000..ffd57d86d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalOperationServiceTest.java @@ -0,0 +1,383 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainDormAuth; +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.mapper.TrainDormAuthMapper; +import com.ruoyi.train.mapper.TrainExternalOperationMapper; + +/** + * 外部设备操作流水服务单元测试 + * 覆盖 A 到 J 全部关键业务场景与安全准则 + * + * @author ruoyi + */ +public class ExternalOperationServiceTest +{ + private ExternalOperationServiceImpl operationService; + private final Map operationStore = new HashMap<>(); + private final Map authStore = new HashMap<>(); + private final AtomicLong opIdGen = new AtomicLong(1); + + @Before + public void setUp() throws Exception + { + operationService = new ExternalOperationServiceImpl(); + operationStore.clear(); + authStore.clear(); + + TrainExternalOperationMapper opMapper = (TrainExternalOperationMapper) Proxy.newProxyInstance( + TrainExternalOperationMapper.class.getClassLoader(), + new Class[]{TrainExternalOperationMapper.class}, + (proxy, method, args) -> { + String mName = method.getName(); + if ("selectExternalOperationById".equals(mName)) { + return operationStore.get((Long) args[0]); + } else if ("selectByIdempotencyKey".equals(mName)) { + String key = (String) args[0]; + for (TrainExternalOperation op : operationStore.values()) { + if (key.equals(op.getIdempotencyKey())) { + return op; + } + } + return null; + } else if ("selectByDoorAuthId".equals(mName)) { + Long aId = (Long) args[0]; + List res = new ArrayList<>(); + for (TrainExternalOperation op : operationStore.values()) { + if (aId.equals(op.getDoorAuthId())) { + res.add(op); + } + } + return res; + } else if ("selectByExternalOperationId".equals(mName)) { + String vCode = (String) args[0]; + String eId = (String) args[1]; + for (TrainExternalOperation op : operationStore.values()) { + if (vCode.equals(op.getVendorCode()) && eId.equals(op.getExternalOperationId())) { + return op; + } + } + return null; + } else if ("selectPendingPollOperations".equals(mName)) { + String vCode = (String) args[0]; + List res = new ArrayList<>(); + for (TrainExternalOperation op : operationStore.values()) { + String st = op.getOperationStatus(); + if ("ACCEPTED".equals(st) || "PENDING".equals(st) || "RETRY_WAIT".equals(st)) { + if (vCode == null || vCode.equals(op.getVendorCode())) { + res.add(op); + } + } + } + return res; + } else if ("selectExternalOperationList".equals(mName)) { + TrainExternalOperation q = (TrainExternalOperation) args[0]; + List res = new ArrayList<>(); + for (TrainExternalOperation op : operationStore.values()) { + if (q == null || q.getDoorAuthId() == null || q.getDoorAuthId().equals(op.getDoorAuthId())) { + res.add(op); + } + } + return res; + } else if ("insertExternalOperation".equals(mName)) { + TrainExternalOperation op = (TrainExternalOperation) args[0]; + long id = opIdGen.getAndIncrement(); + op.setOperationId(id); + operationStore.put(id, op); + return 1; + } else if ("updateExternalOperationStatus".equals(mName)) { + TrainExternalOperation op = (TrainExternalOperation) args[0]; + operationStore.put(op.getOperationId(), op); + return 1; + } else if ("updateExternalReference".equals(mName)) { + Long opId = (Long) args[0]; + String extOpId = (String) args[1]; + String extHwId = (String) args[2]; + String extKeyId = (String) args[3]; + String uBy = (String) args[4]; + TrainExternalOperation op = operationStore.get(opId); + if (op != null) { + op.setExternalOperationId(extOpId); + op.setExternalHardwareId(extHwId); + op.setExternalKeyId(extKeyId); + op.setUpdateBy(uBy); + return 1; + } + return 0; + } + return null; + } + ); + + TrainDormAuthMapper authMapper = (TrainDormAuthMapper) Proxy.newProxyInstance( + TrainDormAuthMapper.class.getClassLoader(), + new Class[]{TrainDormAuthMapper.class}, + (proxy, method, args) -> { + if ("selectTrainDormAuthByAuthId".equals(method.getName())) { + return authStore.get((Long) args[0]); + } + return null; + } + ); + + setField(operationService, "operationMapper", opMapper); + setField(operationService, "dormAuthMapper", authMapper); + + // 初始化默认合法门禁授权 + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(1001L); + auth.setAccommodationId(2001L); + auth.setFaceSyncStatus(0); + authStore.put(auth.getAuthId(), auth); + } + + // A. Create GRANT operation + @Test + public void testA_createGrantOperation_success() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(1001L); + op.setOperationType("GRANT"); + op.setIdempotencyKey("KEY-GRANT-001"); + + TrainExternalOperation created = operationService.createOperation(op); + assertNotNull(created); + assertNotNull(created.getOperationId()); + assertEquals("CREATED", created.getOperationStatus()); + assertEquals("DOOR_AUTH", created.getBusinessType()); + assertEquals("DMS", created.getVendorCode()); + assertEquals(Integer.valueOf(0), created.getAttemptCount()); + } + + // B. DoorAuth missing rejected + @Test + public void testB_doorAuthMissing_rejected() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(9999L); + op.setOperationType("GRANT"); + op.setIdempotencyKey("KEY-ERR-001"); + + try { + operationService.createOperation(op); + fail("Should reject when doorAuth missing"); + } catch (ServiceException e) { + assertTrue(e.getMessage().contains("目标门禁授权不存在")); + } + } + + // C. Idempotency duplicate rejected/returned + @Test + public void testC_idempotencyDuplicate_returnsExistingWithoutInsert() + { + TrainExternalOperation first = new TrainExternalOperation(); + first.setDoorAuthId(1001L); + first.setOperationType("GRANT"); + first.setIdempotencyKey("KEY-IDEMPOTENT-001"); + TrainExternalOperation created = operationService.createOperation(first); + + operationService.transitStatus(created.getOperationId(), "REQUESTED", null, null, "admin"); + + TrainExternalOperation second = new TrainExternalOperation(); + second.setDoorAuthId(1001L); + second.setOperationType("GRANT"); + second.setIdempotencyKey("KEY-IDEMPOTENT-001"); + + TrainExternalOperation result = operationService.createOperation(second); + assertNotNull(result); + assertEquals(created.getOperationId(), result.getOperationId()); + assertEquals("REQUESTED", result.getOperationStatus()); + } + + // D. External operation ID initially null + @Test + public void testD_externalOperationId_initiallyNull() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(1001L); + op.setOperationType("REVOKE"); + op.setIdempotencyKey("KEY-NULL-OPID"); + + TrainExternalOperation created = operationService.createOperation(op); + assertNull(created.getExternalOperationId()); + } + + // E. Bind external operation ID + @Test + public void testE_bindExternalOperationId_success() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(1001L); + op.setOperationType("GRANT"); + op.setIdempotencyKey("KEY-BIND-OPID"); + TrainExternalOperation created = operationService.createOperation(op); + + TrainExternalOperation updated = operationService.bindExternalReference( + created.getOperationId(), "DMS-OP-9988", "HW-01", "KEY-01", "admin"); + assertNotNull(updated); + assertEquals("DMS-OP-9988", updated.getExternalOperationId()); + assertEquals("HW-01", updated.getExternalHardwareId()); + assertEquals("KEY-01", updated.getExternalKeyId()); + } + + // F. PENDING query + @Test + public void testF_pendingQuery() + { + TrainExternalOperation op1 = new TrainExternalOperation(); + op1.setDoorAuthId(1001L); + op1.setOperationType("GRANT"); + op1.setIdempotencyKey("KEY-POLL-01"); + TrainExternalOperation c1 = operationService.createOperation(op1); + operationService.transitStatus(c1.getOperationId(), "PENDING", null, null, "admin"); + + TrainExternalOperation op2 = new TrainExternalOperation(); + op2.setDoorAuthId(1001L); + op2.setOperationType("REVOKE"); + op2.setIdempotencyKey("KEY-POLL-02"); + TrainExternalOperation c2 = operationService.createOperation(op2); + operationService.transitStatus(c2.getOperationId(), "ACCEPTED", null, null, "admin"); + + List list = operationService.listPendingPollOperations("DMS", new Date(System.currentTimeMillis() + 10000)); + assertEquals(2, list.size()); + } + + // G. SUCCESS terminal + @Test(expected = ServiceException.class) + public void testG_successTerminal_cannotTransitBackToPending() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(1001L); + op.setOperationType("GRANT"); + op.setIdempotencyKey("KEY-TERM-01"); + TrainExternalOperation created = operationService.createOperation(op); + + operationService.transitStatus(created.getOperationId(), "SUCCESS", null, null, "admin"); + + try { + operationService.transitStatus(created.getOperationId(), "PENDING", null, null, "admin"); + fail("Should reject reversing SUCCESS status"); + } catch (ServiceException e) { + assertTrue(e.getMessage().contains("禁止状态逆转")); + throw e; + } + } + + // H. FAILED history retained + @Test + public void testH_failedHistoryRetained_terminalGuard() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(1001L); + op.setOperationType("GRANT"); + op.setIdempotencyKey("KEY-FAIL-01"); + TrainExternalOperation created = operationService.createOperation(op); + + operationService.transitStatus(created.getOperationId(), "FAILED", "ERR-400", "Device timeout", "admin"); + + try { + operationService.transitStatus(created.getOperationId(), "CREATED", null, null, "admin"); + fail("Should reject resetting FAILED to CREATED"); + } catch (ServiceException e) { + assertTrue(e.getMessage().contains("禁止重置")); + } + } + + // I. Retry creates new operation + @Test + public void testI_retryCreatesNewOperation() + { + TrainExternalOperation op = new TrainExternalOperation(); + op.setDoorAuthId(1001L); + op.setOperationType("GRANT"); + op.setIdempotencyKey("KEY-ATTEMPT-1"); + TrainExternalOperation created = operationService.createOperation(op); + operationService.transitStatus(created.getOperationId(), "FAILED", "500", "Gateway error", "admin"); + + TrainExternalOperation retry = operationService.createRetryOperation( + created.getOperationId(), "KEY-ATTEMPT-2", "admin"); + assertNotNull(retry); + assertNotEquals(created.getOperationId(), retry.getOperationId()); + assertEquals("KEY-ATTEMPT-2", retry.getIdempotencyKey()); + assertEquals(Integer.valueOf(1), retry.getAttemptCount()); + assertEquals("CREATED", retry.getOperationStatus()); + + List history = operationService.listOperationsByDoorAuthId(1001L); + assertEquals(2, history.size()); + } + + // J. Sensitive fields absent + @Test + public void testJ_sensitiveFieldsAbsent() + { + Field[] fields = TrainExternalOperation.class.getDeclaredFields(); + for (Field f : fields) { + String name = f.getName().toLowerCase(); + assertFalse("Entity must not contain password field: " + name, name.contains("password")); + assertFalse("Entity must not contain card number field: " + name, name.contains("cardnumber") || name.contains("card_no")); + assertFalse("Entity must not contain secret/token: " + name, name.contains("secret") || name.contains("token")); + assertFalse("Entity must not contain raw payload: " + name, name.contains("rawrequest") || name.contains("rawresponse")); + } + } + + // K. REVOKE is operation_type, reaching status=SUCCESS without mutating prior GRANT operation + @Test + public void testK_revocationIsOperationType_withSuccessStatus() + { + // 1. 历史 GRANT 操作已成功达成 SUCCESS + TrainExternalOperation grantOp = new TrainExternalOperation(); + grantOp.setDoorAuthId(1001L); + grantOp.setOperationType("GRANT"); + grantOp.setIdempotencyKey("KEY-GRANT-099"); + TrainExternalOperation createdGrant = operationService.createOperation(grantOp); + operationService.transitStatus(createdGrant.getOperationId(), "SUCCESS", null, null, "admin"); + + // 2. 发起注销操作 (operation_type = REVOKE) + TrainExternalOperation revokeOp = new TrainExternalOperation(); + revokeOp.setDoorAuthId(1001L); + revokeOp.setOperationType("REVOKE"); + revokeOp.setIdempotencyKey("KEY-REVOKE-001"); + TrainExternalOperation createdRevoke = operationService.createOperation(revokeOp); + + assertEquals("REVOKE", createdRevoke.getOperationType()); + assertEquals("CREATED", createdRevoke.getOperationStatus()); + + // 3. 注销外部调用成功返回终态 SUCCESS (并非 REVOKED) + TrainExternalOperation completedRevoke = operationService.transitStatus( + createdRevoke.getOperationId(), "SUCCESS", null, null, "admin"); + assertEquals("SUCCESS", completedRevoke.getOperationStatus()); + + // 4. 验证历史 GRANT 操作依然保持 SUCCESS,未被篡改 + TrainExternalOperation originalGrant = operationService.getOperationById(createdGrant.getOperationId()); + assertEquals("SUCCESS", originalGrant.getOperationStatus()); + assertEquals("GRANT", originalGrant.getOperationType()); + } + + // L. REVOKED is NOT an operation status + @Test + public void testL_revokedIsNotOperationStatus() + { + assertFalse("Operation status constants must not define STATUS_REVOKED", + TrainExternalOperation.STATUS_SUCCESS.equals("REVOKED")); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceTest.java new file mode 100644 index 000000000..3050d578f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceTest.java @@ -0,0 +1,379 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainExternalResourceMapping; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainExternalResourceMappingMapper; + +/** + * 外部资源映射服务单元测试 + * 覆盖 A 到 I 全部关键业务场景 + * + * @author ruoyi + */ +public class ExternalResourceMappingServiceTest +{ + private ExternalResourceMappingServiceImpl mappingService; + private final Map mappingStore = new HashMap<>(); + private final Map dormStore = new HashMap<>(); + private final AtomicLong mappingIdGen = new AtomicLong(1); + + @Before + public void setUp() throws Exception + { + mappingService = new ExternalResourceMappingServiceImpl(); + mappingStore.clear(); + dormStore.clear(); + + TrainExternalResourceMappingMapper mappingMapper = (TrainExternalResourceMappingMapper) Proxy.newProxyInstance( + TrainExternalResourceMappingMapper.class.getClassLoader(), + new Class[]{TrainExternalResourceMappingMapper.class}, + (proxy, method, args) -> { + String mName = method.getName(); + if ("selectExternalResourceMappingById".equals(mName)) { + return mappingStore.get((Long) args[0]); + } else if ("selectActiveRoomMapping".equals(mName)) { + String vCode = (String) args[0]; + String dId = (String) args[1]; + for (TrainExternalResourceMapping m : mappingStore.values()) { + if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId()) + && "DORM_ROOM".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) { + return m; + } + } + return null; + } else if ("selectActiveLockMappings".equals(mName)) { + String vCode = (String) args[0]; + String dId = (String) args[1]; + List res = new ArrayList<>(); + for (TrainExternalResourceMapping m : mappingStore.values()) { + if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId()) + && "DOOR_LOCK".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) { + res.add(m); + } + } + return res; + } else if ("selectActiveByExternalResource".equals(mName)) { + String vCode = (String) args[0]; + String rType = (String) args[1]; + String rId = (String) args[2]; + for (TrainExternalResourceMapping m : mappingStore.values()) { + if (vCode.equals(m.getVendorCode()) && rType.equals(m.getExternalResourceType()) + && rId.equals(m.getExternalResourceId()) && "ACTIVE".equals(m.getMappingStatus())) { + return m; + } + } + return null; + } else if ("selectExternalResourceMappingList".equals(mName)) { + TrainExternalResourceMapping q = (TrainExternalResourceMapping) args[0]; + List res = new ArrayList<>(); + for (TrainExternalResourceMapping m : mappingStore.values()) { + if (q == null || q.getDormId() == null || q.getDormId().equals(m.getDormId())) { + res.add(m); + } + } + return res; + } else if ("insertExternalResourceMapping".equals(mName)) { + TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0]; + long id = mappingIdGen.getAndIncrement(); + m.setMappingId(id); + mappingStore.put(id, m); + return 1; + } else if ("updateExternalResourceMapping".equals(mName)) { + TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0]; + mappingStore.put(m.getMappingId(), m); + return 1; + } else if ("deactivateMapping".equals(mName)) { + Long id = (Long) args[0]; + String uBy = (String) args[1]; + TrainExternalResourceMapping m = mappingStore.get(id); + if (m != null) { + m.setMappingStatus("INACTIVE"); + m.setUpdateBy(uBy); + return 1; + } + return 0; + } else if ("deactivateRoomMapping".equals(mName)) { + String vCode = (String) args[0]; + String dId = (String) args[1]; + String uBy = (String) args[2]; + for (TrainExternalResourceMapping m : mappingStore.values()) { + if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId()) + && "DORM_ROOM".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) { + m.setMappingStatus("INACTIVE"); + m.setUpdateBy(uBy); + return 1; + } + } + return 0; + } + return null; + } + ); + + TrainDormitoryInfoMapper dormMapper = (TrainDormitoryInfoMapper) Proxy.newProxyInstance( + TrainDormitoryInfoMapper.class.getClassLoader(), + new Class[]{TrainDormitoryInfoMapper.class}, + (proxy, method, args) -> { + if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) { + return dormStore.get(args[0]); + } + return null; + } + ); + + setField(mappingService, "mappingMapper", mappingMapper); + setField(mappingService, "dormitoryInfoMapper", dormMapper); + + // 注入默认合法宿舍 + TrainDormitoryInfo dorm101 = new TrainDormitoryInfo(); + dorm101.setDormId("DORM-101"); + dorm101.setDormBuilding("1号楼"); + dorm101.setDormFloor("1层"); + dorm101.setDormRoom("101"); + dorm101.setCapacity(2); + dormStore.put(dorm101.getDormId(), dorm101); + + TrainDormitoryInfo dorm102 = new TrainDormitoryInfo(); + dorm102.setDormId("DORM-102"); + dorm102.setDormBuilding("1号楼"); + dorm102.setDormFloor("1层"); + dorm102.setDormRoom("102"); + dorm102.setCapacity(2); + dormStore.put(dorm102.getDormId(), dorm102); + } + + // A. Create room mapping + @Test + public void testA_createRoomMapping_success() + { + TrainExternalResourceMapping mapping = new TrainExternalResourceMapping(); + mapping.setDormId("DORM-101"); + mapping.setVendorCode("DMS"); + mapping.setExternalResourceId("PLACE-8801"); + + TrainExternalResourceMapping created = mappingService.bindRoomMapping(mapping); + + assertNotNull(created); + assertNotNull(created.getMappingId()); + assertEquals("DORM_ROOM", created.getLocalResourceType()); + assertEquals("ROOM", created.getExternalResourceType()); + assertEquals("ACTIVE", created.getMappingStatus()); + assertEquals(Integer.valueOf(1), created.getIsPrimary()); + } + + // A2. ROOM and DOOR_LOCK can share same external_resource_id legally + @Test + public void testA2_sameExternalIdForRoomAndLock_allowed() + { + // 1. 绑定 ROOM 外部ID为 10001 + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId("DORM-101"); + room.setVendorCode("DMS"); + room.setExternalResourceId("10001"); + TrainExternalResourceMapping resRoom = mappingService.bindRoomMapping(room); + assertNotNull(resRoom); + assertEquals("ROOM", resRoom.getExternalResourceType()); + + // 2. 绑定 DOOR_LOCK 外部ID同样为 10001 (不同类型,完全合法) + TrainExternalResourceMapping lock = new TrainExternalResourceMapping(); + lock.setDormId("DORM-101"); + lock.setVendorCode("DMS"); + lock.setExternalResourceId("10001"); + TrainExternalResourceMapping resLock = mappingService.bindLockMapping(lock); + assertNotNull(resLock); + assertEquals("DOOR_LOCK", resLock.getExternalResourceType()); + assertEquals(resRoom.getExternalResourceId(), resLock.getExternalResourceId()); + } + + // B. Duplicate active room mapping rejected + @Test + public void testB_duplicateActiveRoomMapping_rejected() + { + TrainExternalResourceMapping first = new TrainExternalResourceMapping(); + first.setDormId("DORM-101"); + first.setVendorCode("DMS"); + first.setExternalResourceId("PLACE-8801"); + mappingService.bindRoomMapping(first); + + TrainExternalResourceMapping second = new TrainExternalResourceMapping(); + second.setDormId("DORM-101"); + second.setVendorCode("DMS"); + second.setExternalResourceId("PLACE-8802"); + + try { + mappingService.bindRoomMapping(second); + fail("Should reject duplicate active room mapping on same dorm"); + } catch (ServiceException e) { + assertTrue(e.getMessage().contains("已存在有效的外部房间映射")); + } + } + + // C. Deactivate old mapping then rebind allowed + @Test + public void testC_deactivateOldMappingThenRebind_allowed() + { + TrainExternalResourceMapping first = new TrainExternalResourceMapping(); + first.setDormId("DORM-101"); + first.setVendorCode("DMS"); + first.setExternalResourceId("PLACE-8801"); + TrainExternalResourceMapping created = mappingService.bindRoomMapping(first); + + boolean deactivated = mappingService.deactivateMapping(created.getMappingId(), "admin"); + assertTrue(deactivated); + + TrainExternalResourceMapping second = new TrainExternalResourceMapping(); + second.setDormId("DORM-101"); + second.setVendorCode("DMS"); + second.setExternalResourceId("PLACE-8802"); + + TrainExternalResourceMapping rebound = mappingService.bindRoomMapping(second); + assertNotNull(rebound); + assertEquals("ACTIVE", rebound.getMappingStatus()); + assertEquals("PLACE-8802", rebound.getExternalResourceId()); + } + + // D. One room multiple lock mapping allowed + @Test + public void testD_oneRoomMultipleLockMapping_allowed() + { + TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping(); + lock1.setDormId("DORM-101"); + lock1.setVendorCode("DMS"); + lock1.setExternalResourceId("LOCK-001"); + lock1.setExternalHardwareId("HW-SN-001"); + + TrainExternalResourceMapping res1 = mappingService.bindLockMapping(lock1); + assertEquals(Integer.valueOf(1), res1.getIsPrimary()); + + TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping(); + lock2.setDormId("DORM-101"); + lock2.setVendorCode("DMS"); + lock2.setExternalResourceId("LOCK-002"); + lock2.setExternalHardwareId("HW-SN-002"); + + TrainExternalResourceMapping res2 = mappingService.bindLockMapping(lock2); + assertEquals(Integer.valueOf(0), res2.getIsPrimary()); + + List locks = mappingService.getActiveLockMappings("DMS", "DORM-101"); + assertEquals(2, locks.size()); + } + + // E. Same external lock active binding to two rooms rejected + @Test + public void testE_sameExternalLockActiveBindingToTwoRooms_rejected() + { + TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping(); + lock1.setDormId("DORM-101"); + lock1.setVendorCode("DMS"); + lock1.setExternalResourceId("LOCK-SHARED"); + mappingService.bindLockMapping(lock1); + + TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping(); + lock2.setDormId("DORM-102"); + lock2.setVendorCode("DMS"); + lock2.setExternalResourceId("LOCK-SHARED"); + + try { + mappingService.bindLockMapping(lock2); + fail("Should reject same external lock bound to another dorm"); + } catch (ServiceException e) { + assertTrue(e.getMessage().contains("已处于有效绑定状态")); + } + } + + // F. Invalid dorm rejected + @Test + public void testF_invalidDorm_rejected() + { + TrainExternalResourceMapping mapping = new TrainExternalResourceMapping(); + mapping.setDormId("DORM-NON-EXIST"); + mapping.setVendorCode("DMS"); + mapping.setExternalResourceId("PLACE-9999"); + + try { + mappingService.bindRoomMapping(mapping); + fail("Should reject invalid dormId"); + } catch (ServiceException e) { + assertTrue(e.getMessage().contains("目标宿舍不存在")); + } + } + + // G. History retained + @Test + public void testG_historyRetained_listQuery() + { + TrainExternalResourceMapping first = new TrainExternalResourceMapping(); + first.setDormId("DORM-101"); + first.setVendorCode("DMS"); + first.setExternalResourceId("PLACE-OLD"); + TrainExternalResourceMapping created = mappingService.bindRoomMapping(first); + mappingService.deactivateMapping(created.getMappingId(), "admin"); + + TrainExternalResourceMapping second = new TrainExternalResourceMapping(); + second.setDormId("DORM-101"); + second.setVendorCode("DMS"); + second.setExternalResourceId("PLACE-NEW"); + mappingService.bindRoomMapping(second); + + TrainExternalResourceMapping query = new TrainExternalResourceMapping(); + query.setDormId("DORM-101"); + List all = mappingService.listMappings(query); + + assertEquals(2, all.size()); + } + + // H. Query active room + @Test + public void testH_queryActiveRoom() + { + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId("DORM-101"); + room.setVendorCode("DMS"); + room.setExternalResourceId("PLACE-101"); + mappingService.bindRoomMapping(room); + + TrainExternalResourceMapping found = mappingService.getActiveRoomMapping("DMS", "DORM-101"); + assertNotNull(found); + assertEquals("PLACE-101", found.getExternalResourceId()); + } + + // I. Query lock list + @Test + public void testI_queryLockList() + { + TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping(); + lock1.setDormId("DORM-101"); + lock1.setVendorCode("DMS"); + lock1.setExternalResourceId("LOCK-01"); + mappingService.bindLockMapping(lock1); + + TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping(); + lock2.setDormId("DORM-101"); + lock2.setVendorCode("DMS"); + lock2.setExternalResourceId("LOCK-02"); + mappingService.bindLockMapping(lock2); + + List locks = mappingService.getActiveLockMappings("DMS", "DORM-101"); + assertEquals(2, locks.size()); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql b/RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql new file mode 100644 index 000000000..edef61496 --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql @@ -0,0 +1,69 @@ +-- 006_accommodation_history_constraint.sql +-- 目标:移除 train_accommodation 全局唯一约束 uq_accommodation_enroll, +-- 创建部分唯一索引 uq_accommodation_enroll_active (每个 enrollment 最多 1 条有效住宿), +-- 允许学员拥有多条退宿历史记录 (status=2),彻底支持调房与历史留痕。 + +DO $$ +DECLARE + v_dup_count INTEGER; + v_con_def TEXT; + v_idx_def TEXT; +BEGIN + -- 1. Pre-Migration Data Safety Guard + -- 检查是否已存在同一个 enrollment_id 拥有 2 条或以上处于有效状态 (0=预分配, 1=已入住) 的住宿记录 + SELECT COUNT(*) INTO v_dup_count + FROM ( + SELECT enrollment_id + FROM train_accommodation + WHERE accommodation_status IN (0, 1) + GROUP BY enrollment_id + HAVING COUNT(*) > 1 + ) t; + + IF v_dup_count > 0 THEN + RAISE EXCEPTION 'Pre-migration check failed: found % enrollment(s) with multiple active accommodations (status IN (0,1)). Manual resolution required before applying migration 006.', v_dup_count; + END IF; + + -- 2. Existing Constraint Guard (Idempotent Drop) + -- 检查约束 uq_accommodation_enroll 是否存在且符合预期 + SELECT pg_get_constraintdef(c.oid) INTO v_con_def + FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'train_accommodation' + AND c.conname = 'uq_accommodation_enroll'; + + IF v_con_def IS NOT NULL THEN + -- 验证旧约束是否为 UNIQUE (enrollment_id) + IF v_con_def ILIKE 'UNIQUE (enrollment_id)%' THEN + RAISE NOTICE 'Dropping legacy global unique constraint: uq_accommodation_enroll'; + ALTER TABLE train_accommodation DROP CONSTRAINT uq_accommodation_enroll; + ELSE + RAISE EXCEPTION 'Constraint collision: constraint uq_accommodation_enroll exists but definition is unexpected: %', v_con_def; + END IF; + ELSE + RAISE NOTICE 'Constraint uq_accommodation_enroll does not exist, skipping drop.'; + END IF; + + -- 3. New Index Collision Guard & Creation + -- 检查部分唯一索引 uq_accommodation_enroll_active + SELECT pg_get_indexdef(i.indexrelid) INTO v_idx_def + FROM pg_index i + JOIN pg_class c ON i.indexrelid = c.oid + JOIN pg_class t ON i.indrelid = t.oid + WHERE t.relname = 'train_accommodation' + AND c.relname = 'uq_accommodation_enroll_active'; + + IF v_idx_def IS NOT NULL THEN + -- 验证已有索引是否为预期的部分唯一索引 + IF v_idx_def ILIKE '%UNIQUE INDEX uq_accommodation_enroll_active ON %train_accommodation%enrollment_id%WHERE%accommodation_status%' THEN + RAISE NOTICE 'Partial unique index uq_accommodation_enroll_active already exists with expected definition, skipping creation.'; + ELSE + RAISE EXCEPTION 'Index collision: index uq_accommodation_enroll_active exists but definition differs: %', v_idx_def; + END IF; + ELSE + RAISE NOTICE 'Creating partial unique index: uq_accommodation_enroll_active'; + CREATE UNIQUE INDEX uq_accommodation_enroll_active + ON train_accommodation (enrollment_id) + WHERE accommodation_status IN (0, 1); + END IF; +END $$; diff --git a/RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql b/RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql new file mode 100644 index 000000000..afc2626ee --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql @@ -0,0 +1,114 @@ +-- ============================================================================== +-- 脚本名称: 008_external_resource_operation.sql +-- 业务模块: DMS/外部物联网集成 - 外部资源映射与操作流水持久化模型 (R.3B Wave 1) +-- 适用数据库: KingbaseES V8 / V9 (PostgreSQL 方言兼容) +-- 安全编号: 008 (001-003 基线, 004-005 R.2分支占用, 006 Wave0, 007 保留禁止执行) +-- 幂等性设计: 支持重复安全执行,纯增量结构,不含破坏性语句 +-- ============================================================================== + +-- 1. 创建外部资源映射表 train_external_resource_mapping +CREATE TABLE IF NOT EXISTS train_external_resource_mapping ( + mapping_id BIGSERIAL PRIMARY KEY, + dorm_id VARCHAR(32) NOT NULL, + vendor_code VARCHAR(32) NOT NULL DEFAULT 'DMS', + local_resource_type VARCHAR(32) NOT NULL, + external_resource_type VARCHAR(32) NOT NULL, + external_resource_id VARCHAR(64) NOT NULL, + external_parent_id VARCHAR(64), + external_hardware_id VARCHAR(64), + external_project_id VARCHAR(64), + is_primary SMALLINT NOT NULL DEFAULT 1, + mapping_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + last_sync_time TIMESTAMP, + create_by VARCHAR(64) DEFAULT '', + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) DEFAULT '', + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + remark VARCHAR(500), + CONSTRAINT fk_ext_res_map_dorm FOREIGN KEY (dorm_id) REFERENCES train_dormitory_info(dorm_id) +); + +COMMENT ON TABLE train_external_resource_mapping IS '外部资源映射关联表(支持一房多锁与多供应商适配)'; +COMMENT ON COLUMN train_external_resource_mapping.mapping_id IS '映射ID(主键)'; +COMMENT ON COLUMN train_external_resource_mapping.dorm_id IS '本地宿舍ID(外键)'; +COMMENT ON COLUMN train_external_resource_mapping.vendor_code IS '外部供应商标识(如 DMS)'; +COMMENT ON COLUMN train_external_resource_mapping.local_resource_type IS '本地资源类型(DORM_ROOM, DOOR_LOCK)'; +COMMENT ON COLUMN train_external_resource_mapping.external_resource_type IS '外部资源类型(ROOM, DOOR_LOCK)'; +COMMENT ON COLUMN train_external_resource_mapping.external_resource_id IS '外部系统资源全局主键(如 placeId, lockId)'; +COMMENT ON COLUMN train_external_resource_mapping.external_parent_id IS '外部父级资源标识(如 building/floor placeId)'; +COMMENT ON COLUMN train_external_resource_mapping.external_hardware_id IS '外部硬件标识/SN(如门锁 hardwareId)'; +COMMENT ON COLUMN train_external_resource_mapping.external_project_id IS '外部项目/租户站点ID(DMS projectId)'; +COMMENT ON COLUMN train_external_resource_mapping.is_primary IS '是否主门锁/主资源(1是, 0否)'; +COMMENT ON COLUMN train_external_resource_mapping.mapping_status IS '映射状态(ACTIVE有效, INACTIVE失效)'; +COMMENT ON COLUMN train_external_resource_mapping.last_sync_time IS '最近同步元数据时间'; + +-- 1.1 约束与索引:同一宿舍同一厂商最多1个 ACTIVE 房间映射 (局部唯一索引) +CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_res_map_active_room +ON train_external_resource_mapping (vendor_code, dorm_id) +WHERE external_resource_type = 'ROOM' AND mapping_status = 'ACTIVE'; + +-- 1.2 约束与索引:同一外部资源在同一厂商下不能同时 ACTIVE 绑定到多个本地房间 (局部唯一索引) +CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_res_map_active_ext_res +ON train_external_resource_mapping (vendor_code, external_resource_type, external_resource_id) +WHERE mapping_status = 'ACTIVE'; + +-- 1.3 常规外键与查询索引 +CREATE INDEX IF NOT EXISTS idx_ext_res_map_dorm ON train_external_resource_mapping (dorm_id); +CREATE INDEX IF NOT EXISTS idx_ext_res_map_ext_res ON train_external_resource_mapping (vendor_code, external_resource_type, external_resource_id); + + +-- 2. 创建外部操作流水表 train_external_operation +CREATE TABLE IF NOT EXISTS train_external_operation ( + operation_id BIGSERIAL PRIMARY KEY, + door_auth_id BIGINT NOT NULL, + business_type VARCHAR(32) NOT NULL DEFAULT 'DOOR_AUTH', + vendor_code VARCHAR(32) NOT NULL DEFAULT 'DMS', + operation_type VARCHAR(32) NOT NULL, + operation_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + idempotency_key VARCHAR(128) NOT NULL, + external_operation_id VARCHAR(64), + external_request_type INTEGER, + external_hardware_id VARCHAR(64), + external_key_id VARCHAR(64), + attempt_count INTEGER NOT NULL DEFAULT 0, + next_poll_time TIMESTAMP, + requested_at TIMESTAMP, + completed_at TIMESTAMP, + last_error_code VARCHAR(64), + last_error_message VARCHAR(500), + create_by VARCHAR(64) DEFAULT '', + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) DEFAULT '', + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + remark VARCHAR(500), + CONSTRAINT fk_ext_op_door_auth FOREIGN KEY (door_auth_id) REFERENCES train_dorm_auth(auth_id) +); + +COMMENT ON TABLE train_external_operation IS '外部设备操作流水与异步轮询对账表(中立模型,无明文敏感数据)'; +COMMENT ON COLUMN train_external_operation.operation_id IS '本地操作流水ID(主键)'; +COMMENT ON COLUMN train_external_operation.door_auth_id IS '关联门禁授权主记录ID(外键)'; +COMMENT ON COLUMN train_external_operation.business_type IS '业务类型(默认 DOOR_AUTH)'; +COMMENT ON COLUMN train_external_operation.vendor_code IS '外部供应商标识(如 DMS)'; +COMMENT ON COLUMN train_external_operation.operation_type IS '操作类型(GRANT下发, REVOKE注销, STATUS_QUERY查询)'; +COMMENT ON COLUMN train_external_operation.operation_status IS '操作状态(CREATED, REQUESTED, ACCEPTED, PENDING, RETRY_WAIT, SUCCESS, FAILED, TIMEOUT)'; +COMMENT ON COLUMN train_external_operation.idempotency_key IS '本地全局幂等键(防重复调用)'; +COMMENT ON COLUMN train_external_operation.external_operation_id IS '外部返回的操作流水ID(如 DMS operationId,初始为NULL)'; +COMMENT ON COLUMN train_external_operation.external_request_type IS '外部请求类型数字码(如 1下发密码, 3删除密码)'; +COMMENT ON COLUMN train_external_operation.external_hardware_id IS '外部硬件标识/SN'; +COMMENT ON COLUMN train_external_operation.external_key_id IS '外部凭证钥匙ID(如 DMS keyId)'; +COMMENT ON COLUMN train_external_operation.attempt_count IS '尝试/轮询次数'; +COMMENT ON COLUMN train_external_operation.next_poll_time IS '下次轮询执行时间戳'; +COMMENT ON COLUMN train_external_operation.requested_at IS '向外部发出请求的时间'; +COMMENT ON COLUMN train_external_operation.completed_at IS '操作终态达成时间'; +COMMENT ON COLUMN train_external_operation.last_error_code IS '最后一次错误编码'; +COMMENT ON COLUMN train_external_operation.last_error_message IS '最后一次错误描述'; + +-- 2.1 约束与索引:幂等键全局唯一约束 +CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_op_idempotency ON train_external_operation (idempotency_key); + +-- 2.2 约束与索引:门禁授权外键索引与外部操作ID索引 +CREATE INDEX IF NOT EXISTS idx_ext_op_door_auth ON train_external_operation (door_auth_id); +CREATE INDEX IF NOT EXISTS idx_ext_op_ext_id ON train_external_operation (vendor_code, external_operation_id); + +-- 2.3 约束与索引:待轮询操作快速检索组合索引 +CREATE INDEX IF NOT EXISTS idx_ext_op_poll ON train_external_operation (operation_status, next_poll_time); diff --git a/RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql b/RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql new file mode 100644 index 000000000..f529dbc12 --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql @@ -0,0 +1,31 @@ +-- ============================================================================== +-- 脚本名称: 009_dorm_authorization_orchestration.sql +-- 业务模块: DMS/外部物联网集成 - 宿舍门禁业务授权编排扩展模型 (R.3B Wave 3A) +-- 适用数据库: KingbaseES V8 / V9 (PostgreSQL 方言兼容) +-- 安全编号: 009 (001-003 基线, 004-005 R.2分支占用, 006 Wave0, 007 保留禁止执行, 008 Wave1) +-- 幂等性设计: 支持重复安全执行,纯增量列结构与索引,不含破坏性语句 +-- 安全红线: 严禁引入明文密码列,密码仅在内存短暂留存,严禁落库 +-- ============================================================================== + +-- 1. 扩展 train_dorm_auth 业务授权字段(增量列添加,防冲突幂等设计) + +-- 1.1 凭据类型列 (默认 ONLINE_PASSWORD,未来可扩展 QR, CARD, MOBILE_KEY) +ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS credential_type VARCHAR(32) DEFAULT 'ONLINE_PASSWORD' NOT NULL; +COMMENT ON COLUMN train_dorm_auth.credential_type IS '凭据类型 (ONLINE_PASSWORD 在线密码, QR 二维码, CARD 卡片, MOBILE_KEY 手机钥匙)'; + +-- 1.2 厂商中立业务授权状态列 (从历史 face_sync_status 进化为语义严谨的独立业务状态) +-- 状态枚举:PENDING_GRANT(待下发), GRANTING(下发中), ACTIVE(已生效), PENDING_REVOKE(待注销), REVOKING(注销中), REVOKED(已注销), ERROR(下发或注销失败) +ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS auth_status VARCHAR(32) DEFAULT 'PENDING_GRANT' NOT NULL; +COMMENT ON COLUMN train_dorm_auth.auth_status IS '业务授权状态 (PENDING_GRANT, GRANTING, ACTIVE, PENDING_REVOKE, REVOKING, REVOKED, ERROR)'; + +-- 1.3 关联外部操作流水ID (用于关联并追踪当前最新驱动的 ExternalOperation) +ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS latest_operation_id BIGINT; +COMMENT ON COLUMN train_dorm_auth.latest_operation_id IS '最近一次绑定的外部操作流水ID (关联 train_external_operation.operation_id)'; + +-- 1.4 授权操作世代/版本号 (用于防旧操作晚到结果覆盖更新状态,保证操作时序单调性) +ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS operation_version INTEGER DEFAULT 1 NOT NULL; +COMMENT ON COLUMN train_dorm_auth.operation_version IS '授权操作版本号 (乐观并发与迟到操作防覆写防护)'; + +-- 2. 索引优化 (幂等添加) +CREATE INDEX IF NOT EXISTS idx_dorm_auth_status ON train_dorm_auth (auth_status); +CREATE INDEX IF NOT EXISTS idx_dorm_auth_latest_op ON train_dorm_auth (latest_operation_id); 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/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 cca2ec8bd..3910314ac 100644 --- a/build_ui/src/layout/components/Sidebar/Logo.vue +++ b/build_ui/src/layout/components/Sidebar/Logo.vue @@ -2,11 +2,11 @@