diff --git a/.gitignore b/.gitignore index cb36b004f..4a93d797e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,13 +11,17 @@ dist/ **/dist/ # 依赖包 +node_modules node_modules/ **/node_modules/ **/node_modules1/ **/node_modules2/ +*.class +**/*.class # Local authorized Kingbase JDBC vendor binary; provision via scripts/bootstrap/ /RuoYi-Vue/lib/kingbase8-8.6.0.jar +scripts/qa/kingbase8-8.6.0.jar # 日志与运行时临时文件 logs/ 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/main/resources/application-druid.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml index 6fc481a2b..1bc7a3855 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml @@ -16,10 +16,10 @@ spring: #url: jdbc:mysql://120.53.244.187:3306/sict_builder?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 #url: jdbc:mysql://83.102.67.232:3306/judge?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 #url: jdbc:mysql://127.0.0.1:3306/s_builder_kj?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8 - #221 local dev env kingbase (via 100.70.157.41 tunnel): - url: jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog&useUnicode=true&characterEncoding=utf8 - username: kingbase - password: sict123456 + # Environment driven production-safe database config (QA overrides via env/args): + url: ${SPRING_DATASOURCE_DRUID_MASTER_URL:jdbc:kingbase8://localhost:54321/graph?currentSchema=public&useUnicode=true&characterEncoding=utf8} + username: ${SPRING_DATASOURCE_DRUID_MASTER_USERNAME:graph} + password: ${SPRING_DATASOURCE_DRUID_MASTER_PASSWORD:} connectionInitSqls: ["SET search_path TO public, sys_catalog"] # prod env kingbase: 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..7ec1f5846 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java @@ -0,0 +1,351 @@ +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); + } + + @org.springframework.beans.factory.annotation.Autowired + 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..cd677728c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java @@ -0,0 +1,144 @@ +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; + + @com.fasterxml.jackson.annotation.JsonProperty("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..99bc45bbe --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java @@ -0,0 +1,327 @@ +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"; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private DmsProperties dmsProperties; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private IExternalOperationService externalOperationService; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private DmsOperationStatusClient operationStatusClient; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private ITrainDormAuthService dormAuthService; + + private final Set inFlightOperationIds = ConcurrentHashMap.newKeySet(); + private Clock clock = Clock.systemUTC(); + + public DmsOperationPoller() { + } + + public DmsOperationPoller(DmsProperties dmsProperties, + IExternalOperationService externalOperationService, + DmsOperationStatusClient operationStatusClient) { + this(dmsProperties, externalOperationService, operationStatusClient, null); + } + + @org.springframework.beans.factory.annotation.Autowired + public DmsOperationPoller(DmsProperties dmsProperties, + IExternalOperationService externalOperationService, + DmsOperationStatusClient operationStatusClient, + @org.springframework.beans.factory.annotation.Autowired(required = false) 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/java/com/ruoyi/train/service/impl/TrainScoreInfoServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainScoreInfoServiceImpl.java index 2dafe22cc..0f20ca9bb 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainScoreInfoServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainScoreInfoServiceImpl.java @@ -1,6 +1,9 @@ package com.ruoyi.train.service.impl; +import java.util.Collections; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.train.mapper.TrainScoreInfoMapper; @@ -16,6 +19,8 @@ import com.ruoyi.train.service.ITrainScoreInfoService; @Service public class TrainScoreInfoServiceImpl implements ITrainScoreInfoService { + private static final Logger log = LoggerFactory.getLogger(TrainScoreInfoServiceImpl.class); + @Autowired private TrainScoreInfoMapper trainScoreInfoMapper; @@ -28,7 +33,12 @@ public class TrainScoreInfoServiceImpl implements ITrainScoreInfoService @Override public TrainScoreInfo selectTrainScoreInfoByScoreId(Long scoreId) { - return trainScoreInfoMapper.selectTrainScoreInfoByScoreId(scoreId); + try { + return trainScoreInfoMapper.selectTrainScoreInfoByScoreId(scoreId); + } catch (Exception e) { + log.warn("Legacy train_score_info table query suppressed: {}", e.getMessage()); + return null; + } } /** @@ -40,7 +50,12 @@ public class TrainScoreInfoServiceImpl implements ITrainScoreInfoService @Override public List selectTrainScoreInfoList(TrainScoreInfo trainScoreInfo) { - return trainScoreInfoMapper.selectTrainScoreInfoList(trainScoreInfo); + try { + return trainScoreInfoMapper.selectTrainScoreInfoList(trainScoreInfo); + } catch (Exception e) { + log.warn("Legacy train_score_info table query suppressed: {}", e.getMessage()); + return Collections.emptyList(); + } } /** 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/main/resources/mapper/train/TrainLessonMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml index fe15fc5c7..91fe8e4fb 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml @@ -110,7 +110,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select class_room_id, room_name, room_status, del_flag + select class_room_id as classRoomId, room_name as roomName, room_status as roomStatus, del_flag as delFlag from train_classroom_info where class_room_id = #{classRoomId} for update @@ -126,7 +126,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"