合并门禁:将DMS真实接口探测结果集成至质量验证基线
This commit is contained in:
commit
758fd79e16
139 files changed
+18058
-483
No files matched your search
+245
@@ -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)
|
||||
* <p>
|
||||
* 安全红线:
|
||||
* 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<TrainExternalResourceMapping> 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<String, Object> 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<TrainDormAuth> 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<TrainExternalOperation> operations = externalOperationService.listOperationsByDoorAuthId(doorAuthId);
|
||||
List<Map<String, Object>> sanitizedList = new ArrayList<>();
|
||||
if (operations != null)
|
||||
{
|
||||
for (TrainExternalOperation op : operations)
|
||||
{
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员手动触发参训学员门禁权限对账
|
||||
* <p>
|
||||
* 严格遵循相同前置条件校验规则:未报到、未分宿、缺映射或门锁配置异常均正常返回 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -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<TrainAccommodation> 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-4
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -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<TrainAccommodationController> 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<TrainDormAuthController> 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());
|
||||
}
|
||||
}
|
||||
+87
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
+56
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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"));
|
||||
}
|
||||
}
|
||||
+118
@@ -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"));
|
||||
}
|
||||
}
|
||||
+269
@@ -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<TrainExternalResourceMapping> mappingDb;
|
||||
private List<TrainDormAuth> dormAuthDb;
|
||||
private List<TrainExternalOperation> 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<TrainExternalResourceMapping> 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<TrainExternalOperation> 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<String, Object> data1 = (Map<String, Object>) 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<String, Object> data2 = (Map<String, Object>) 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<String, Object> data3 = (Map<String, Object>) 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<Map<String, Object>> list = (List<Map<String, Object>>) r.get("data");
|
||||
assertEquals(1, list.size());
|
||||
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+299
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+209
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+252
@@ -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 访问令牌集中管理器
|
||||
* <p>
|
||||
* 核心并发架构与安全保证:
|
||||
* 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<TokenEntry> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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 智能门锁适配客户端
|
||||
* <p>
|
||||
* 遵循 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<DmsDoorLockDto> listDoorLocksByPlaceId(Long placeId) {
|
||||
if (placeId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, Object> 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();
|
||||
}
|
||||
}
|
||||
+224
@@ -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 客户端
|
||||
* <p>
|
||||
* 核心设计准则:
|
||||
* 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> T postQuery(String path, Object requestBody, Class<T> responseClass) {
|
||||
return executePost(path, requestBody, responseClass, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 写操作请求 (涉及设备凭据变更,isMutation = true,读取超时绝不自动重试)
|
||||
*/
|
||||
public <T> T postMutation(String path, Object requestBody, Class<T> responseClass) {
|
||||
return executePost(path, requestBody, responseClass, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求并反序列化指定对象
|
||||
*/
|
||||
public <T> T post(String path, Object requestBody, Class<T> 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> T executePost(String path, Object requestBody, Class<T> 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");
|
||||
}
|
||||
}
|
||||
+140
@@ -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<DmsBuildingDto> listBuildings(Long projectId) {
|
||||
Map<String, Object> 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<DmsFloorDto> listFloors(Long buildingId) {
|
||||
Map<String, Object> 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<DmsRoomDto> listRooms(Long floorId) {
|
||||
Map<String, Object> 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<DmsRoomDto> listRooms(String building, String floor, String room, Integer offset, Integer limit) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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 <T> List<T> extractList(JSONObject resp, Class<T> 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();
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+105
@@ -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)
|
||||
* <p>
|
||||
* 核心设计准则:
|
||||
* 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+194
@@ -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;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DMS 响应包装信封
|
||||
*/
|
||||
public class DmsBaseResponse<T> implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Boolean success;
|
||||
private String code;
|
||||
private String message;
|
||||
private T data;
|
||||
private List<T> 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<T> getRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
public void setRows(List<T> 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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+57
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+93
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+57
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+67
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+70
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+116
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+72
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+37
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+30
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+83
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+45
@@ -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]'" +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+41
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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<DmsDoorLockDto> listDoorLocksByPlaceId(Long placeId) {
|
||||
return doorLockClient.listDoorLocksByPlaceId(placeId);
|
||||
}
|
||||
}
|
||||
+42
@@ -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<DmsBuildingDto> listBuildings(Long projectId) {
|
||||
return locationClient.listBuildings(projectId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DmsFloorDto> listFloors(Long buildingId) {
|
||||
return locationClient.listFloors(buildingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DmsRoomDto> listRooms(Long floorId) {
|
||||
return locationClient.listRooms(floorId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DmsRoomDto getRoom(Long roomId) {
|
||||
return locationClient.getRoom(roomId);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
|
||||
/**
|
||||
* DMS 外部操作状态集中映射器
|
||||
* <p>
|
||||
* 将 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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+13
@@ -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<DmsDoorLockDto> listDoorLocksByPlaceId(Long placeId);
|
||||
}
|
||||
+21
@@ -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<DmsBuildingDto> listBuildings(Long projectId);
|
||||
|
||||
List<DmsFloorDto> listFloors(Long buildingId);
|
||||
|
||||
List<DmsRoomDto> listRooms(Long floorId);
|
||||
|
||||
DmsRoomDto getRoom(Long roomId);
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
|
||||
import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.IExternalResourceMappingService;
|
||||
import com.ruoyi.train.service.ITrainDormAuthService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 宿舍门禁业务授权统一编排器 (Wave 3A)
|
||||
* <p>
|
||||
* 核心架构与安全准则:
|
||||
* 1. 状态驱动对账 (Reconciliation Model):业务事实 → 期望状态 → 外部操作,与触发顺序完全解耦。
|
||||
* 2. 最小特权主锁策略 (PRIMARY_LOCK_ONLY):房间内仅对且必须对唯一定义的 1 把主门锁下发凭据。
|
||||
* 3. 调房安全策略 (GRANT_NEW_THEN_REVOKE_OLD):先下发新房凭据,新房对账生效后再撤销旧房;新房失败严格保留旧房。
|
||||
* 4. 事务边界清晰 (LONG_DB_TRANSACTION_AROUND_HTTP = NO):严禁长事务包住远程 HTTP 调用,外部调用异常绝不回滚业务主事务。
|
||||
* 5. 密码安全与短暂留存:SecureRandom 强随机生成,明文仅在内存局部变量留存,严禁落库、严禁日志输出。
|
||||
* 6. 操作幂等与防重:业务授权唯一性 + 外部操作幂等键双重防护,重复触发无副作用。
|
||||
*/
|
||||
@Service
|
||||
public class AccessAuthorizationOrchestrator {
|
||||
private static final Logger log = LoggerFactory.getLogger(AccessAuthorizationOrchestrator.class);
|
||||
|
||||
public static final String VENDOR_DMS = "DMS";
|
||||
public static final String POLICY_PRIMARY_LOCK_ONLY = "PRIMARY_LOCK_ONLY";
|
||||
public static final String VALIDITY_WINDOW_SOURCE = "BUSINESS_POLICY_REQUIRED";
|
||||
|
||||
private final TrainEnrollmentMapper enrollmentMapper;
|
||||
private final TrainAccommodationMapper accommodationMapper;
|
||||
private final TrainDormAuthMapper dormAuthMapper;
|
||||
private final ITrainDormAuthService dormAuthService;
|
||||
private final IExternalResourceMappingService resourceMappingService;
|
||||
private final IExternalOperationService operationService;
|
||||
private final ExternalCredentialGateway credentialGateway;
|
||||
|
||||
protected AccessAuthorizationOrchestrator() {
|
||||
this(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public AccessAuthorizationOrchestrator(TrainEnrollmentMapper enrollmentMapper,
|
||||
TrainAccommodationMapper accommodationMapper,
|
||||
TrainDormAuthMapper dormAuthMapper,
|
||||
ITrainDormAuthService dormAuthService,
|
||||
IExternalResourceMappingService resourceMappingService,
|
||||
IExternalOperationService operationService,
|
||||
ExternalCredentialGateway credentialGateway) {
|
||||
this.enrollmentMapper = enrollmentMapper;
|
||||
this.accommodationMapper = accommodationMapper;
|
||||
this.dormAuthMapper = dormAuthMapper;
|
||||
this.dormAuthService = dormAuthService;
|
||||
this.resourceMappingService = resourceMappingService;
|
||||
this.operationService = operationService;
|
||||
this.credentialGateway = credentialGateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心对账入口:检查指定参训学员当前业务事实,并执行状态调谐
|
||||
*
|
||||
* @param enrollmentId 参训档案ID
|
||||
* @return 对账结果
|
||||
*/
|
||||
public ReconciliationResult reconcile(Long enrollmentId) {
|
||||
if (enrollmentId == null) {
|
||||
return ReconciliationResult.waiting("Missing enrollmentId");
|
||||
}
|
||||
|
||||
TrainEnrollment enrollment = enrollmentMapper.selectTrainEnrollmentById(enrollmentId);
|
||||
if (enrollment == null) {
|
||||
return ReconciliationResult.waiting("Enrollment not found: " + enrollmentId);
|
||||
}
|
||||
|
||||
Integer enrollmentStatus = enrollment.getEnrollmentStatus();
|
||||
TrainAccommodation activeAcc = accommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId);
|
||||
List<TrainAccommodation> 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<TrainAccommodation> 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<TrainAccommodation> 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<TrainExternalResourceMapping> lockMappings = resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId);
|
||||
if (lockMappings == null || lockMappings.isEmpty()) {
|
||||
return ReconciliationResult.waiting("No door locks mapped for dormId " + dormId);
|
||||
}
|
||||
|
||||
List<TrainExternalResourceMapping> 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<TrainAccommodation> 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]");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
/**
|
||||
* 门禁权限对账决策枚举 (厂商中立)
|
||||
*/
|
||||
public enum ReconciliationDecision {
|
||||
/**
|
||||
* 无需操作 (当前状态已与业务事实对齐)
|
||||
*/
|
||||
NO_ACTION,
|
||||
|
||||
/**
|
||||
* 需要发放权限 (满足所有前置业务条件,待下发凭据)
|
||||
*/
|
||||
GRANT_REQUIRED,
|
||||
|
||||
/**
|
||||
* 需要注销权限 (学员退训/结训或调房退宿,需收回凭据)
|
||||
*/
|
||||
REVOKE_REQUIRED,
|
||||
|
||||
/**
|
||||
* 等待前置业务条件 (未报到、未分房、无门锁映射等)
|
||||
*/
|
||||
WAITING_FOR_PREREQUISITE
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 门禁权限对账结果 DTO
|
||||
*/
|
||||
public class ReconciliationResult implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long enrollmentId;
|
||||
private ReconciliationDecision decision;
|
||||
private String reason;
|
||||
private Long doorAuthId;
|
||||
private Long operationId;
|
||||
private boolean externalInvoked;
|
||||
private boolean success;
|
||||
|
||||
public ReconciliationResult() {
|
||||
}
|
||||
|
||||
public static ReconciliationResult noAction(String reason) {
|
||||
ReconciliationResult res = new ReconciliationResult();
|
||||
res.setDecision(ReconciliationDecision.NO_ACTION);
|
||||
res.setReason(reason);
|
||||
res.setSuccess(true);
|
||||
return res;
|
||||
}
|
||||
|
||||
public static ReconciliationResult waiting(String reason) {
|
||||
ReconciliationResult res = new ReconciliationResult();
|
||||
res.setDecision(ReconciliationDecision.WAITING_FOR_PREREQUISITE);
|
||||
res.setReason(reason);
|
||||
res.setSuccess(true);
|
||||
return res;
|
||||
}
|
||||
|
||||
public static ReconciliationResult grantRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) {
|
||||
ReconciliationResult res = new ReconciliationResult();
|
||||
res.setDecision(ReconciliationDecision.GRANT_REQUIRED);
|
||||
res.setDoorAuthId(doorAuthId);
|
||||
res.setOperationId(operationId);
|
||||
res.setExternalInvoked(externalInvoked);
|
||||
res.setSuccess(success);
|
||||
res.setReason(reason);
|
||||
return res;
|
||||
}
|
||||
|
||||
public static ReconciliationResult revokeRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) {
|
||||
ReconciliationResult res = new ReconciliationResult();
|
||||
res.setDecision(ReconciliationDecision.REVOKE_REQUIRED);
|
||||
res.setDoorAuthId(doorAuthId);
|
||||
res.setOperationId(operationId);
|
||||
res.setExternalInvoked(externalInvoked);
|
||||
res.setSuccess(success);
|
||||
res.setReason(reason);
|
||||
return res;
|
||||
}
|
||||
|
||||
public Long getEnrollmentId() {
|
||||
return enrollmentId;
|
||||
}
|
||||
|
||||
public void setEnrollmentId(Long enrollmentId) {
|
||||
this.enrollmentId = enrollmentId;
|
||||
}
|
||||
|
||||
public ReconciliationDecision getDecision() {
|
||||
return decision;
|
||||
}
|
||||
|
||||
public void setDecision(ReconciliationDecision decision) {
|
||||
this.decision = decision;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Long getDoorAuthId() {
|
||||
return doorAuthId;
|
||||
}
|
||||
|
||||
public void setDoorAuthId(Long doorAuthId) {
|
||||
this.doorAuthId = doorAuthId;
|
||||
}
|
||||
|
||||
public Long getOperationId() {
|
||||
return operationId;
|
||||
}
|
||||
|
||||
public void setOperationId(Long operationId) {
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
public boolean isExternalInvoked() {
|
||||
return externalInvoked;
|
||||
}
|
||||
|
||||
public void setExternalInvoked(boolean externalInvoked) {
|
||||
this.externalInvoked = externalInvoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时管理端一次性明文展示凭据 (ADMIN_ONE_TIME_DISPLAY 策略)
|
||||
* 声明为 transient,仅在单次创建成功内存调用栈短暂留存,严禁落库、严禁序列化传输至非授权端、严禁日志输出
|
||||
*/
|
||||
private transient String oneTimePlaintextCredential;
|
||||
|
||||
public String getOneTimePlaintextCredential() {
|
||||
return oneTimePlaintextCredential;
|
||||
}
|
||||
|
||||
public void setOneTimePlaintextCredential(String oneTimePlaintextCredential) {
|
||||
this.oneTimePlaintextCredential = oneTimePlaintextCredential;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public void setSuccess(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ReconciliationResult{" +
|
||||
"enrollmentId=" + enrollmentId +
|
||||
", decision=" + decision +
|
||||
", reason='" + reason + '\'' +
|
||||
", doorAuthId=" + doorAuthId +
|
||||
", operationId=" + operationId +
|
||||
", externalInvoked=" + externalInvoked +
|
||||
", success=" + success +
|
||||
", oneTimeCredential=" + (oneTimePlaintextCredential != null ? "[PROTECTED]" : "null") +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
package com.ruoyi.train.integration.dms.poller;
|
||||
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsAuthenticationException;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsOperationStatusMapper;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.ITrainDormAuthService;
|
||||
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* DMS 异步设备操作结果轮询对账调度器
|
||||
* <p>
|
||||
* 核心设计准则:
|
||||
* 1. 条件化启动:仅当 dms.enabled=true 时加载并运行,安全默认关闭。
|
||||
* 2. 初始延迟与间隔:遵循 DMS 官方 10 秒后对账建议,非阻塞。
|
||||
* 3. 终态不可逆:SUCCESS, FAILED, TIMEOUT 为终态,绝不覆写或回退。
|
||||
* 4. 进程内单飞防并发:使用 ConcurrentHashMap.newKeySet() 杜绝同一操作并发重复轮询。
|
||||
* 5. 错误分类与脱敏持久化:区分 AUTH_ERROR, NETWORK_ERROR, VENDOR_BUSINESS_ERROR, DEVICE_FAILED, DEVICE_TIMEOUT。
|
||||
* 6. 支持注入 Clock 实现测试精确控制。
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "dms", name = "enabled", havingValue = "true")
|
||||
public class DmsOperationPoller {
|
||||
private static final Logger log = LoggerFactory.getLogger(DmsOperationPoller.class);
|
||||
|
||||
public static final String ERROR_CATEGORY_AUTH = "AUTH_ERROR";
|
||||
public static final String ERROR_CATEGORY_NETWORK = "NETWORK_ERROR";
|
||||
public static final String ERROR_CATEGORY_VENDOR_BIZ = "VENDOR_BUSINESS_ERROR";
|
||||
public static final String ERROR_CATEGORY_DEVICE_FAILED = "DEVICE_FAILED";
|
||||
public static final String ERROR_CATEGORY_DEVICE_TIMEOUT = "DEVICE_TIMEOUT";
|
||||
public static final String ERROR_CATEGORY_UNKNOWN_STATUS = "UNKNOWN_STATUS";
|
||||
|
||||
private final DmsProperties dmsProperties;
|
||||
private final IExternalOperationService externalOperationService;
|
||||
private final DmsOperationStatusClient operationStatusClient;
|
||||
private final ITrainDormAuthService dormAuthService;
|
||||
|
||||
private final Set<Long> inFlightOperationIds = ConcurrentHashMap.newKeySet();
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
public DmsOperationPoller(DmsProperties dmsProperties,
|
||||
IExternalOperationService externalOperationService,
|
||||
DmsOperationStatusClient operationStatusClient) {
|
||||
this(dmsProperties, externalOperationService, operationStatusClient, null);
|
||||
}
|
||||
|
||||
public DmsOperationPoller(DmsProperties dmsProperties,
|
||||
IExternalOperationService externalOperationService,
|
||||
DmsOperationStatusClient operationStatusClient,
|
||||
ITrainDormAuthService dormAuthService) {
|
||||
this.dmsProperties = dmsProperties;
|
||||
this.externalOperationService = externalOperationService;
|
||||
this.operationStatusClient = operationStatusClient;
|
||||
this.dormAuthService = dormAuthService;
|
||||
}
|
||||
|
||||
public void setClock(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Clock getClock() {
|
||||
return this.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时调度执行入口 (每 10 秒执行一次,初始延迟 10 秒)
|
||||
*/
|
||||
@Scheduled(initialDelayString = "${dms.poll-initial-delay-seconds:10}000",
|
||||
fixedDelayString = "${dms.poll-interval-seconds:10}000")
|
||||
public void scheduledPoll() {
|
||||
if (!dmsProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
pollPendingOperations();
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心批量轮询处理方法,返回本次成功处理或更新的记录数
|
||||
*/
|
||||
public int pollPendingOperations() {
|
||||
Instant now = clock.instant();
|
||||
Date threshold = Date.from(now);
|
||||
List<TrainExternalOperation> 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");
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.ruoyi.train.integration.dms.security;
|
||||
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
/**
|
||||
* 安全随机门禁凭据密码生成器
|
||||
* <p>
|
||||
* 严格安全准则:
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+117
@@ -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<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId);
|
||||
|
||||
/**
|
||||
* 查询住宿安排列表
|
||||
*
|
||||
* @param accommodation 住宿安排查询条件
|
||||
* @return 住宿安排集合
|
||||
*/
|
||||
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation);
|
||||
|
||||
/**
|
||||
* 根据开班计划ID查询该班级下所有有效住宿安排 (准备用于结课批量撤权等)
|
||||
*
|
||||
* @param planId 开班计划ID
|
||||
* @return 住宿安排集合
|
||||
*/
|
||||
public List<TrainAccommodation> 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);
|
||||
}
|
||||
+68
-28
@@ -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<TrainDormAuth> 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);
|
||||
}
|
||||
+103
@@ -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<TrainExternalOperation> 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<TrainExternalOperation> selectPendingPollOperations(
|
||||
@Param("vendorCode") String vendorCode,
|
||||
@Param("maxPollTime") Date maxPollTime);
|
||||
|
||||
/**
|
||||
* 查询外部操作流水列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 操作列表
|
||||
*/
|
||||
public List<TrainExternalOperation> 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);
|
||||
}
|
||||
+96
@@ -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<TrainExternalResourceMapping> 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<TrainExternalResourceMapping> 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);
|
||||
}
|
||||
+131
@@ -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<TrainExternalOperation> 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<TrainExternalOperation> 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);
|
||||
}
|
||||
+91
@@ -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<TrainExternalResourceMapping> getActiveLockMappings(String vendorCode, String dormId);
|
||||
|
||||
/**
|
||||
* 条件查询外部资源映射列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 映射列表
|
||||
*/
|
||||
public List<TrainExternalResourceMapping> 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);
|
||||
}
|
||||
+122
@@ -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<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId);
|
||||
|
||||
/**
|
||||
* 查询住宿安排列表
|
||||
*
|
||||
* @param accommodation 住宿安排过滤条件
|
||||
* @return 住宿安排集合
|
||||
*/
|
||||
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation);
|
||||
|
||||
/**
|
||||
* 查询指定开班计划下所有有效住宿安排
|
||||
*
|
||||
* @param planId 开班计划ID
|
||||
* @return 有效住宿列表
|
||||
*/
|
||||
public List<TrainAccommodation> 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);
|
||||
}
|
||||
+84
-24
@@ -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<TrainDormAuth> 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);
|
||||
}
|
||||
+31
-1
@@ -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<EnrollmentItemVO> 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);
|
||||
|
||||
+40
@@ -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<EnrollmentItemVO> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
@@ -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<String> VALID_TYPES = new HashSet<>(Arrays.asList(
|
||||
"GRANT", "REVOKE", "STATUS_QUERY", "REMOTE_UNLOCK"
|
||||
));
|
||||
|
||||
private static final Set<String> 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<TrainExternalOperation> 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<TrainExternalOperation> 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;
|
||||
}
|
||||
}
|
||||
+214
@@ -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<TrainExternalResourceMapping> getActiveLockMappings(String vendorCode, String dormId)
|
||||
{
|
||||
if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId))
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return mappingMapper.selectActiveLockMappings(vendorCode, dormId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainExternalResourceMapping> 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<TrainExternalResourceMapping> 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() + "],禁止跨房间重复绑定");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -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<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
return trainAccommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询住宿安排列表
|
||||
*
|
||||
* @param accommodation 住宿安排
|
||||
* @return 住宿安排
|
||||
*/
|
||||
@Override
|
||||
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
|
||||
{
|
||||
return trainAccommodationMapper.selectTrainAccommodationList(accommodation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定开班计划下所有有效住宿安排
|
||||
*
|
||||
* @param planId 开班计划ID
|
||||
* @return 有效住宿列表
|
||||
*/
|
||||
@Override
|
||||
public List<TrainAccommodation> 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);
|
||||
}
|
||||
}
|
||||
+229
-27
@@ -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<TrainDormAuth> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainAccommodationMapper">
|
||||
|
||||
<resultMap type="TrainAccommodation" id="TrainAccommodationResult">
|
||||
<result property="accommodationId" column="accommodation_id" />
|
||||
<result property="enrollmentId" column="enrollment_id" />
|
||||
<result property="dormId" column="dorm_id" />
|
||||
<result property="bedNo" column="bed_no" />
|
||||
<result property="assignedTime" column="assigned_time" />
|
||||
<result property="accommodationStatus" column="accommodation_status" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="studentId" column="student_id" />
|
||||
<result property="studentName" column="student_name" />
|
||||
<result property="gender" column="gender" />
|
||||
<result property="orgName" column="org_name" />
|
||||
<result property="phone" column="phone" />
|
||||
<result property="planId" column="plan_id" />
|
||||
<result property="planName" column="plan_name" />
|
||||
<result property="dormBuilding" column="dorm_building" />
|
||||
<result property="dormRoom" column="dorm_room" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainAccommodationVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<select id="selectTrainAccommodationList" parameterType="TrainAccommodation" resultMap="TrainAccommodationResult">
|
||||
<include refid="selectTrainAccommodationVo"/>
|
||||
<where>
|
||||
<if test="accommodationId != null">
|
||||
AND a.accommodation_id = #{accommodationId}
|
||||
</if>
|
||||
<if test="enrollmentId != null">
|
||||
AND a.enrollment_id = #{enrollmentId}
|
||||
</if>
|
||||
<if test="dormId != null and dormId != ''">
|
||||
AND a.dorm_id = #{dormId}
|
||||
</if>
|
||||
<if test="bedNo != null and bedNo != ''">
|
||||
AND a.bed_no LIKE concat('%', #{bedNo}, '%')
|
||||
</if>
|
||||
<if test="accommodationStatus != null">
|
||||
AND a.accommodation_status = #{accommodationStatus}
|
||||
</if>
|
||||
<if test="planId != null and planId != ''">
|
||||
AND e.plan_id = #{planId}
|
||||
</if>
|
||||
<if test="studentId != null and studentId != ''">
|
||||
AND e.student_id = #{studentId}
|
||||
</if>
|
||||
<if test="dormBuilding != null and dormBuilding != ''">
|
||||
AND d.dorm_building LIKE concat('%', #{dormBuilding}, '%')
|
||||
</if>
|
||||
<if test="dormRoom != null and dormRoom != ''">
|
||||
AND d.dorm_room LIKE concat('%', #{dormRoom}, '%')
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY a.assigned_time DESC
|
||||
</select>
|
||||
|
||||
<select id="selectTrainAccommodationByAccommodationId" parameterType="Long" resultMap="TrainAccommodationResult">
|
||||
<include refid="selectTrainAccommodationVo"/>
|
||||
WHERE a.accommodation_id = #{accommodationId}
|
||||
</select>
|
||||
|
||||
<select id="selectTrainAccommodationByEnrollmentId" parameterType="Long" resultMap="TrainAccommodationResult">
|
||||
<include refid="selectTrainAccommodationVo"/>
|
||||
WHERE a.enrollment_id = #{enrollmentId}
|
||||
ORDER BY a.assigned_time DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveAccommodationByEnrollmentId" parameterType="Long" resultMap="TrainAccommodationResult">
|
||||
<include refid="selectTrainAccommodationVo"/>
|
||||
WHERE a.enrollment_id = #{enrollmentId}
|
||||
AND a.accommodation_status IN (0, 1)
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectAccommodationHistoryByEnrollmentId" parameterType="Long" resultMap="TrainAccommodationResult">
|
||||
<include refid="selectTrainAccommodationVo"/>
|
||||
WHERE a.enrollment_id = #{enrollmentId}
|
||||
ORDER BY a.assigned_time DESC, a.accommodation_id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectActiveAccommodationsByPlanId" parameterType="String" resultMap="TrainAccommodationResult">
|
||||
<include refid="selectTrainAccommodationVo"/>
|
||||
WHERE e.plan_id = #{planId}
|
||||
AND a.accommodation_status IN (0, 1)
|
||||
ORDER BY a.assigned_time ASC
|
||||
</select>
|
||||
|
||||
<select id="countActiveByDormId" parameterType="String" resultType="int">
|
||||
SELECT COUNT(1)
|
||||
FROM train_accommodation
|
||||
WHERE dorm_id = #{dormId}
|
||||
AND accommodation_status IN (0, 1)
|
||||
</select>
|
||||
|
||||
<insert id="insertTrainAccommodation" parameterType="TrainAccommodation" useGeneratedKeys="true" keyProperty="accommodationId">
|
||||
INSERT INTO train_accommodation
|
||||
<trim prefix="(" suffix=")" suffixOverrides=",">
|
||||
<if test="enrollmentId != null">enrollment_id,</if>
|
||||
<if test="dormId != null and dormId != ''">dorm_id,</if>
|
||||
<if test="bedNo != null and bedNo != ''">bed_no,</if>
|
||||
<if test="assignedTime != null">assigned_time,</if>
|
||||
<if test="accommodationStatus != null">accommodation_status,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
<if test="createTime != null">create_time,</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by,</if>
|
||||
<if test="updateTime != null">update_time,</if>
|
||||
<if test="remark != null and remark != ''">remark,</if>
|
||||
</trim>
|
||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||
<if test="enrollmentId != null">#{enrollmentId},</if>
|
||||
<if test="dormId != null and dormId != ''">#{dormId},</if>
|
||||
<if test="bedNo != null and bedNo != ''">#{bedNo},</if>
|
||||
<if test="assignedTime != null">#{assignedTime},</if>
|
||||
<if test="accommodationStatus != null">#{accommodationStatus},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
<if test="createTime != null">#{createTime},</if>
|
||||
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
|
||||
<if test="updateTime != null">#{updateTime},</if>
|
||||
<if test="remark != null and remark != ''">#{remark},</if>
|
||||
</trim>
|
||||
</insert>
|
||||
|
||||
<update id="updateTrainAccommodation" parameterType="TrainAccommodation">
|
||||
UPDATE train_accommodation
|
||||
<trim prefix="SET" suffixOverrides=",">
|
||||
<if test="dormId != null and dormId != ''">dorm_id = #{dormId},</if>
|
||||
<if test="bedNo != null and bedNo != ''">bed_no = #{bedNo},</if>
|
||||
<if test="assignedTime != null">assigned_time = #{assignedTime},</if>
|
||||
<if test="accommodationStatus != null">accommodation_status = #{accommodationStatus},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="updateTime != null">update_time = #{updateTime},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
</trim>
|
||||
WHERE accommodation_id = #{accommodationId}
|
||||
</update>
|
||||
|
||||
<update id="updateAccommodationStatus">
|
||||
UPDATE train_accommodation
|
||||
SET accommodation_status = #{accommodationStatus},
|
||||
update_by = #{updateBy},
|
||||
update_time = #{updateTime}
|
||||
WHERE accommodation_id = #{accommodationId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteTrainAccommodationByAccommodationId" parameterType="Long">
|
||||
DELETE FROM train_accommodation WHERE accommodation_id = #{accommodationId}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteTrainAccommodationByAccommodationIds" parameterType="Long">
|
||||
DELETE FROM train_accommodation WHERE accommodation_id IN
|
||||
<foreach item="accommodationId" collection="array" open="(" separator="," close=")">
|
||||
#{accommodationId}
|
||||
</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
@@ -3,101 +3,222 @@
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainDormAuthMapper">
|
||||
|
||||
|
||||
<resultMap type="TrainDormAuth" id="TrainDormAuthResult">
|
||||
<result property="authId" column="auth_id" />
|
||||
<result property="studentId" column="student_id" />
|
||||
<result property="planId" column="plan_id" />
|
||||
<result property="dormId" column="dorm_id" />
|
||||
<result property="bedNo" column="bed_no" />
|
||||
<result property="authId" column="auth_id" />
|
||||
<result property="accommodationId" column="accommodation_id" />
|
||||
<result property="authStart" column="auth_start" />
|
||||
<result property="authEnd" column="auth_end" />
|
||||
<result property="faceSyncStatus" column="face_sync_status" />
|
||||
<result property="authStart" column="auth_start" />
|
||||
<result property="authEnd" column="auth_end" />
|
||||
<result property="createUser" column="create_user" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="retryCount" column="retry_count" />
|
||||
<result property="lastError" column="last_error" />
|
||||
<result property="externalAuthId" column="external_auth_id" />
|
||||
<result property="lastSyncTime" column="last_sync_time" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="studentId" column="student_id" />
|
||||
<result property="studentName" column="student_name" />
|
||||
<result property="planId" column="plan_id" />
|
||||
<result property="planName" column="plan_name" />
|
||||
<result property="dormId" column="dorm_id" />
|
||||
<result property="dormBuilding" column="dorm_building" />
|
||||
<result property="dormRoom" column="dorm_room" />
|
||||
<result property="bedNo" column="bed_no" />
|
||||
<result property="createUser" column="create_by" />
|
||||
<result property="authStatus" column="auth_status" />
|
||||
<result property="credentialType" column="credential_type" />
|
||||
<result property="latestOperationId" column="latest_operation_id" />
|
||||
<result property="operationVersion" column="operation_version" />
|
||||
<result property="enrollmentId" column="enrollment_id" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainDormAuthVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<select id="selectTrainDormAuthList" parameterType="TrainDormAuth" resultMap="TrainDormAuthResult">
|
||||
<include refid="selectTrainDormAuthVo"/>
|
||||
<where>
|
||||
<if test="studentId != null and studentId != ''"> and student_id = #{studentId}</if>
|
||||
<if test="planId != null and planId != ''"> and plan_id = #{planId}</if>
|
||||
<if test="dormId != null and dormId != ''"> and dorm_id = #{dormId}</if>
|
||||
<if test="bedNo != null and bedNo != ''"> and bed_no = #{bedNo}</if>
|
||||
<if test="faceSyncStatus != null "> and face_sync_status = #{faceSyncStatus}</if>
|
||||
<if test="authStart != null "> and auth_start = #{authStart}</if>
|
||||
<if test="authEnd != null "> and auth_end = #{authEnd}</if>
|
||||
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
|
||||
<where>
|
||||
<if test="authId != null">
|
||||
AND a.auth_id = #{authId}
|
||||
</if>
|
||||
<if test="accommodationId != null">
|
||||
AND a.accommodation_id = #{accommodationId}
|
||||
</if>
|
||||
<if test="faceSyncStatus != null">
|
||||
AND a.face_sync_status = #{faceSyncStatus}
|
||||
</if>
|
||||
<if test="authStart != null">
|
||||
AND a.auth_start >= #{authStart}
|
||||
</if>
|
||||
<if test="authEnd != null">
|
||||
AND a.auth_end <= #{authEnd}
|
||||
</if>
|
||||
<if test="studentId != null and studentId != ''">
|
||||
AND e.student_id = #{studentId}
|
||||
</if>
|
||||
<if test="planId != null and planId != ''">
|
||||
AND e.plan_id = #{planId}
|
||||
</if>
|
||||
<if test="dormId != null and dormId != ''">
|
||||
AND acc.dorm_id = #{dormId}
|
||||
</if>
|
||||
<if test="bedNo != null and bedNo != ''">
|
||||
AND acc.bed_no = #{bedNo}
|
||||
</if>
|
||||
<if test="createBy != null and createBy != ''">
|
||||
AND a.create_by = #{createBy}
|
||||
</if>
|
||||
<if test="createUser != null and createUser != ''">
|
||||
AND a.create_by = #{createUser}
|
||||
</if>
|
||||
<if test="authStatus != null and authStatus != ''">
|
||||
AND a.auth_status = #{authStatus}
|
||||
</if>
|
||||
<if test="enrollmentId != null">
|
||||
AND acc.enrollment_id = #{enrollmentId}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY a.create_time DESC
|
||||
</select>
|
||||
|
||||
|
||||
<select id="selectTrainDormAuthByAuthId" parameterType="Long" resultMap="TrainDormAuthResult">
|
||||
<include refid="selectTrainDormAuthVo"/>
|
||||
where auth_id = #{authId}
|
||||
WHERE a.auth_id = #{authId}
|
||||
</select>
|
||||
|
||||
<select id="selectTrainDormAuthByAccommodationId" parameterType="Long" resultMap="TrainDormAuthResult">
|
||||
<include refid="selectTrainDormAuthVo"/>
|
||||
WHERE a.accommodation_id = #{accommodationId}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertTrainDormAuth" parameterType="TrainDormAuth" useGeneratedKeys="true" keyProperty="authId">
|
||||
insert into train_dorm_auth
|
||||
INSERT INTO train_dorm_auth
|
||||
<trim prefix="(" suffix=")" suffixOverrides=",">
|
||||
<if test="studentId != null and studentId != ''">student_id,</if>
|
||||
<if test="planId != null and planId != ''">plan_id,</if>
|
||||
<if test="dormId != null and dormId != ''">dorm_id,</if>
|
||||
<if test="bedNo != null">bed_no,</if>
|
||||
<if test="faceSyncStatus != null">face_sync_status,</if>
|
||||
<if test="accommodationId != null">accommodation_id,</if>
|
||||
<if test="authStatus != null and authStatus != ''">auth_status,</if>
|
||||
<if test="credentialType != null and credentialType != ''">credential_type,</if>
|
||||
<if test="latestOperationId != null">latest_operation_id,</if>
|
||||
<if test="operationVersion != null">operation_version,</if>
|
||||
<if test="authStart != null">auth_start,</if>
|
||||
<if test="authEnd != null">auth_end,</if>
|
||||
<if test="createUser != null and createUser != ''">create_user,</if>
|
||||
<if test="faceSyncStatus != null">face_sync_status,</if>
|
||||
<if test="retryCount != null">retry_count,</if>
|
||||
<if test="lastError != null and lastError != ''">last_error,</if>
|
||||
<if test="externalAuthId != null and externalAuthId != ''">external_auth_id,</if>
|
||||
<if test="lastSyncTime != null">last_sync_time,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
<if test="createTime != null">create_time,</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by,</if>
|
||||
<if test="updateTime != null">update_time,</if>
|
||||
<if test="remark != null">remark,</if>
|
||||
</trim>
|
||||
<if test="remark != null and remark != ''">remark,</if>
|
||||
</trim>
|
||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||
<if test="studentId != null and studentId != ''">#{studentId},</if>
|
||||
<if test="planId != null and planId != ''">#{planId},</if>
|
||||
<if test="dormId != null and dormId != ''">#{dormId},</if>
|
||||
<if test="bedNo != null">#{bedNo},</if>
|
||||
<if test="faceSyncStatus != null">#{faceSyncStatus},</if>
|
||||
<if test="accommodationId != null">#{accommodationId},</if>
|
||||
<if test="authStatus != null and authStatus != ''">#{authStatus},</if>
|
||||
<if test="credentialType != null and credentialType != ''">#{credentialType},</if>
|
||||
<if test="latestOperationId != null">#{latestOperationId},</if>
|
||||
<if test="operationVersion != null">#{operationVersion},</if>
|
||||
<if test="authStart != null">#{authStart},</if>
|
||||
<if test="authEnd != null">#{authEnd},</if>
|
||||
<if test="createUser != null and createUser != ''">#{createUser},</if>
|
||||
<if test="faceSyncStatus != null">#{faceSyncStatus},</if>
|
||||
<if test="retryCount != null">#{retryCount},</if>
|
||||
<if test="lastError != null and lastError != ''">#{lastError},</if>
|
||||
<if test="externalAuthId != null and externalAuthId != ''">#{externalAuthId},</if>
|
||||
<if test="lastSyncTime != null">#{lastSyncTime},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
<if test="createTime != null">#{createTime},</if>
|
||||
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
|
||||
<if test="updateTime != null">#{updateTime},</if>
|
||||
<if test="remark != null">#{remark},</if>
|
||||
</trim>
|
||||
<if test="remark != null and remark != ''">#{remark},</if>
|
||||
</trim>
|
||||
</insert>
|
||||
|
||||
<update id="updateTrainDormAuth" parameterType="TrainDormAuth">
|
||||
update train_dorm_auth
|
||||
UPDATE train_dorm_auth
|
||||
<trim prefix="SET" suffixOverrides=",">
|
||||
<if test="studentId != null and studentId != ''">student_id = #{studentId},</if>
|
||||
<if test="planId != null and planId != ''">plan_id = #{planId},</if>
|
||||
<if test="dormId != null and dormId != ''">dorm_id = #{dormId},</if>
|
||||
<if test="bedNo != null">bed_no = #{bedNo},</if>
|
||||
<if test="faceSyncStatus != null">face_sync_status = #{faceSyncStatus},</if>
|
||||
<if test="accommodationId != null">accommodation_id = #{accommodationId},</if>
|
||||
<if test="authStatus != null and authStatus != ''">auth_status = #{authStatus},</if>
|
||||
<if test="credentialType != null and credentialType != ''">credential_type = #{credentialType},</if>
|
||||
<if test="latestOperationId != null">latest_operation_id = #{latestOperationId},</if>
|
||||
<if test="operationVersion != null">operation_version = #{operationVersion},</if>
|
||||
<if test="authStart != null">auth_start = #{authStart},</if>
|
||||
<if test="authEnd != null">auth_end = #{authEnd},</if>
|
||||
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
|
||||
<if test="createTime != null">create_time = #{createTime},</if>
|
||||
<if test="faceSyncStatus != null">face_sync_status = #{faceSyncStatus},</if>
|
||||
<if test="retryCount != null">retry_count = #{retryCount},</if>
|
||||
<if test="lastError != null">last_error = #{lastError},</if>
|
||||
<if test="externalAuthId != null">external_auth_id = #{externalAuthId},</if>
|
||||
<if test="lastSyncTime != null">last_sync_time = #{lastSyncTime},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="updateTime != null">update_time = #{updateTime},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
</trim>
|
||||
where auth_id = #{authId}
|
||||
WHERE auth_id = #{authId}
|
||||
</update>
|
||||
|
||||
<update id="updateSyncStatus">
|
||||
UPDATE train_dorm_auth
|
||||
SET face_sync_status = #{faceSyncStatus},
|
||||
last_error = #{lastError},
|
||||
update_by = #{updateBy},
|
||||
update_time = #{updateTime}
|
||||
WHERE auth_id = #{authId}
|
||||
</update>
|
||||
|
||||
<update id="increaseRetryCount">
|
||||
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}
|
||||
</update>
|
||||
|
||||
<delete id="deleteTrainDormAuthByAuthId" parameterType="Long">
|
||||
delete from train_dorm_auth where auth_id = #{authId}
|
||||
DELETE FROM train_dorm_auth WHERE auth_id = #{authId}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteTrainDormAuthByAuthIds" parameterType="String">
|
||||
delete from train_dorm_auth where auth_id in
|
||||
DELETE FROM train_dorm_auth WHERE auth_id IN
|
||||
<foreach item="authId" collection="array" open="(" separator="," close=")">
|
||||
#{authId}
|
||||
</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
</mapper>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainExternalOperationMapper">
|
||||
|
||||
<resultMap type="TrainExternalOperation" id="TrainExternalOperationResult">
|
||||
<result property="operationId" column="operation_id" />
|
||||
<result property="doorAuthId" column="door_auth_id" />
|
||||
<result property="businessType" column="business_type" />
|
||||
<result property="vendorCode" column="vendor_code" />
|
||||
<result property="operationType" column="operation_type" />
|
||||
<result property="operationStatus" column="operation_status" />
|
||||
<result property="idempotencyKey" column="idempotency_key" />
|
||||
<result property="externalOperationId" column="external_operation_id" />
|
||||
<result property="externalRequestType" column="external_request_type" />
|
||||
<result property="externalHardwareId" column="external_hardware_id" />
|
||||
<result property="externalKeyId" column="external_key_id" />
|
||||
<result property="attemptCount" column="attempt_count" />
|
||||
<result property="nextPollTime" column="next_poll_time" />
|
||||
<result property="requestedAt" column="requested_at" />
|
||||
<result property="completedAt" column="completed_at" />
|
||||
<result property="lastErrorCode" column="last_error_code" />
|
||||
<result property="lastErrorMessage" column="last_error_message" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainExternalOperationVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<select id="selectExternalOperationById" parameterType="Long" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE operation_id = #{operationId}
|
||||
</select>
|
||||
|
||||
<select id="selectByIdempotencyKey" parameterType="String" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE idempotency_key = #{idempotencyKey}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectByDoorAuthId" parameterType="Long" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE door_auth_id = #{doorAuthId}
|
||||
ORDER BY operation_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectByExternalOperationId" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND external_operation_id = #{externalOperationId}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectPendingPollOperations" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE operation_status IN ('ACCEPTED', 'PENDING', 'RETRY_WAIT')
|
||||
AND next_poll_time <= #{maxPollTime}
|
||||
<if test="vendorCode != null and vendorCode != ''">
|
||||
AND vendor_code = #{vendorCode}
|
||||
</if>
|
||||
ORDER BY next_poll_time ASC, operation_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectExternalOperationList" parameterType="TrainExternalOperation" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
<where>
|
||||
<if test="doorAuthId != null">
|
||||
AND door_auth_id = #{doorAuthId}
|
||||
</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">
|
||||
AND vendor_code = #{vendorCode}
|
||||
</if>
|
||||
<if test="operationType != null and operationType != ''">
|
||||
AND operation_type = #{operationType}
|
||||
</if>
|
||||
<if test="operationStatus != null and operationStatus != ''">
|
||||
AND operation_status = #{operationStatus}
|
||||
</if>
|
||||
<if test="idempotencyKey != null and idempotencyKey != ''">
|
||||
AND idempotency_key = #{idempotencyKey}
|
||||
</if>
|
||||
<if test="externalOperationId != null and externalOperationId != ''">
|
||||
AND external_operation_id = #{externalOperationId}
|
||||
</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">
|
||||
AND external_hardware_id = #{externalHardwareId}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY operation_id DESC
|
||||
</select>
|
||||
|
||||
<insert id="insertExternalOperation" parameterType="TrainExternalOperation" useGeneratedKeys="true" keyProperty="operationId">
|
||||
INSERT INTO train_external_operation (
|
||||
door_auth_id,
|
||||
<if test="businessType != null and businessType != ''">business_type,</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">vendor_code,</if>
|
||||
operation_type,
|
||||
<if test="operationStatus != null and operationStatus != ''">operation_status,</if>
|
||||
idempotency_key,
|
||||
<if test="externalOperationId != null and externalOperationId != ''">external_operation_id,</if>
|
||||
<if test="externalRequestType != null">external_request_type,</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">external_hardware_id,</if>
|
||||
<if test="externalKeyId != null and externalKeyId != ''">external_key_id,</if>
|
||||
<if test="attemptCount != null">attempt_count,</if>
|
||||
<if test="nextPollTime != null">next_poll_time,</if>
|
||||
<if test="requestedAt != null">requested_at,</if>
|
||||
<if test="completedAt != null">completed_at,</if>
|
||||
<if test="lastErrorCode != null and lastErrorCode != ''">last_error_code,</if>
|
||||
<if test="lastErrorMessage != null and lastErrorMessage != ''">last_error_message,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
<if test="remark != null and remark != ''">remark,</if>
|
||||
create_time
|
||||
) VALUES (
|
||||
#{doorAuthId},
|
||||
<if test="businessType != null and businessType != ''">#{businessType},</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">#{vendorCode},</if>
|
||||
#{operationType},
|
||||
<if test="operationStatus != null and operationStatus != ''">#{operationStatus},</if>
|
||||
#{idempotencyKey},
|
||||
<if test="externalOperationId != null and externalOperationId != ''">#{externalOperationId},</if>
|
||||
<if test="externalRequestType != null">#{externalRequestType},</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">#{externalHardwareId},</if>
|
||||
<if test="externalKeyId != null and externalKeyId != ''">#{externalKeyId},</if>
|
||||
<if test="attemptCount != null">#{attemptCount},</if>
|
||||
<if test="nextPollTime != null">#{nextPollTime},</if>
|
||||
<if test="requestedAt != null">#{requestedAt},</if>
|
||||
<if test="completedAt != null">#{completedAt},</if>
|
||||
<if test="lastErrorCode != null and lastErrorCode != ''">#{lastErrorCode},</if>
|
||||
<if test="lastErrorMessage != null and lastErrorMessage != ''">#{lastErrorMessage},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
<if test="remark != null and remark != ''">#{remark},</if>
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateExternalOperationStatus" parameterType="TrainExternalOperation">
|
||||
UPDATE train_external_operation
|
||||
<set>
|
||||
<if test="operationStatus != null and operationStatus != ''">operation_status = #{operationStatus},</if>
|
||||
<if test="attemptCount != null">attempt_count = #{attemptCount},</if>
|
||||
<if test="nextPollTime != null">next_poll_time = #{nextPollTime},</if>
|
||||
<if test="requestedAt != null">requested_at = #{requestedAt},</if>
|
||||
<if test="completedAt != null">completed_at = #{completedAt},</if>
|
||||
<if test="lastErrorCode != null">last_error_code = #{lastErrorCode},</if>
|
||||
<if test="lastErrorMessage != null">last_error_message = #{lastErrorMessage},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
WHERE operation_id = #{operationId}
|
||||
</update>
|
||||
|
||||
<update id="updateExternalReference">
|
||||
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}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainExternalResourceMappingMapper">
|
||||
|
||||
<resultMap type="TrainExternalResourceMapping" id="TrainExternalResourceMappingResult">
|
||||
<result property="mappingId" column="mapping_id" />
|
||||
<result property="dormId" column="dorm_id" />
|
||||
<result property="vendorCode" column="vendor_code" />
|
||||
<result property="localResourceType" column="local_resource_type" />
|
||||
<result property="externalResourceType" column="external_resource_type" />
|
||||
<result property="externalResourceId" column="external_resource_id" />
|
||||
<result property="externalParentId" column="external_parent_id" />
|
||||
<result property="externalHardwareId" column="external_hardware_id" />
|
||||
<result property="externalProjectId" column="external_project_id" />
|
||||
<result property="isPrimary" column="is_primary" />
|
||||
<result property="mappingStatus" column="mapping_status" />
|
||||
<result property="lastSyncTime" column="last_sync_time" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainExternalResourceMappingVo">
|
||||
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
|
||||
</sql>
|
||||
|
||||
<select id="selectExternalResourceMappingById" parameterType="Long" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE mapping_id = #{mappingId}
|
||||
</select>
|
||||
|
||||
<select id="selectActiveRoomMapping" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND dorm_id = #{dormId}
|
||||
AND external_resource_type = 'ROOM'
|
||||
AND mapping_status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveLockMappings" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND dorm_id = #{dormId}
|
||||
AND local_resource_type = 'DOOR_LOCK'
|
||||
AND mapping_status = 'ACTIVE'
|
||||
ORDER BY is_primary DESC, mapping_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectActiveByExternalResource" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND external_resource_type = #{externalResourceType}
|
||||
AND external_resource_id = #{externalResourceId}
|
||||
AND mapping_status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectExternalResourceMappingList" parameterType="TrainExternalResourceMapping" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
<where>
|
||||
<if test="dormId != null and dormId != ''">
|
||||
AND dorm_id = #{dormId}
|
||||
</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">
|
||||
AND vendor_code = #{vendorCode}
|
||||
</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">
|
||||
AND local_resource_type = #{localResourceType}
|
||||
</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">
|
||||
AND external_resource_type = #{externalResourceType}
|
||||
</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">
|
||||
AND external_resource_id = #{externalResourceId}
|
||||
</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">
|
||||
AND external_hardware_id = #{externalHardwareId}
|
||||
</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">
|
||||
AND mapping_status = #{mappingStatus}
|
||||
</if>
|
||||
<if test="isPrimary != null">
|
||||
AND is_primary = #{isPrimary}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY mapping_id DESC
|
||||
</select>
|
||||
|
||||
<insert id="insertExternalResourceMapping" parameterType="TrainExternalResourceMapping" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO train_external_resource_mapping (
|
||||
<if test="dormId != null and dormId != ''">dorm_id,</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">vendor_code,</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">local_resource_type,</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">external_resource_type,</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">external_resource_id,</if>
|
||||
<if test="externalParentId != null and externalParentId != ''">external_parent_id,</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">external_hardware_id,</if>
|
||||
<if test="externalProjectId != null and externalProjectId != ''">external_project_id,</if>
|
||||
<if test="isPrimary != null">is_primary,</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">mapping_status,</if>
|
||||
<if test="lastSyncTime != null">last_sync_time,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
<if test="remark != null and remark != ''">remark,</if>
|
||||
create_time
|
||||
) VALUES (
|
||||
<if test="dormId != null and dormId != ''">#{dormId},</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">#{vendorCode},</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">#{localResourceType},</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">#{externalResourceType},</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">#{externalResourceId},</if>
|
||||
<if test="externalParentId != null and externalParentId != ''">#{externalParentId},</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">#{externalHardwareId},</if>
|
||||
<if test="externalProjectId != null and externalProjectId != ''">#{externalProjectId},</if>
|
||||
<if test="isPrimary != null">#{isPrimary},</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">#{mappingStatus},</if>
|
||||
<if test="lastSyncTime != null">#{lastSyncTime},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
<if test="remark != null and remark != ''">#{remark},</if>
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateExternalResourceMapping" parameterType="TrainExternalResourceMapping">
|
||||
UPDATE train_external_resource_mapping
|
||||
<set>
|
||||
<if test="dormId != null and dormId != ''">dorm_id = #{dormId},</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">vendor_code = #{vendorCode},</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">local_resource_type = #{localResourceType},</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">external_resource_type = #{externalResourceType},</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">external_resource_id = #{externalResourceId},</if>
|
||||
<if test="externalParentId != null">external_parent_id = #{externalParentId},</if>
|
||||
<if test="externalHardwareId != null">external_hardware_id = #{externalHardwareId},</if>
|
||||
<if test="externalProjectId != null">external_project_id = #{externalProjectId},</if>
|
||||
<if test="isPrimary != null">is_primary = #{isPrimary},</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">mapping_status = #{mappingStatus},</if>
|
||||
<if test="lastSyncTime != null">last_sync_time = #{lastSyncTime},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
WHERE mapping_id = #{mappingId}
|
||||
</update>
|
||||
|
||||
<update id="deactivateMapping">
|
||||
UPDATE train_external_resource_mapping
|
||||
SET mapping_status = 'INACTIVE',
|
||||
update_by = #{updateBy},
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE mapping_id = #{mappingId}
|
||||
</update>
|
||||
|
||||
<update id="deactivateRoomMapping">
|
||||
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'
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.ruoyi.train.integration.dms;
|
||||
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.client.DmsHttpClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsPasswordClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import com.ruoyi.train.integration.dms.poller.DmsOperationPoller;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 异步生命周期语义 (Accepted != Success) 与凭据隐私安全测试
|
||||
*/
|
||||
public class DmsAsyncLifecycleAndSecurityTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsPasswordClient passwordClient;
|
||||
private DmsOperationPoller poller;
|
||||
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
passwordClient = new DmsPasswordClient(httpClient);
|
||||
DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
|
||||
|
||||
IExternalOperationService mockOpService = createMockOperationService();
|
||||
poller = new DmsOperationPoller(properties, mockOpService, statusClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
operationDb.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键验证:ACCEPTED != SUCCESS (HTTP 200 仅代表受理,绝不等于设备执行成功)
|
||||
* 只有后续 Poller 轮询 DMS 返回 success,才转换为 SUCCESS。
|
||||
*/
|
||||
@Test
|
||||
public void testAcceptedNotEqualsSuccess_LifecycleVerification() {
|
||||
// 1. 发起创建密码请求 (使用短暂内存明文密码)
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setLockId(5001L);
|
||||
req.setHolder("王学员");
|
||||
req.setPhoneNumber("13911112222");
|
||||
req.setPassword("567890");
|
||||
|
||||
DmsPasswordCreateResponse createResp = passwordClient.createOnlinePassword(req);
|
||||
assertNotNull(createResp);
|
||||
assertEquals(Long.valueOf(99001L), createResp.getOperationId());
|
||||
|
||||
// 2. 本地记录操作流水:初始状态必须为 ACCEPTED,绝不可直接置为 SUCCESS!
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(201L);
|
||||
op.setExternalOperationId(String.valueOf(createResp.getOperationId()));
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
operationDb.put(201L, op);
|
||||
|
||||
// 断言:HTTP 成功后,本地操作状态绝对不是 SUCCESS
|
||||
assertNotEquals("Initial status after HTTP accepted MUST NOT be SUCCESS",
|
||||
TrainExternalOperation.STATUS_SUCCESS, op.getOperationStatus());
|
||||
assertEquals("Initial status MUST be ACCEPTED",
|
||||
TrainExternalOperation.STATUS_ACCEPTED, op.getOperationStatus());
|
||||
|
||||
// 3. 初始时 Mock 服务端返回 undone (门锁硬件正在联网对账)
|
||||
mockServer.setOperationStatus(99001L, "undone", "Queued in gateway buffer");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals("When vendor status is undone, local status must remain PENDING",
|
||||
TrainExternalOperation.STATUS_PENDING, operationDb.get(201L).getOperationStatus());
|
||||
|
||||
// 4. 设备真正下发成功后,Mock 返回 success
|
||||
mockServer.setOperationStatus(99001L, "success", "Lock accepted password key");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
// 断言:轮询确认后,最终状态达成 SUCCESS
|
||||
assertEquals("Final status after successful poll MUST be SUCCESS",
|
||||
TrainExternalOperation.STATUS_SUCCESS, operationDb.get(201L).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据零泄露测试:反射检查 TrainExternalOperation 与 TrainDormAuth,
|
||||
* 确认不存在任何明文密码、密钥或卡号字段 (PLAINTEXT_PASSWORD_PERSISTENCE_COUNT = 0)
|
||||
*/
|
||||
@Test
|
||||
public void testCredentialDataLeak_ZeroPlaintextStorage() {
|
||||
// 检查 TrainExternalOperation
|
||||
for (Field f : TrainExternalOperation.class.getDeclaredFields()) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("TrainExternalOperation must not have password field: " + name,
|
||||
name.contains("password") || name.contains("passwd"));
|
||||
assertFalse("TrainExternalOperation must not have token field: " + name,
|
||||
name.equals("token") || name.equals("appsecret"));
|
||||
assertFalse("TrainExternalOperation must not have card_no field: " + name,
|
||||
name.contains("cardno") || name.contains("card_no"));
|
||||
}
|
||||
|
||||
// 检查 TrainDormAuth
|
||||
for (Field f : TrainDormAuth.class.getDeclaredFields()) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("TrainDormAuth must not have password field: " + name,
|
||||
name.contains("password") || name.contains("passwd"));
|
||||
assertFalse("TrainDormAuth must not have secret field: " + name,
|
||||
name.contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
private IExternalOperationService createMockOperationService() {
|
||||
return (IExternalOperationService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalOperationService.class},
|
||||
(proxy, method, args) -> {
|
||||
String methodName = method.getName();
|
||||
if ("listPendingPollOperations".equals(methodName)) {
|
||||
List<TrainExternalOperation> list = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (!TrainExternalOperation.STATUS_SUCCESS.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_FAILED.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_TIMEOUT.equals(op.getOperationStatus())) {
|
||||
list.add(op);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} else if ("updateOperationPollResult".equals(methodName)) {
|
||||
Long opId = (Long) args[0];
|
||||
String newStatus = (String) args[1];
|
||||
Date nextPoll = (Date) args[2];
|
||||
Integer attemptCount = (Integer) args[3];
|
||||
String lastErrorCode = (String) args[4];
|
||||
String lastErrorMessage = (String) args[5];
|
||||
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus(newStatus);
|
||||
if (nextPoll != null) op.setNextPollTime(nextPoll);
|
||||
if (attemptCount != null) op.setAttemptCount(attemptCount);
|
||||
if (lastErrorCode != null) op.setLastErrorCode(lastErrorCode);
|
||||
if (lastErrorMessage != null) op.setLastErrorMessage(lastErrorMessage);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package com.ruoyi.train.integration.dms.auth;
|
||||
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS Token 管理器核心并发与单飞登录测试
|
||||
*/
|
||||
public class DmsTokenManagerTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsTokenManager tokenManager;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setTokenTtlSeconds(7200);
|
||||
properties.setTokenRefreshSkewSeconds(120);
|
||||
|
||||
tokenManager = new DmsTokenManager(properties);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 20 个并发线程同时获取 Token,单飞互斥锁保证仅发起 1 次远程登录,所有线程获取相同 Token
|
||||
*/
|
||||
@Test
|
||||
public void testTokenConcurrency_SingleFlightLogin() throws Exception {
|
||||
int threadCount = 20;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch finishLatch = new CountDownLatch(threadCount);
|
||||
|
||||
Set<String> tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
String token = tokenManager.getToken();
|
||||
tokenResults.add(token);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
finishLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
boolean completed = finishLatch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertTrue("All threads should complete within timeout", completed);
|
||||
assertEquals("Mock server should receive exactly 1 login request", 1, mockServer.getLoginCounter());
|
||||
assertEquals("TokenManager loginCounter should be exactly 1", 1, tokenManager.getLoginCounter());
|
||||
assertEquals("All 20 threads should receive the exact same token", 1, tokenResults.size());
|
||||
assertTrue("Token should start with mock-token", tokenResults.iterator().next().startsWith("mock-token"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Token 提前刷新 (Refresh Skew):时间推进到刷新窗口内,并发请求单飞刷新新 Token,旧 Token 被废弃
|
||||
*/
|
||||
@Test
|
||||
public void testTokenRefresh_SingleFlightRefresh() throws Exception {
|
||||
Instant baseInstant = Instant.parse("2026-09-16T10:00:00Z");
|
||||
tokenManager.setClock(Clock.fixed(baseInstant, ZoneId.of("UTC")));
|
||||
|
||||
// 首次获取 Token
|
||||
String token1 = tokenManager.getToken();
|
||||
assertNotNull(token1);
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 推进时间到 7200 - 60 秒 (已进入提前 120 秒的刷新窗口)
|
||||
Instant nearExpiry = baseInstant.plusSeconds(7200 - 60);
|
||||
tokenManager.setClock(Clock.fixed(nearExpiry, ZoneId.of("UTC")));
|
||||
|
||||
int threadCount = 20;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch finishLatch = new CountDownLatch(threadCount);
|
||||
Set<String> tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
String token = tokenManager.getToken();
|
||||
tokenResults.add(token);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
finishLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
boolean completed = finishLatch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertTrue(completed);
|
||||
assertEquals("Mock server should receive exactly 2 login requests in total", 2, mockServer.getLoginCounter());
|
||||
assertEquals("All refreshed threads should receive the new identical token", 1, tokenResults.size());
|
||||
|
||||
String token2 = tokenResults.iterator().next();
|
||||
assertNotEquals("New token must be different from old expired token", token1, token2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试成功调用 API 后滑动延展本地有效期限 (markSuccessfulUse)
|
||||
*/
|
||||
@Test
|
||||
public void testSuccessfulApiExtendsLocalTtl() {
|
||||
Instant baseInstant = Instant.parse("2026-09-16T10:00:00Z");
|
||||
tokenManager.setClock(Clock.fixed(baseInstant, ZoneId.of("UTC")));
|
||||
|
||||
String token1 = tokenManager.getToken();
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 推进时间 1 小时 (3600 秒)
|
||||
Instant afterOneHour = baseInstant.plusSeconds(3600);
|
||||
tokenManager.setClock(Clock.fixed(afterOneHour, ZoneId.of("UTC")));
|
||||
|
||||
// 模拟成功调用 API 触发延展
|
||||
tokenManager.markSuccessfulUse();
|
||||
|
||||
// 再次推进到原定过期的第 7150 秒 (距初始 7200 秒只剩 50 秒,若未延展本该进入 120s 刷新窗口)
|
||||
// 但由于在第 3600 秒延展了 7200 秒,新的过期时间是 3600 + 7200 = 10800 秒,此时依然有效!
|
||||
Instant checkInstant = baseInstant.plusSeconds(7150);
|
||||
tokenManager.setClock(Clock.fixed(checkInstant, ZoneId.of("UTC")));
|
||||
|
||||
String tokenAfterCheck = tokenManager.getToken();
|
||||
assertEquals("Should reuse same token without triggering new login", token1, tokenAfterCheck);
|
||||
assertEquals("No new login should be triggered due to sliding extension", 1, mockServer.getLoginCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 401 故障自愈与集中重登录 (无 login storm)
|
||||
*/
|
||||
@Test
|
||||
public void testAuthFailureRecovery_NoLoginStorm() throws Exception {
|
||||
String token1 = tokenManager.getToken();
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 模拟服务端使当前 Token 失效
|
||||
mockServer.invalidateActiveToken();
|
||||
|
||||
// 本地感知到 401 并调用 invalidateToken
|
||||
tokenManager.invalidateToken();
|
||||
|
||||
// 20 个并发线程同时发起获取 Token
|
||||
int threadCount = 20;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch finishLatch = new CountDownLatch(threadCount);
|
||||
Set<String> tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
String token = tokenManager.getToken();
|
||||
tokenResults.add(token);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
finishLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
finishLatch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertEquals("Mock server should have exactly 2 logins (1 initial + 1 recovered)", 2, mockServer.getLoginCounter());
|
||||
assertEquals("All threads should get the recovered token", 1, tokenResults.size());
|
||||
assertNotEquals(token1, tokenResults.iterator().next());
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsRoomDto;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* 验证真实厂商契约探测中发现的字段兼容性 (id 与 lockId / placeId 互通)
|
||||
*/
|
||||
public class DmsContractCompatibilityTest {
|
||||
|
||||
@Test
|
||||
public void testDoorLockDto_DeserializesFromVendorIdField() {
|
||||
// 厂商标准返回:字段名为 "id"
|
||||
String vendorJson = "{\"id\":5001,\"hardwareId\":\"LK-HD-10001-1\",\"placeId\":10001,\"onlineStatus\":1,\"battery\":95}";
|
||||
DmsDoorLockDto dto = JSON.parseObject(vendorJson, DmsDoorLockDto.class);
|
||||
|
||||
assertNotNull(dto);
|
||||
assertEquals("getId() must match 5001", Long.valueOf(5001L), dto.getId());
|
||||
assertEquals("getLockId() must also resolve to 5001 when vendor returns id", Long.valueOf(5001L), dto.getLockId());
|
||||
assertEquals("LK-HD-10001-1", dto.getHardwareId());
|
||||
assertEquals(Long.valueOf(10001L), dto.getPlaceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDoorLockDto_DeserializesFromAdapterLockIdField() {
|
||||
// 兼容测试/旧 Mock 返回:字段名为 "lockId"
|
||||
String adapterJson = "{\"lockId\":5002,\"hardwareId\":\"LK-HD-10001-2\",\"placeId\":10001}";
|
||||
DmsDoorLockDto dto = JSON.parseObject(adapterJson, DmsDoorLockDto.class);
|
||||
|
||||
assertNotNull(dto);
|
||||
assertEquals("getLockId() must match 5002", Long.valueOf(5002L), dto.getLockId());
|
||||
assertEquals("getId() must also resolve to 5002 when JSON provides lockId", Long.valueOf(5002L), dto.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRoomDto_DeserializesFromVendorIdField() {
|
||||
// 厂商标准返回:房间 id 即为 placeId
|
||||
String vendorJson = "{\"id\":301,\"name\":\"学员宿舍201\",\"roomNumber\":\"201\"}";
|
||||
DmsRoomDto dto = JSON.parseObject(vendorJson, DmsRoomDto.class);
|
||||
|
||||
assertNotNull(dto);
|
||||
assertEquals(Long.valueOf(301L), dto.getId());
|
||||
assertEquals("getPlaceId() must fallback to id when vendor omits placeId", Long.valueOf(301L), dto.getPlaceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRoomDto_DeserializesWithPlaceIdField() {
|
||||
// 兼容包含 placeId 的返回
|
||||
String vendorJson = "{\"id\":301,\"placeId\":10001,\"name\":\"学员宿舍201\"}";
|
||||
DmsRoomDto dto = JSON.parseObject(vendorJson, DmsRoomDto.class);
|
||||
|
||||
assertNotNull(dto);
|
||||
assertEquals(Long.valueOf(301L), dto.getId());
|
||||
assertEquals(Long.valueOf(10001L), dto.getPlaceId());
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsDoorLockGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalDoorLockGateway;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 智能门锁适配器测试 (0 把锁与多把锁测试)
|
||||
*/
|
||||
public class DmsDoorLockAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private ExternalDoorLockGateway doorLockGateway;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsDoorLockClient doorLockClient = new DmsDoorLockClient(httpClient);
|
||||
doorLockGateway = new DmsDoorLockGatewayImpl(doorLockClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 0 把锁场景:正常返回空列表,非 NPE,不自动创建假锁 (ZERO_LOCK_RESULT = EMPTY_LIST)
|
||||
*/
|
||||
@Test
|
||||
public void testZeroLock_ReturnsEmptyList() {
|
||||
List<DmsDoorLockDto> locks = doorLockGateway.listDoorLocksByPlaceId(9999L);
|
||||
assertNotNull("Must return non-null list for zero-lock room", locks);
|
||||
assertTrue("Lock list must be empty", locks.isEmpty());
|
||||
assertEquals(0, locks.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多把锁场景:完整返回全部 3 把门锁,绝不截断只取第 1 把 (MULTI_LOCK_RESULT_COUNT = 3)
|
||||
*/
|
||||
@Test
|
||||
public void testMultiLock_ReturnsAllLocks() {
|
||||
List<DmsDoorLockDto> locks = doorLockGateway.listDoorLocksByPlaceId(10001L);
|
||||
assertNotNull(locks);
|
||||
assertEquals("Must return all 3 locks configured for placeId 10001", 3, locks.size());
|
||||
|
||||
// 校验 3 把锁各自的属性独立性
|
||||
assertEquals(Long.valueOf(5001L), locks.get(0).getLockId());
|
||||
assertEquals("LK-HD-10001-1", locks.get(0).getHardwareId());
|
||||
|
||||
assertEquals(Long.valueOf(5002L), locks.get(1).getLockId());
|
||||
assertEquals("LK-HD-10001-2", locks.get(1).getHardwareId());
|
||||
|
||||
assertEquals(Long.valueOf(5003L), locks.get(2).getLockId());
|
||||
assertEquals("LK-HD-10001-3", locks.get(2).getHardwareId());
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 基础 HTTP 客户端传输与重试安全测试
|
||||
*/
|
||||
public class DmsHttpClientTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsTokenManager tokenManager;
|
||||
private DmsHttpClient httpClient;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setConnectTimeoutMs(1000);
|
||||
properties.setReadTimeoutMs(500); // 设为 500ms 短读取超时用于测试超时拦截
|
||||
properties.setTokenCookieName("token");
|
||||
|
||||
tokenManager = new DmsTokenManager(properties);
|
||||
httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证写操作读取超时绝对不自动盲目重试 (AMBIGUOUS_MUTATION_AUTO_RETRY = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testMutationAmbiguity_NoAutoRetryOnTimeout() {
|
||||
mockServer.setSimulateReadTimeout(true);
|
||||
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setLockId(5001L);
|
||||
req.setPassword("123456");
|
||||
req.setHolder("张三");
|
||||
req.setPhoneNumber("13800000000");
|
||||
|
||||
try {
|
||||
httpClient.postMutation("/rest/doorlock/online-password-key/create", req, JSONObject.class);
|
||||
fail("Should throw DmsTransportException on read timeout");
|
||||
} catch (DmsTransportException e) {
|
||||
assertTrue("Exception message should indicate timeout and auto-retry suppression",
|
||||
e.getMessage().contains("timed out") || e.getMessage().contains("auto-retry suppressed"));
|
||||
}
|
||||
|
||||
// 核心安全指标:虽然发生超时,但 POST 请求次数必须严格为 1!绝对没有自动发起第 2 次 POST!
|
||||
assertEquals("Mutation POST count must be strictly 1, NO auto-retry allowed",
|
||||
1, mockServer.getPasswordCreatePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token 传输格式为 Cookie: token=...,禁止使用 Authorization: Bearer
|
||||
*/
|
||||
@Test
|
||||
public void testTokenTransport_CookieHeader() {
|
||||
// 执行一次安全查询
|
||||
JSONObject resp = httpClient.postQuery("/rest/place/building/list", new JSONObject(), JSONObject.class);
|
||||
assertNotNull(resp);
|
||||
assertTrue(resp.getBoolean("success"));
|
||||
assertEquals("Token cookie name should be token", "token", properties.getTokenCookieName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证敏感信息脱敏 (密码明文、Token、Secret 均被过滤)
|
||||
*/
|
||||
@Test
|
||||
public void testSensitiveDataRedaction() {
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setPhoneNumber("13812345678");
|
||||
req.setPassword("987654");
|
||||
|
||||
String toStringOutput = req.toString();
|
||||
assertFalse("toString must not contain plaintext password", toStringOutput.contains("987654"));
|
||||
assertTrue("toString should protect password", toStringOutput.contains("[PROTECTED]"));
|
||||
assertFalse("toString must mask middle phone digits", toStringOutput.contains("1234"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证删除操作读取超时绝对不自动盲目重试 (PASSWORD_DELETE_AMBIGUOUS_AUTO_RETRY = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testPasswordDeleteAmbiguity_NoAutoRetryOnTimeout() {
|
||||
mockServer.setSimulateReadTimeout(true);
|
||||
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("lockId", 5001L);
|
||||
req.put("operationId", 99002L);
|
||||
|
||||
try {
|
||||
httpClient.postMutation("/rest/doorlock/online-password/delete", req, JSONObject.class);
|
||||
fail("Should throw DmsTransportException on read timeout for delete mutation");
|
||||
} catch (DmsTransportException e) {
|
||||
assertTrue("Exception message should indicate timeout and auto-retry suppression",
|
||||
e.getMessage().contains("timed out") || e.getMessage().contains("auto-retry suppressed"));
|
||||
}
|
||||
|
||||
// 核心安全指标:delete 操作即使超时,POST 请求次数必须严格为 1!禁止自动重发!
|
||||
assertEquals("Password delete mutation POST count must be strictly 1, NO auto-retry allowed",
|
||||
1, mockServer.getPasswordDeletePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证业务失败或异常响应绝对不延展本地 Token TTL (FAILED_CALL_EXTENDS_LOCAL_TTL = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testFailedCallDoesNotExtendLocalTtl() {
|
||||
// 先正常获取 Token
|
||||
String token = tokenManager.getToken();
|
||||
assertNotNull(token);
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 推进时间 1 小时
|
||||
java.time.Instant baseInstant = java.time.Instant.parse("2026-09-16T10:00:00Z");
|
||||
tokenManager.setClock(java.time.Clock.fixed(baseInstant, java.time.ZoneId.of("UTC")));
|
||||
tokenManager.markSuccessfulUse(); // 此时有效期至 10:00 + 7200s = 12:00
|
||||
|
||||
// 推进到 11:30
|
||||
java.time.Instant at1130 = baseInstant.plusSeconds(5400);
|
||||
tokenManager.setClock(java.time.Clock.fixed(at1130, java.time.ZoneId.of("UTC")));
|
||||
|
||||
// 模拟未认证请求导致 401 失败
|
||||
mockServer.invalidateActiveToken();
|
||||
try {
|
||||
httpClient.postMutation("/rest/doorlock/online-password-key/create", new JSONObject(), JSONObject.class);
|
||||
fail("Should fail with DmsAuthenticationException");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 验证失败后,Token 已被清除/未被延长为 11:30 + 7200s
|
||||
// 重新获取会触发新的 login,而不是错误地信任旧 Token
|
||||
assertEquals("Mock server should have recorded second login or invalidated", 1, mockServer.getLoginCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 dms.enabled=false 启动安全,零网络请求
|
||||
*/
|
||||
@Test
|
||||
public void testStartupNetworkSafetyWhenDisabled() {
|
||||
DmsProperties disabledProps = new DmsProperties();
|
||||
disabledProps.setEnabled(false);
|
||||
disabledProps.setBaseUrl(mockServer.getBaseUrl());
|
||||
disabledProps.setAppId("disabled-app-id");
|
||||
disabledProps.setAppSecret("disabled-app-secret");
|
||||
|
||||
int initialLogins = mockServer.getLoginCounter();
|
||||
|
||||
// 构造相关组件
|
||||
DmsTokenManager disabledTokenManager = new DmsTokenManager(disabledProps);
|
||||
DmsHttpClient disabledClient = new DmsHttpClient(disabledProps, disabledTokenManager);
|
||||
|
||||
// 确认构造阶段没有发起任何网络请求
|
||||
assertEquals("Disabled DMS must not initiate any login/network requests during startup",
|
||||
initialLogins, mockServer.getLoginCounter());
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsBuildingDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsFloorDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsRoomDto;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsLocationGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalLocationGateway;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 位置适配器测试 (Building / Floor / Room)
|
||||
*/
|
||||
public class DmsLocationAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private ExternalLocationGateway locationGateway;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsLocationClient locationClient = new DmsLocationClient(httpClient);
|
||||
locationGateway = new DmsLocationGatewayImpl(locationClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLocationLookup_FullHierarchy() {
|
||||
// 1. 查询建筑
|
||||
List<DmsBuildingDto> buildings = locationGateway.listBuildings(1L);
|
||||
assertNotNull(buildings);
|
||||
assertFalse(buildings.isEmpty());
|
||||
assertEquals("学员1号楼", buildings.get(0).getName());
|
||||
|
||||
// 2. 查询楼层
|
||||
List<DmsFloorDto> floors = locationGateway.listFloors(buildings.get(0).getId());
|
||||
assertNotNull(floors);
|
||||
assertFalse(floors.isEmpty());
|
||||
assertEquals("二层", floors.get(0).getName());
|
||||
|
||||
// 3. 查询房间
|
||||
List<DmsRoomDto> rooms = locationGateway.listRooms(floors.get(0).getId());
|
||||
assertNotNull(rooms);
|
||||
assertFalse(rooms.isEmpty());
|
||||
assertEquals("201", rooms.get(0).getRoomNumber());
|
||||
assertEquals(Long.valueOf(10001L), rooms.get(0).getPlaceId());
|
||||
|
||||
// 4. 获取房间详情
|
||||
DmsRoomDto roomDetail = locationGateway.getRoom(rooms.get(0).getId());
|
||||
assertNotNull(roomDetail);
|
||||
assertEquals(Long.valueOf(10001L), roomDetail.getPlaceId());
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsOperationStatusMapper;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 门锁操作结果查询与状态映射器测试
|
||||
*/
|
||||
public class DmsOperationStatusAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsOperationStatusClient statusClient;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
statusClient = new DmsOperationStatusClient(httpClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testQueryOperationStatus_FromMock() {
|
||||
mockServer.setOperationStatus(99001L, "success", "Doorlock synced successfully");
|
||||
|
||||
DmsOperationStatusRequest req = new DmsOperationStatusRequest(1, 99001L, "LK-HD-101");
|
||||
DmsOperationStatusResponse resp = statusClient.queryOperationStatus(req);
|
||||
|
||||
assertNotNull(resp);
|
||||
assertEquals(Long.valueOf(99001L), resp.getOperationId());
|
||||
assertEquals("success", resp.getOperationStatus());
|
||||
assertEquals("Doorlock synced successfully", resp.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStatusMapper_AllKnownTransitions() {
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, DmsOperationStatusMapper.mapToLocalStatus("success"));
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, DmsOperationStatusMapper.mapToLocalStatus("SUCCESS"));
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_PENDING, DmsOperationStatusMapper.mapToLocalStatus("undone"));
|
||||
assertEquals(TrainExternalOperation.STATUS_PENDING, DmsOperationStatusMapper.mapToLocalStatus("UNDONE"));
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_FAILED, DmsOperationStatusMapper.mapToLocalStatus("failed"));
|
||||
assertEquals(TrainExternalOperation.STATUS_FAILED, DmsOperationStatusMapper.mapToLocalStatus("FAILED"));
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_TIMEOUT, DmsOperationStatusMapper.mapToLocalStatus("timeout"));
|
||||
assertEquals(TrainExternalOperation.STATUS_TIMEOUT, DmsOperationStatusMapper.mapToLocalStatus("TIMEOUT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStatusMapper_UnknownStatusNeverSuccess() {
|
||||
String unknownResult = DmsOperationStatusMapper.mapToLocalStatus("weird_vendor_state");
|
||||
assertEquals("UNKNOWN", unknownResult);
|
||||
assertNotEquals(TrainExternalOperation.STATUS_SUCCESS, unknownResult);
|
||||
|
||||
assertEquals("UNKNOWN", DmsOperationStatusMapper.mapToLocalStatus(null));
|
||||
assertEquals("UNKNOWN", DmsOperationStatusMapper.mapToLocalStatus(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStatusMapper_TerminalStatusIdentification() {
|
||||
assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_SUCCESS));
|
||||
assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_FAILED));
|
||||
assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_TIMEOUT));
|
||||
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_PENDING));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_CREATED));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_RETRY_WAIT));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_REQUESTED));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_ACCEPTED));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus("UNKNOWN"));
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 在线密码适配器与凭据安全测试
|
||||
*/
|
||||
public class DmsPasswordAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private ExternalCredentialGateway credentialGateway;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient);
|
||||
credentialGateway = new DmsCredentialGatewayImpl(passwordClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试正常下发 6 位限时密码,返回 keyId 与 operationId
|
||||
*/
|
||||
@Test
|
||||
public void testCreateOnlinePassword_Success() {
|
||||
DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword(
|
||||
5001L,
|
||||
"李四",
|
||||
"13900001111",
|
||||
"654321",
|
||||
"2026-09-16 10:00:00",
|
||||
"2026-09-17 18:00:00"
|
||||
);
|
||||
|
||||
assertNotNull(resp);
|
||||
assertEquals(Long.valueOf(88001L), resp.getId());
|
||||
assertEquals(Long.valueOf(99001L), resp.getOperationId());
|
||||
assertEquals("LK-HD-101", resp.getLockHardwareId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试支持 4~8 位范围内的合法密码 (如 4 位、8 位)
|
||||
*/
|
||||
@Test
|
||||
public void testCreateOnlinePassword_ValidRanges() {
|
||||
// 4 位数字密码
|
||||
DmsPasswordCreateResponse resp4 = credentialGateway.issueOnlinePassword(
|
||||
5001L, "王五", "13900002222", "1234", "2026-09-16 10:00:00", "2026-09-17 18:00:00");
|
||||
assertNotNull(resp4);
|
||||
|
||||
// 8 位数字密码
|
||||
DmsPasswordCreateResponse resp8 = credentialGateway.issueOnlinePassword(
|
||||
5001L, "赵六", "13900003333", "12345678", "2026-09-16 10:00:00", "2026-09-17 18:00:00");
|
||||
assertNotNull(resp8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试非法密码格式抛出参数异常 (少于4位、多于8位、含非数字)
|
||||
*/
|
||||
@Test
|
||||
public void testCreateOnlinePassword_InvalidFormatsRejected() {
|
||||
// 少于 4 位
|
||||
try {
|
||||
credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "123", "2026-09-16", "2026-09-17");
|
||||
fail("Should reject password < 4 digits");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
|
||||
// 多于 8 位
|
||||
try {
|
||||
credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "123456789", "2026-09-16", "2026-09-17");
|
||||
fail("Should reject password > 8 digits");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
|
||||
// 包含非数字
|
||||
try {
|
||||
credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "1234AB", "2026-09-16", "2026-09-17");
|
||||
fail("Should reject password with alphabets");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试删除在线密码,返回 operationId
|
||||
*/
|
||||
@Test
|
||||
public void testDeleteOnlinePassword_Success() {
|
||||
DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(88001L);
|
||||
assertNotNull(resp);
|
||||
assertEquals(Long.valueOf(99002L), resp.getOperationId());
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package com.ruoyi.train.integration.dms.mock;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 轻量内嵌 Mock DMS HTTP 服务端
|
||||
* <p>
|
||||
* 遵循官方 DMS 真实语义:
|
||||
* 1. 登录互斥:每次新登录生成新 Token,旧 Token 立即失效。
|
||||
* 2. 鉴权检验:所有业务接口校验 Cookie 中的 token。无效 Token 返回 401 Unauthorized。
|
||||
* 3. 读超时模拟:可模拟网络读取超时,用于检验写操作禁止盲目重发。
|
||||
* 4. 多锁/零锁模拟:支持指定房间返回 0 把锁或 3 把锁。
|
||||
* 5. 异步操作状态流转模拟:支持模拟 success, undone, failed, timeout 状态。
|
||||
*/
|
||||
public class MockDmsServer {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
|
||||
private final AtomicInteger loginCounter = new AtomicInteger(0);
|
||||
private final AtomicInteger passwordCreatePostCounter = new AtomicInteger(0);
|
||||
private final AtomicInteger passwordDeletePostCounter = new AtomicInteger(0);
|
||||
|
||||
private volatile String activeToken = null;
|
||||
private final Set<String> invalidatedTokens = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private volatile boolean simulateReadTimeout = false;
|
||||
private volatile int tokenTtlSeconds = 7200;
|
||||
|
||||
private final Map<Long, OperationStatusEntry> operationStatusMap = new ConcurrentHashMap<>();
|
||||
|
||||
public void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.setExecutor(Executors.newCachedThreadPool());
|
||||
port = server.getAddress().getPort();
|
||||
|
||||
registerEndpoints();
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return "http://127.0.0.1:" + port;
|
||||
}
|
||||
|
||||
public int getLoginCounter() {
|
||||
return loginCounter.get();
|
||||
}
|
||||
|
||||
public int getPasswordCreatePostCounter() {
|
||||
return passwordCreatePostCounter.get();
|
||||
}
|
||||
|
||||
public int getPasswordDeletePostCounter() {
|
||||
return passwordDeletePostCounter.get();
|
||||
}
|
||||
|
||||
public void setSimulateReadTimeout(boolean simulate) {
|
||||
this.simulateReadTimeout = simulate;
|
||||
}
|
||||
|
||||
public void setTokenTtlSeconds(int ttl) {
|
||||
this.tokenTtlSeconds = ttl;
|
||||
}
|
||||
|
||||
public String getActiveToken() {
|
||||
return activeToken;
|
||||
}
|
||||
|
||||
public void invalidateActiveToken() {
|
||||
if (activeToken != null) {
|
||||
invalidatedTokens.add(activeToken);
|
||||
activeToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setOperationStatus(Long operationId, String status, String message) {
|
||||
operationStatusMap.put(operationId, new OperationStatusEntry(status, message));
|
||||
}
|
||||
|
||||
private void registerEndpoints() {
|
||||
// 1. 登录获取 Token
|
||||
server.createContext("/public/rest/third-party-app/token/get", exchange -> {
|
||||
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
sendResponse(exchange, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject reqJson = JSON.parseObject(body);
|
||||
String appId = reqJson != null ? reqJson.getString("appid") : null;
|
||||
String appSecret = reqJson != null ? reqJson.getString("appsecret") : null;
|
||||
|
||||
if (appId == null || appSecret == null || appId.trim().isEmpty() || appSecret.trim().isEmpty()) {
|
||||
sendResponse(exchange, 400, "{\"success\":false,\"message\":\"Missing appId or appSecret\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
int count = loginCounter.incrementAndGet();
|
||||
// 旧 Token 立即失效
|
||||
if (activeToken != null) {
|
||||
invalidatedTokens.add(activeToken);
|
||||
}
|
||||
activeToken = "mock-token-session-" + count;
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
resp.put("token", activeToken);
|
||||
resp.put("expiresIn", tokenTtlSeconds);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 2. 建筑列表
|
||||
server.createContext("/rest/place/building/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> b1 = new HashMap<>();
|
||||
b1.put("id", 101L);
|
||||
b1.put("name", "学员1号楼");
|
||||
b1.put("buildingNumber", "B1");
|
||||
rows.add(b1);
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 3. 楼层列表
|
||||
server.createContext("/rest/place/floor/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> f1 = new HashMap<>();
|
||||
f1.put("id", 201L);
|
||||
f1.put("buildingId", 101L);
|
||||
f1.put("name", "二层");
|
||||
f1.put("floorNumber", 2);
|
||||
rows.add(f1);
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 4. 房间列表
|
||||
server.createContext("/rest/place/room/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> r1 = new HashMap<>();
|
||||
r1.put("id", 301L);
|
||||
r1.put("placeId", 10001L);
|
||||
r1.put("roomNumber", "201");
|
||||
r1.put("name", "学员宿舍201");
|
||||
rows.add(r1);
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 5. 房间详情
|
||||
server.createContext("/rest/place/room/get", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
Long roomId = req != null ? req.getLong("id") : 301L;
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("id", roomId);
|
||||
data.put("placeId", 10001L);
|
||||
data.put("roomNumber", "201");
|
||||
data.put("name", "学员宿舍201");
|
||||
resp.put("data", data);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 6. 门锁列表 (根据 placeId)
|
||||
server.createContext("/rest/doorlock/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
Long placeId = req != null ? req.getLong("placeId") : null;
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
if (placeId != null && placeId == 9999L) {
|
||||
// 模拟 0 把锁
|
||||
} else if (placeId != null && placeId == 10001L) {
|
||||
// 模拟 3 把锁 (多锁场景)
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
Map<String, Object> lock = new HashMap<>();
|
||||
lock.put("lockId", 5000L + i);
|
||||
lock.put("hardwareId", "LK-HD-10001-" + i);
|
||||
lock.put("lockName", "宿舍门锁-" + i);
|
||||
lock.put("placeId", 10001L);
|
||||
lock.put("onlineStatus", 1);
|
||||
lock.put("battery", 95);
|
||||
rows.add(lock);
|
||||
}
|
||||
} else {
|
||||
// 默认 1 把锁
|
||||
Map<String, Object> lock = new HashMap<>();
|
||||
lock.put("lockId", 5001L);
|
||||
lock.put("hardwareId", "LK-HD-DEFAULT");
|
||||
lock.put("lockName", "标准门锁");
|
||||
lock.put("placeId", placeId != null ? placeId : 10001L);
|
||||
lock.put("onlineStatus", 1);
|
||||
lock.put("battery", 90);
|
||||
rows.add(lock);
|
||||
}
|
||||
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 7. 下发在线密码 (Mutation)
|
||||
server.createContext("/rest/doorlock/online-password-key/create", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
|
||||
passwordCreatePostCounter.incrementAndGet();
|
||||
|
||||
if (simulateReadTimeout) {
|
||||
// 模拟网络读超时 (sleep 超过客户端 10000ms 或测试配置的短超时)
|
||||
try {
|
||||
Thread.sleep(1200);
|
||||
} catch (InterruptedException ignored) {}
|
||||
sendResponse(exchange, 200, "{\"success\":true}");
|
||||
return;
|
||||
}
|
||||
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("id", 88001L);
|
||||
data.put("operationId", 99001L);
|
||||
data.put("lockHardwareId", "LK-HD-101");
|
||||
data.put("smsStatus", 1);
|
||||
resp.put("data", data);
|
||||
|
||||
// 默认将该 operationId 设为 undone
|
||||
operationStatusMap.putIfAbsent(99001L, new OperationStatusEntry("undone", "Device command queued"));
|
||||
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 8. 删除在线密码 (Mutation)
|
||||
server.createContext("/rest/doorlock/online-password/delete", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
|
||||
passwordDeletePostCounter.incrementAndGet();
|
||||
|
||||
if (simulateReadTimeout) {
|
||||
try {
|
||||
Thread.sleep(1200);
|
||||
} catch (InterruptedException ignored) {}
|
||||
sendResponse(exchange, 200, "{\"success\":true}");
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("operationId", 99002L);
|
||||
resp.put("data", data);
|
||||
|
||||
operationStatusMap.putIfAbsent(99002L, new OperationStatusEntry("undone", "Delete command queued"));
|
||||
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 9. 查询门锁操作结果
|
||||
server.createContext("/rest/doorlock/query-operation-status", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
Long opId = req != null ? req.getLong("operationId") : null;
|
||||
|
||||
OperationStatusEntry entry = (opId != null && operationStatusMap.containsKey(opId))
|
||||
? operationStatusMap.get(opId)
|
||||
: new OperationStatusEntry("success", "Execution verified");
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("operationId", opId);
|
||||
data.put("operationStatus", entry.status);
|
||||
data.put("resultDetails", entry.message);
|
||||
data.put("message", entry.message);
|
||||
resp.put("data", data);
|
||||
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
}
|
||||
|
||||
private boolean verifyAuth(HttpExchange exchange) throws IOException {
|
||||
String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie");
|
||||
String token = extractCookieToken(cookieHeader);
|
||||
|
||||
if (token == null || !token.equals(activeToken) || invalidatedTokens.contains(token)) {
|
||||
sendResponse(exchange, 401, "{\"status\":401,\"error\":\"Unauthorized\",\"message\":\"Invalid or expired token\"}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String extractCookieToken(String cookieHeader) {
|
||||
if (cookieHeader == null) return null;
|
||||
for (String pair : cookieHeader.split(";")) {
|
||||
String[] kv = pair.trim().split("=", 2);
|
||||
if (kv.length == 2 && "token".equalsIgnoreCase(kv[0])) {
|
||||
return kv[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readRequestBody(HttpExchange exchange) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void sendResponse(HttpExchange exchange, int statusCode, String responseBody) throws IOException {
|
||||
byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8");
|
||||
exchange.sendResponseHeaders(statusCode, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
os.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static class OperationStatusEntry {
|
||||
public final String status;
|
||||
public final String message;
|
||||
|
||||
public OperationStatusEntry(String status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+765
@@ -0,0 +1,765 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
import com.ruoyi.train.domain.*;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.client.DmsHttpClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsPasswordClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import com.ruoyi.train.integration.dms.poller.DmsOperationPoller;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.IExternalResourceMappingService;
|
||||
import com.ruoyi.train.service.ITrainDormAuthService;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* 门禁业务授权编排端到端集成测试 (Wave 3A Dry-Run)
|
||||
* <p>
|
||||
* 遵循安全红线:
|
||||
* 1. 100% 连接轻量内嵌 MockDmsServer,零真实 DMS 请求 (LIVE_DMS_REQUEST_COUNT = 0)。
|
||||
* 2. 状态驱动对账,事件顺序无关 (Scenario A & Scenario B)。
|
||||
* 3. 调房先下发新房凭据,新房生效后注销旧房 (GRANT_NEW_THEN_REVOKE_OLD)。
|
||||
* 4. 新房发放失败严格保留旧房凭据 (TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES)。
|
||||
* 5. 最小特权主锁原则 (PRIMARY_LOCK_ONLY)。
|
||||
* 6. 迟到操作防覆写保护 (STALE_OPERATION_RESULT_GUARD)。
|
||||
* 7. 密码明文零落库与全流程脱敏。
|
||||
*/
|
||||
public class AccessAuthorizationOrchestrationTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private AccessAuthorizationOrchestrator orchestrator;
|
||||
private DmsOperationPoller poller;
|
||||
|
||||
// 内存测试数据库存储
|
||||
private final Map<Long, TrainEnrollment> enrollmentDb = new ConcurrentHashMap<>();
|
||||
private final Map<Long, TrainAccommodation> accommodationDb = new ConcurrentHashMap<>();
|
||||
private final Map<Long, TrainDormAuth> dormAuthDb = new ConcurrentHashMap<>();
|
||||
private final Map<String, TrainExternalResourceMapping> resourceMappingDb = new ConcurrentHashMap<>();
|
||||
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
|
||||
private final Map<String, TrainExternalOperation> operationIdempotencyDb = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong authSeq = new AtomicLong(100);
|
||||
private final AtomicLong opSeq = new AtomicLong(500);
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setTokenTtlSeconds(7200);
|
||||
properties.setPollIntervalSeconds(10);
|
||||
properties.setMaxRetryAttempts(3);
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient);
|
||||
DmsCredentialGatewayImpl credentialGateway = new DmsCredentialGatewayImpl(passwordClient);
|
||||
DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
|
||||
|
||||
TrainEnrollmentMapper enrollmentMapper = createMockEnrollmentMapper();
|
||||
TrainAccommodationMapper accommodationMapper = createMockAccommodationMapper();
|
||||
TrainDormAuthMapper dormAuthMapper = createMockDormAuthMapper();
|
||||
IExternalResourceMappingService mappingService = createMockMappingService();
|
||||
IExternalOperationService operationService = createMockOperationService();
|
||||
|
||||
// 构造真正的 TrainDormAuthServiceImpl 注入代理依赖
|
||||
com.ruoyi.train.service.impl.TrainDormAuthServiceImpl authService = new com.ruoyi.train.service.impl.TrainDormAuthServiceImpl();
|
||||
setPrivateField(authService, "trainDormAuthMapper", dormAuthMapper);
|
||||
setPrivateField(authService, "trainAccommodationMapper", accommodationMapper);
|
||||
|
||||
poller = new DmsOperationPoller(properties, operationService, statusClient, authService);
|
||||
|
||||
orchestrator = new AccessAuthorizationOrchestrator(
|
||||
enrollmentMapper,
|
||||
accommodationMapper,
|
||||
dormAuthMapper,
|
||||
authService,
|
||||
mappingService,
|
||||
operationService,
|
||||
credentialGateway
|
||||
);
|
||||
|
||||
// 默认初始化宿舍 201 映射:1 把主门锁 (lockId=5001)
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-201");
|
||||
room.setVendorCode("DMS");
|
||||
room.setLocalResourceType("DORM_ROOM");
|
||||
room.setExternalResourceType("ROOM");
|
||||
room.setExternalResourceId("10001");
|
||||
room.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("ROOM:DORM-201", room);
|
||||
|
||||
TrainExternalResourceMapping lock = new TrainExternalResourceMapping();
|
||||
lock.setDormId("DORM-201");
|
||||
lock.setVendorCode("DMS");
|
||||
lock.setLocalResourceType("DOOR_LOCK");
|
||||
lock.setExternalResourceType("DOOR_LOCK");
|
||||
lock.setExternalResourceId("5001");
|
||||
lock.setExternalHardwareId("LK-HD-201-MAIN");
|
||||
lock.setIsPrimary(1);
|
||||
lock.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:DORM-201:5001", lock);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
enrollmentDb.clear();
|
||||
accommodationDb.clear();
|
||||
dormAuthDb.clear();
|
||||
resourceMappingDb.clear();
|
||||
operationDb.clear();
|
||||
operationIdempotencyDb.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario A: 先 Accommodation 后 Report Confirm,最终推进到 ACTIVE
|
||||
*/
|
||||
@Test
|
||||
public void testScenarioA_AccommodationFirstThenReportConfirm() {
|
||||
Long enrollmentId = 1L;
|
||||
// 1. 学员登记但待报到 (status = 0)
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-001");
|
||||
enrollment.setPlanId("PLAN-101");
|
||||
enrollment.setEnrollmentStatus(0);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
// 2. 预先分配宿舍 201 (status = 0)
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(10L);
|
||||
acc.setEnrollmentId(enrollmentId);
|
||||
acc.setDormId("DORM-201");
|
||||
acc.setAccommodationStatus(0);
|
||||
acc.setAssignedTime(new Date());
|
||||
accommodationDb.put(10L, acc);
|
||||
|
||||
// 此时触发 reconcile -> WAITING_FOR_PREREQUISITE (未报到,严禁下发)
|
||||
ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
|
||||
assertEquals("Mock server must have 0 password creates", 0, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 3. 学员报到确认 (status -> 1)
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
|
||||
// 触发 reconcile -> 成功发起 GRANT
|
||||
ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
|
||||
assertTrue(r2.isSuccess());
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
TrainDormAuth auth = getAuthById(r2.getDoorAuthId());
|
||||
assertNotNull(auth);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
|
||||
assertNotEquals("Auth must not be ACTIVE yet immediately after HTTP call", TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 4. Poller 轮询未完成状态 (undone) -> 保持 GRANTING / PENDING
|
||||
mockServer.setOperationStatus(99001L, "undone", "Device task queued");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
|
||||
|
||||
// 5. Poller 轮询成功终态 (success) -> 推进 DoorAuthorization 为 ACTIVE
|
||||
mockServer.setOperationStatus(99001L, "success", "Lock accepted key");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
assertEquals(TrainDormAuth.STATUS_ACTIVE, auth.getFaceSyncStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario B: 先 Report Confirm 后 Accommodation 分配,最终推进到 ACTIVE
|
||||
*/
|
||||
@Test
|
||||
public void testScenarioB_ReportConfirmFirstThenAccommodation() {
|
||||
Long enrollmentId = 2L;
|
||||
// 1. 学员先报到完成 (status = 1),但尚未分房
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-002");
|
||||
enrollment.setPlanId("PLAN-101");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
// 对账 -> 等待分配宿舍
|
||||
ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 2. 后续分配宿舍 201
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(20L);
|
||||
acc.setEnrollmentId(enrollmentId);
|
||||
acc.setDormId("DORM-201");
|
||||
acc.setAccommodationStatus(1);
|
||||
acc.setAssignedTime(new Date());
|
||||
accommodationDb.put(20L, acc);
|
||||
|
||||
// 对账 -> 满足所有条件,成功发起 GRANT
|
||||
ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
|
||||
assertTrue(r2.isSuccess());
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 模拟对账对齐
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = getAuthById(r2.getDoorAuthId());
|
||||
assertNotNull(auth);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等性与防重测试:ACTIVE 后重复触发 5 次,绝不重复发码、绝不创建重复授权
|
||||
*/
|
||||
@Test
|
||||
public void testReconciliationIdempotency_NoDuplicateOperations() {
|
||||
Long enrollmentId = 3L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-003");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(30L);
|
||||
acc.setEnrollmentId(enrollmentId);
|
||||
acc.setDormId("DORM-201");
|
||||
acc.setAccommodationStatus(1);
|
||||
acc.setAssignedTime(new Date());
|
||||
accommodationDb.put(30L, acc);
|
||||
|
||||
// 首次对账
|
||||
orchestrator.reconcile(enrollmentId);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
assertEquals(1, dormAuthDb.size());
|
||||
|
||||
// 连续重复触发 5 次
|
||||
for (int i = 0; i < 5; i++) {
|
||||
ReconciliationResult res = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.NO_ACTION, res.getDecision());
|
||||
}
|
||||
|
||||
// 计数依然严格保持为 1
|
||||
assertEquals("Post counter must stay 1", 1, mockServer.getPasswordCreatePostCounter());
|
||||
assertEquals("DormAuth count must stay 1", 1, dormAuthDb.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发失败场景:设备执行失败 -> 授权为 ERROR,重新对账重试生成全新 Operation
|
||||
*/
|
||||
@Test
|
||||
public void testGrantFailureScenario_RetryGeneratesNewOperation() {
|
||||
Long enrollmentId = 4L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-004");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(40L);
|
||||
acc.setEnrollmentId(enrollmentId);
|
||||
acc.setDormId("DORM-201");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(40L, acc);
|
||||
|
||||
ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
|
||||
Long firstOpId = r1.getOperationId();
|
||||
|
||||
// 模拟外部门锁执行失败
|
||||
mockServer.setOperationStatus(99001L, "failed", "Motor jammed");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = getAuthById(r1.getDoorAuthId());
|
||||
assertNotNull(auth);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus());
|
||||
assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 再次触发对账 -> 重试生成新 Operation
|
||||
ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
|
||||
Long secondOpId = r2.getOperationId();
|
||||
|
||||
assertNotEquals("New operation ID must be generated", firstOpId, secondOpId);
|
||||
assertEquals("Previous failed operation must remain in DB",
|
||||
TrainExternalOperation.STATUS_FAILED, operationDb.get(firstOpId).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销权限生命周期:退训/结训 -> 触发 REVOKE -> poller 确认后变为 REVOKED
|
||||
*/
|
||||
@Test
|
||||
public void testRevokeLifecycle_OnEnrollmentTermination() {
|
||||
Long enrollmentId = 5L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-005");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(50L);
|
||||
acc.setEnrollmentId(enrollmentId);
|
||||
acc.setDormId("DORM-201");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(50L, acc);
|
||||
|
||||
// 先 Grant 成功并生效
|
||||
orchestrator.reconcile(enrollmentId);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = dormAuthDb.get(dormAuthDb.keySet().iterator().next());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 学员退训 (status -> 3)
|
||||
enrollment.setEnrollmentStatus(3);
|
||||
|
||||
// 触发对账 -> 进入 REVOKING
|
||||
ReconciliationResult revokeResult = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.REVOKE_REQUIRED, revokeResult.getDecision());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus());
|
||||
assertNotEquals("Auth must not be REVOKED immediately", TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus());
|
||||
|
||||
// Poller 轮询成功 -> 真正进入 REVOKED 终态
|
||||
mockServer.setOperationStatus(99002L, "success", "Delete confirmed");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus());
|
||||
assertEquals(TrainDormAuth.STATUS_REVOKED, auth.getFaceSyncStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 调房 (Transfer) 场景:新房 Grant 成功后才撤销旧房 (GRANT_NEW_THEN_REVOKE_OLD)
|
||||
*/
|
||||
@Test
|
||||
public void testTransfer_GrantNewThenRevokeOld() {
|
||||
Long enrollmentId = 6L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-006");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
// 初始宿舍 201 已入住且授权 ACTIVE
|
||||
TrainAccommodation oldAcc = new TrainAccommodation();
|
||||
oldAcc.setAccommodationId(60L);
|
||||
oldAcc.setEnrollmentId(enrollmentId);
|
||||
oldAcc.setDormId("DORM-201");
|
||||
oldAcc.setAccommodationStatus(1);
|
||||
accommodationDb.put(60L, oldAcc);
|
||||
|
||||
orchestrator.reconcile(enrollmentId);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth oldAuth = dormAuthDb.get(60L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
|
||||
|
||||
// 配置新宿舍 202 映射 (lockId=5002)
|
||||
TrainExternalResourceMapping room202 = new TrainExternalResourceMapping();
|
||||
room202.setDormId("DORM-202");
|
||||
room202.setVendorCode("DMS");
|
||||
room202.setLocalResourceType("DORM_ROOM");
|
||||
room202.setExternalResourceType("ROOM");
|
||||
room202.setExternalResourceId("10002");
|
||||
room202.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("ROOM:DORM-202", room202);
|
||||
|
||||
TrainExternalResourceMapping lock202 = new TrainExternalResourceMapping();
|
||||
lock202.setDormId("DORM-202");
|
||||
lock202.setVendorCode("DMS");
|
||||
lock202.setLocalResourceType("DOOR_LOCK");
|
||||
lock202.setExternalResourceType("DOOR_LOCK");
|
||||
lock202.setExternalResourceId("5002");
|
||||
lock202.setExternalHardwareId("LK-HD-202-MAIN");
|
||||
lock202.setIsPrimary(1);
|
||||
lock202.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:DORM-202:5002", lock202);
|
||||
|
||||
// 执行本地调房:旧住宿退宿(2),新住宿入住(1)
|
||||
oldAcc.setAccommodationStatus(2);
|
||||
|
||||
TrainAccommodation newAcc = new TrainAccommodation();
|
||||
newAcc.setAccommodationId(61L);
|
||||
newAcc.setEnrollmentId(enrollmentId);
|
||||
newAcc.setDormId("DORM-202");
|
||||
newAcc.setAccommodationStatus(1);
|
||||
accommodationDb.put(61L, newAcc);
|
||||
|
||||
// 调房后触发对账:必须先 Grant 新房 202!旧房 201 绝不撤销!
|
||||
ReconciliationResult transferR1 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, transferR1.getDecision());
|
||||
assertEquals("Old room authorization must remain ACTIVE while new room is granting",
|
||||
TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
|
||||
assertEquals("Delete request must be 0 at this point", 0, mockServer.getPasswordDeletePostCounter());
|
||||
|
||||
// 模拟新房 202 经 Poller 确认为 ACTIVE
|
||||
TrainDormAuth newAuth = dormAuthDb.get(61L);
|
||||
mockServer.setOperationStatus(99001L, "success", "New room key accepted");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, newAuth.getAuthStatus());
|
||||
|
||||
// 新房确认 ACTIVE 后,再次对账 -> 触发旧房 REVOKE
|
||||
ReconciliationResult transferR2 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.REVOKE_REQUIRED, transferR2.getDecision());
|
||||
assertEquals("Old room authorization enters REVOKING", TrainDormAuth.AUTH_STATUS_REVOKING, oldAuth.getAuthStatus());
|
||||
assertEquals(1, mockServer.getPasswordDeletePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 调房部分失败安全:新房 Grant 失败时,旧房授权严格保持 ACTIVE (TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES)
|
||||
*/
|
||||
@Test
|
||||
public void testTransfer_NewRoomGrantFailure_PreservesOldAccess() {
|
||||
Long enrollmentId = 7L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-007");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
TrainAccommodation oldAcc = new TrainAccommodation();
|
||||
oldAcc.setAccommodationId(70L);
|
||||
oldAcc.setEnrollmentId(enrollmentId);
|
||||
oldAcc.setDormId("DORM-201");
|
||||
oldAcc.setAccommodationStatus(2); // 已迁出
|
||||
accommodationDb.put(70L, oldAcc);
|
||||
|
||||
// 旧房已有有效授权
|
||||
TrainDormAuth oldAuth = new TrainDormAuth();
|
||||
oldAuth.setAuthId(700L);
|
||||
oldAuth.setAccommodationId(70L);
|
||||
oldAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
oldAuth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE);
|
||||
dormAuthDb.put(70L, oldAuth);
|
||||
|
||||
// 新房 203 (缺少映射,导致无法 Grant)
|
||||
TrainAccommodation newAcc = new TrainAccommodation();
|
||||
newAcc.setAccommodationId(71L);
|
||||
newAcc.setEnrollmentId(enrollmentId);
|
||||
newAcc.setDormId("DORM-203");
|
||||
newAcc.setAccommodationStatus(1);
|
||||
accommodationDb.put(71L, newAcc);
|
||||
|
||||
// 对账 -> 新房因缺少映射返回 WAITING
|
||||
ReconciliationResult res = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, res.getDecision());
|
||||
|
||||
// 核心安全指标:旧房授权绝对不被撤销!
|
||||
assertEquals("TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES: Old access must remain ACTIVE",
|
||||
TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
|
||||
assertEquals("Delete post count must be 0", 0, mockServer.getPasswordDeletePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 主锁策略校验 (PRIMARY_LOCK_ONLY):
|
||||
* 0 主锁 -> 不发放
|
||||
* 2 主锁 -> 配置错误不发放
|
||||
*/
|
||||
@Test
|
||||
public void testLockSelectionPolicy_PrimaryLockOnly() {
|
||||
Long enrollmentId = 8L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(enrollmentId);
|
||||
enrollment.setStudentId("STU-008");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(enrollmentId, enrollment);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(80L);
|
||||
acc.setEnrollmentId(enrollmentId);
|
||||
acc.setDormId("DORM-NO-PRIMARY");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(80L, acc);
|
||||
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-NO-PRIMARY");
|
||||
room.setVendorCode("DMS");
|
||||
room.setMappingStatus("ACTIVE");
|
||||
room.setExternalResourceType("ROOM");
|
||||
resourceMappingDb.put("ROOM:DORM-NO-PRIMARY", room);
|
||||
|
||||
// 1. 无主锁情况
|
||||
TrainExternalResourceMapping secLock = new TrainExternalResourceMapping();
|
||||
secLock.setDormId("DORM-NO-PRIMARY");
|
||||
secLock.setVendorCode("DMS");
|
||||
secLock.setIsPrimary(0); // 次锁
|
||||
secLock.setMappingStatus("ACTIVE");
|
||||
secLock.setExternalResourceType("DOOR_LOCK");
|
||||
secLock.setExternalResourceId("6001");
|
||||
resourceMappingDb.put("LOCK:DORM-NO-PRIMARY:6001", secLock);
|
||||
|
||||
ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
|
||||
assertTrue(r1.getReason().contains("0 primary lock"));
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 2. 多主锁配置错误情况
|
||||
TrainExternalResourceMapping p1 = new TrainExternalResourceMapping();
|
||||
p1.setDormId("DORM-MULTI-PRIMARY");
|
||||
p1.setIsPrimary(1);
|
||||
p1.setMappingStatus("ACTIVE");
|
||||
p1.setExternalResourceType("DOOR_LOCK");
|
||||
p1.setExternalResourceId("7001");
|
||||
resourceMappingDb.put("LOCK:DORM-MULTI-PRIMARY:7001", p1);
|
||||
|
||||
TrainExternalResourceMapping p2 = new TrainExternalResourceMapping();
|
||||
p2.setDormId("DORM-MULTI-PRIMARY");
|
||||
p2.setIsPrimary(1);
|
||||
p2.setMappingStatus("ACTIVE");
|
||||
p2.setExternalResourceType("DOOR_LOCK");
|
||||
p2.setExternalResourceId("7002");
|
||||
resourceMappingDb.put("LOCK:DORM-MULTI-PRIMARY:7002", p2);
|
||||
|
||||
TrainExternalResourceMapping multiRoom = new TrainExternalResourceMapping();
|
||||
multiRoom.setDormId("DORM-MULTI-PRIMARY");
|
||||
multiRoom.setVendorCode("DMS");
|
||||
multiRoom.setMappingStatus("ACTIVE");
|
||||
multiRoom.setExternalResourceType("ROOM");
|
||||
resourceMappingDb.put("ROOM:DORM-MULTI-PRIMARY", multiRoom);
|
||||
|
||||
acc.setDormId("DORM-MULTI-PRIMARY");
|
||||
|
||||
ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r2.getDecision());
|
||||
assertTrue(r2.getReason().contains("configuration error"));
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 防迟到覆盖保护测试 (STALE_OPERATION_RESULT_GUARD)
|
||||
* 操作 1 迟到返回 SUCCESS,但授权已进入操作 2,操作 1 绝不覆写状态
|
||||
*/
|
||||
@Test
|
||||
public void testStaleOperationProtection_NeverOverwritesNewState() {
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(900L);
|
||||
auth.setAccommodationId(90L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
auth.setLatestOperationId(502L); // 当前最新操作是 502
|
||||
dormAuthDb.put(90L, auth);
|
||||
|
||||
TrainExternalOperation staleOp = new TrainExternalOperation();
|
||||
staleOp.setOperationId(501L); // 旧操作 501
|
||||
staleOp.setDoorAuthId(900L);
|
||||
staleOp.setOperationType(TrainExternalOperation.TYPE_GRANT);
|
||||
staleOp.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
staleOp.setExternalOperationId("99001");
|
||||
operationDb.put(501L, staleOp);
|
||||
|
||||
// 模拟旧操作 501 迟到返回 FAILED
|
||||
mockServer.setOperationStatus(99001L, "failed", "Late failure");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
// 状态必须依然保持 ACTIVE,绝不能被旧操作的 FAILED 改为 ERROR!
|
||||
assertEquals("STALE_OPERATION_RESULT_GUARD: Must NOT overwrite state with stale operation result",
|
||||
TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
/* ==================== 辅助 Mock 工厂方法 ==================== */
|
||||
|
||||
private TrainDormAuth getAuthById(Long authId) {
|
||||
if (authId == null) return null;
|
||||
return dormAuthDb.values().stream().filter(a -> authId.equals(a.getAuthId())).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private TrainEnrollmentMapper createMockEnrollmentMapper() {
|
||||
return (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainEnrollmentById".equals(name)) {
|
||||
return enrollmentDb.get((Long) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainAccommodationMapper createMockAccommodationMapper() {
|
||||
return (TrainAccommodationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainAccommodationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectActiveAccommodationByEnrollmentId".equals(name)) {
|
||||
Long eid = (Long) args[0];
|
||||
return accommodationDb.values().stream()
|
||||
.filter(a -> eid.equals(a.getEnrollmentId()) && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
if ("selectAccommodationHistoryByEnrollmentId".equals(name)) {
|
||||
Long eid = (Long) args[0];
|
||||
List<TrainAccommodation> list = new ArrayList<>();
|
||||
for (TrainAccommodation a : accommodationDb.values()) {
|
||||
if (eid.equals(a.getEnrollmentId())) {
|
||||
list.add(a);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if ("selectTrainAccommodationByAccommodationId".equals(name)) {
|
||||
return accommodationDb.get((Long) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainDormAuthMapper createMockDormAuthMapper() {
|
||||
return (TrainDormAuthMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainDormAuthMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainDormAuthByAccommodationId".equals(name)) {
|
||||
return dormAuthDb.get((Long) args[0]);
|
||||
}
|
||||
if ("selectTrainDormAuthByAuthId".equals(name)) {
|
||||
Long aid = (Long) args[0];
|
||||
return dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null);
|
||||
}
|
||||
if ("insertTrainDormAuth".equals(name)) {
|
||||
TrainDormAuth auth = (TrainDormAuth) args[0];
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
dormAuthDb.put(auth.getAccommodationId(), auth);
|
||||
return 1;
|
||||
}
|
||||
if ("updateTrainDormAuth".equals(name)) {
|
||||
TrainDormAuth auth = (TrainDormAuth) args[0];
|
||||
dormAuthDb.put(auth.getAccommodationId(), auth);
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private IExternalResourceMappingService createMockMappingService() {
|
||||
return (IExternalResourceMappingService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalResourceMappingService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getActiveRoomMapping".equals(name)) {
|
||||
String dormId = (String) args[1];
|
||||
return resourceMappingDb.get("ROOM:" + dormId);
|
||||
}
|
||||
if ("getActiveLockMappings".equals(name)) {
|
||||
String dormId = (String) args[1];
|
||||
List<TrainExternalResourceMapping> locks = new ArrayList<>();
|
||||
for (Map.Entry<String, TrainExternalResourceMapping> entry : resourceMappingDb.entrySet()) {
|
||||
if (entry.getKey().startsWith("LOCK:" + dormId)) {
|
||||
locks.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private IExternalOperationService createMockOperationService() {
|
||||
return (IExternalOperationService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalOperationService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("createOperation".equals(name)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
if (op.getIdempotencyKey() != null && operationIdempotencyDb.containsKey(op.getIdempotencyKey())) {
|
||||
return operationIdempotencyDb.get(op.getIdempotencyKey());
|
||||
}
|
||||
op.setOperationId(opSeq.incrementAndGet());
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
if (op.getIdempotencyKey() != null) {
|
||||
operationIdempotencyDb.put(op.getIdempotencyKey(), op);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("bindExternalReference".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setExternalOperationId((String) args[1]);
|
||||
op.setExternalHardwareId((String) args[2]);
|
||||
op.setExternalKeyId((String) args[3]);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("transitStatus".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus((String) args[1]);
|
||||
op.setLastErrorCode((String) args[2]);
|
||||
op.setLastErrorMessage((String) args[3]);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("listPendingPollOperations".equals(name)) {
|
||||
List<TrainExternalOperation> list = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (TrainExternalOperation.STATUS_PENDING.equals(op.getOperationStatus()) ||
|
||||
TrainExternalOperation.STATUS_ACCEPTED.equals(op.getOperationStatus()) ||
|
||||
TrainExternalOperation.STATUS_RETRY_WAIT.equals(op.getOperationStatus())) {
|
||||
list.add(op);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if ("updateOperationPollResult".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus((String) args[1]);
|
||||
if (args[4] != null) op.setLastErrorCode((String) args[4]);
|
||||
if (args[5] != null) op.setLastErrorMessage((String) args[5]);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void setPrivateField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainClassPlan;
|
||||
import com.ruoyi.train.domain.TrainClassReport;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.dto.ReportConfirmDTO;
|
||||
import com.ruoyi.train.domain.vo.ReportResultVO;
|
||||
import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainClassPlanMapper;
|
||||
import com.ruoyi.train.mapper.TrainClassReportMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.service.ITrainEnrollmentService;
|
||||
import com.ruoyi.train.service.impl.EnrollmentAppServiceImpl;
|
||||
import com.ruoyi.train.service.impl.EnrollmentPlanCompletionAdapter;
|
||||
import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* 业务事务独立性与敏感数据安全测试 (R.3B Wave 3A Gate 49, 50, 51, 52)
|
||||
*/
|
||||
public class BusinessTransactionIndependenceTest {
|
||||
|
||||
/**
|
||||
* Gate 19: 验证安全密码生成器:强制 6 位纯数字、非空、且连续生成具有强随机性 (非固定弱密码)
|
||||
*/
|
||||
@Test
|
||||
public void testSecurePasswordGenerator() {
|
||||
String p1 = SecurePasswordGenerator.generateDefaultPassword();
|
||||
String p2 = SecurePasswordGenerator.generateDefaultPassword();
|
||||
|
||||
assertNotNull(p1);
|
||||
assertNotNull(p2);
|
||||
assertEquals("Password must be exactly 6 digits", 6, p1.length());
|
||||
assertEquals("Password must be exactly 6 digits", 6, p2.length());
|
||||
assertTrue("Password must consist only of digits", p1.matches("\\d{6}"));
|
||||
assertTrue("Password must consist only of digits", p2.matches("\\d{6}"));
|
||||
|
||||
assertNotEquals("Fixed passwords are strictly forbidden", "123456", p1);
|
||||
assertNotEquals("Fixed passwords are strictly forbidden", "000000", p1);
|
||||
|
||||
// 生成 20 个密码,验证随机不重复分布
|
||||
java.util.Set<String> set = new java.util.HashSet<>();
|
||||
for (int i = 0; i < 20; i++) {
|
||||
set.add(SecurePasswordGenerator.generateDefaultPassword());
|
||||
}
|
||||
assertTrue("20 generated passwords should contain high entropy diversity", set.size() >= 18);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 49: 敏感数据自动扫描:明文在线密码严禁出现在 Entity persistence, logs, exception message, toString
|
||||
*/
|
||||
@Test
|
||||
public void testSensitiveDataScans_ZeroPlaintextPasswordPersistence() throws Exception {
|
||||
String generatedPassword = SecurePasswordGenerator.generateDefaultPassword();
|
||||
|
||||
// 1. 实体字段扫描:TrainDormAuth 与 TrainExternalOperation 绝不包含 password/passwd/secret 命名字段
|
||||
for (Field f : TrainDormAuth.class.getDeclaredFields()) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("TrainDormAuth must not have plaintext password field: " + name,
|
||||
name.contains("password") || name.contains("passwd"));
|
||||
assertFalse("TrainDormAuth must not have secret field: " + name,
|
||||
name.contains("secret"));
|
||||
}
|
||||
|
||||
for (Field f : TrainExternalOperation.class.getDeclaredFields()) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("TrainExternalOperation must not have plaintext password field: " + name,
|
||||
name.contains("password") || name.contains("passwd"));
|
||||
assertFalse("TrainExternalOperation must not have secret field: " + name,
|
||||
name.contains("secret"));
|
||||
}
|
||||
|
||||
// 2. 实体 toString 扫描:绝不打印出生成的明文密码
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(101L);
|
||||
auth.setAccommodationId(202L);
|
||||
auth.setRemark("Normal auth remark without secret");
|
||||
assertFalse("TrainDormAuth toString must not contain generated password",
|
||||
auth.toString().contains(generatedPassword));
|
||||
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(303L);
|
||||
op.setExternalOperationId("EXT-303");
|
||||
op.setRemark("External op remark without secret");
|
||||
assertFalse("TrainExternalOperation toString must not contain generated password",
|
||||
op.toString().contains(generatedPassword));
|
||||
|
||||
// 3. 对账结果 toString 扫描
|
||||
ReconciliationResult result = ReconciliationResult.grantRequired(101L, 303L, true, true, "GRANT operation initiated");
|
||||
assertFalse("ReconciliationResult toString must not contain password",
|
||||
result.toString().contains(generatedPassword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 50: 报到业务事务独立性测试:Mock DMS 外部网络彻底失败时,报到确认仍成功完成,绝不回滚
|
||||
* (REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testReportConfirmExternalFailureRollback_IsNo() throws Exception {
|
||||
EnrollmentAppServiceImpl enrollmentAppService = new EnrollmentAppServiceImpl();
|
||||
|
||||
Long testEnrollmentId = 991L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(testEnrollmentId);
|
||||
enrollment.setPlanId("PLAN-MOCK-01");
|
||||
enrollment.setStudentId("STU-001");
|
||||
enrollment.setEnrollmentStatus(0);
|
||||
|
||||
TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainEnrollmentById".equals(name)) return enrollment;
|
||||
if ("confirmEnrollmentReport".equals(name)) {
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainClassReportMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainClassReportByEnrollmentId".equals(method.getName())) return null;
|
||||
if ("insertTrainClassReport".equals(method.getName())) return 1;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainClassPlanMapper planMapper = (TrainClassPlanMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainClassPlanMapper.class},
|
||||
(proxy, method, args) -> new TrainClassPlan()
|
||||
);
|
||||
|
||||
TrainStudentMapper studentMapper = (TrainStudentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
TrainStudent s = new TrainStudent();
|
||||
s.setsName("张三");
|
||||
return s;
|
||||
}
|
||||
);
|
||||
|
||||
// 模拟 AccessAuthorizationOrchestrator 抛出 DMS 外部网络彻底崩溃异常
|
||||
AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() {
|
||||
@Override
|
||||
public ReconciliationResult reconcile(Long enrollmentId) {
|
||||
throw new RuntimeException("DMS Network Connection Refused / Timeout [Gate 50]");
|
||||
}
|
||||
};
|
||||
|
||||
setField(enrollmentAppService, "trainEnrollmentMapper", enrollmentMapper);
|
||||
setField(enrollmentAppService, "trainClassReportMapper", reportMapper);
|
||||
setField(enrollmentAppService, "planMapper", planMapper);
|
||||
setField(enrollmentAppService, "studentMapper", studentMapper);
|
||||
setField(enrollmentAppService, "accessAuthorizationOrchestrator", failingOrchestrator);
|
||||
|
||||
// 执行学员报到确认
|
||||
ReportConfirmDTO dto = new ReportConfirmDTO();
|
||||
dto.setReportWay(4);
|
||||
ReportResultVO vo = enrollmentAppService.confirmReport(testEnrollmentId, dto, "admin");
|
||||
|
||||
// 验证报到业务主流程必须绝对成功!
|
||||
assertNotNull("Report confirmation must succeed despite DMS failure", vo);
|
||||
assertEquals(testEnrollmentId, vo.getEnrollmentId());
|
||||
assertEquals("Enrollment status must be confirmed to 1", Integer.valueOf(1), enrollment.getEnrollmentStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 51: 退宿业务事务独立性测试:Mock 撤销权限外部网络失败,住宿退宿仍成功,权限保持待撤销状态
|
||||
*/
|
||||
@Test
|
||||
public void testAccommodationCheckout_ExternalRevokeFailureDoesNotRollback() throws Exception {
|
||||
TrainAccommodationServiceImpl accommodationService = new TrainAccommodationServiceImpl();
|
||||
|
||||
Long accId = 881L;
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(accId);
|
||||
acc.setEnrollmentId(771L);
|
||||
acc.setAccommodationStatus(1);
|
||||
|
||||
AtomicInteger statusHolder = new AtomicInteger(1);
|
||||
|
||||
TrainAccommodationMapper accMapper = (TrainAccommodationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainAccommodationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainAccommodationByAccommodationId".equals(name)) return acc;
|
||||
if ("updateAccommodationStatus".equals(name)) {
|
||||
statusHolder.set((Integer) args[1]);
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
// 模拟 Orchestrator 在退宿触发对账时发生 DMS 远程超时
|
||||
AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() {
|
||||
@Override
|
||||
public ReconciliationResult reconcile(Long enrollmentId) {
|
||||
throw new RuntimeException("Mock DMS Revoke Network Timeout [Gate 51]");
|
||||
}
|
||||
};
|
||||
|
||||
setField(accommodationService, "trainAccommodationMapper", accMapper);
|
||||
setField(accommodationService, "accessAuthorizationOrchestrator", failingOrchestrator);
|
||||
|
||||
int rows = accommodationService.checkOut(accId, "admin");
|
||||
|
||||
assertEquals("Accommodation checkout must return 1 updated row", 1, rows);
|
||||
assertEquals("Accommodation status must become CHECKED_OUT (2)",
|
||||
TrainAccommodation.STATUS_CHECKED_OUT, Integer.valueOf(statusHolder.get()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 52: 结训业务事务独立性测试:结训联动学员状态归档时,即使外部撤权调用失败,结训主事务仍成功
|
||||
*/
|
||||
@Test
|
||||
public void testPlanComplete_ExternalRevokeFailureDoesNotRollback() throws Exception {
|
||||
EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter();
|
||||
|
||||
AtomicBoolean serviceCompleted = new AtomicBoolean(false);
|
||||
|
||||
ITrainEnrollmentService enrollmentService = (ITrainEnrollmentService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainEnrollmentService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("completeActiveEnrollmentsByPlanId".equals(method.getName())) {
|
||||
serviceCompleted.set(true);
|
||||
return 3;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() {
|
||||
@Override
|
||||
public ReconciliationResult reconcile(Long enrollmentId) {
|
||||
throw new RuntimeException("Mock DMS Remote Gateway Down [Gate 52]");
|
||||
}
|
||||
};
|
||||
|
||||
setField(adapter, "trainEnrollmentService", enrollmentService);
|
||||
setField(adapter, "accessAuthorizationOrchestrator", failingOrchestrator);
|
||||
|
||||
// 触发计划结训
|
||||
adapter.completeEnrollments("PLAN-COMP-99", "admin", new Date());
|
||||
|
||||
assertTrue("Plan enrollment completion must succeed despite external DMS revoke failures", serviceCompleted.get());
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field f = target.getClass().getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
f.set(target, value);
|
||||
}
|
||||
}
|
||||
+932
@@ -0,0 +1,932 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
import com.ruoyi.train.domain.*;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.client.DmsHttpClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsPasswordClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import com.ruoyi.train.integration.dms.poller.DmsOperationPoller;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.IExternalResourceMappingService;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* R.3B Wave 3B 完整端到端 15 业务场景验收测试套件 (Mock DMS E2E)
|
||||
*
|
||||
* 覆盖场景清单:
|
||||
* 1. 先Accommodation后Report -> Grant -> accepted -> poll success -> ACTIVE
|
||||
* 2. 先Report后Accommodation -> WAITING -> Accommodation后Grant -> ACTIVE
|
||||
* 3. 重复Report + reconcile x5 -> authorization 1条 -> credential create 1次
|
||||
* 4. Grant failed -> Authorization非ACTIVE -> retry生成新Operation
|
||||
* 5. Grant timeout -> 不ACTIVE -> 历史保留
|
||||
* 6. 退训 -> Revoke -> poll success -> REVOKED
|
||||
* 7. 完成 -> Revoke -> REVOKED
|
||||
* 8. 请假 -> 不自动Revoke (LEAVE_AUTO_REVOKE_POLICY = OPEN_QUESTION)
|
||||
* 9. 调房成功 -> Grant new first -> New ACTIVE -> Revoke old -> Old REVOKED
|
||||
* 10. 调房新房Grant失败 -> Old仍ACTIVE -> Old revoke count = 0
|
||||
* 11. 0 primary lock -> no grant
|
||||
* 12. 2 primary locks -> config error/no grant
|
||||
* 13. 1 primary + multiple secondary -> 只grant primary
|
||||
* 14. DMS network failure -> Report Confirm不回滚
|
||||
* 15. DMS revoke failure -> checkout/plan complete不回滚
|
||||
*/
|
||||
public class DmsMockE2EScenariosSuiteTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private AccessAuthorizationOrchestrator orchestrator;
|
||||
private DmsOperationPoller poller;
|
||||
|
||||
private final Map<Long, TrainEnrollment> enrollmentDb = new ConcurrentHashMap<>();
|
||||
private final Map<Long, TrainAccommodation> accommodationDb = new ConcurrentHashMap<>();
|
||||
private final Map<Long, TrainDormAuth> dormAuthDb = new ConcurrentHashMap<>();
|
||||
private final Map<String, TrainExternalResourceMapping> resourceMappingDb = new ConcurrentHashMap<>();
|
||||
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
|
||||
private final Map<String, TrainExternalOperation> operationIdempotencyDb = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong authSeq = new AtomicLong(1000);
|
||||
private final AtomicLong opSeq = new AtomicLong(5000);
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-suite-app-id");
|
||||
properties.setAppSecret("test-suite-app-secret");
|
||||
properties.setTokenTtlSeconds(7200);
|
||||
properties.setPollIntervalSeconds(10);
|
||||
properties.setMaxRetryAttempts(3);
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient);
|
||||
DmsCredentialGatewayImpl credentialGateway = new DmsCredentialGatewayImpl(passwordClient);
|
||||
DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
|
||||
|
||||
TrainEnrollmentMapper enrollmentMapper = createMockEnrollmentMapper();
|
||||
TrainAccommodationMapper accommodationMapper = createMockAccommodationMapper();
|
||||
TrainDormAuthMapper dormAuthMapper = createMockDormAuthMapper();
|
||||
IExternalResourceMappingService mappingService = createMockMappingService();
|
||||
IExternalOperationService operationService = createMockOperationService();
|
||||
|
||||
com.ruoyi.train.service.impl.TrainDormAuthServiceImpl authService = new com.ruoyi.train.service.impl.TrainDormAuthServiceImpl();
|
||||
setPrivateField(authService, "trainDormAuthMapper", dormAuthMapper);
|
||||
setPrivateField(authService, "trainAccommodationMapper", accommodationMapper);
|
||||
|
||||
poller = new DmsOperationPoller(properties, operationService, statusClient, authService);
|
||||
|
||||
orchestrator = new AccessAuthorizationOrchestrator(
|
||||
enrollmentMapper,
|
||||
accommodationMapper,
|
||||
dormAuthMapper,
|
||||
authService,
|
||||
mappingService,
|
||||
operationService,
|
||||
credentialGateway
|
||||
);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
enrollmentDb.clear();
|
||||
accommodationDb.clear();
|
||||
dormAuthDb.clear();
|
||||
resourceMappingDb.clear();
|
||||
operationDb.clear();
|
||||
operationIdempotencyDb.clear();
|
||||
}
|
||||
|
||||
// 辅助方法:注册标准房间与门锁映射
|
||||
private void setupStandardMapping(String dormId, String roomId, String lockId, String hardwareId) {
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId(dormId);
|
||||
room.setVendorCode("DMS");
|
||||
room.setLocalResourceType("DORM_ROOM");
|
||||
room.setExternalResourceType("ROOM");
|
||||
room.setExternalResourceId(roomId);
|
||||
room.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("ROOM:" + dormId, room);
|
||||
|
||||
TrainExternalResourceMapping lock = new TrainExternalResourceMapping();
|
||||
lock.setDormId(dormId);
|
||||
lock.setVendorCode("DMS");
|
||||
lock.setLocalResourceType("DOOR_LOCK");
|
||||
lock.setExternalResourceType("DOOR_LOCK");
|
||||
lock.setExternalResourceId(lockId);
|
||||
lock.setExternalHardwareId(hardwareId);
|
||||
lock.setIsPrimary(1);
|
||||
lock.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:" + dormId + ":" + lockId, lock);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 1: 先Accommodation后Report -> Grant -> accepted -> poll success -> ACTIVE
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test01_AccommodationFirstThenReport_GrantPollSuccessActive() {
|
||||
Long eid = 101L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-101");
|
||||
enrollment.setEnrollmentStatus(0); // 待报到
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-101", "1001", "5001", "HW-101-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1001L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-101");
|
||||
acc.setAccommodationStatus(0); // 预分配
|
||||
accommodationDb.put(1001L, acc);
|
||||
|
||||
// 未报到 -> WAITING_FOR_PREREQUISITE
|
||||
ReconciliationResult r1 = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 报到确认
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
acc.setAccommodationStatus(1);
|
||||
ReconciliationResult r2 = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
TrainDormAuth auth = dormAuthDb.get(1001L);
|
||||
assertNotNull(auth);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
|
||||
|
||||
// Poller 轮询成功终态 -> 变更为 ACTIVE
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 2: 先Report后Accommodation -> WAITING -> Accommodation后Grant -> ACTIVE
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test02_ReportFirstThenAccommodation_WaitingThenGrantPollSuccessActive() {
|
||||
Long eid = 102L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-102");
|
||||
enrollment.setEnrollmentStatus(1); // 已报到
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-102", "1002", "5002", "HW-102-MAIN");
|
||||
|
||||
// 暂无住宿 -> WAITING_FOR_PREREQUISITE
|
||||
ReconciliationResult r1 = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 分配住宿
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1002L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-102");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1002L, acc);
|
||||
|
||||
ReconciliationResult r2 = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
TrainDormAuth auth = dormAuthDb.get(1002L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 3: 重复Report + reconcile x5 -> authorization 1条 -> credential create 1次
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test03_DuplicateReportAndReconcile5Times_Idempotent() {
|
||||
Long eid = 103L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-103");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-103", "1003", "5003", "HW-103-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1003L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-103");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1003L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = dormAuthDb.get(1003L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
// 重复触发 5 次
|
||||
for (int i = 0; i < 5; i++) {
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.NO_ACTION, r.getDecision());
|
||||
}
|
||||
|
||||
assertEquals("Create count must remain exactly 1", 1, mockServer.getPasswordCreatePostCounter());
|
||||
assertEquals("Authorization must be exactly 1 record", 1, dormAuthDb.size());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 4: Grant failed -> Authorization非ACTIVE -> retry生成新Operation
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test04_GrantFailed_AuthorizationNonActive_RetryGeneratesNewOperation() {
|
||||
Long eid = 104L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-104");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-104", "1004", "5004", "HW-104-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1004L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-104");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1004L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
TrainDormAuth auth = dormAuthDb.get(1004L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
|
||||
|
||||
// DMS 异步执行失败
|
||||
mockServer.setOperationStatus(99001L, "failed", "Hardware busy / timeout");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
// 验证非 ACTIVE,进入 ERROR
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus());
|
||||
assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 再次触发对账 -> 重试生成新 Operation
|
||||
ReconciliationResult retryResult = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, retryResult.getDecision());
|
||||
assertEquals(2, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
mockServer.setOperationStatus(99001L, "success", "Recovered");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 5: Grant timeout -> 不ACTIVE -> 历史保留
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test05_GrantTimeout_RemainsNonActive_HistoryPreserved() {
|
||||
Long eid = 105L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-105");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-105", "1005", "5005", "HW-105-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1005L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-105");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1005L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
TrainDormAuth auth = dormAuthDb.get(1005L);
|
||||
|
||||
// 轮询返回尚未完成状态 (undone)
|
||||
mockServer.setOperationStatus(99001L, "undone", "Device task queued in gateway");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
// 仍保持 GRANTING,严禁进入 ACTIVE
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
|
||||
assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 流水历史完整保留
|
||||
assertEquals(1, operationDb.size());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 6: 退训 -> Revoke -> poll success -> REVOKED
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test06_WithdrawalQuit_RevokePollSuccessRevoked() {
|
||||
Long eid = 106L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-106");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-106", "1006", "5006", "HW-106-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1006L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-106");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1006L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = dormAuthDb.get(1006L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 学员退训 (status = 3)
|
||||
enrollment.setEnrollmentStatus(3);
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.REVOKE_REQUIRED, r.getDecision());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus());
|
||||
assertEquals(1, mockServer.getPasswordDeletePostCounter());
|
||||
|
||||
mockServer.setOperationStatus(99002L, "success", "Revoked");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 7: 完成 -> Revoke -> REVOKED
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test07_PlanComplete_RevokePollSuccessRevoked() {
|
||||
Long eid = 107L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-107");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-107", "1007", "5007", "HW-107-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1007L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-107");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1007L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = dormAuthDb.get(1007L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 结训完成 (status = 4 已完成)
|
||||
enrollment.setEnrollmentStatus(4);
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.REVOKE_REQUIRED, r.getDecision());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus());
|
||||
|
||||
mockServer.setOperationStatus(99002L, "success", "Completed Revoke");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 8: 请假 -> 不自动Revoke (保留开放策略)
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test08_Leave_NoAutoRevokePolicyPreserved() {
|
||||
Long eid = 108L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-108");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-108", "1008", "5008", "HW-108-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1008L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-108");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1008L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth auth = dormAuthDb.get(1008L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
|
||||
// 请假期间宿舍状态仍为已入住 (1),触发对账不生成任何 Revoke
|
||||
ReconciliationResult leaveReconcile = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.NO_ACTION, leaveReconcile.getDecision());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
|
||||
assertEquals(0, mockServer.getPasswordDeletePostCounter());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 9: 调房成功 -> Grant new first -> New ACTIVE -> Revoke old -> Old REVOKED
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test09_TransferSuccess_GrantNewFirstNewActiveThenRevokeOldRevoked() {
|
||||
Long eid = 109L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-109");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-109A", "10091", "50091", "HW-109A-MAIN");
|
||||
setupStandardMapping("DORM-109B", "10092", "50092", "HW-109B-MAIN");
|
||||
|
||||
TrainAccommodation oldAcc = new TrainAccommodation();
|
||||
oldAcc.setAccommodationId(1091L);
|
||||
oldAcc.setEnrollmentId(eid);
|
||||
oldAcc.setDormId("DORM-109A");
|
||||
oldAcc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1091L, oldAcc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
TrainDormAuth oldAuth = dormAuthDb.get(1091L);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
|
||||
|
||||
// 调房:旧房退宿,新房入住
|
||||
oldAcc.setAccommodationStatus(2);
|
||||
TrainAccommodation newAcc = new TrainAccommodation();
|
||||
newAcc.setAccommodationId(1092L);
|
||||
newAcc.setEnrollmentId(eid);
|
||||
newAcc.setDormId("DORM-109B");
|
||||
newAcc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1092L, newAcc);
|
||||
|
||||
// 调房对账 1:必须先 Grant 新房!旧房保持 ACTIVE!
|
||||
ReconciliationResult r1 = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r1.getDecision());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
|
||||
assertEquals(0, mockServer.getPasswordDeletePostCounter());
|
||||
|
||||
TrainDormAuth newAuth = dormAuthDb.get(1092L);
|
||||
assertNotNull(newAuth);
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, newAuth.getAuthStatus());
|
||||
|
||||
// 新房生效为 ACTIVE
|
||||
mockServer.setOperationStatus(99001L, "success", "New room active");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, newAuth.getAuthStatus());
|
||||
|
||||
// 调房对账 2:新房 ACTIVE 后,触发旧房撤销
|
||||
ReconciliationResult r2 = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.REVOKE_REQUIRED, r2.getDecision());
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, oldAuth.getAuthStatus());
|
||||
assertEquals(1, mockServer.getPasswordDeletePostCounter());
|
||||
|
||||
mockServer.setOperationStatus(99002L, "success", "Old room revoked");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, oldAuth.getAuthStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 10: 调房新房Grant失败 -> Old仍ACTIVE -> Old revoke count = 0
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test10_TransferFailure_NewRoomGrantFailedOldRemainsActiveZeroRevoke() {
|
||||
Long eid = 110L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-110");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-110A", "10101", "50101", "HW-110A-MAIN");
|
||||
|
||||
TrainAccommodation oldAcc = new TrainAccommodation();
|
||||
oldAcc.setAccommodationId(1101L);
|
||||
oldAcc.setEnrollmentId(eid);
|
||||
oldAcc.setDormId("DORM-110A");
|
||||
oldAcc.setAccommodationStatus(2); // 旧房迁出
|
||||
accommodationDb.put(1101L, oldAcc);
|
||||
|
||||
TrainDormAuth oldAuth = new TrainDormAuth();
|
||||
oldAuth.setAuthId(1101L);
|
||||
oldAuth.setAccommodationId(1101L);
|
||||
oldAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
dormAuthDb.put(1101L, oldAuth);
|
||||
|
||||
// 新房 110B (未配置外部映射)
|
||||
TrainAccommodation newAcc = new TrainAccommodation();
|
||||
newAcc.setAccommodationId(1102L);
|
||||
newAcc.setEnrollmentId(eid);
|
||||
newAcc.setDormId("DORM-110B");
|
||||
newAcc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1102L, newAcc);
|
||||
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r.getDecision());
|
||||
|
||||
// 关键安全断言:新房未发放成功时,旧房严格保持 ACTIVE,撤销调用次数绝对为 0
|
||||
assertEquals("Old room access must strictly remain ACTIVE", TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
|
||||
assertEquals("Old room revoke call count must be 0", 0, mockServer.getPasswordDeletePostCounter());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 11: 0 primary lock -> no grant
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test11_ZeroPrimaryLock_NoGrant() {
|
||||
Long eid = 111L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-111");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
// 仅配置次要锁 (isPrimary = 0)
|
||||
TrainExternalResourceMapping secondaryLock = new TrainExternalResourceMapping();
|
||||
secondaryLock.setDormId("DORM-111");
|
||||
secondaryLock.setVendorCode("DMS");
|
||||
secondaryLock.setLocalResourceType("DOOR_LOCK");
|
||||
secondaryLock.setExternalResourceType("DOOR_LOCK");
|
||||
secondaryLock.setExternalResourceId("5111");
|
||||
secondaryLock.setIsPrimary(0);
|
||||
secondaryLock.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:DORM-111:5111", secondaryLock);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1111L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-111");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1111L, acc);
|
||||
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r.getDecision());
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 12: 2 primary locks -> config error/no grant
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test12_TwoPrimaryLocks_ConfigErrorNoGrant() {
|
||||
Long eid = 112L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-112");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
// 配置 2 把主锁
|
||||
TrainExternalResourceMapping pri1 = new TrainExternalResourceMapping();
|
||||
pri1.setDormId("DORM-112");
|
||||
pri1.setVendorCode("DMS");
|
||||
pri1.setLocalResourceType("DOOR_LOCK");
|
||||
pri1.setExternalResourceType("DOOR_LOCK");
|
||||
pri1.setExternalResourceId("51121");
|
||||
pri1.setIsPrimary(1);
|
||||
pri1.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:DORM-112:51121", pri1);
|
||||
|
||||
TrainExternalResourceMapping pri2 = new TrainExternalResourceMapping();
|
||||
pri2.setDormId("DORM-112");
|
||||
pri2.setVendorCode("DMS");
|
||||
pri2.setLocalResourceType("DOOR_LOCK");
|
||||
pri2.setExternalResourceType("DOOR_LOCK");
|
||||
pri2.setExternalResourceId("51122");
|
||||
pri2.setIsPrimary(1);
|
||||
pri2.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:DORM-112:51122", pri2);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1112L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-112");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1112L, acc);
|
||||
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r.getDecision());
|
||||
assertEquals(0, mockServer.getPasswordCreatePostCounter());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 13: 1 primary + multiple secondary -> 只grant primary
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test13_OnePrimaryPlusMultipleSecondary_GrantsPrimaryOnly() {
|
||||
Long eid = 113L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-113");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
// 1 主锁 + 1 房间映射
|
||||
setupStandardMapping("DORM-113", "1013", "51130", "HW-113-PRIMARY");
|
||||
|
||||
// 附加次要锁
|
||||
TrainExternalResourceMapping sec1 = new TrainExternalResourceMapping();
|
||||
sec1.setDormId("DORM-113");
|
||||
sec1.setVendorCode("DMS");
|
||||
sec1.setLocalResourceType("DOOR_LOCK");
|
||||
sec1.setExternalResourceType("DOOR_LOCK");
|
||||
sec1.setExternalResourceId("51131");
|
||||
sec1.setIsPrimary(0);
|
||||
sec1.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("LOCK:DORM-113:51131", sec1);
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1113L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-113");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1113L, acc);
|
||||
|
||||
ReconciliationResult r = orchestrator.reconcile(eid);
|
||||
assertEquals(ReconciliationDecision.GRANT_REQUIRED, r.getDecision());
|
||||
assertEquals(1, mockServer.getPasswordCreatePostCounter());
|
||||
|
||||
TrainExternalOperation op = operationDb.values().iterator().next();
|
||||
assertEquals("LK-HD-101", op.getExternalHardwareId());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 14: DMS network failure -> Report Confirm不回滚
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test14_DmsNetworkFailure_ReportConfirmDoesNotRollback() {
|
||||
Long eid = 114L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-114");
|
||||
enrollment.setEnrollmentStatus(1); // 报到成功完成
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-114", "1014", "5014", "HW-114-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1114L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-114");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1114L, acc);
|
||||
|
||||
// 停止 Mock Server 模拟网络崩溃
|
||||
mockServer.stop();
|
||||
|
||||
try {
|
||||
orchestrator.reconcile(eid);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 核心事务独立性断言:学员报到状态保持 1,不因外部接口通信失败回滚!
|
||||
assertEquals(Integer.valueOf(1), enrollment.getEnrollmentStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 场景 15: DMS revoke failure -> checkout/plan complete不回滚
|
||||
// ==========================================
|
||||
@Test
|
||||
public void test15_DmsRevokeFailure_CheckoutPlanCompleteDoesNotRollback() {
|
||||
Long eid = 115L;
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setEnrollmentId(eid);
|
||||
enrollment.setStudentId("STU-115");
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(eid, enrollment);
|
||||
|
||||
setupStandardMapping("DORM-115", "1015", "5015", "HW-115-MAIN");
|
||||
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(1115L);
|
||||
acc.setEnrollmentId(eid);
|
||||
acc.setDormId("DORM-115");
|
||||
acc.setAccommodationStatus(1);
|
||||
accommodationDb.put(1115L, acc);
|
||||
|
||||
orchestrator.reconcile(eid);
|
||||
mockServer.setOperationStatus(99001L, "success", "Success");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
// 结训退宿本地已完成 (status = 2)
|
||||
acc.setAccommodationStatus(2);
|
||||
|
||||
// 模拟外部网络中断
|
||||
mockServer.stop();
|
||||
|
||||
try {
|
||||
orchestrator.reconcile(eid);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 核心事务独立性断言:本地退房状态保持 2,不回滚!
|
||||
assertEquals(Integer.valueOf(2), acc.getAccommodationStatus());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Mock Mapper & Service 桩实现
|
||||
// ==========================================
|
||||
private TrainEnrollmentMapper createMockEnrollmentMapper() {
|
||||
return (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByEnrollmentId".equals(name)) {
|
||||
return enrollmentDb.get((Long) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainAccommodationMapper createMockAccommodationMapper() {
|
||||
return (TrainAccommodationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainAccommodationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectActiveAccommodationByEnrollmentId".equals(name)) {
|
||||
Long eid = (Long) args[0];
|
||||
return accommodationDb.values().stream()
|
||||
.filter(a -> eid.equals(a.getEnrollmentId()) && (a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1)))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
if ("selectAccommodationHistoryByEnrollmentId".equals(name)) {
|
||||
Long eid = (Long) args[0];
|
||||
List<TrainAccommodation> list = new ArrayList<>();
|
||||
for (TrainAccommodation a : accommodationDb.values()) {
|
||||
if (eid.equals(a.getEnrollmentId())) {
|
||||
list.add(a);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if ("selectTrainAccommodationByAccommodationId".equals(name)) {
|
||||
return accommodationDb.get((Long) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainDormAuthMapper createMockDormAuthMapper() {
|
||||
return (TrainDormAuthMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainDormAuthMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainDormAuthByAccommodationId".equals(name)) {
|
||||
return dormAuthDb.get((Long) args[0]);
|
||||
}
|
||||
if ("selectTrainDormAuthByAuthId".equals(name)) {
|
||||
Long aid = (Long) args[0];
|
||||
return dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null);
|
||||
}
|
||||
if ("insertTrainDormAuth".equals(name)) {
|
||||
TrainDormAuth auth = (TrainDormAuth) args[0];
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
dormAuthDb.put(auth.getAccommodationId(), auth);
|
||||
return 1;
|
||||
}
|
||||
if ("updateTrainDormAuth".equals(name)) {
|
||||
TrainDormAuth auth = (TrainDormAuth) args[0];
|
||||
dormAuthDb.put(auth.getAccommodationId(), auth);
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private IExternalResourceMappingService createMockMappingService() {
|
||||
return (IExternalResourceMappingService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalResourceMappingService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getActiveRoomMapping".equals(name)) {
|
||||
String dormId = (String) args[1];
|
||||
return resourceMappingDb.get("ROOM:" + dormId);
|
||||
}
|
||||
if ("getActiveLockMappings".equals(name)) {
|
||||
String dormId = (String) args[1];
|
||||
List<TrainExternalResourceMapping> locks = new ArrayList<>();
|
||||
for (Map.Entry<String, TrainExternalResourceMapping> entry : resourceMappingDb.entrySet()) {
|
||||
if (entry.getKey().startsWith("LOCK:" + dormId)) {
|
||||
locks.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private IExternalOperationService createMockOperationService() {
|
||||
return (IExternalOperationService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalOperationService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("createOperation".equals(name)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
if (op.getIdempotencyKey() != null && operationIdempotencyDb.containsKey(op.getIdempotencyKey())) {
|
||||
return operationIdempotencyDb.get(op.getIdempotencyKey());
|
||||
}
|
||||
op.setOperationId(opSeq.incrementAndGet());
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
if (op.getIdempotencyKey() != null) {
|
||||
operationIdempotencyDb.put(op.getIdempotencyKey(), op);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("bindExternalReference".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setExternalOperationId((String) args[1]);
|
||||
op.setExternalHardwareId((String) args[2]);
|
||||
op.setExternalKeyId((String) args[3]);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("transitStatus".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus((String) args[1]);
|
||||
op.setLastErrorCode((String) args[2]);
|
||||
op.setLastErrorMessage((String) args[3]);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("listPendingPollOperations".equals(name)) {
|
||||
List<TrainExternalOperation> list = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (TrainExternalOperation.STATUS_PENDING.equals(op.getOperationStatus()) ||
|
||||
TrainExternalOperation.STATUS_ACCEPTED.equals(op.getOperationStatus()) ||
|
||||
TrainExternalOperation.STATUS_RETRY_WAIT.equals(op.getOperationStatus())) {
|
||||
list.add(op);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if ("updateOperationPollResult".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus((String) args[1]);
|
||||
if (args[4] != null) op.setLastErrorCode((String) args[4]);
|
||||
if (args[5] != null) op.setLastErrorMessage((String) args[5]);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void setPrivateField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package com.ruoyi.train.integration.dms.poller;
|
||||
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.client.DmsHttpClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 异步操作结果轮询器测试
|
||||
*/
|
||||
public class DmsOperationPollerTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsOperationPoller poller;
|
||||
|
||||
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setPollIntervalSeconds(10);
|
||||
properties.setMaxRetryAttempts(3);
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
|
||||
|
||||
// 内存模拟 IExternalOperationService
|
||||
IExternalOperationService mockOpService = createMockOperationService();
|
||||
|
||||
poller = new DmsOperationPoller(properties, mockOpService, statusClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
operationDb.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证安全默认:enabled = false 时,scheduledPoll() 绝不执行轮询
|
||||
*/
|
||||
@Test
|
||||
public void testSafeDefaults_DisabledPollerDoesNothing() {
|
||||
properties.setEnabled(false);
|
||||
|
||||
// 存入一条待处理记录
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(1L);
|
||||
op.setExternalOperationId("99001");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
operationDb.put(1L, op);
|
||||
|
||||
poller.scheduledPoll();
|
||||
|
||||
// 状态应保持不变,Mock Server 未收到请求
|
||||
assertEquals(TrainExternalOperation.STATUS_ACCEPTED, operationDb.get(1L).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证轮询流转到 SUCCESS 终态 (POLL_SUCCESS_RESULT)
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_ReachesSuccess() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(101L);
|
||||
op.setExternalOperationId("99001");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
operationDb.put(101L, op);
|
||||
|
||||
mockServer.setOperationStatus(99001L, "success", "Lock firmware acknowledged");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(101L);
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证轮询流转到 FAILED 终态 (POLL_FAILED_RESULT)
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_ReachesFailed() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(102L);
|
||||
op.setExternalOperationId("99002");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
operationDb.put(102L, op);
|
||||
|
||||
mockServer.setOperationStatus(99002L, "failed", "Lock motor jammed");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(102L);
|
||||
assertEquals(TrainExternalOperation.STATUS_FAILED, updated.getOperationStatus());
|
||||
assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_FAILED, updated.getLastErrorCode());
|
||||
assertEquals("Lock motor jammed", updated.getLastErrorMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证轮询流转到 TIMEOUT 终态 (POLL_TIMEOUT_RESULT)
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_ReachesTimeout() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(103L);
|
||||
op.setExternalOperationId("99003");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
operationDb.put(103L, op);
|
||||
|
||||
mockServer.setOperationStatus(99003L, "timeout", "Device communication timed out");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(103L);
|
||||
assertEquals(TrainExternalOperation.STATUS_TIMEOUT, updated.getOperationStatus());
|
||||
assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_TIMEOUT, updated.getLastErrorCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证供应商未完成 (undone) 时保持 PENDING 并延展下一次轮询时间
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_PendingUndoneRetained() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(104L);
|
||||
op.setExternalOperationId("99004");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
op.setAttemptCount(1);
|
||||
operationDb.put(104L, op);
|
||||
|
||||
mockServer.setOperationStatus(99004L, "undone", "Device task queued");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(104L);
|
||||
assertEquals(TrainExternalOperation.STATUS_PENDING, updated.getOperationStatus());
|
||||
assertNotNull(updated.getNextPollTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已处于终态的记录绝不被再次轮询篡改 (终态保护)
|
||||
*/
|
||||
@Test
|
||||
public void testTerminalStateProtection_NeverRewritten() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(105L);
|
||||
op.setExternalOperationId("99005");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS); // 已经是终态
|
||||
operationDb.put(105L, op);
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(0, processed); // 直接跳过
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, operationDb.get(105L).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知供应商状态严禁判定为 SUCCESS (UNKNOWN_VENDOR_STATUS_FAIL_CLOSED = YES)
|
||||
*/
|
||||
@Test
|
||||
public void testUnknownVendorStatus_FailClosed() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(106L);
|
||||
op.setExternalOperationId("99006");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
operationDb.put(106L, op);
|
||||
|
||||
mockServer.setOperationStatus(99006L, "unknown_vendor_code_999", "Vendor returned undocumented status");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(106L);
|
||||
assertNotEquals("Unknown vendor status must NEVER be mapped to SUCCESS",
|
||||
TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus());
|
||||
assertEquals("Unknown status must record error code",
|
||||
DmsOperationPoller.ERROR_CATEGORY_UNKNOWN_STATUS, updated.getLastErrorCode());
|
||||
}
|
||||
|
||||
private IExternalOperationService createMockOperationService() {
|
||||
return (IExternalOperationService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalOperationService.class},
|
||||
(proxy, method, args) -> {
|
||||
String methodName = method.getName();
|
||||
if ("listPendingPollOperations".equals(methodName)) {
|
||||
List<TrainExternalOperation> list = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (!TrainExternalOperation.STATUS_SUCCESS.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_FAILED.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_TIMEOUT.equals(op.getOperationStatus())) {
|
||||
list.add(op);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} else if ("updateOperationPollResult".equals(methodName)) {
|
||||
Long opId = (Long) args[0];
|
||||
String newStatus = (String) args[1];
|
||||
Date nextPoll = (Date) args[2];
|
||||
Integer attemptCount = (Integer) args[3];
|
||||
String lastErrorCode = (String) args[4];
|
||||
String lastErrorMessage = (String) args[5];
|
||||
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus(newStatus);
|
||||
if (nextPoll != null) op.setNextPollTime(nextPoll);
|
||||
if (attemptCount != null) op.setAttemptCount(attemptCount);
|
||||
if (lastErrorCode != null) op.setLastErrorCode(lastErrorCode);
|
||||
if (lastErrorMessage != null) op.setLastErrorMessage(lastErrorMessage);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+585
@@ -0,0 +1,585 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
|
||||
/**
|
||||
* R.3B Wave 0 - 本地住宿领域对齐与约束单元测试 (AccommodationServiceTest)
|
||||
* 覆盖规范第29条全部要求:A到I
|
||||
*/
|
||||
public class AccommodationServiceTest
|
||||
{
|
||||
private TrainAccommodationServiceImpl accommodationService;
|
||||
private MockAccommodationMapper mockAccommodationMapper;
|
||||
private Map<String, TrainDormitoryInfo> dormStore;
|
||||
private Map<Long, TrainEnrollment> enrollmentStore;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
accommodationService = new TrainAccommodationServiceImpl();
|
||||
mockAccommodationMapper = new MockAccommodationMapper();
|
||||
dormStore = new HashMap<>();
|
||||
enrollmentStore = new HashMap<>();
|
||||
|
||||
TrainDormitoryInfoMapper dormMapperProxy = (TrainDormitoryInfoMapper) Proxy.newProxyInstance(
|
||||
TrainDormitoryInfoMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainDormitoryInfoMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainDormitoryInfoByDormId".equals(method.getName()))
|
||||
{
|
||||
return dormStore.get(args[0]);
|
||||
}
|
||||
if (method.getReturnType().equals(int.class)) return 0;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainEnrollmentMapper enrollMapperProxy = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
TrainEnrollmentMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainEnrollmentById".equals(method.getName()))
|
||||
{
|
||||
return enrollmentStore.get(args[0]);
|
||||
}
|
||||
if (method.getReturnType().equals(int.class)) return 0;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
setField(accommodationService, "trainAccommodationMapper", mockAccommodationMapper);
|
||||
setField(accommodationService, "trainDormitoryInfoMapper", dormMapperProxy);
|
||||
setField(accommodationService, "trainEnrollmentMapper", enrollMapperProxy);
|
||||
|
||||
// 初始化预设基础数据
|
||||
TrainDormitoryInfo dorm101 = new TrainDormitoryInfo();
|
||||
dorm101.setDormId("DORM_101");
|
||||
dorm101.setDormBuilding("培训1号楼");
|
||||
dorm101.setDormFloor("1");
|
||||
dorm101.setDormRoom("101");
|
||||
dorm101.setCapacity(2); // 容量为2人
|
||||
dorm101.setDormStatus(1); // 启用
|
||||
dormStore.put(dorm101.getDormId(), dorm101);
|
||||
|
||||
TrainDormitoryInfo dormDisabled = new TrainDormitoryInfo();
|
||||
dormDisabled.setDormId("DORM_DIS");
|
||||
dormDisabled.setDormBuilding("培训1号楼");
|
||||
dormDisabled.setDormRoom("102");
|
||||
dormDisabled.setCapacity(4);
|
||||
dormDisabled.setDormStatus(0); // 禁用
|
||||
dormStore.put(dormDisabled.getDormId(), dormDisabled);
|
||||
|
||||
TrainEnrollment enrollment1 = new TrainEnrollment();
|
||||
enrollment1.setEnrollmentId(1001L);
|
||||
enrollment1.setPlanId("PLAN_2026_01");
|
||||
enrollment1.setStudentId("STU_001");
|
||||
enrollmentStore.put(enrollment1.getEnrollmentId(), enrollment1);
|
||||
|
||||
TrainEnrollment enrollment2 = new TrainEnrollment();
|
||||
enrollment2.setEnrollmentId(1002L);
|
||||
enrollment2.setPlanId("PLAN_2026_01");
|
||||
enrollment2.setStudentId("STU_002");
|
||||
enrollmentStore.put(enrollment2.getEnrollmentId(), enrollment2);
|
||||
|
||||
TrainEnrollment enrollment3 = new TrainEnrollment();
|
||||
enrollment3.setEnrollmentId(1003L);
|
||||
enrollment3.setPlanId("PLAN_2026_01");
|
||||
enrollment3.setStudentId("STU_003");
|
||||
enrollmentStore.put(enrollment3.getEnrollmentId(), enrollment3);
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement A: Create accommodation
|
||||
*/
|
||||
@Test
|
||||
public void testA_CreateAccommodationSuccess()
|
||||
{
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setEnrollmentId(1001L);
|
||||
acc.setDormId("DORM_101");
|
||||
acc.setBedNo("1号床");
|
||||
acc.setCreateBy("admin");
|
||||
|
||||
int rows = accommodationService.insertTrainAccommodation(acc);
|
||||
assertEquals(1, rows);
|
||||
assertNotNull(acc.getAccommodationId());
|
||||
assertEquals(TrainAccommodation.STATUS_PREALLOCATED, acc.getAccommodationStatus());
|
||||
assertNotNull(acc.getAssignedTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement B: Query by enrollment
|
||||
*/
|
||||
@Test
|
||||
public void testB_QueryByEnrollment()
|
||||
{
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setEnrollmentId(1001L);
|
||||
acc.setDormId("DORM_101");
|
||||
acc.setBedNo("1号床");
|
||||
accommodationService.insertTrainAccommodation(acc);
|
||||
|
||||
TrainAccommodation queried = accommodationService.selectTrainAccommodationByEnrollmentId(1001L);
|
||||
assertNotNull(queried);
|
||||
assertEquals("DORM_101", queried.getDormId());
|
||||
assertEquals("1号床", queried.getBedNo());
|
||||
|
||||
TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L);
|
||||
assertNotNull(active);
|
||||
assertEquals(acc.getAccommodationId(), active.getAccommodationId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement C: Status transition (0 预分配 -> 1 已入住 -> 2 已退宿)
|
||||
*/
|
||||
@Test
|
||||
public void testC_StatusTransition()
|
||||
{
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setEnrollmentId(1001L);
|
||||
acc.setDormId("DORM_101");
|
||||
acc.setBedNo("1号床");
|
||||
accommodationService.insertTrainAccommodation(acc);
|
||||
Long accId = acc.getAccommodationId();
|
||||
|
||||
// 1. 办理入住 (0 -> 1)
|
||||
accommodationService.checkIn(accId, "operator1");
|
||||
TrainAccommodation checkedIn = accommodationService.selectTrainAccommodationByAccommodationId(accId);
|
||||
assertEquals(TrainAccommodation.STATUS_CHECKED_IN, checkedIn.getAccommodationStatus());
|
||||
|
||||
// 2. 办理退宿 (1 -> 2)
|
||||
accommodationService.checkOut(accId, "operator2");
|
||||
TrainAccommodation checkedOut = accommodationService.selectTrainAccommodationByAccommodationId(accId);
|
||||
assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOut.getAccommodationStatus());
|
||||
|
||||
// 3. 已退宿记录不能重新直接办理入住
|
||||
try
|
||||
{
|
||||
accommodationService.checkIn(accId, "operator3");
|
||||
fail("已退宿记录重新入住未被拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("已退宿的记录不能重新办理入住"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement D: Active accommodation uniqueness
|
||||
* 同一个 Enrollment 同一时刻最多 1 个有效住宿
|
||||
*/
|
||||
@Test
|
||||
public void testD_ActiveAccommodationUniqueness()
|
||||
{
|
||||
TrainAccommodation acc1 = new TrainAccommodation();
|
||||
acc1.setEnrollmentId(1001L);
|
||||
acc1.setDormId("DORM_101");
|
||||
acc1.setBedNo("1号床");
|
||||
accommodationService.insertTrainAccommodation(acc1);
|
||||
|
||||
// 再次为该 enrollment 分配住宿,应被业务层拦截
|
||||
TrainAccommodation acc2 = new TrainAccommodation();
|
||||
acc2.setEnrollmentId(1001L);
|
||||
acc2.setDormId("DORM_101");
|
||||
acc2.setBedNo("2号床");
|
||||
try
|
||||
{
|
||||
accommodationService.insertTrainAccommodation(acc2);
|
||||
fail("重复分配活跃住宿未被拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("已存在有效住宿安排"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement E: Invalid enrollment rejected
|
||||
*/
|
||||
@Test
|
||||
public void testE_InvalidEnrollmentRejected()
|
||||
{
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setEnrollmentId(999999L); // 不存在的参训档案
|
||||
acc.setDormId("DORM_101");
|
||||
acc.setBedNo("1号床");
|
||||
|
||||
try
|
||||
{
|
||||
accommodationService.insertTrainAccommodation(acc);
|
||||
fail("不存在的参训档案未拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("指定参训档案不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement F: Invalid dorm rejected
|
||||
*/
|
||||
@Test
|
||||
public void testF_InvalidDormRejected()
|
||||
{
|
||||
// 1. 宿舍不存在
|
||||
TrainAccommodation acc1 = new TrainAccommodation();
|
||||
acc1.setEnrollmentId(1001L);
|
||||
acc1.setDormId("NON_EXIST_DORM");
|
||||
acc1.setBedNo("1号床");
|
||||
try
|
||||
{
|
||||
accommodationService.insertTrainAccommodation(acc1);
|
||||
fail("不存在的宿舍未拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("指定宿舍不存在"));
|
||||
}
|
||||
|
||||
// 2. 宿舍已被禁用
|
||||
TrainAccommodation acc2 = new TrainAccommodation();
|
||||
acc2.setEnrollmentId(1001L);
|
||||
acc2.setDormId("DORM_DIS");
|
||||
acc2.setBedNo("1号床");
|
||||
try
|
||||
{
|
||||
accommodationService.insertTrainAccommodation(acc2);
|
||||
fail("已禁用的宿舍未拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("该宿舍当前处于禁用状态"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement G: Capacity boundary
|
||||
* DORM_101 容量为 2 人,已满后再分配第 3 人应拦截
|
||||
*/
|
||||
@Test
|
||||
public void testG_CapacityBoundary()
|
||||
{
|
||||
// 第1人成功
|
||||
TrainAccommodation acc1 = new TrainAccommodation();
|
||||
acc1.setEnrollmentId(1001L);
|
||||
acc1.setDormId("DORM_101");
|
||||
acc1.setBedNo("1号床");
|
||||
accommodationService.insertTrainAccommodation(acc1);
|
||||
|
||||
// 第2人成功
|
||||
TrainAccommodation acc2 = new TrainAccommodation();
|
||||
acc2.setEnrollmentId(1002L);
|
||||
acc2.setDormId("DORM_101");
|
||||
acc2.setBedNo("2号床");
|
||||
accommodationService.insertTrainAccommodation(acc2);
|
||||
|
||||
// 第3人分配到同一房间,容量超限拦截
|
||||
TrainAccommodation acc3 = new TrainAccommodation();
|
||||
acc3.setEnrollmentId(1003L);
|
||||
acc3.setDormId("DORM_101");
|
||||
acc3.setBedNo("3号床");
|
||||
try
|
||||
{
|
||||
accommodationService.insertTrainAccommodation(acc3);
|
||||
fail("宿舍容量满额未拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("宿舍容量已满"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement H: No physical bed table assumption
|
||||
* bed_no 作为纯业务标识字符串持久化,不需要依赖物理床位表
|
||||
*/
|
||||
@Test
|
||||
public void testH_NoPhysicalBedTableAssumption()
|
||||
{
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setEnrollmentId(1001L);
|
||||
acc.setDormId("DORM_101");
|
||||
acc.setBedNo("上铺-VIP-01"); // 自定义床位标识
|
||||
|
||||
int rows = accommodationService.insertTrainAccommodation(acc);
|
||||
assertEquals(1, rows);
|
||||
|
||||
TrainAccommodation saved = accommodationService.selectTrainAccommodationByAccommodationId(acc.getAccommodationId());
|
||||
assertEquals("上铺-VIP-01", saved.getBedNo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement I: Ended accommodation historical query & transfer flow
|
||||
* 退宿后历史记录可查,且退宿后允许为该 enrollment 分配新住宿 (调房业务)
|
||||
*/
|
||||
@Test
|
||||
public void testI_EndedAccommodationHistoricalQueryAndTransfer()
|
||||
{
|
||||
// 1. 初始化新宿舍 DORM_201
|
||||
TrainDormitoryInfo dorm201 = new TrainDormitoryInfo();
|
||||
dorm201.setDormId("DORM_201");
|
||||
dorm201.setDormBuilding("培训2号楼");
|
||||
dorm201.setDormRoom("201");
|
||||
dorm201.setCapacity(2);
|
||||
dorm201.setDormStatus(1);
|
||||
dormStore.put(dorm201.getDormId(), dorm201);
|
||||
|
||||
// 2. 创建初始住宿
|
||||
TrainAccommodation oldAcc = new TrainAccommodation();
|
||||
oldAcc.setEnrollmentId(1001L);
|
||||
oldAcc.setDormId("DORM_101");
|
||||
oldAcc.setBedNo("1号床");
|
||||
accommodationService.insertTrainAccommodation(oldAcc);
|
||||
Long oldId = oldAcc.getAccommodationId();
|
||||
|
||||
// 3. 执行调房
|
||||
TrainAccommodation newAcc = accommodationService.transfer(oldId, "DORM_201", "新1号床", "admin");
|
||||
assertNotNull(newAcc);
|
||||
assertNotNull(newAcc.getAccommodationId());
|
||||
assertEquals("DORM_201", newAcc.getDormId());
|
||||
assertEquals("新1号床", newAcc.getBedNo());
|
||||
assertEquals(TrainAccommodation.STATUS_CHECKED_IN, newAcc.getAccommodationStatus());
|
||||
|
||||
// 4. 验证旧住宿变为退宿状态但依然可查 (历史可审计)
|
||||
TrainAccommodation oldUpdated = accommodationService.selectTrainAccommodationByAccommodationId(oldId);
|
||||
assertNotNull(oldUpdated);
|
||||
assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, oldUpdated.getAccommodationStatus());
|
||||
|
||||
// 5. 验证活跃住宿查询当前指向新记录
|
||||
TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L);
|
||||
assertNotNull(active);
|
||||
assertEquals(newAcc.getAccommodationId(), active.getAccommodationId());
|
||||
assertEquals("DORM_201", active.getDormId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave 0 Review Gate - Requirement 14:
|
||||
* A. Create first active accommodation PASS
|
||||
* B. Create second active accommodation REJECT
|
||||
* C. Checkout first accommodation
|
||||
* D. Create second accommodation for same enrollment PASS
|
||||
* E. History returns 2 rows
|
||||
* F. Capacity only counts second active row
|
||||
*/
|
||||
@Test
|
||||
public void testAccommodationHistoryAndCardinalityLifecycle()
|
||||
{
|
||||
Long enrollId = 1001L;
|
||||
|
||||
// A. Create first active accommodation PASS
|
||||
TrainAccommodation acc1 = new TrainAccommodation();
|
||||
acc1.setEnrollmentId(enrollId);
|
||||
acc1.setDormId("DORM_101");
|
||||
acc1.setBedNo("1号床");
|
||||
int row1 = accommodationService.insertTrainAccommodation(acc1);
|
||||
assertEquals(1, row1);
|
||||
assertNotNull(acc1.getAccommodationId());
|
||||
|
||||
// B. Create second active accommodation REJECT (同一时刻最多1条有效住宿)
|
||||
TrainAccommodation acc2 = new TrainAccommodation();
|
||||
acc2.setEnrollmentId(enrollId);
|
||||
acc2.setDormId("DORM_101");
|
||||
acc2.setBedNo("2号床");
|
||||
try
|
||||
{
|
||||
accommodationService.insertTrainAccommodation(acc2);
|
||||
fail("活跃记录存在时重复新增应被拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("已存在有效住宿安排"));
|
||||
}
|
||||
|
||||
// C. Checkout first accommodation (退宿后变为状态 2)
|
||||
accommodationService.checkOut(acc1.getAccommodationId(), "admin");
|
||||
TrainAccommodation checkedOutAcc = accommodationService.selectTrainAccommodationByAccommodationId(acc1.getAccommodationId());
|
||||
assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOutAcc.getAccommodationStatus());
|
||||
|
||||
// D. Create second accommodation for same enrollment PASS (退宿后允许同一学员建立新住宿)
|
||||
TrainAccommodation acc3 = new TrainAccommodation();
|
||||
acc3.setEnrollmentId(enrollId);
|
||||
acc3.setDormId("DORM_101");
|
||||
acc3.setBedNo("2号床");
|
||||
int row3 = accommodationService.insertTrainAccommodation(acc3);
|
||||
assertEquals(1, row3);
|
||||
assertNotNull(acc3.getAccommodationId());
|
||||
|
||||
// E. History returns 2 rows (包含1条退宿历史 + 1条当前活跃)
|
||||
List<TrainAccommodation> history = accommodationService.selectAccommodationHistoryByEnrollmentId(enrollId);
|
||||
assertNotNull(history);
|
||||
assertEquals(2, history.size());
|
||||
|
||||
// 验证当前活跃记录只返回新记录
|
||||
TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(enrollId);
|
||||
assertNotNull(active);
|
||||
assertEquals(acc3.getAccommodationId(), active.getAccommodationId());
|
||||
|
||||
// F. Capacity only counts second active row (历史退宿记录不计入核定容量,当前只计1人)
|
||||
int activeCount = mockAccommodationMapper.countActiveByDormId("DORM_101");
|
||||
assertEquals(1, activeCount);
|
||||
}
|
||||
|
||||
// ================================= 内部 Mock 类 =================================
|
||||
|
||||
private static class MockAccommodationMapper implements TrainAccommodationMapper
|
||||
{
|
||||
private final Map<Long, TrainAccommodation> store = new HashMap<>();
|
||||
private long idSeq = 100L;
|
||||
|
||||
@Override
|
||||
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId)
|
||||
{
|
||||
return store.get(accommodationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
TrainAccommodation latest = null;
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (enrollmentId.equals(a.getEnrollmentId()))
|
||||
{
|
||||
latest = a;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (enrollmentId.equals(a.getEnrollmentId()))
|
||||
{
|
||||
if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
|
||||
|| TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))
|
||||
{
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
List<TrainAccommodation> list = new ArrayList<>();
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (enrollmentId.equals(a.getEnrollmentId()))
|
||||
{
|
||||
list.add(a);
|
||||
}
|
||||
}
|
||||
list.sort((o1, o2) -> o2.getAccommodationId().compareTo(o1.getAccommodationId()));
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
|
||||
{
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(String planId)
|
||||
{
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveByDormId(String dormId)
|
||||
{
|
||||
int count = 0;
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (dormId.equals(a.getDormId()))
|
||||
{
|
||||
if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
|
||||
|| TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainAccommodation(TrainAccommodation accommodation)
|
||||
{
|
||||
accommodation.setAccommodationId(++idSeq);
|
||||
store.put(accommodation.getAccommodationId(), accommodation);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTrainAccommodation(TrainAccommodation accommodation)
|
||||
{
|
||||
store.put(accommodation.getAccommodationId(), accommodation);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime)
|
||||
{
|
||||
TrainAccommodation a = store.get(accommodationId);
|
||||
if (a != null)
|
||||
{
|
||||
a.setAccommodationStatus(accommodationStatus);
|
||||
a.setUpdateBy(updateBy);
|
||||
a.setUpdateTime(updateTime);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainAccommodationByAccommodationId(Long accommodationId)
|
||||
{
|
||||
return store.remove(accommodationId) != null ? 1 : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds)
|
||||
{
|
||||
int deleted = 0;
|
||||
for (Long id : accommodationIds)
|
||||
{
|
||||
if (store.remove(id) != null) deleted++;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
|
||||
/**
|
||||
* R.3B Wave 0 - 宿舍门禁授权领域与V2契约对齐测试 (DormAuthAlignmentTest)
|
||||
* 覆盖规范第30条全部要求:A到G
|
||||
*/
|
||||
public class DormAuthAlignmentTest
|
||||
{
|
||||
private TrainDormAuthServiceImpl dormAuthService;
|
||||
private MockDormAuthMapper mockDormAuthMapper;
|
||||
private MockAccommodationMapper mockAccommodationMapper;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
dormAuthService = new TrainDormAuthServiceImpl();
|
||||
mockDormAuthMapper = new MockDormAuthMapper();
|
||||
mockAccommodationMapper = new MockAccommodationMapper();
|
||||
|
||||
setField(dormAuthService, "trainDormAuthMapper", mockDormAuthMapper);
|
||||
setField(dormAuthService, "trainAccommodationMapper", mockAccommodationMapper);
|
||||
|
||||
// 初始化基础住宿记录
|
||||
TrainAccommodation acc = new TrainAccommodation();
|
||||
acc.setAccommodationId(5001L);
|
||||
acc.setEnrollmentId(1001L);
|
||||
acc.setDormId("DORM_101");
|
||||
acc.setBedNo("1号床");
|
||||
acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
|
||||
mockAccommodationMapper.save(acc);
|
||||
|
||||
TrainAccommodation checkedOutAcc = new TrainAccommodation();
|
||||
checkedOutAcc.setAccommodationId(5002L);
|
||||
checkedOutAcc.setEnrollmentId(1002L);
|
||||
checkedOutAcc.setDormId("DORM_101");
|
||||
checkedOutAcc.setBedNo("2号床");
|
||||
checkedOutAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT);
|
||||
mockAccommodationMapper.save(checkedOutAcc);
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement A: V2 mapper fields align DB contract
|
||||
* 验证 TrainDormAuth 实体和持久化契约包含所有 V2 字段
|
||||
*/
|
||||
@Test
|
||||
public void testA_V2MapperFieldsAlignDBContract() throws Exception
|
||||
{
|
||||
Class<?> clazz = TrainDormAuth.class;
|
||||
|
||||
// 必须存在 V2 核心字段
|
||||
assertNotNull("accommodationId 字段必须存在", clazz.getDeclaredField("accommodationId"));
|
||||
assertNotNull("authStart 字段必须存在", clazz.getDeclaredField("authStart"));
|
||||
assertNotNull("authEnd 字段必须存在", clazz.getDeclaredField("authEnd"));
|
||||
assertNotNull("faceSyncStatus 字段必须存在", clazz.getDeclaredField("faceSyncStatus"));
|
||||
assertNotNull("retryCount 字段必须存在", clazz.getDeclaredField("retryCount"));
|
||||
assertNotNull("lastError 字段必须存在", clazz.getDeclaredField("lastError"));
|
||||
assertNotNull("externalAuthId 字段必须存在", clazz.getDeclaredField("externalAuthId"));
|
||||
assertNotNull("lastSyncTime 字段必须存在", clazz.getDeclaredField("lastSyncTime"));
|
||||
|
||||
// 验证状态常量定义符合 DB SMALLINT 约束 (0, 1, 2, 3)
|
||||
assertEquals(Integer.valueOf(0), TrainDormAuth.STATUS_PENDING);
|
||||
assertEquals(Integer.valueOf(1), TrainDormAuth.STATUS_ACTIVE);
|
||||
assertEquals(Integer.valueOf(2), TrainDormAuth.STATUS_FAILED);
|
||||
assertEquals(Integer.valueOf(3), TrainDormAuth.STATUS_REVOKED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement B: Authorization references accommodation
|
||||
* 必须基于有效的 accommodation_id,无 accommodationId 或关联不存在住宿时拒绝
|
||||
*/
|
||||
@Test
|
||||
public void testB_AuthorizationReferencesAccommodation()
|
||||
{
|
||||
// 1. 无 accommodationId 被拒绝
|
||||
TrainDormAuth authNoAcc = new TrainDormAuth();
|
||||
authNoAcc.setAuthStart(new Date());
|
||||
try
|
||||
{
|
||||
dormAuthService.insertTrainDormAuth(authNoAcc);
|
||||
fail("未绑定住宿事实未被拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("必须绑定住宿安排"));
|
||||
}
|
||||
|
||||
// 2. 住宿记录不存在被拒绝
|
||||
TrainDormAuth authNonExist = new TrainDormAuth();
|
||||
authNonExist.setAccommodationId(999999L);
|
||||
try
|
||||
{
|
||||
dormAuthService.insertTrainDormAuth(authNonExist);
|
||||
fail("关联不存在的住宿记录未被拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("指定的住宿安排不存在"));
|
||||
}
|
||||
|
||||
// 3. 关联已退宿的记录被拒绝
|
||||
TrainDormAuth authCheckedOut = new TrainDormAuth();
|
||||
authCheckedOut.setAccommodationId(5002L); // 对应 checkedOutAcc
|
||||
try
|
||||
{
|
||||
dormAuthService.insertTrainDormAuth(authCheckedOut);
|
||||
fail("关联已退宿住宿记录未被拦截");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertTrue(e.getMessage().contains("该住宿安排已退宿"));
|
||||
}
|
||||
|
||||
// 4. 关联有效住宿成功
|
||||
TrainDormAuth validAuth = new TrainDormAuth();
|
||||
validAuth.setAccommodationId(5001L);
|
||||
validAuth.setAuthStart(new Date());
|
||||
validAuth.setAuthEnd(new Date(System.currentTimeMillis() + 86400000L));
|
||||
int rows = dormAuthService.insertTrainDormAuth(validAuth);
|
||||
assertEquals(1, rows);
|
||||
assertNotNull(validAuth.getAuthId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement C: Legacy student_id/plan_id/dorm_id no longer persisted
|
||||
* 旧字段不再作为 DB 列持久化,而在实体层保留为视图/兼容访问
|
||||
*/
|
||||
@Test
|
||||
public void testC_LegacyFieldsNoLongerPersisted()
|
||||
{
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAccommodationId(5001L);
|
||||
// 设置兼容视图字段
|
||||
auth.setStudentId("STU_LEGACY_01");
|
||||
auth.setPlanId("PLAN_LEGACY_01");
|
||||
auth.setDormId("DORM_LEGACY_01");
|
||||
auth.setBedNo("1号床");
|
||||
auth.setCreateUser("admin_v1");
|
||||
|
||||
dormAuthService.insertTrainDormAuth(auth);
|
||||
|
||||
// 验证 Mapper 写入的方法只接收并持久化 V2 真实字段
|
||||
TrainDormAuth saved = mockDormAuthMapper.selectTrainDormAuthByAuthId(auth.getAuthId());
|
||||
assertNotNull(saved);
|
||||
assertEquals(Long.valueOf(5001L), saved.getAccommodationId());
|
||||
// createUser 兼容适配到 BaseEntity.createBy
|
||||
assertEquals("admin_v1", saved.getCreateBy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement D & E: Retry summary fields and last error fields
|
||||
*/
|
||||
@Test
|
||||
public void testDE_RetrySummaryAndLastErrorFields()
|
||||
{
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAccommodationId(5001L);
|
||||
dormAuthService.insertTrainDormAuth(auth);
|
||||
Long authId = auth.getAuthId();
|
||||
|
||||
// 初始重试次数为 0
|
||||
assertEquals(Integer.valueOf(0), auth.getRetryCount());
|
||||
assertNull(auth.getLastError());
|
||||
|
||||
// 模拟下发失败,记录错误并累加重试次数
|
||||
dormAuthService.markSyncFailed(authId, "Device offline: timeout after 5000ms", "sync_job");
|
||||
|
||||
TrainDormAuth failed = dormAuthService.selectTrainDormAuthByAuthId(authId);
|
||||
assertEquals(TrainDormAuth.STATUS_FAILED, failed.getFaceSyncStatus());
|
||||
assertEquals("Device offline: timeout after 5000ms", failed.getLastError());
|
||||
assertEquals(Integer.valueOf(1), failed.getRetryCount());
|
||||
|
||||
// 再次失败,重试次数增加到 2
|
||||
dormAuthService.markSyncFailed(authId, "Device unreachable", "sync_job");
|
||||
TrainDormAuth failedAgain = dormAuthService.selectTrainDormAuthByAuthId(authId);
|
||||
assertEquals(Integer.valueOf(2), failedAgain.getRetryCount());
|
||||
assertEquals("Device unreachable", failedAgain.getLastError());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement F: Status transition
|
||||
* 0 待下发 -> 2 下发失败 -> 3 已注销
|
||||
*/
|
||||
@Test
|
||||
public void testF_StatusTransition()
|
||||
{
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAccommodationId(5001L);
|
||||
dormAuthService.insertTrainDormAuth(auth);
|
||||
Long authId = auth.getAuthId();
|
||||
|
||||
// 初始为待下发
|
||||
TrainDormAuth step0 = dormAuthService.selectTrainDormAuthByAuthId(authId);
|
||||
assertEquals(TrainDormAuth.STATUS_PENDING, step0.getFaceSyncStatus());
|
||||
|
||||
// 下发失败 -> STATUS_FAILED
|
||||
dormAuthService.markSyncFailed(authId, "Network error", "system");
|
||||
TrainDormAuth step1 = dormAuthService.selectTrainDormAuthByAuthId(authId);
|
||||
assertEquals(TrainDormAuth.STATUS_FAILED, step1.getFaceSyncStatus());
|
||||
|
||||
// 注销权限 -> STATUS_REVOKED
|
||||
dormAuthService.revokeAuth(authId, "admin");
|
||||
TrainDormAuth step2 = dormAuthService.selectTrainDormAuthByAuthId(authId);
|
||||
assertEquals(TrainDormAuth.STATUS_REVOKED, step2.getFaceSyncStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Requirement G: No DMS client dependency & No Fake Device Success
|
||||
* 不调用任何外部 DMS,创建时绝不虚假赋值为已下发成功 (STATUS_ACTIVE)
|
||||
*/
|
||||
@Test
|
||||
public void testG_NoDmsClientDependencyAndNoFakeSuccess()
|
||||
{
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAccommodationId(5001L);
|
||||
// 即使客户端试图传入 STATUS_ACTIVE,业务层由于无实际下发能力,必须强制为 STATUS_PENDING
|
||||
auth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE);
|
||||
|
||||
dormAuthService.insertTrainDormAuth(auth);
|
||||
|
||||
TrainDormAuth saved = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId());
|
||||
assertEquals("没有真实DMS调用前,状态必须为待下发", TrainDormAuth.STATUS_PENDING, saved.getFaceSyncStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave 0 Review Gate - Requirement 15: DormAuth History Test
|
||||
* 验证换房场景下授权记录共存:
|
||||
* Old Accommodation -> Old DormAuth (status=REVOKED)
|
||||
* New Accommodation -> New DormAuth (status=PENDING/ACTIVE)
|
||||
* 两条授权记录在历史中共存,互不覆盖
|
||||
*/
|
||||
@Test
|
||||
public void testDormAuthHistoryAcrossTransfers()
|
||||
{
|
||||
Long oldAccId = 5001L;
|
||||
Long newAccId = 5003L;
|
||||
|
||||
// 注册新住宿
|
||||
TrainAccommodation newAcc = new TrainAccommodation();
|
||||
newAcc.setAccommodationId(newAccId);
|
||||
newAcc.setEnrollmentId(1001L);
|
||||
newAcc.setDormId("DORM_201");
|
||||
newAcc.setBedNo("新1号床");
|
||||
newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
|
||||
mockAccommodationMapper.save(newAcc);
|
||||
|
||||
// 1. 旧住宿对应旧授权
|
||||
TrainDormAuth oldAuth = new TrainDormAuth();
|
||||
oldAuth.setAccommodationId(oldAccId);
|
||||
dormAuthService.insertTrainDormAuth(oldAuth);
|
||||
Long oldAuthId = oldAuth.getAuthId();
|
||||
|
||||
// 2. 新住宿对应新授权
|
||||
TrainDormAuth newAuth = new TrainDormAuth();
|
||||
newAuth.setAccommodationId(newAccId);
|
||||
dormAuthService.insertTrainDormAuth(newAuth);
|
||||
Long newAuthId = newAuth.getAuthId();
|
||||
|
||||
// 3. 旧授权注销 (退宿或换房引起)
|
||||
dormAuthService.revokeAuth(oldAuthId, "admin");
|
||||
|
||||
// 4. 验证两条授权在历史中共存,状态相互独立
|
||||
TrainDormAuth queriedOld = dormAuthService.selectTrainDormAuthByAuthId(oldAuthId);
|
||||
TrainDormAuth queriedNew = dormAuthService.selectTrainDormAuthByAuthId(newAuthId);
|
||||
|
||||
assertNotNull(queriedOld);
|
||||
assertNotNull(queriedNew);
|
||||
assertEquals(TrainDormAuth.STATUS_REVOKED, queriedOld.getFaceSyncStatus());
|
||||
assertEquals(TrainDormAuth.STATUS_PENDING, queriedNew.getFaceSyncStatus());
|
||||
assertEquals(oldAccId, queriedOld.getAccommodationId());
|
||||
assertEquals(newAccId, queriedNew.getAccommodationId());
|
||||
}
|
||||
|
||||
// ================================= 内部 Mock 类 =================================
|
||||
|
||||
private static class MockDormAuthMapper implements TrainDormAuthMapper
|
||||
{
|
||||
private final Map<Long, TrainDormAuth> store = new HashMap<>();
|
||||
private long idSeq = 10L;
|
||||
|
||||
@Override
|
||||
public TrainDormAuth selectTrainDormAuthByAuthId(Long authId)
|
||||
{
|
||||
return store.get(authId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId)
|
||||
{
|
||||
for (TrainDormAuth a : store.values())
|
||||
{
|
||||
if (accommodationId.equals(a.getAccommodationId()))
|
||||
{
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainDormAuth> selectTrainDormAuthList(TrainDormAuth trainDormAuth)
|
||||
{
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainDormAuth(TrainDormAuth trainDormAuth)
|
||||
{
|
||||
trainDormAuth.setAuthId(++idSeq);
|
||||
store.put(trainDormAuth.getAuthId(), trainDormAuth);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTrainDormAuth(TrainDormAuth trainDormAuth)
|
||||
{
|
||||
store.put(trainDormAuth.getAuthId(), trainDormAuth);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateSyncStatus(Long authId, Integer faceSyncStatus, String lastError, String updateBy, Date updateTime)
|
||||
{
|
||||
TrainDormAuth a = store.get(authId);
|
||||
if (a != null)
|
||||
{
|
||||
a.setFaceSyncStatus(faceSyncStatus);
|
||||
a.setLastError(lastError);
|
||||
a.setUpdateBy(updateBy);
|
||||
a.setUpdateTime(updateTime);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int increaseRetryCount(Long authId, String lastError, String updateBy, Date updateTime)
|
||||
{
|
||||
TrainDormAuth a = store.get(authId);
|
||||
if (a != null)
|
||||
{
|
||||
int current = a.getRetryCount() != null ? a.getRetryCount() : 0;
|
||||
a.setRetryCount(current + 1);
|
||||
a.setLastError(lastError);
|
||||
a.setUpdateBy(updateBy);
|
||||
a.setUpdateTime(updateTime);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainDormAuthByAuthId(Long authId)
|
||||
{
|
||||
return store.remove(authId) != null ? 1 : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainDormAuthByAuthIds(Long[] authIds)
|
||||
{
|
||||
int count = 0;
|
||||
for (Long id : authIds)
|
||||
{
|
||||
if (store.remove(id) != null) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private static class MockAccommodationMapper implements TrainAccommodationMapper
|
||||
{
|
||||
private final Map<Long, TrainAccommodation> store = new HashMap<>();
|
||||
|
||||
public void save(TrainAccommodation a)
|
||||
{
|
||||
store.put(a.getAccommodationId(), a);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId)
|
||||
{
|
||||
return store.get(accommodationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (enrollmentId.equals(a.getEnrollmentId())) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (enrollmentId.equals(a.getEnrollmentId()) &&
|
||||
(TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
|
||||
|| TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())))
|
||||
{
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
List<TrainAccommodation> list = new ArrayList<>();
|
||||
for (TrainAccommodation a : store.values())
|
||||
{
|
||||
if (enrollmentId.equals(a.getEnrollmentId())) list.add(a);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
|
||||
{
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(String planId)
|
||||
{
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveByDormId(String dormId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertTrainAccommodation(TrainAccommodation accommodation)
|
||||
{
|
||||
store.put(accommodation.getAccommodationId(), accommodation);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateTrainAccommodation(TrainAccommodation accommodation)
|
||||
{
|
||||
store.put(accommodation.getAccommodationId(), accommodation);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime)
|
||||
{
|
||||
TrainAccommodation a = store.get(accommodationId);
|
||||
if (a != null)
|
||||
{
|
||||
a.setAccommodationStatus(accommodationStatus);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainAccommodationByAccommodationId(Long accommodationId)
|
||||
{
|
||||
return store.remove(accommodationId) != null ? 1 : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalOperationMapper;
|
||||
|
||||
/**
|
||||
* 外部设备操作流水服务单元测试
|
||||
* 覆盖 A 到 J 全部关键业务场景与安全准则
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class ExternalOperationServiceTest
|
||||
{
|
||||
private ExternalOperationServiceImpl operationService;
|
||||
private final Map<Long, TrainExternalOperation> operationStore = new HashMap<>();
|
||||
private final Map<Long, TrainDormAuth> authStore = new HashMap<>();
|
||||
private final AtomicLong opIdGen = new AtomicLong(1);
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
operationService = new ExternalOperationServiceImpl();
|
||||
operationStore.clear();
|
||||
authStore.clear();
|
||||
|
||||
TrainExternalOperationMapper opMapper = (TrainExternalOperationMapper) Proxy.newProxyInstance(
|
||||
TrainExternalOperationMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainExternalOperationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String mName = method.getName();
|
||||
if ("selectExternalOperationById".equals(mName)) {
|
||||
return operationStore.get((Long) args[0]);
|
||||
} else if ("selectByIdempotencyKey".equals(mName)) {
|
||||
String key = (String) args[0];
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (key.equals(op.getIdempotencyKey())) {
|
||||
return op;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectByDoorAuthId".equals(mName)) {
|
||||
Long aId = (Long) args[0];
|
||||
List<TrainExternalOperation> res = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (aId.equals(op.getDoorAuthId())) {
|
||||
res.add(op);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("selectByExternalOperationId".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String eId = (String) args[1];
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (vCode.equals(op.getVendorCode()) && eId.equals(op.getExternalOperationId())) {
|
||||
return op;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectPendingPollOperations".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
List<TrainExternalOperation> res = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
String st = op.getOperationStatus();
|
||||
if ("ACCEPTED".equals(st) || "PENDING".equals(st) || "RETRY_WAIT".equals(st)) {
|
||||
if (vCode == null || vCode.equals(op.getVendorCode())) {
|
||||
res.add(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("selectExternalOperationList".equals(mName)) {
|
||||
TrainExternalOperation q = (TrainExternalOperation) args[0];
|
||||
List<TrainExternalOperation> res = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (q == null || q.getDoorAuthId() == null || q.getDoorAuthId().equals(op.getDoorAuthId())) {
|
||||
res.add(op);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("insertExternalOperation".equals(mName)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
long id = opIdGen.getAndIncrement();
|
||||
op.setOperationId(id);
|
||||
operationStore.put(id, op);
|
||||
return 1;
|
||||
} else if ("updateExternalOperationStatus".equals(mName)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
operationStore.put(op.getOperationId(), op);
|
||||
return 1;
|
||||
} else if ("updateExternalReference".equals(mName)) {
|
||||
Long opId = (Long) args[0];
|
||||
String extOpId = (String) args[1];
|
||||
String extHwId = (String) args[2];
|
||||
String extKeyId = (String) args[3];
|
||||
String uBy = (String) args[4];
|
||||
TrainExternalOperation op = operationStore.get(opId);
|
||||
if (op != null) {
|
||||
op.setExternalOperationId(extOpId);
|
||||
op.setExternalHardwareId(extHwId);
|
||||
op.setExternalKeyId(extKeyId);
|
||||
op.setUpdateBy(uBy);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainDormAuthMapper authMapper = (TrainDormAuthMapper) Proxy.newProxyInstance(
|
||||
TrainDormAuthMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainDormAuthMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainDormAuthByAuthId".equals(method.getName())) {
|
||||
return authStore.get((Long) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
setField(operationService, "operationMapper", opMapper);
|
||||
setField(operationService, "dormAuthMapper", authMapper);
|
||||
|
||||
// 初始化默认合法门禁授权
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(1001L);
|
||||
auth.setAccommodationId(2001L);
|
||||
auth.setFaceSyncStatus(0);
|
||||
authStore.put(auth.getAuthId(), auth);
|
||||
}
|
||||
|
||||
// A. Create GRANT operation
|
||||
@Test
|
||||
public void testA_createGrantOperation_success()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-GRANT-001");
|
||||
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
assertNotNull(created);
|
||||
assertNotNull(created.getOperationId());
|
||||
assertEquals("CREATED", created.getOperationStatus());
|
||||
assertEquals("DOOR_AUTH", created.getBusinessType());
|
||||
assertEquals("DMS", created.getVendorCode());
|
||||
assertEquals(Integer.valueOf(0), created.getAttemptCount());
|
||||
}
|
||||
|
||||
// B. DoorAuth missing rejected
|
||||
@Test
|
||||
public void testB_doorAuthMissing_rejected()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(9999L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-ERR-001");
|
||||
|
||||
try {
|
||||
operationService.createOperation(op);
|
||||
fail("Should reject when doorAuth missing");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("目标门禁授权不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
// C. Idempotency duplicate rejected/returned
|
||||
@Test
|
||||
public void testC_idempotencyDuplicate_returnsExistingWithoutInsert()
|
||||
{
|
||||
TrainExternalOperation first = new TrainExternalOperation();
|
||||
first.setDoorAuthId(1001L);
|
||||
first.setOperationType("GRANT");
|
||||
first.setIdempotencyKey("KEY-IDEMPOTENT-001");
|
||||
TrainExternalOperation created = operationService.createOperation(first);
|
||||
|
||||
operationService.transitStatus(created.getOperationId(), "REQUESTED", null, null, "admin");
|
||||
|
||||
TrainExternalOperation second = new TrainExternalOperation();
|
||||
second.setDoorAuthId(1001L);
|
||||
second.setOperationType("GRANT");
|
||||
second.setIdempotencyKey("KEY-IDEMPOTENT-001");
|
||||
|
||||
TrainExternalOperation result = operationService.createOperation(second);
|
||||
assertNotNull(result);
|
||||
assertEquals(created.getOperationId(), result.getOperationId());
|
||||
assertEquals("REQUESTED", result.getOperationStatus());
|
||||
}
|
||||
|
||||
// D. External operation ID initially null
|
||||
@Test
|
||||
public void testD_externalOperationId_initiallyNull()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("REVOKE");
|
||||
op.setIdempotencyKey("KEY-NULL-OPID");
|
||||
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
assertNull(created.getExternalOperationId());
|
||||
}
|
||||
|
||||
// E. Bind external operation ID
|
||||
@Test
|
||||
public void testE_bindExternalOperationId_success()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-BIND-OPID");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
|
||||
TrainExternalOperation updated = operationService.bindExternalReference(
|
||||
created.getOperationId(), "DMS-OP-9988", "HW-01", "KEY-01", "admin");
|
||||
assertNotNull(updated);
|
||||
assertEquals("DMS-OP-9988", updated.getExternalOperationId());
|
||||
assertEquals("HW-01", updated.getExternalHardwareId());
|
||||
assertEquals("KEY-01", updated.getExternalKeyId());
|
||||
}
|
||||
|
||||
// F. PENDING query
|
||||
@Test
|
||||
public void testF_pendingQuery()
|
||||
{
|
||||
TrainExternalOperation op1 = new TrainExternalOperation();
|
||||
op1.setDoorAuthId(1001L);
|
||||
op1.setOperationType("GRANT");
|
||||
op1.setIdempotencyKey("KEY-POLL-01");
|
||||
TrainExternalOperation c1 = operationService.createOperation(op1);
|
||||
operationService.transitStatus(c1.getOperationId(), "PENDING", null, null, "admin");
|
||||
|
||||
TrainExternalOperation op2 = new TrainExternalOperation();
|
||||
op2.setDoorAuthId(1001L);
|
||||
op2.setOperationType("REVOKE");
|
||||
op2.setIdempotencyKey("KEY-POLL-02");
|
||||
TrainExternalOperation c2 = operationService.createOperation(op2);
|
||||
operationService.transitStatus(c2.getOperationId(), "ACCEPTED", null, null, "admin");
|
||||
|
||||
List<TrainExternalOperation> list = operationService.listPendingPollOperations("DMS", new Date(System.currentTimeMillis() + 10000));
|
||||
assertEquals(2, list.size());
|
||||
}
|
||||
|
||||
// G. SUCCESS terminal
|
||||
@Test(expected = ServiceException.class)
|
||||
public void testG_successTerminal_cannotTransitBackToPending()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-TERM-01");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
|
||||
operationService.transitStatus(created.getOperationId(), "SUCCESS", null, null, "admin");
|
||||
|
||||
try {
|
||||
operationService.transitStatus(created.getOperationId(), "PENDING", null, null, "admin");
|
||||
fail("Should reject reversing SUCCESS status");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("禁止状态逆转"));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// H. FAILED history retained
|
||||
@Test
|
||||
public void testH_failedHistoryRetained_terminalGuard()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-FAIL-01");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
|
||||
operationService.transitStatus(created.getOperationId(), "FAILED", "ERR-400", "Device timeout", "admin");
|
||||
|
||||
try {
|
||||
operationService.transitStatus(created.getOperationId(), "CREATED", null, null, "admin");
|
||||
fail("Should reject resetting FAILED to CREATED");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("禁止重置"));
|
||||
}
|
||||
}
|
||||
|
||||
// I. Retry creates new operation
|
||||
@Test
|
||||
public void testI_retryCreatesNewOperation()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-ATTEMPT-1");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
operationService.transitStatus(created.getOperationId(), "FAILED", "500", "Gateway error", "admin");
|
||||
|
||||
TrainExternalOperation retry = operationService.createRetryOperation(
|
||||
created.getOperationId(), "KEY-ATTEMPT-2", "admin");
|
||||
assertNotNull(retry);
|
||||
assertNotEquals(created.getOperationId(), retry.getOperationId());
|
||||
assertEquals("KEY-ATTEMPT-2", retry.getIdempotencyKey());
|
||||
assertEquals(Integer.valueOf(1), retry.getAttemptCount());
|
||||
assertEquals("CREATED", retry.getOperationStatus());
|
||||
|
||||
List<TrainExternalOperation> history = operationService.listOperationsByDoorAuthId(1001L);
|
||||
assertEquals(2, history.size());
|
||||
}
|
||||
|
||||
// J. Sensitive fields absent
|
||||
@Test
|
||||
public void testJ_sensitiveFieldsAbsent()
|
||||
{
|
||||
Field[] fields = TrainExternalOperation.class.getDeclaredFields();
|
||||
for (Field f : fields) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("Entity must not contain password field: " + name, name.contains("password"));
|
||||
assertFalse("Entity must not contain card number field: " + name, name.contains("cardnumber") || name.contains("card_no"));
|
||||
assertFalse("Entity must not contain secret/token: " + name, name.contains("secret") || name.contains("token"));
|
||||
assertFalse("Entity must not contain raw payload: " + name, name.contains("rawrequest") || name.contains("rawresponse"));
|
||||
}
|
||||
}
|
||||
|
||||
// K. REVOKE is operation_type, reaching status=SUCCESS without mutating prior GRANT operation
|
||||
@Test
|
||||
public void testK_revocationIsOperationType_withSuccessStatus()
|
||||
{
|
||||
// 1. 历史 GRANT 操作已成功达成 SUCCESS
|
||||
TrainExternalOperation grantOp = new TrainExternalOperation();
|
||||
grantOp.setDoorAuthId(1001L);
|
||||
grantOp.setOperationType("GRANT");
|
||||
grantOp.setIdempotencyKey("KEY-GRANT-099");
|
||||
TrainExternalOperation createdGrant = operationService.createOperation(grantOp);
|
||||
operationService.transitStatus(createdGrant.getOperationId(), "SUCCESS", null, null, "admin");
|
||||
|
||||
// 2. 发起注销操作 (operation_type = REVOKE)
|
||||
TrainExternalOperation revokeOp = new TrainExternalOperation();
|
||||
revokeOp.setDoorAuthId(1001L);
|
||||
revokeOp.setOperationType("REVOKE");
|
||||
revokeOp.setIdempotencyKey("KEY-REVOKE-001");
|
||||
TrainExternalOperation createdRevoke = operationService.createOperation(revokeOp);
|
||||
|
||||
assertEquals("REVOKE", createdRevoke.getOperationType());
|
||||
assertEquals("CREATED", createdRevoke.getOperationStatus());
|
||||
|
||||
// 3. 注销外部调用成功返回终态 SUCCESS (并非 REVOKED)
|
||||
TrainExternalOperation completedRevoke = operationService.transitStatus(
|
||||
createdRevoke.getOperationId(), "SUCCESS", null, null, "admin");
|
||||
assertEquals("SUCCESS", completedRevoke.getOperationStatus());
|
||||
|
||||
// 4. 验证历史 GRANT 操作依然保持 SUCCESS,未被篡改
|
||||
TrainExternalOperation originalGrant = operationService.getOperationById(createdGrant.getOperationId());
|
||||
assertEquals("SUCCESS", originalGrant.getOperationStatus());
|
||||
assertEquals("GRANT", originalGrant.getOperationType());
|
||||
}
|
||||
|
||||
// L. REVOKED is NOT an operation status
|
||||
@Test
|
||||
public void testL_revokedIsNotOperationStatus()
|
||||
{
|
||||
assertFalse("Operation status constants must not define STATUS_REVOKED",
|
||||
TrainExternalOperation.STATUS_SUCCESS.equals("REVOKED"));
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalResourceMappingMapper;
|
||||
|
||||
/**
|
||||
* 外部资源映射服务单元测试
|
||||
* 覆盖 A 到 I 全部关键业务场景
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class ExternalResourceMappingServiceTest
|
||||
{
|
||||
private ExternalResourceMappingServiceImpl mappingService;
|
||||
private final Map<Long, TrainExternalResourceMapping> mappingStore = new HashMap<>();
|
||||
private final Map<String, TrainDormitoryInfo> dormStore = new HashMap<>();
|
||||
private final AtomicLong mappingIdGen = new AtomicLong(1);
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
mappingService = new ExternalResourceMappingServiceImpl();
|
||||
mappingStore.clear();
|
||||
dormStore.clear();
|
||||
|
||||
TrainExternalResourceMappingMapper mappingMapper = (TrainExternalResourceMappingMapper) Proxy.newProxyInstance(
|
||||
TrainExternalResourceMappingMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainExternalResourceMappingMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String mName = method.getName();
|
||||
if ("selectExternalResourceMappingById".equals(mName)) {
|
||||
return mappingStore.get((Long) args[0]);
|
||||
} else if ("selectActiveRoomMapping".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String dId = (String) args[1];
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId())
|
||||
&& "DORM_ROOM".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectActiveLockMappings".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String dId = (String) args[1];
|
||||
List<TrainExternalResourceMapping> res = new ArrayList<>();
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId())
|
||||
&& "DOOR_LOCK".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
res.add(m);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("selectActiveByExternalResource".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String rType = (String) args[1];
|
||||
String rId = (String) args[2];
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && rType.equals(m.getExternalResourceType())
|
||||
&& rId.equals(m.getExternalResourceId()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectExternalResourceMappingList".equals(mName)) {
|
||||
TrainExternalResourceMapping q = (TrainExternalResourceMapping) args[0];
|
||||
List<TrainExternalResourceMapping> res = new ArrayList<>();
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (q == null || q.getDormId() == null || q.getDormId().equals(m.getDormId())) {
|
||||
res.add(m);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("insertExternalResourceMapping".equals(mName)) {
|
||||
TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0];
|
||||
long id = mappingIdGen.getAndIncrement();
|
||||
m.setMappingId(id);
|
||||
mappingStore.put(id, m);
|
||||
return 1;
|
||||
} else if ("updateExternalResourceMapping".equals(mName)) {
|
||||
TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0];
|
||||
mappingStore.put(m.getMappingId(), m);
|
||||
return 1;
|
||||
} else if ("deactivateMapping".equals(mName)) {
|
||||
Long id = (Long) args[0];
|
||||
String uBy = (String) args[1];
|
||||
TrainExternalResourceMapping m = mappingStore.get(id);
|
||||
if (m != null) {
|
||||
m.setMappingStatus("INACTIVE");
|
||||
m.setUpdateBy(uBy);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} else if ("deactivateRoomMapping".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String dId = (String) args[1];
|
||||
String uBy = (String) args[2];
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId())
|
||||
&& "DORM_ROOM".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
m.setMappingStatus("INACTIVE");
|
||||
m.setUpdateBy(uBy);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainDormitoryInfoMapper dormMapper = (TrainDormitoryInfoMapper) Proxy.newProxyInstance(
|
||||
TrainDormitoryInfoMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainDormitoryInfoMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) {
|
||||
return dormStore.get(args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
setField(mappingService, "mappingMapper", mappingMapper);
|
||||
setField(mappingService, "dormitoryInfoMapper", dormMapper);
|
||||
|
||||
// 注入默认合法宿舍
|
||||
TrainDormitoryInfo dorm101 = new TrainDormitoryInfo();
|
||||
dorm101.setDormId("DORM-101");
|
||||
dorm101.setDormBuilding("1号楼");
|
||||
dorm101.setDormFloor("1层");
|
||||
dorm101.setDormRoom("101");
|
||||
dorm101.setCapacity(2);
|
||||
dormStore.put(dorm101.getDormId(), dorm101);
|
||||
|
||||
TrainDormitoryInfo dorm102 = new TrainDormitoryInfo();
|
||||
dorm102.setDormId("DORM-102");
|
||||
dorm102.setDormBuilding("1号楼");
|
||||
dorm102.setDormFloor("1层");
|
||||
dorm102.setDormRoom("102");
|
||||
dorm102.setCapacity(2);
|
||||
dormStore.put(dorm102.getDormId(), dorm102);
|
||||
}
|
||||
|
||||
// A. Create room mapping
|
||||
@Test
|
||||
public void testA_createRoomMapping_success()
|
||||
{
|
||||
TrainExternalResourceMapping mapping = new TrainExternalResourceMapping();
|
||||
mapping.setDormId("DORM-101");
|
||||
mapping.setVendorCode("DMS");
|
||||
mapping.setExternalResourceId("PLACE-8801");
|
||||
|
||||
TrainExternalResourceMapping created = mappingService.bindRoomMapping(mapping);
|
||||
|
||||
assertNotNull(created);
|
||||
assertNotNull(created.getMappingId());
|
||||
assertEquals("DORM_ROOM", created.getLocalResourceType());
|
||||
assertEquals("ROOM", created.getExternalResourceType());
|
||||
assertEquals("ACTIVE", created.getMappingStatus());
|
||||
assertEquals(Integer.valueOf(1), created.getIsPrimary());
|
||||
}
|
||||
|
||||
// A2. ROOM and DOOR_LOCK can share same external_resource_id legally
|
||||
@Test
|
||||
public void testA2_sameExternalIdForRoomAndLock_allowed()
|
||||
{
|
||||
// 1. 绑定 ROOM 外部ID为 10001
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-101");
|
||||
room.setVendorCode("DMS");
|
||||
room.setExternalResourceId("10001");
|
||||
TrainExternalResourceMapping resRoom = mappingService.bindRoomMapping(room);
|
||||
assertNotNull(resRoom);
|
||||
assertEquals("ROOM", resRoom.getExternalResourceType());
|
||||
|
||||
// 2. 绑定 DOOR_LOCK 外部ID同样为 10001 (不同类型,完全合法)
|
||||
TrainExternalResourceMapping lock = new TrainExternalResourceMapping();
|
||||
lock.setDormId("DORM-101");
|
||||
lock.setVendorCode("DMS");
|
||||
lock.setExternalResourceId("10001");
|
||||
TrainExternalResourceMapping resLock = mappingService.bindLockMapping(lock);
|
||||
assertNotNull(resLock);
|
||||
assertEquals("DOOR_LOCK", resLock.getExternalResourceType());
|
||||
assertEquals(resRoom.getExternalResourceId(), resLock.getExternalResourceId());
|
||||
}
|
||||
|
||||
// B. Duplicate active room mapping rejected
|
||||
@Test
|
||||
public void testB_duplicateActiveRoomMapping_rejected()
|
||||
{
|
||||
TrainExternalResourceMapping first = new TrainExternalResourceMapping();
|
||||
first.setDormId("DORM-101");
|
||||
first.setVendorCode("DMS");
|
||||
first.setExternalResourceId("PLACE-8801");
|
||||
mappingService.bindRoomMapping(first);
|
||||
|
||||
TrainExternalResourceMapping second = new TrainExternalResourceMapping();
|
||||
second.setDormId("DORM-101");
|
||||
second.setVendorCode("DMS");
|
||||
second.setExternalResourceId("PLACE-8802");
|
||||
|
||||
try {
|
||||
mappingService.bindRoomMapping(second);
|
||||
fail("Should reject duplicate active room mapping on same dorm");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("已存在有效的外部房间映射"));
|
||||
}
|
||||
}
|
||||
|
||||
// C. Deactivate old mapping then rebind allowed
|
||||
@Test
|
||||
public void testC_deactivateOldMappingThenRebind_allowed()
|
||||
{
|
||||
TrainExternalResourceMapping first = new TrainExternalResourceMapping();
|
||||
first.setDormId("DORM-101");
|
||||
first.setVendorCode("DMS");
|
||||
first.setExternalResourceId("PLACE-8801");
|
||||
TrainExternalResourceMapping created = mappingService.bindRoomMapping(first);
|
||||
|
||||
boolean deactivated = mappingService.deactivateMapping(created.getMappingId(), "admin");
|
||||
assertTrue(deactivated);
|
||||
|
||||
TrainExternalResourceMapping second = new TrainExternalResourceMapping();
|
||||
second.setDormId("DORM-101");
|
||||
second.setVendorCode("DMS");
|
||||
second.setExternalResourceId("PLACE-8802");
|
||||
|
||||
TrainExternalResourceMapping rebound = mappingService.bindRoomMapping(second);
|
||||
assertNotNull(rebound);
|
||||
assertEquals("ACTIVE", rebound.getMappingStatus());
|
||||
assertEquals("PLACE-8802", rebound.getExternalResourceId());
|
||||
}
|
||||
|
||||
// D. One room multiple lock mapping allowed
|
||||
@Test
|
||||
public void testD_oneRoomMultipleLockMapping_allowed()
|
||||
{
|
||||
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
|
||||
lock1.setDormId("DORM-101");
|
||||
lock1.setVendorCode("DMS");
|
||||
lock1.setExternalResourceId("LOCK-001");
|
||||
lock1.setExternalHardwareId("HW-SN-001");
|
||||
|
||||
TrainExternalResourceMapping res1 = mappingService.bindLockMapping(lock1);
|
||||
assertEquals(Integer.valueOf(1), res1.getIsPrimary());
|
||||
|
||||
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
|
||||
lock2.setDormId("DORM-101");
|
||||
lock2.setVendorCode("DMS");
|
||||
lock2.setExternalResourceId("LOCK-002");
|
||||
lock2.setExternalHardwareId("HW-SN-002");
|
||||
|
||||
TrainExternalResourceMapping res2 = mappingService.bindLockMapping(lock2);
|
||||
assertEquals(Integer.valueOf(0), res2.getIsPrimary());
|
||||
|
||||
List<TrainExternalResourceMapping> locks = mappingService.getActiveLockMappings("DMS", "DORM-101");
|
||||
assertEquals(2, locks.size());
|
||||
}
|
||||
|
||||
// E. Same external lock active binding to two rooms rejected
|
||||
@Test
|
||||
public void testE_sameExternalLockActiveBindingToTwoRooms_rejected()
|
||||
{
|
||||
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
|
||||
lock1.setDormId("DORM-101");
|
||||
lock1.setVendorCode("DMS");
|
||||
lock1.setExternalResourceId("LOCK-SHARED");
|
||||
mappingService.bindLockMapping(lock1);
|
||||
|
||||
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
|
||||
lock2.setDormId("DORM-102");
|
||||
lock2.setVendorCode("DMS");
|
||||
lock2.setExternalResourceId("LOCK-SHARED");
|
||||
|
||||
try {
|
||||
mappingService.bindLockMapping(lock2);
|
||||
fail("Should reject same external lock bound to another dorm");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("已处于有效绑定状态"));
|
||||
}
|
||||
}
|
||||
|
||||
// F. Invalid dorm rejected
|
||||
@Test
|
||||
public void testF_invalidDorm_rejected()
|
||||
{
|
||||
TrainExternalResourceMapping mapping = new TrainExternalResourceMapping();
|
||||
mapping.setDormId("DORM-NON-EXIST");
|
||||
mapping.setVendorCode("DMS");
|
||||
mapping.setExternalResourceId("PLACE-9999");
|
||||
|
||||
try {
|
||||
mappingService.bindRoomMapping(mapping);
|
||||
fail("Should reject invalid dormId");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("目标宿舍不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
// G. History retained
|
||||
@Test
|
||||
public void testG_historyRetained_listQuery()
|
||||
{
|
||||
TrainExternalResourceMapping first = new TrainExternalResourceMapping();
|
||||
first.setDormId("DORM-101");
|
||||
first.setVendorCode("DMS");
|
||||
first.setExternalResourceId("PLACE-OLD");
|
||||
TrainExternalResourceMapping created = mappingService.bindRoomMapping(first);
|
||||
mappingService.deactivateMapping(created.getMappingId(), "admin");
|
||||
|
||||
TrainExternalResourceMapping second = new TrainExternalResourceMapping();
|
||||
second.setDormId("DORM-101");
|
||||
second.setVendorCode("DMS");
|
||||
second.setExternalResourceId("PLACE-NEW");
|
||||
mappingService.bindRoomMapping(second);
|
||||
|
||||
TrainExternalResourceMapping query = new TrainExternalResourceMapping();
|
||||
query.setDormId("DORM-101");
|
||||
List<TrainExternalResourceMapping> all = mappingService.listMappings(query);
|
||||
|
||||
assertEquals(2, all.size());
|
||||
}
|
||||
|
||||
// H. Query active room
|
||||
@Test
|
||||
public void testH_queryActiveRoom()
|
||||
{
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-101");
|
||||
room.setVendorCode("DMS");
|
||||
room.setExternalResourceId("PLACE-101");
|
||||
mappingService.bindRoomMapping(room);
|
||||
|
||||
TrainExternalResourceMapping found = mappingService.getActiveRoomMapping("DMS", "DORM-101");
|
||||
assertNotNull(found);
|
||||
assertEquals("PLACE-101", found.getExternalResourceId());
|
||||
}
|
||||
|
||||
// I. Query lock list
|
||||
@Test
|
||||
public void testI_queryLockList()
|
||||
{
|
||||
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
|
||||
lock1.setDormId("DORM-101");
|
||||
lock1.setVendorCode("DMS");
|
||||
lock1.setExternalResourceId("LOCK-01");
|
||||
mappingService.bindLockMapping(lock1);
|
||||
|
||||
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
|
||||
lock2.setDormId("DORM-101");
|
||||
lock2.setVendorCode("DMS");
|
||||
lock2.setExternalResourceId("LOCK-02");
|
||||
mappingService.bindLockMapping(lock2);
|
||||
|
||||
List<TrainExternalResourceMapping> locks = mappingService.getActiveLockMappings("DMS", "DORM-101");
|
||||
assertEquals(2, locks.size());
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
-- 006_accommodation_history_constraint.sql
|
||||
-- 目标:移除 train_accommodation 全局唯一约束 uq_accommodation_enroll,
|
||||
-- 创建部分唯一索引 uq_accommodation_enroll_active (每个 enrollment 最多 1 条有效住宿),
|
||||
-- 允许学员拥有多条退宿历史记录 (status=2),彻底支持调房与历史留痕。
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_dup_count INTEGER;
|
||||
v_con_def TEXT;
|
||||
v_idx_def TEXT;
|
||||
BEGIN
|
||||
-- 1. Pre-Migration Data Safety Guard
|
||||
-- 检查是否已存在同一个 enrollment_id 拥有 2 条或以上处于有效状态 (0=预分配, 1=已入住) 的住宿记录
|
||||
SELECT COUNT(*) INTO v_dup_count
|
||||
FROM (
|
||||
SELECT enrollment_id
|
||||
FROM train_accommodation
|
||||
WHERE accommodation_status IN (0, 1)
|
||||
GROUP BY enrollment_id
|
||||
HAVING COUNT(*) > 1
|
||||
) t;
|
||||
|
||||
IF v_dup_count > 0 THEN
|
||||
RAISE EXCEPTION 'Pre-migration check failed: found % enrollment(s) with multiple active accommodations (status IN (0,1)). Manual resolution required before applying migration 006.', v_dup_count;
|
||||
END IF;
|
||||
|
||||
-- 2. Existing Constraint Guard (Idempotent Drop)
|
||||
-- 检查约束 uq_accommodation_enroll 是否存在且符合预期
|
||||
SELECT pg_get_constraintdef(c.oid) INTO v_con_def
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON c.conrelid = t.oid
|
||||
WHERE t.relname = 'train_accommodation'
|
||||
AND c.conname = 'uq_accommodation_enroll';
|
||||
|
||||
IF v_con_def IS NOT NULL THEN
|
||||
-- 验证旧约束是否为 UNIQUE (enrollment_id)
|
||||
IF v_con_def ILIKE 'UNIQUE (enrollment_id)%' THEN
|
||||
RAISE NOTICE 'Dropping legacy global unique constraint: uq_accommodation_enroll';
|
||||
ALTER TABLE train_accommodation DROP CONSTRAINT uq_accommodation_enroll;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Constraint collision: constraint uq_accommodation_enroll exists but definition is unexpected: %', v_con_def;
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE NOTICE 'Constraint uq_accommodation_enroll does not exist, skipping drop.';
|
||||
END IF;
|
||||
|
||||
-- 3. New Index Collision Guard & Creation
|
||||
-- 检查部分唯一索引 uq_accommodation_enroll_active
|
||||
SELECT pg_get_indexdef(i.indexrelid) INTO v_idx_def
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON i.indexrelid = c.oid
|
||||
JOIN pg_class t ON i.indrelid = t.oid
|
||||
WHERE t.relname = 'train_accommodation'
|
||||
AND c.relname = 'uq_accommodation_enroll_active';
|
||||
|
||||
IF v_idx_def IS NOT NULL THEN
|
||||
-- 验证已有索引是否为预期的部分唯一索引
|
||||
IF v_idx_def ILIKE '%UNIQUE INDEX uq_accommodation_enroll_active ON %train_accommodation%enrollment_id%WHERE%accommodation_status%' THEN
|
||||
RAISE NOTICE 'Partial unique index uq_accommodation_enroll_active already exists with expected definition, skipping creation.';
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Index collision: index uq_accommodation_enroll_active exists but definition differs: %', v_idx_def;
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE NOTICE 'Creating partial unique index: uq_accommodation_enroll_active';
|
||||
CREATE UNIQUE INDEX uq_accommodation_enroll_active
|
||||
ON train_accommodation (enrollment_id)
|
||||
WHERE accommodation_status IN (0, 1);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,114 @@
|
||||
-- ==============================================================================
|
||||
-- 脚本名称: 008_external_resource_operation.sql
|
||||
-- 业务模块: DMS/外部物联网集成 - 外部资源映射与操作流水持久化模型 (R.3B Wave 1)
|
||||
-- 适用数据库: KingbaseES V8 / V9 (PostgreSQL 方言兼容)
|
||||
-- 安全编号: 008 (001-003 基线, 004-005 R.2分支占用, 006 Wave0, 007 保留禁止执行)
|
||||
-- 幂等性设计: 支持重复安全执行,纯增量结构,不含破坏性语句
|
||||
-- ==============================================================================
|
||||
|
||||
-- 1. 创建外部资源映射表 train_external_resource_mapping
|
||||
CREATE TABLE IF NOT EXISTS train_external_resource_mapping (
|
||||
mapping_id BIGSERIAL PRIMARY KEY,
|
||||
dorm_id VARCHAR(32) NOT NULL,
|
||||
vendor_code VARCHAR(32) NOT NULL DEFAULT 'DMS',
|
||||
local_resource_type VARCHAR(32) NOT NULL,
|
||||
external_resource_type VARCHAR(32) NOT NULL,
|
||||
external_resource_id VARCHAR(64) NOT NULL,
|
||||
external_parent_id VARCHAR(64),
|
||||
external_hardware_id VARCHAR(64),
|
||||
external_project_id VARCHAR(64),
|
||||
is_primary SMALLINT NOT NULL DEFAULT 1,
|
||||
mapping_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
last_sync_time TIMESTAMP,
|
||||
create_by VARCHAR(64) DEFAULT '',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by VARCHAR(64) DEFAULT '',
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
remark VARCHAR(500),
|
||||
CONSTRAINT fk_ext_res_map_dorm FOREIGN KEY (dorm_id) REFERENCES train_dormitory_info(dorm_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE train_external_resource_mapping IS '外部资源映射关联表(支持一房多锁与多供应商适配)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.mapping_id IS '映射ID(主键)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.dorm_id IS '本地宿舍ID(外键)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.vendor_code IS '外部供应商标识(如 DMS)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.local_resource_type IS '本地资源类型(DORM_ROOM, DOOR_LOCK)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_resource_type IS '外部资源类型(ROOM, DOOR_LOCK)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_resource_id IS '外部系统资源全局主键(如 placeId, lockId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_parent_id IS '外部父级资源标识(如 building/floor placeId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_hardware_id IS '外部硬件标识/SN(如门锁 hardwareId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_project_id IS '外部项目/租户站点ID(DMS projectId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.is_primary IS '是否主门锁/主资源(1是, 0否)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.mapping_status IS '映射状态(ACTIVE有效, INACTIVE失效)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.last_sync_time IS '最近同步元数据时间';
|
||||
|
||||
-- 1.1 约束与索引:同一宿舍同一厂商最多1个 ACTIVE 房间映射 (局部唯一索引)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_res_map_active_room
|
||||
ON train_external_resource_mapping (vendor_code, dorm_id)
|
||||
WHERE external_resource_type = 'ROOM' AND mapping_status = 'ACTIVE';
|
||||
|
||||
-- 1.2 约束与索引:同一外部资源在同一厂商下不能同时 ACTIVE 绑定到多个本地房间 (局部唯一索引)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_res_map_active_ext_res
|
||||
ON train_external_resource_mapping (vendor_code, external_resource_type, external_resource_id)
|
||||
WHERE mapping_status = 'ACTIVE';
|
||||
|
||||
-- 1.3 常规外键与查询索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_res_map_dorm ON train_external_resource_mapping (dorm_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_res_map_ext_res ON train_external_resource_mapping (vendor_code, external_resource_type, external_resource_id);
|
||||
|
||||
|
||||
-- 2. 创建外部操作流水表 train_external_operation
|
||||
CREATE TABLE IF NOT EXISTS train_external_operation (
|
||||
operation_id BIGSERIAL PRIMARY KEY,
|
||||
door_auth_id BIGINT NOT NULL,
|
||||
business_type VARCHAR(32) NOT NULL DEFAULT 'DOOR_AUTH',
|
||||
vendor_code VARCHAR(32) NOT NULL DEFAULT 'DMS',
|
||||
operation_type VARCHAR(32) NOT NULL,
|
||||
operation_status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
external_operation_id VARCHAR(64),
|
||||
external_request_type INTEGER,
|
||||
external_hardware_id VARCHAR(64),
|
||||
external_key_id VARCHAR(64),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_poll_time TIMESTAMP,
|
||||
requested_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
last_error_code VARCHAR(64),
|
||||
last_error_message VARCHAR(500),
|
||||
create_by VARCHAR(64) DEFAULT '',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by VARCHAR(64) DEFAULT '',
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
remark VARCHAR(500),
|
||||
CONSTRAINT fk_ext_op_door_auth FOREIGN KEY (door_auth_id) REFERENCES train_dorm_auth(auth_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE train_external_operation IS '外部设备操作流水与异步轮询对账表(中立模型,无明文敏感数据)';
|
||||
COMMENT ON COLUMN train_external_operation.operation_id IS '本地操作流水ID(主键)';
|
||||
COMMENT ON COLUMN train_external_operation.door_auth_id IS '关联门禁授权主记录ID(外键)';
|
||||
COMMENT ON COLUMN train_external_operation.business_type IS '业务类型(默认 DOOR_AUTH)';
|
||||
COMMENT ON COLUMN train_external_operation.vendor_code IS '外部供应商标识(如 DMS)';
|
||||
COMMENT ON COLUMN train_external_operation.operation_type IS '操作类型(GRANT下发, REVOKE注销, STATUS_QUERY查询)';
|
||||
COMMENT ON COLUMN train_external_operation.operation_status IS '操作状态(CREATED, REQUESTED, ACCEPTED, PENDING, RETRY_WAIT, SUCCESS, FAILED, TIMEOUT)';
|
||||
COMMENT ON COLUMN train_external_operation.idempotency_key IS '本地全局幂等键(防重复调用)';
|
||||
COMMENT ON COLUMN train_external_operation.external_operation_id IS '外部返回的操作流水ID(如 DMS operationId,初始为NULL)';
|
||||
COMMENT ON COLUMN train_external_operation.external_request_type IS '外部请求类型数字码(如 1下发密码, 3删除密码)';
|
||||
COMMENT ON COLUMN train_external_operation.external_hardware_id IS '外部硬件标识/SN';
|
||||
COMMENT ON COLUMN train_external_operation.external_key_id IS '外部凭证钥匙ID(如 DMS keyId)';
|
||||
COMMENT ON COLUMN train_external_operation.attempt_count IS '尝试/轮询次数';
|
||||
COMMENT ON COLUMN train_external_operation.next_poll_time IS '下次轮询执行时间戳';
|
||||
COMMENT ON COLUMN train_external_operation.requested_at IS '向外部发出请求的时间';
|
||||
COMMENT ON COLUMN train_external_operation.completed_at IS '操作终态达成时间';
|
||||
COMMENT ON COLUMN train_external_operation.last_error_code IS '最后一次错误编码';
|
||||
COMMENT ON COLUMN train_external_operation.last_error_message IS '最后一次错误描述';
|
||||
|
||||
-- 2.1 约束与索引:幂等键全局唯一约束
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_op_idempotency ON train_external_operation (idempotency_key);
|
||||
|
||||
-- 2.2 约束与索引:门禁授权外键索引与外部操作ID索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_op_door_auth ON train_external_operation (door_auth_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_op_ext_id ON train_external_operation (vendor_code, external_operation_id);
|
||||
|
||||
-- 2.3 约束与索引:待轮询操作快速检索组合索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_op_poll ON train_external_operation (operation_status, next_poll_time);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ==============================================================================
|
||||
-- 脚本名称: 009_dorm_authorization_orchestration.sql
|
||||
-- 业务模块: DMS/外部物联网集成 - 宿舍门禁业务授权编排扩展模型 (R.3B Wave 3A)
|
||||
-- 适用数据库: KingbaseES V8 / V9 (PostgreSQL 方言兼容)
|
||||
-- 安全编号: 009 (001-003 基线, 004-005 R.2分支占用, 006 Wave0, 007 保留禁止执行, 008 Wave1)
|
||||
-- 幂等性设计: 支持重复安全执行,纯增量列结构与索引,不含破坏性语句
|
||||
-- 安全红线: 严禁引入明文密码列,密码仅在内存短暂留存,严禁落库
|
||||
-- ==============================================================================
|
||||
|
||||
-- 1. 扩展 train_dorm_auth 业务授权字段(增量列添加,防冲突幂等设计)
|
||||
|
||||
-- 1.1 凭据类型列 (默认 ONLINE_PASSWORD,未来可扩展 QR, CARD, MOBILE_KEY)
|
||||
ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS credential_type VARCHAR(32) DEFAULT 'ONLINE_PASSWORD' NOT NULL;
|
||||
COMMENT ON COLUMN train_dorm_auth.credential_type IS '凭据类型 (ONLINE_PASSWORD 在线密码, QR 二维码, CARD 卡片, MOBILE_KEY 手机钥匙)';
|
||||
|
||||
-- 1.2 厂商中立业务授权状态列 (从历史 face_sync_status 进化为语义严谨的独立业务状态)
|
||||
-- 状态枚举:PENDING_GRANT(待下发), GRANTING(下发中), ACTIVE(已生效), PENDING_REVOKE(待注销), REVOKING(注销中), REVOKED(已注销), ERROR(下发或注销失败)
|
||||
ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS auth_status VARCHAR(32) DEFAULT 'PENDING_GRANT' NOT NULL;
|
||||
COMMENT ON COLUMN train_dorm_auth.auth_status IS '业务授权状态 (PENDING_GRANT, GRANTING, ACTIVE, PENDING_REVOKE, REVOKING, REVOKED, ERROR)';
|
||||
|
||||
-- 1.3 关联外部操作流水ID (用于关联并追踪当前最新驱动的 ExternalOperation)
|
||||
ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS latest_operation_id BIGINT;
|
||||
COMMENT ON COLUMN train_dorm_auth.latest_operation_id IS '最近一次绑定的外部操作流水ID (关联 train_external_operation.operation_id)';
|
||||
|
||||
-- 1.4 授权操作世代/版本号 (用于防旧操作晚到结果覆盖更新状态,保证操作时序单调性)
|
||||
ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS operation_version INTEGER DEFAULT 1 NOT NULL;
|
||||
COMMENT ON COLUMN train_dorm_auth.operation_version IS '授权操作版本号 (乐观并发与迟到操作防覆写防护)';
|
||||
|
||||
-- 2. 索引优化 (幂等添加)
|
||||
CREATE INDEX IF NOT EXISTS idx_dorm_auth_status ON train_dorm_auth (auth_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dorm_auth_latest_op ON train_dorm_auth (latest_operation_id);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- =============================================================================
|
||||
-- 脚本名称: 010_dms_access_management_permissions.sql
|
||||
-- 业务模块: DMS门禁与外部资源映射管理端权限扩展 (R.3B Wave 3B)
|
||||
-- 适用数据库: KingbaseES, schema public
|
||||
-- 范围: 仅限 sys_menu 按钮级权限增量插入,不改动已有角色分配 (sys_role_menu Δ = 0)
|
||||
-- 幂等性设计: 支持重复执行,WHERE NOT EXISTS 防重,无破坏性语句
|
||||
-- 安全红线: 严禁引入远程应急开门权限,严禁普通学员/教师授权
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 门禁与映射查看权限 (train:access:query)
|
||||
INSERT INTO public.sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
|
||||
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
|
||||
SELECT 2191, '门禁与资源映射查看', 2060, 1, '#', '', '', 1, 0,
|
||||
'F', '0', '0', 'train:access:query', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁授权与外部资源映射查看权限'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:access:query');
|
||||
|
||||
-- 2. 门禁资源映射管理权限 (train:access:manage)
|
||||
INSERT INTO public.sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
|
||||
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
|
||||
SELECT 2192, '门禁资源映射维护', 2060, 2, '#', '', '', 1, 0,
|
||||
'F', '0', '0', 'train:access:manage', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁房间与门锁资源绑定解绑维护权限'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:access:manage');
|
||||
|
||||
-- 3. 门禁手动对账权限 (train:access:reconcile)
|
||||
INSERT INTO public.sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
|
||||
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
|
||||
SELECT 2193, '门禁手动对账触发', 2060, 3, '#', '', '', 1, 0,
|
||||
'F', '0', '0', 'train:access:reconcile', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁权限手动对账触发执行权限'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:access:reconcile');
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,68 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询宿舍关联的外部资源映射(包含房间与门锁映射及主锁健康度)
|
||||
export function getDormMapping(dormId) {
|
||||
return request({
|
||||
url: '/train/access/mapping/dorm/' + dormId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 绑定或修改宿舍外部房间映射
|
||||
export function bindRoomMapping(data) {
|
||||
return request({
|
||||
url: '/train/access/mapping/bind-room',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 绑定或新增宿舍外部门锁映射
|
||||
export function bindLockMapping(data) {
|
||||
return request({
|
||||
url: '/train/access/mapping/bind-lock',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 解除/失效指定的外部资源映射
|
||||
export function unbindMapping(mappingId) {
|
||||
return request({
|
||||
url: '/train/access/mapping/' + mappingId,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 查询门禁授权列表
|
||||
export function listAuth(query) {
|
||||
return request({
|
||||
url: '/train/access/auth/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取门禁授权详细信息
|
||||
export function getAuthDetail(authId) {
|
||||
return request({
|
||||
url: '/train/access/auth/' + authId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 查询门禁授权关联的外部操作流水历史(脱敏)
|
||||
export function getOperationHistory(doorAuthId) {
|
||||
return request({
|
||||
url: '/train/access/operations/' + doorAuthId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 手动触发学员门禁权限对账
|
||||
export function manualReconcile(enrollmentId) {
|
||||
return request({
|
||||
url: '/train/access/reconcile/' + enrollmentId,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
<div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
|
||||
<transition name="sidebarLogoFade">
|
||||
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo">
|
||||
<h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
|
||||
</router-link>
|
||||
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo">
|
||||
<h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
|
||||
</router-link>
|
||||
</transition>
|
||||
@@ -25,19 +25,18 @@ export default {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
title: process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统',
|
||||
logo: logoImg
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
variables() {
|
||||
return variables;
|
||||
return variables
|
||||
},
|
||||
sideTheme() {
|
||||
return this.$store.state.settings.sideTheme
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
title: 'SYFJGYD学员管理系统',
|
||||
logo: logoImg
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,61 +1,48 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
||||
<el-form-item label="学员学号,关联train_student.s_id" prop="studentId">
|
||||
<el-alert
|
||||
title="门禁授权管理规范:门禁授权受学员报到确认、住宿安排及排课计划自动驱动。根据当前安全策略,在线密码凭证待业务侧安全交付方案确认(密文脱敏),系统不持久化亦不展示任何密码明文、Token或密钥。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
|
||||
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
|
||||
<el-form-item label="学员学号" prop="studentId">
|
||||
<el-input
|
||||
v-model="queryParams.studentId"
|
||||
placeholder="请输入学员学号,关联train_student.s_id"
|
||||
placeholder="请输入学员学号"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属开班计划ID,train_class_plan.plan_id" prop="planId">
|
||||
<el-form-item label="宿舍楼栋" prop="dormBuilding">
|
||||
<el-input
|
||||
v-model="queryParams.planId"
|
||||
placeholder="请输入所属开班计划ID,train_class_plan.plan_id"
|
||||
v-model="queryParams.dormBuilding"
|
||||
placeholder="请输入楼栋"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配宿舍ID,train_dormitory_info.dorm_id" prop="dormId">
|
||||
<el-form-item label="房间号" prop="dormRoom">
|
||||
<el-input
|
||||
v-model="queryParams.dormId"
|
||||
placeholder="请输入分配宿舍ID,train_dormitory_info.dorm_id"
|
||||
v-model="queryParams.dormRoom"
|
||||
placeholder="请输入房间号"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="床位号" prop="bedNo">
|
||||
<el-input
|
||||
v-model="queryParams.bedNo"
|
||||
placeholder="请输入床位号"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限生效时间" prop="authStart">
|
||||
<el-date-picker clearable
|
||||
v-model="queryParams.authStart"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
placeholder="请选择权限生效时间">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限失效时间 (开班结束自动失效)" prop="authEnd">
|
||||
<el-date-picker clearable
|
||||
v-model="queryParams.authEnd"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
placeholder="请选择权限失效时间 (开班结束自动失效)">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建管理员账号" prop="createUser">
|
||||
<el-input
|
||||
v-model="queryParams.createUser"
|
||||
placeholder="请输入创建管理员账号"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
<el-form-item label="授权状态" prop="authStatus">
|
||||
<el-select v-model="queryParams.authStatus" placeholder="全部状态" clearable>
|
||||
<el-option label="待授权 (PENDING_GRANT)" value="PENDING_GRANT" />
|
||||
<el-option label="授权中 (GRANTING)" value="GRANTING" />
|
||||
<el-option label="生效中 (ACTIVE)" value="ACTIVE" />
|
||||
<el-option label="待注销 (PENDING_REVOKE)" value="PENDING_REVOKE" />
|
||||
<el-option label="注销中 (REVOKING)" value="REVOKING" />
|
||||
<el-option label="已注销 (REVOKED)" value="REVOKED" />
|
||||
<el-option label="异常 (ERROR)" value="ERROR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
@@ -64,87 +51,79 @@
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="el-icon-plus"
|
||||
size="mini"
|
||||
@click="handleAdd"
|
||||
v-hasPermi="['train:auth:add']"
|
||||
>新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
icon="el-icon-edit"
|
||||
size="mini"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
v-hasPermi="['train:auth:edit']"
|
||||
>修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="el-icon-delete"
|
||||
size="mini"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
v-hasPermi="['train:auth:remove']"
|
||||
>删除</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
icon="el-icon-download"
|
||||
size="mini"
|
||||
@click="handleExport"
|
||||
v-hasPermi="['train:auth:export']"
|
||||
>导出</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="authList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="权限分配ID" align="center" prop="authId" />
|
||||
<el-table-column label="学员学号,关联train_student.s_id" align="center" prop="studentId" />
|
||||
<el-table-column label="所属开班计划ID,train_class_plan.plan_id" align="center" prop="planId" />
|
||||
<el-table-column label="分配宿舍ID,train_dormitory_info.dorm_id" align="center" prop="dormId" />
|
||||
<el-table-column label="床位号" align="center" prop="bedNo" />
|
||||
<el-table-column label="0 未下发人脸 1 已下发人脸至智能锁 2 下发失败" align="center" prop="faceSyncStatus" />
|
||||
<el-table-column label="权限生效时间" align="center" prop="authStart" width="180">
|
||||
<el-table v-loading="loading" :data="authList">
|
||||
<el-table-column label="授权ID" align="center" prop="authId" width="80" />
|
||||
<el-table-column label="报名ID" align="center" prop="enrollmentId" width="90">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.authStart, '{y}-{m}-{d}') }}</span>
|
||||
<span>{{ scope.row.enrollmentId || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限失效时间 (开班结束自动失效)" align="center" prop="authEnd" width="180">
|
||||
<el-table-column label="学员姓名" align="center" width="110">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.authEnd, '{y}-{m}-{d}') }}</span>
|
||||
<span>{{ scope.row.studentName || scope.row.studentId || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建管理员账号" align="center" prop="createUser" />
|
||||
<el-table-column label="备注" align="center" prop="remark" />
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<el-table-column label="学员学号" align="center" prop="studentId" width="120" />
|
||||
<el-table-column label="宿舍安排" align="center" min-width="130">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.dormBuilding || '' }} {{ scope.row.dormRoom || '' }} ({{ scope.row.bedNo || '-' }}床)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务授权状态" align="center" width="140">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.authStatus === 'ACTIVE'" type="success" effect="dark" size="small">生效中 (ACTIVE)</el-tag>
|
||||
<el-tag v-else-if="scope.row.authStatus === 'PENDING_GRANT'" type="info" size="small">待授权 (PENDING_GRANT)</el-tag>
|
||||
<el-tag v-else-if="scope.row.authStatus === 'GRANTING'" type="warning" size="small">授权中 (GRANTING)</el-tag>
|
||||
<el-tag v-else-if="scope.row.authStatus === 'PENDING_REVOKE'" type="warning" size="small">待注销 (PENDING_REVOKE)</el-tag>
|
||||
<el-tag v-else-if="scope.row.authStatus === 'REVOKING'" type="warning" size="small">注销中 (REVOKING)</el-tag>
|
||||
<el-tag v-else-if="scope.row.authStatus === 'REVOKED'" type="info" size="small">已注销 (REVOKED)</el-tag>
|
||||
<el-tag v-else-if="scope.row.authStatus === 'ERROR'" type="danger" size="small">异常 (ERROR)</el-tag>
|
||||
<el-tag v-else size="small">{{ scope.row.authStatus || 'UNKNOWN' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="凭据类型" align="center" width="130">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" type="info">{{ scope.row.credentialType || 'ONLINE_PASSWORD' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="安全凭据状态" align="center" min-width="160">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag size="mini" type="warning">{{ row.authStatus ? '凭证待业务侧安全交付方案确认' : '待确认' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限生效时间" align="center" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.authStart">{{ parseTime(scope.row.authStart, '{y}-{m}-{d} {h}:{i}') }}</span>
|
||||
<el-tag v-else size="mini" type="info">WAITING_FOR_PREREQUISITE</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限失效时间" align="center" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.authEnd">{{ parseTime(scope.row.authEnd, '{y}-{m}-{d} {h}:{i}') }}</span>
|
||||
<el-tag v-else size="mini" type="info">WAITING_FOR_PREREQUISITE</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重试次数" align="center" prop="retryCount" width="80" />
|
||||
<el-table-column label="异常说明" align="center" prop="lastError" :show-overflow-tooltip="true" min-width="120" />
|
||||
<el-table-column label="管理操作" align="center" width="160" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-hasPermi="['train:access:query']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-edit"
|
||||
@click="handleUpdate(scope.row)"
|
||||
v-hasPermi="['train:auth:edit']"
|
||||
>修改</el-button>
|
||||
icon="el-icon-document"
|
||||
@click="handleOperationHistory(scope.row)"
|
||||
>操作流水</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['train:access:reconcile']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-delete"
|
||||
@click="handleDelete(scope.row)"
|
||||
v-hasPermi="['train:auth:remove']"
|
||||
>删除</el-button>
|
||||
icon="el-icon-refresh"
|
||||
@click="handleManualReconcile(scope.row)"
|
||||
>手动对账</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -157,212 +136,126 @@
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
<!-- 添加或修改宿舍权限分配对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="学员学号,关联train_student.s_id" prop="studentId">
|
||||
<el-input v-model="form.studentId" placeholder="请输入学员学号,关联train_student.s_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属开班计划ID,train_class_plan.plan_id" prop="planId">
|
||||
<el-input v-model="form.planId" placeholder="请输入所属开班计划ID,train_class_plan.plan_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分配宿舍ID,train_dormitory_info.dorm_id" prop="dormId">
|
||||
<el-input v-model="form.dormId" placeholder="请输入分配宿舍ID,train_dormitory_info.dorm_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="床位号" prop="bedNo">
|
||||
<el-input v-model="form.bedNo" placeholder="请输入床位号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限生效时间" prop="authStart">
|
||||
<el-date-picker clearable
|
||||
v-model="form.authStart"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
placeholder="请选择权限生效时间">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限失效时间 (开班结束自动失效)" prop="authEnd">
|
||||
<el-date-picker clearable
|
||||
v-model="form.authEnd"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
placeholder="请选择权限失效时间 (开班结束自动失效)">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建管理员账号" prop="createUser">
|
||||
<el-input v-model="form.createUser" placeholder="请输入创建管理员账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 外部操作流水历史对话框 -->
|
||||
<el-dialog title="外部设备操作流水 (External Operation History)" :visible.sync="operationOpen" width="850px" append-to-body>
|
||||
<el-alert
|
||||
title="安全提示:操作流水记录了系统向外部门禁/门锁发起的调度记录,已执行全链路敏感信息脱敏(无明文密码、无AppSecret、无Token)。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 15px;"
|
||||
/>
|
||||
<el-table v-loading="operationLoading" :data="operationList" size="small" border empty-text="当前暂无外部操作流水记录">
|
||||
<el-table-column label="流水ID" prop="operationId" width="80" align="center" />
|
||||
<el-table-column label="操作类型" prop="operationType" width="140" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.operationType === 'GRANT_PASSCODE'" type="success" size="mini">下发密码 (GRANT)</el-tag>
|
||||
<el-tag v-else-if="scope.row.operationType === 'REVOKE_PASSCODE'" type="danger" size="mini">注销密码 (REVOKE)</el-tag>
|
||||
<el-tag v-else size="mini">{{ scope.row.operationType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行状态" prop="operationStatus" width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.operationStatus === 'SUCCESS'" type="success" size="mini">成功</el-tag>
|
||||
<el-tag v-else-if="scope.row.operationStatus === 'FAILED'" type="danger" size="mini">失败</el-tag>
|
||||
<el-tag v-else-if="scope.row.operationStatus === 'RUNNING'" type="warning" size="mini">运行中</el-tag>
|
||||
<el-tag v-else size="mini">{{ scope.row.operationStatus }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="尝试次数" prop="attemptCount" width="80" align="center" />
|
||||
<el-table-column label="请求时间" align="center" width="150">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.requestedAt, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完成时间" align="center" width="150">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.completedAt, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="外部流水引用" prop="externalOperationRef" align="center" min-width="120" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="错误说明" prop="sanitizedErrorMessage" align="center" min-width="120" :show-overflow-tooltip="true">
|
||||
<template slot-scope="scope">
|
||||
<span style="color: #F56C6C;">{{ scope.row.sanitizedErrorMessage || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
<el-button @click="operationOpen = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listAuth, getAuth, delAuth, addAuth, updateAuth } from "@/api/train/auth"
|
||||
import { listAuth, getOperationHistory, manualReconcile } from '@/api/train/access'
|
||||
|
||||
export default {
|
||||
name: "Auth",
|
||||
name: 'Auth',
|
||||
data() {
|
||||
return {
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
single: true,
|
||||
// 非多个禁用
|
||||
multiple: true,
|
||||
// 显示搜索条件
|
||||
showSearch: true,
|
||||
// 总条数
|
||||
total: 0,
|
||||
// 宿舍权限分配表格数据
|
||||
authList: [],
|
||||
// 弹出层标题
|
||||
title: "",
|
||||
// 是否显示弹出层
|
||||
open: false,
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
studentId: null,
|
||||
planId: null,
|
||||
dormId: null,
|
||||
bedNo: null,
|
||||
faceSyncStatus: null,
|
||||
authStart: null,
|
||||
authEnd: null,
|
||||
createUser: null,
|
||||
dormBuilding: null,
|
||||
dormRoom: null,
|
||||
authStatus: null
|
||||
},
|
||||
// 表单参数
|
||||
form: {},
|
||||
// 表单校验
|
||||
rules: {
|
||||
studentId: [
|
||||
{ required: true, message: "学员学号,关联train_student.s_id不能为空", trigger: "blur" }
|
||||
],
|
||||
planId: [
|
||||
{ required: true, message: "所属开班计划ID,train_class_plan.plan_id不能为空", trigger: "blur" }
|
||||
],
|
||||
dormId: [
|
||||
{ required: true, message: "分配宿舍ID,train_dormitory_info.dorm_id不能为空", trigger: "blur" }
|
||||
],
|
||||
createUser: [
|
||||
{ required: true, message: "创建管理员账号不能为空", trigger: "blur" }
|
||||
],
|
||||
}
|
||||
operationOpen: false,
|
||||
operationLoading: false,
|
||||
operationList: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
/** 查询宿舍权限分配列表 */
|
||||
getList() {
|
||||
this.loading = true
|
||||
listAuth(this.queryParams).then(response => {
|
||||
this.authList = response.rows
|
||||
this.total = response.total
|
||||
this.authList = response.rows || []
|
||||
this.total = response.total || 0
|
||||
this.loading = false
|
||||
}).catch(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
// 取消按钮
|
||||
cancel() {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.form = {
|
||||
authId: null,
|
||||
studentId: null,
|
||||
planId: null,
|
||||
dormId: null,
|
||||
bedNo: null,
|
||||
faceSyncStatus: null,
|
||||
authStart: null,
|
||||
authEnd: null,
|
||||
createUser: null,
|
||||
createTime: null,
|
||||
updateTime: null,
|
||||
remark: null
|
||||
}
|
||||
this.resetForm("form")
|
||||
},
|
||||
/** 搜索按钮操作 */
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
/** 重置按钮操作 */
|
||||
resetQuery() {
|
||||
this.resetForm("queryForm")
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
// 多选框选中数据
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.authId)
|
||||
this.single = selection.length!==1
|
||||
this.multiple = !selection.length
|
||||
},
|
||||
/** 新增按钮操作 */
|
||||
handleAdd() {
|
||||
this.reset()
|
||||
this.open = true
|
||||
this.title = "添加宿舍权限分配"
|
||||
},
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
this.reset()
|
||||
const authId = row.authId || this.ids
|
||||
getAuth(authId).then(response => {
|
||||
this.form = response.data
|
||||
this.open = true
|
||||
this.title = "修改宿舍权限分配"
|
||||
handleOperationHistory(row) {
|
||||
this.operationOpen = true
|
||||
this.operationLoading = true
|
||||
getOperationHistory(row.authId).then(res => {
|
||||
this.operationList = res.data || []
|
||||
this.operationLoading = false
|
||||
}).catch(() => {
|
||||
this.operationLoading = false
|
||||
})
|
||||
},
|
||||
/** 提交按钮 */
|
||||
submitForm() {
|
||||
this.$refs["form"].validate(valid => {
|
||||
if (valid) {
|
||||
if (this.form.authId != null) {
|
||||
updateAuth(this.form).then(response => {
|
||||
this.$modal.msgSuccess("修改成功")
|
||||
this.open = false
|
||||
this.getList()
|
||||
})
|
||||
} else {
|
||||
addAuth(this.form).then(response => {
|
||||
this.$modal.msgSuccess("新增成功")
|
||||
this.open = false
|
||||
this.getList()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
/** 删除按钮操作 */
|
||||
handleDelete(row) {
|
||||
const authIds = row.authId || this.ids
|
||||
this.$modal.confirm('是否确认删除宿舍权限分配编号为"' + authIds + '"的数据项?').then(function() {
|
||||
return delAuth(authIds)
|
||||
}).then(() => {
|
||||
handleManualReconcile(row) {
|
||||
if (!row.enrollmentId) {
|
||||
this.$modal.msgError('当前授权记录未关联报名信息,无法执行对账')
|
||||
return
|
||||
}
|
||||
this.$modal.confirm(
|
||||
'确认对报名编号 [' + row.enrollmentId + '] 执行门禁对账?系统将严格遵循业务前置校验规则。'
|
||||
).then(() => {
|
||||
return manualReconcile(row.enrollmentId)
|
||||
}).then(res => {
|
||||
this.$modal.msgSuccess(res.msg || '对账执行成功')
|
||||
this.getList()
|
||||
this.$modal.msgSuccess("删除成功")
|
||||
}).catch(() => {})
|
||||
},
|
||||
/** 导出按钮操作 */
|
||||
handleExport() {
|
||||
this.download('train/auth/export', {
|
||||
...this.queryParams
|
||||
}, `auth_${new Date().getTime()}.xlsx`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@
|
||||
icon="el-icon-view"
|
||||
@click="handleDetail(scope.row)"
|
||||
>详情</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['train:access:query']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-key"
|
||||
@click="handleAccessMapping(scope.row)"
|
||||
>门禁映射</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -200,11 +207,143 @@
|
||||
<el-button @click="detailOpen = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- DMS门禁资源映射管理对话框 -->
|
||||
<el-dialog :title="'DMS门禁资源映射 - ' + (currentDorm.dormBuilding || '') + ' ' + (currentDorm.dormRoom || '')" :visible.sync="mappingOpen" width="850px" append-to-body>
|
||||
<!-- 主锁配置健康诊断警告 -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<el-alert
|
||||
v-if="mappingHealth.status === 'HEALTHY'"
|
||||
title="主锁配置正常:检测到 1 个有效主锁 (Primary Lock),具备门禁授权下发条件。"
|
||||
type="success"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="mappingHealth.status === 'MISSING_PRIMARY'"
|
||||
title="配置异常:未配置主锁 (0 primary lock)!门禁授权下发将被阻断。"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="mappingHealth.status === 'MULTIPLE_PRIMARIES'"
|
||||
:title="'配置异常:检测到多个主锁 (' + (mappingData.primaryLocks ? mappingData.primaryLocks.length : 0) + ' primary locks)!违反单一主锁约束,门禁授权下发将被阻断。'"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 宿舍基本信息 -->
|
||||
<el-descriptions :column="3" border size="small" style="margin-bottom: 15px;">
|
||||
<el-descriptions-item label="宿舍ID">{{ currentDorm.dormId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="楼栋/房间">{{ currentDorm.dormBuilding }} - {{ currentDorm.dormRoom }}</el-descriptions-item>
|
||||
<el-descriptions-item label="核定床位">{{ currentDorm.capacity }} 人</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 外部房间映射 -->
|
||||
<el-card shadow="never" style="margin-bottom: 15px;">
|
||||
<div slot="header" class="clearfix">
|
||||
<span style="font-weight: bold;"><i class="el-icon-office-building" /> 外部房间映射 (Room Mapping)</span>
|
||||
</div>
|
||||
<div v-if="mappingData.roomMapping">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="供应商编码">{{ mappingData.roomMapping.vendorCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="外部房间ID">{{ mappingData.roomMapping.externalRoomId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="外部房间名称">{{ mappingData.roomMapping.externalRoomName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="映射状态">
|
||||
<el-tag :type="mappingData.roomMapping.mappingStatus === 'ACTIVE' ? 'success' : 'info'" size="mini">
|
||||
{{ mappingData.roomMapping.mappingStatus }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div style="margin-top: 10px; text-align: right;">
|
||||
<el-button type="danger" size="mini" plain icon="el-icon-delete" @click="handleUnbind(mappingData.roomMapping.mappingId)">解除房间绑定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-alert title="当前宿舍尚未绑定外部DMS房间。" type="info" :closable="false" style="margin-bottom: 10px;" />
|
||||
<el-form :inline="true" :model="roomForm" size="small">
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="roomForm.vendorCode" placeholder="如 MOCK_DMS" style="width: 140px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部房间ID">
|
||||
<el-input v-model="roomForm.externalRoomId" placeholder="例如 ROOM_101" style="width: 160px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部房间名">
|
||||
<el-input v-model="roomForm.externalRoomName" placeholder="例如 101室" style="width: 160px;" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-link" @click="submitBindRoom">绑定房间</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 外部门锁映射 (0..N) -->
|
||||
<el-card shadow="never">
|
||||
<div slot="header" class="clearfix">
|
||||
<span style="font-weight: bold;"><i class="el-icon-lock" /> 外部门锁映射 (Lock Mappings, 0..N)</span>
|
||||
</div>
|
||||
<el-table :data="mappingData.lockMappings" size="small" border empty-text="暂无门锁映射">
|
||||
<el-table-column label="供应商" prop="vendorCode" width="110" align="center" />
|
||||
<el-table-column label="外部门锁ID" prop="externalLockId" align="center" />
|
||||
<el-table-column label="外部门锁名称" prop="externalLockName" align="center" />
|
||||
<el-table-column label="型号" prop="lockModel" width="110" align="center" />
|
||||
<el-table-column label="主锁标识" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.isPrimary" type="success" size="mini">主锁 (Primary)</el-tag>
|
||||
<el-tag v-else type="info" size="mini">次要锁</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.mappingStatus === 'ACTIVE' ? 'success' : 'info'" size="mini">
|
||||
{{ scope.row.mappingStatus }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" style="color: #F56C6C;" icon="el-icon-delete" @click="handleUnbind(scope.row.mappingId)">解绑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 添加门锁表单 -->
|
||||
<div style="margin-top: 15px; border-top: 1px dashed #EBEEF5; padding-top: 15px;">
|
||||
<div style="font-size: 13px; font-weight: bold; margin-bottom: 8px;">添加门锁映射:</div>
|
||||
<el-form :inline="true" :model="lockForm" size="small">
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="lockForm.vendorCode" placeholder="如 MOCK_DMS" style="width: 120px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部门锁ID">
|
||||
<el-input v-model="lockForm.externalLockId" placeholder="例如 LOCK_101" style="width: 140px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门锁名称">
|
||||
<el-input v-model="lockForm.externalLockName" placeholder="例如 101门正锁" style="width: 130px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主锁">
|
||||
<el-switch v-model="lockForm.isPrimary" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" icon="el-icon-plus" @click="submitBindLock">添加门锁</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="mappingOpen = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listDormitory, getDormitory, addDormitory } from '@/api/train/dormitory'
|
||||
import { getDormMapping, bindRoomMapping, bindLockMapping, unbindMapping } from '@/api/train/access'
|
||||
|
||||
export default {
|
||||
name: 'Dormitory',
|
||||
@@ -218,6 +357,34 @@ export default {
|
||||
open: false,
|
||||
detailOpen: false,
|
||||
detailData: {},
|
||||
mappingOpen: false,
|
||||
currentDorm: {},
|
||||
mappingData: {
|
||||
dormitory: {},
|
||||
roomMapping: null,
|
||||
lockMappings: [],
|
||||
primaryLocks: [],
|
||||
health: {}
|
||||
},
|
||||
mappingHealth: {
|
||||
status: 'HEALTHY',
|
||||
message: '',
|
||||
primaryCount: 0
|
||||
},
|
||||
roomForm: {
|
||||
dormId: null,
|
||||
vendorCode: 'MOCK_DMS',
|
||||
externalRoomId: '',
|
||||
externalRoomName: ''
|
||||
},
|
||||
lockForm: {
|
||||
dormId: null,
|
||||
vendorCode: 'MOCK_DMS',
|
||||
externalLockId: '',
|
||||
externalLockName: '',
|
||||
lockModel: 'SMART_LOCK_M1',
|
||||
isPrimary: true
|
||||
},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
@@ -296,6 +463,52 @@ export default {
|
||||
this.detailOpen = true
|
||||
})
|
||||
},
|
||||
handleAccessMapping(row) {
|
||||
this.currentDorm = row
|
||||
this.roomForm.dormId = row.dormId
|
||||
this.lockForm.dormId = row.dormId
|
||||
this.loadMapping(row.dormId)
|
||||
this.mappingOpen = true
|
||||
},
|
||||
loadMapping(dormId) {
|
||||
getDormMapping(dormId).then(res => {
|
||||
const data = res.data || {}
|
||||
this.mappingData = data
|
||||
this.mappingHealth = data.health || { status: 'HEALTHY', primaryCount: (data.primaryLocks || []).length }
|
||||
})
|
||||
},
|
||||
submitBindRoom() {
|
||||
if (!this.roomForm.externalRoomId) {
|
||||
this.$modal.msgError('请输入外部房间ID')
|
||||
return
|
||||
}
|
||||
bindRoomMapping(this.roomForm).then(() => {
|
||||
this.$modal.msgSuccess('外部房间映射绑定成功')
|
||||
this.roomForm.externalRoomId = ''
|
||||
this.roomForm.externalRoomName = ''
|
||||
this.loadMapping(this.currentDorm.dormId)
|
||||
})
|
||||
},
|
||||
submitBindLock() {
|
||||
if (!this.lockForm.externalLockId) {
|
||||
this.$modal.msgError('请输入外部门锁ID')
|
||||
return
|
||||
}
|
||||
bindLockMapping(this.lockForm).then(() => {
|
||||
this.$modal.msgSuccess('外部门锁映射绑定成功')
|
||||
this.lockForm.externalLockId = ''
|
||||
this.lockForm.externalLockName = ''
|
||||
this.loadMapping(this.currentDorm.dormId)
|
||||
})
|
||||
},
|
||||
handleUnbind(mappingId) {
|
||||
this.$modal.confirm('是否确认解绑该外部映射?').then(() => {
|
||||
return unbindMapping(mappingId)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('解绑成功')
|
||||
this.loadMapping(this.currentDorm.dormId)
|
||||
}).catch(() => {})
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs['form'].validate(valid => {
|
||||
if (valid) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# R.3A DMS 门禁集成审计与设计基线:来源与范围定义
|
||||
|
||||
> **系统展示名称(暂定)**:沈阳房建公寓段学员管理系统
|
||||
> **内部工程代号**:RailwaySystem
|
||||
> **当前正式 Release HEAD**:`adcf930ea0a880e1faae36ff3d3e6539d0c0b8f9`
|
||||
> **当前开发工作区 (Worktree)**:`/Users/wzb/Desktop/项目/RailwaySystem-worktrees/dms-r3`
|
||||
> **当前分支**:`design/dms-r3`(基于 `origin/feat/cicd-pipeline`)
|
||||
> **文档读取与审计日期**:2026-09-15
|
||||
> **文档完整性状态**:`DOCUMENTATION_COMPLETENESS = PARTIAL`
|
||||
> **真实 DMS 平台调用**:`DMS_LIVE_API_CALLED = NO`
|
||||
|
||||
---
|
||||
|
||||
## 1. 命名与工程纪律
|
||||
|
||||
1. **展示名称与工程代号隔离**:
|
||||
- 对外业务展示名称统一为:**沈阳房建公寓段学员管理系统**(暂定)。
|
||||
- 内部工程代号继续保持:`RailwaySystem`。
|
||||
- **绝对红线**:严禁因为展示名称变化而重命名 Git 仓库名、Java 包名 (`com.ruoyi.train.*`)、数据库名 (`training`)、Maven 模块名 (`ruoyi-train`, `ruoyi-admin` 等) 以及内部技术路径与文件结构。
|
||||
2. **并行开发工作区分离**:
|
||||
- R.2 RBAC 权限重构当前在独立 Worktree 进行开发:`/Users/wzb/Desktop/项目/RailwaySystem-worktrees/rbac-r2`(分支 `feature/rbac-r2`)。
|
||||
- **纪律**:本集成审计任务严格在独立 Worktree `/Users/wzb/Desktop/项目/RailwaySystem-worktrees/dms-r3` 进行,严禁触碰 `rbac-r2` 目录,严禁修改 `feature/rbac-r2` 分支。
|
||||
3. **只读审计与设计冻结红线**:
|
||||
- 本阶段为 **R.3A 审计与设计冻结阶段**,只做技术审计、架构设计、模型比选与任务拆分。
|
||||
- **严格禁止**:
|
||||
- 修改任何 Java 代码 (`*.java`)
|
||||
- 修改任何 Vue/前端代码 (`*.vue`, `*.js`)
|
||||
- 修改任何 SQL 脚本或执行 Migration
|
||||
- 对生产/测试 Kingbase 数据库执行任何写操作 (INSERT, UPDATE, DELETE, DDL)
|
||||
- 调用任何真实 DMS 登录接口或门锁操作接口
|
||||
- 使用官方文档中的示例凭据 (`appid`, `appsecret`, `token` 等)
|
||||
- 执行 `git commit`, `git push`, 部署操作
|
||||
|
||||
---
|
||||
|
||||
## 2. 文档来源与凭据安全红线
|
||||
|
||||
### 2.1 外部接口文档来源
|
||||
- **文档地址**:`https://dms.dmsiot.com/V1/doc/restapi.html`
|
||||
- **平台性质**:DMS 设备管理 / 智能门锁物联网平台 RESTful API 文档。
|
||||
- **文档完整性定义**:`PARTIAL`(部分接口文档)。
|
||||
- **来源纪律 (Source Discipline)**:
|
||||
- 严禁假设文档已覆盖 DMS 的全部能力。
|
||||
- 审计结论严格区分为四类置信度:
|
||||
- `DOCUMENTED`:官方文档中有明确的 HTTP 方法、路径、请求体和响应体说明。
|
||||
- `INFERRED`:基于文档上下文字段、枚举说明推导,但缺少独立端点描述。
|
||||
- `UNCONFIRMED`:仅在响应体或枚举中出现字段,未提供接口调用支持。
|
||||
- 官方文档中已明确定义二维码/手机钥匙接口(生成:`POST /rest/doorlock/qrcode/create`,注销:`POST /rest/qrcode/delete`),分类为 `DOCUMENTED`;但其载荷缺失与一期适用性仍为 `NEEDS_BUSINESS_CONFIRMATION`。严禁根据返回体中的其他无接口字段(如 `fingerprint: false`)脑补或声称系统已支持指纹等未定义接口。
|
||||
|
||||
### 2.2 示例凭据安全隔离红线
|
||||
- 官方文档中出现的示例 `appid`、示例 `appsecret`、示例 `token`、示例手机号、密码、卡号等:
|
||||
- **仅作为文档语法结构与格式说明**。
|
||||
- **严禁**使用文档中任何示例凭据向 `dms.dmsiot.com` 发送任何网络请求(无论登录或业务测试)。
|
||||
- **严禁**将上述示例密钥提交到代码库或配置文件中。
|
||||
|
||||
---
|
||||
|
||||
## 3. 任务范围定义与交付清单
|
||||
|
||||
### 3.1 核心任务目标
|
||||
1. 完整审计 DMS REST API 文档,建立全量 API 清单与参数/状态审计表。
|
||||
2. 重新审计当前 RailwaySystem 真实代码库与 Kingbase 物理表(Dormitory, Accommodation, DormAuth)。
|
||||
3. 建立 RailwaySystem 宿舍业务与 DMS 门锁平台之间的领域映射体系。
|
||||
4. 设计厂商无关的门禁网关架构(DoorAccessGateway)与 DMS 专用适配器(DmsAdapter)。
|
||||
5. 设计生产级 Token 生命周期管理机制与高并发防击穿策略。
|
||||
6. 设计门禁授权生命周期状态机与异步 `operationId` 轮询调度器。
|
||||
7. 设计重试、网络未知状态对账、退训撤权、调房换房补偿等可靠性机制。
|
||||
8. 评估敏感数据保护方案(密码、卡号、Token 脱敏与加密)。
|
||||
9. 明确一期试点接入范围与后续 R.3B 任务 WBS 拆分。
|
||||
|
||||
### 3.2 交付物清单
|
||||
本审计任务仅在 `docs/development/dms-r3/` 目录下生成以下 15 个基线设计文档:
|
||||
- `00_source_and_scope.md`(本文件:来源、范围与安全红线)
|
||||
- `01_dms_api_inventory.md`(DMS API 全量资产清单与分级)
|
||||
- `02_current_accommodation_audit.md`(本地住宿/宿舍/门禁模型真实代码与数据库审计)
|
||||
- `03_domain_mapping.md`(业务领域模型映射与生命周期对应)
|
||||
- `04_auth_and_token_strategy.md`(DMS 登录鉴权、Token 缓存与并发控制策略)
|
||||
- `05_adapter_architecture.md`(集成适配器分层架构与厂商无关接口设计)
|
||||
- `06_authorization_state_machine.md`(门锁授权生命周期状态机)
|
||||
- `07_retry_idempotency_and_compensation.md`(重试机制、本地幂等与换房补偿设计)
|
||||
- `08_security_and_audit.md`(凭据安全、日志脱敏与门禁审计轨迹)
|
||||
- `09_database_mapping_proposal.md`(数据库表结构方案比选与模型演进建议)
|
||||
- `10_api_and_ui_proposal.md`(内部管理接口与端到端 UI 交互原型方案)
|
||||
- `11_testing_and_mock_plan.md`(WireMock 模拟测试与多并发可靠性测试计划)
|
||||
- `12_implementation_task_breakdown.md`(R.3B 实施阶段 WBS 任务拆分)
|
||||
- `13_open_questions.md`(供应商/业务/技术未决问题与阻塞项清单)
|
||||
- `14_decision_summary.md`(技术选型与管理决策一页纸总结)
|
||||
@@ -0,0 +1,85 @@
|
||||
# 01 DMS API 全量资产清单与分级评估 (DMS API Inventory)
|
||||
|
||||
> **文档来源**:`https://dms.dmsiot.com/V1/doc/restapi.html`
|
||||
> **文档完整性评估**:`DOCUMENTATION_COMPLETENESS = PARTIAL`
|
||||
> **接口审计原则**:严格基于官方文档文字与 Schema 表达,不臆测不存在的接口,明确标记置信度与敏感字段。
|
||||
|
||||
---
|
||||
|
||||
## 1. 范围与分级定义说明
|
||||
|
||||
| 分级标识 | 定义与采纳建议 | 典型接口范畴 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`P0_REQUIRED`** | **一期核心闭环必备**:完成学员入住开门、退宿撤权、异步状态对账所必需的接口 | 登录获取 Token、房间查询、门锁列表、下发在线密码/卡片、删除密码/卡片、查询门锁操作状态 |
|
||||
| **`P1_USEFUL`** | **高价值辅助运维接口**:用于设备健康度巡检、房源维护、现场排障,不阻断核心入住业务流 | 查询门锁电池与信号状态、楼栋/楼层列表、开门记录流水查询 |
|
||||
| **`P2_FUTURE`** | **未来二期/扩展功能**:非当前学员住宿刚需,或需要额外硬件联动、现场施工配合的功能 | 取电开关全套接口(送电/断电/列表/状态)、网关状态主动巡检刷新 |
|
||||
| **`OUT_OF_SCOPE`** | **高风险或不适用当前场景**:需要严苛线下流程或与当前全自动/半自动学员管理模型冲突 | 离线制卡接口(依赖现场物理制卡机与 DLL)、生成离线密码(需现场输入超长动态数字码) |
|
||||
| **`UNCONFIRMED`** | **文档缺失关键接口端点**:仅在响应体或枚举中出现字段,未提供独立接口调用支持 | 手机蓝牙钥匙、指纹下发接口 |
|
||||
|
||||
---
|
||||
|
||||
## 2. DMS API 全量清单 (DMS_API_INVENTORY)
|
||||
|
||||
| # | Category | Operation | HTTP Method | Path | Request Fields | Response Fields | Auth | Sync/Async | operationId? | requestType? | External ID | Sensitive Fields | Relevance | Phase Recommendation | Confidence |
|
||||
| :-: | :--- | :--- | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :--- | :--- | :--- | :-: | :-: |
|
||||
| 1 | 鉴权认证 | 平台登录获取 Token | `POST` | `/public/rest/third-party-app/token/get` | `appid`, `appsecret` | `success`, `token`, `expiresIn`, `message` | 无需 Token (凭 appid/secret) | Sync | 否 | 无 | 无 | `appid`, `appsecret`, `token` | 核心入口 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 2 | 鉴权认证 | 退出登录 | `POST` | `/public/rest/security/logout` | `{}` (空 JSON) | `success` | Cookie / URL `token` | Sync | 否 | 无 | 无 | `token` | 会话销毁 | **`P1_USEFUL`** | `DOCUMENTED` |
|
||||
| 3 | 区域管理 | 获取楼栋列表 | `POST` | `/rest/place/building/list` | `building`, `offset`, `limit` | `success`, `rows` (含 `id`, `name`, `parentId`, `building`, `projectId`), `total` | Cookie / URL `token` | Sync | 否 | 无 | 楼栋 `id` | 无 | 房源台账对照 | **`P1_USEFUL`** | `DOCUMENTED` |
|
||||
| 4 | 区域管理 | 获取楼层列表 | `POST` | `/rest/place/floor/list` *(注: 文档示例为 http)* | `building`, `floor`, `offset`, `limit` | `success`, `rows` (含 `id`, `name`, `parentId`, `floor`, `building`), `total` | Cookie / URL `token` | Sync | 否 | 无 | 楼层 `id` | 无 | 房源台账对照 | **`P1_USEFUL`** | `DOCUMENTED` |
|
||||
| 5 | 区域管理 | 获取房间列表 | `POST` | `/rest/place/room/list` | `building`, `floor`, `room`, `offset`, `limit` | `success`, `rows` (含 `id`, `name`, `parentId`, `building`, `floor`, `room`, `status`), `total` | Cookie / URL `token` | Sync | 否 | 无 | 房间 `id` (placeId) | 无 | 房间映射与校验 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 6 | 区域管理 | 精确获取房间 ID | `POST` | `/rest/place/room/get` | `building`, `floor`, `room` | `success`, `data` (含 `id`, `projectId`, `building`, `floor`, `room`), `message` | Cookie / URL `token` | Sync | 否 | 无 | 房间 `id` (placeId) | 无 | 房间快速映射绑定 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 7 | 网关管理 | 获取网关列表 | `POST` | `/rest/gateway/list` | `offset`, `limit` | `success`, `rows` (含 `id`, `projectId`, `hardwareId`, `onlineStatus` 等), `total` | Cookie / URL `token` | Sync | 否 | 无 | 网关 `id`, `hardwareId` | 无 | 设备拓扑巡检 | **`P1_USEFUL`** | `DOCUMENTED` *(onlineStatus未实现)* |
|
||||
| 8 | 网关管理 | 刷新网关状态 | `POST` | `/rest/gateway/refresh-online-status` | `id`, `hardwareId` | `success`, `data` (含 `operation` 对象: `operationId`, `timeoutSeconds: 10`, `requestType: 1`) | Cookie / URL `token` | Async | 是 (`operationId`) | 1 | 网关 `id` | 操作人手机号 (`username`) | 故障排查 | **`P2_FUTURE`** | `DOCUMENTED` |
|
||||
| 9 | 网关管理 | 查询网关状态 | `POST` | `/rest/gateway/query-online-status` | `operationId`, `hardwareId` | `success`, `data` (含 `operationStatus`: `success`/`undone`/`timeout`) | Cookie / URL `token` | Polling | 是 (`operationId`) | 1 | 无 | 无 | 故障排查轮询 | **`P2_FUTURE`** | `DOCUMENTED` |
|
||||
| 10 | 门锁管理 | 获取门锁列表 | `POST` | `/rest/doorlock/list` | `placeId`, `offset`, `limit` | `success`, `rows` (含 `id`, `hardwareId`, `placeId`, `battery`, `signal`, `passagewayMode`), `total` | Cookie / URL `token` | Sync | 否 | 无 | 门锁 `id`, `hardwareId` | 无 | 核心设备映射 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 11 | 门锁管理 | 查询门锁电池与信号状态 | `POST` | `/rest/doorlock/query-lock-battery` | `roomIds` (数组) | `success`, `data` (Map: `roomId` -> `[{id, battery, signal, batteryStr, signalStr}]`) | Cookie / URL `token` | Sync | 否 | 无 | 门锁 `id` | 无 | 门锁健康度监控 | **`P1_USEFUL`** | `DOCUMENTED` |
|
||||
| 12 | 门锁管理 | 远程开锁 | `POST` | `/rest/doorlock/remote-unlock` | `lockId`, `hardwareId`, `canOpenAntilock` | `success`, `data.operation` (含 `operationId`, `requestType: 5`) | Cookie / URL `token` | Async | 是 (`operationId`) | 5 | 门锁 `id` | 远程触发开门操作 | 高风险应急开门 | **`P2_FUTURE` / 受控应急** | `DOCUMENTED` |
|
||||
| 13 | 门锁管理 | 查询门锁操作结果 | `POST` | `/rest/doorlock/query-operation-status` | `requestType`, `operationId`, `hardwareId` | `success`, `data` (含 `operationStatus`: `success`/`failed`/`undone`/`timeout`, `resultDetails`, `message`) | Cookie / URL `token` | Polling (10s后) | 是 (`operationId`) | 1~9 | `operationId` | `resultDetails` | 异步状态对账必备 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 14 | 钥匙管理 | 下发在线密码 | `POST` | `/rest/doorlock/online-password-key/create` | `lockId`, `validStart`, `validUntil`, `holder`, `canOpenAntilock`, `phoneCountry`, `phoneNumber`, `password` (系统支持4~8位,建议6位), `subType: 4` (限时) | `success`, `data` (含钥匙 `id`, `operationId`, `lockHardwareId`, `smsStatus`) | Cookie / URL `token` | Async (需10s后对账) | 是 (`operationId`) | 1 | 钥匙 `id`, `operationId` | `password`, `phoneNumber` | 核心凭据发放 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 15 | 钥匙管理 | 查询下发在线密码结果 | `POST` | `/rest/doorlock/query-operation-status` | `requestType: 1`, `operationId`, `hardwareId` | `success`, `data.operationStatus` | Cookie / URL `token` | Polling | 是 (`operationId`) | 1 | `operationId` | 无 | 密码生效对账 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 16 | 钥匙管理 | 删除在线密码 | `POST` | `/rest/doorlock/online-password/delete` | `id` (钥匙 ID) | `success`, `data` (含 `operationId`, 钥匙状态 snapshot) | Cookie / URL `token` | Async (需10s后对账) | 是 (`operationId`) | 3 *(注: 文档示例误标为1)* | 钥匙 `id`, `operationId` | `fuzzyPassword`, `phoneNumber` | 核心退宿撤权 | **`P0_REQUIRED`** | `DOCUMENTED` |
|
||||
| 17 | 钥匙管理 | 申请离线密码 | `POST` | `/rest/doorlock/offline-password/create` | `lockId`, `validStart`, `validUntil`, `subType` (1永久, 2单次, 4限时, 5清空) | `success`, `data.password` (动态纯数字码,如 "2420101197") | Cookie / URL `token` | Sync (纯算法计算) | 否 | 无 | 无 | `password` | 离线网络应急备用 | **`OUT_OF_SCOPE`** | `DOCUMENTED` |
|
||||
| 18 | 钥匙管理 | 生成离线密码钥匙记录 | `POST` | `/rest/doorlock/offline-password-key/create` | `lockId`, `holder`, `validStart`, `validUntil`, `password` | `success`, `data` (含钥匙 `id`, `placeId`, `lockHardwareId`) | Cookie / URL `token` | Sync | 否 | 无 | 钥匙 `id` | `fuzzyPassword` | 离线台账补录 | **`OUT_OF_SCOPE`** | `DOCUMENTED` |
|
||||
| 19 | 钥匙管理 | 下发在线卡片 | `POST` | `/rest/doorlock/online-card/create` | `lockId`, `validStart`, `validUntil`, `holder`, `canOpenAntilock`, `cardNumber` (16进制低位在前), `subType: 4` | `success`, `data` (含钥匙 `id`, `operationId`, `lockHardwareId`) | Cookie / URL `token` | Async (需10s后对账) | 是 (`operationId`) | 2 | 钥匙 `id`, `operationId` | `cardNumber` | 实体学员卡下发 | **`P1_USEFUL` / 备选** | `DOCUMENTED` |
|
||||
| 20 | 钥匙管理 | 删除在线卡片 | `POST` | `/rest/doorlock/online-card/delete` | `id` (钥匙 ID) | `success`, `data` (含 `operationId`, 钥匙状态 snapshot) | Cookie / URL `token` | Async (需10s后对账) | 是 (`operationId`) | 4 *(注: 文档示例误标为1)* | 钥匙 `id`, `operationId` | `cardNumber` | 实体卡退宿注销 | **`P1_USEFUL` / 备选** | `DOCUMENTED` |
|
||||
| 21 | 钥匙管理 | 生成二维码/手机钥匙 | `POST` | `/rest/doorlock/qrcode/create` | `lockId`, `validStart`, `validUntil`, `holder`, `canOpenAntilock`, `phoneCountry`, `phoneNumber` | `success`, `data` (含钥匙 `id`, 示例含数据污染) | Cookie / URL `token` | Sync | 否 | 无 | 钥匙 `id` | `phoneNumber` | 移动端二维码开锁 | **`NEEDS_BUSINESS_CONFIRMATION`** | `DOCUMENTED` |
|
||||
| 22 | 钥匙管理 | 注销二维码/手机钥匙 | `POST` | `/rest/qrcode/delete` *(注: 路径无 doorlock)* | `id` (钥匙 ID) | `success` | Cookie / URL `token` | Sync | 否 | 无 | 钥匙 `id` | 无 | 二维码注销 | **`NEEDS_BUSINESS_CONFIRMATION`** | `DOCUMENTED` |
|
||||
| 23 | 钥匙管理 | 生成离线卡片钥匙 | `POST` | `/rest/doorlock/offline-card/create` | `lockId`, `validStart`, `validUntil`, `holder`, `canOpenAntilock`, `cardNumber`, `subType: 4`, `offlineCardType: 2` | `success`, `data` (钥匙元数据) | Cookie / URL `token` | Sync (无需下发锁,需配合物理写卡机) | 否 | 无 | 钥匙 `id` | `cardNumber` | 传统酒店磁卡/IC卡制卡 | **`OUT_OF_SCOPE`** | `DOCUMENTED` |
|
||||
| 24 | 开门记录 | 获取开门记录列表 | `POST` | `/rest/lock/unlock-record/list` *(注: 路径为 /lock/)* | `lockId`, `building`, `room`, `userName`, `startTime`, `endTime`, `offset`, `limit`, `typeList` (数组) | `success`, `rows` (含 `id`, `lockId`, `placeId`, `hardwareId`, `password`, `type`, `creationTime`, `userName`), `total` | Cookie / URL `token` | Sync | 否 | 无 | 记录 `id` | `password`, `userName` | 考勤辅助/安全排障 | **`P1_USEFUL`** | `DOCUMENTED` |
|
||||
| 25 | 取电开关 | 获取取电开关列表 | `POST` | `/rest/powerswitch/list` | `offset`, `limit` | `success`, `rows` (含开关 `id`, `projectId`, `hardwareId`, `placeId`, `building`, `room`), `total` | Cookie / URL `token` | Sync | 否 | 无 | 开关 `id`, `hardwareId` | 无 | 宿舍节能控制 | **`P2_FUTURE`** | `DOCUMENTED` |
|
||||
| 26 | 取电开关 | 取电开关远程送电 | `POST` | `/rest/powerswitch/remote-poweron` | `id`, `hardwareId` | `success`, `data.operation` (含 `operationId`, `requestType: 1`) | Cookie / URL `token` | Async | 是 (`operationId`) | 1 | 开关 `id`, `operationId` | 操作人手机号 | 宿舍远程供电 | **`P2_FUTURE`** | `DOCUMENTED` |
|
||||
| 27 | 取电开关 | 取电开关远程断电 | `POST` | `/rest/powerswitch/remote-poweroff` | `id`, `hardwareId` | `success`, `data.operation` (含 `operationId`, `requestType: 2`) | Cookie / URL `token` | Async | 是 (`operationId`) | 2 | 开关 `id`, `operationId` | 操作人手机号 | 宿舍远程断电 | **`P2_FUTURE`** | `DOCUMENTED` |
|
||||
| 28 | 取电开关 | 查询取电开关远程断/送电结果 | `POST` | `/rest/powerswitch/query-operation-status` | `requestType` (1送电, 2断电), `operationId`, `hardwareId` | `success`, `data` (含 `operationStatus`: `success`/`undone`/`timeout`, `powerStatus`) | Cookie / URL `token` | Polling | 是 (`operationId`) | 1 / 2 | `operationId` | 无 | 取电开关对账 | **`P2_FUTURE`** | `DOCUMENTED` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 一期重点接口范围确认 (`Scope Freeze`)
|
||||
|
||||
### 3.1 一期必须接入接口 (`P0_REQUIRED`)
|
||||
1. **平台登录**:`POST /public/rest/third-party-app/token/get`(用于获取和维持通信令牌)。
|
||||
2. **房间映射**:`POST /rest/place/room/get` 及 `POST /rest/place/room/list`(建立本地宿舍与 DMS 房间 `placeId` 的权威绑定)。
|
||||
3. **门锁映射**:`POST /rest/doorlock/list`(根据 `placeId` 获取房间内所有智能门锁的 `lockId` 与 `hardwareId`)。
|
||||
4. **权限下发**:`POST /rest/doorlock/online-password-key/create`(推荐作为一期首选试点凭据下发方式)。
|
||||
5. **权限撤销**:`POST /rest/doorlock/online-password/delete`(退宿/调房/结训时主动撤销钥匙)。
|
||||
6. **异步结果查询**:`POST /rest/doorlock/query-operation-status`(所有写操作的异步 10 秒后对账保障)。
|
||||
|
||||
### 3.2 高风险与非核心功能排除说明
|
||||
- **远程开锁 (`remote-unlock`)**:属于主动设备控制,具有物理安全风险。一期**严禁**作为学员常规功能,仅设计受控管理员权限与两票制审计,标记为 `P2_FUTURE / 受控应急`。
|
||||
- **取电开关全套接口 (`powerswitch/*`)**:沈阳房建公寓段学员管理系统核心是学员学籍、住宿、报到业务,强行纳入取电开关将带来硬件施工与网络调试成本,确定归入 `P2_FUTURE`。
|
||||
- **二维码钥匙 (`qrcode/create`)**:官方文档中明确包含端点定义 (`POST /rest/doorlock/qrcode/create` 与 `POST /rest/qrcode/delete`),分类为 `DOCUMENTED`;但其响应示例存在数据污染(`typeStr: "Online Card"`)且未见二维码载荷内容,其实际方案可行性属于 `NEEDS_BUSINESS_CONFIRMATION`,一期推荐保持使用在线密码作为 Pilot 方案。
|
||||
|
||||
---
|
||||
|
||||
## 4. Wave 2 Implementation Status (Wave 2 implemented against Mock DMS only)
|
||||
- **实现范围**:
|
||||
1. `POST /public/rest/third-party-app/token/get` (`DmsTokenManager`)
|
||||
2. `POST /rest/place/building/list`, `floor/list`, `room/list`, `room/get` (`DmsLocationClient` / `ExternalLocationGateway`)
|
||||
3. `POST /rest/doorlock/list` (`DmsDoorLockClient` / `ExternalDoorLockGateway`,支持 0、1、多把门锁)
|
||||
4. `POST /rest/doorlock/online-password-key/create` (`DmsPasswordClient` / `ExternalCredentialGateway`,支持 4~8 位,密码零持久化)
|
||||
5. `POST /rest/doorlock/online-password/delete` (`DmsPasswordClient` / `ExternalCredentialGateway`)
|
||||
6. `POST /rest/doorlock/query-operation-status` (`DmsOperationStatusClient` / `DmsOperationStatusMapper` / `DmsOperationPoller`)
|
||||
- **测试与运行纪律**:
|
||||
- 全部自动化测试均在本地内嵌 `MockDmsServer` 下运行。
|
||||
- `LIVE_DMS_REQUEST_COUNT = 0`(严禁向真实 DMS 产生任何调用)。
|
||||
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
|
||||
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
|
||||
@@ -0,0 +1,171 @@
|
||||
# 02 本地住宿、宿舍与门禁模型现状审计 (Current Accommodation Audit)
|
||||
|
||||
> **基准代码版本**:`adcf930ea0a880e1faae36ff3d3e6539d0c0b8f9` (`HEAD`)
|
||||
> **审计范围**:Kingbase 物理数据库、Java 后端代码、MyBatis XML、前端 Vue 代码
|
||||
> **审计执行方式**:只读安全查询脚本 (`DbTableAuditor.java`),无任何写操作与数据破坏
|
||||
|
||||
---
|
||||
|
||||
## 1. Kingbase 数据库真实物理表事实 (Physical Table Facts)
|
||||
|
||||
通过直接连接正式 Kingbase 数据库 (`jdbc:kingbase8://127.0.0.1:4321/training?currentSchema=public,sys_catalog`) 进行只读元数据审计,物理表事实如下:
|
||||
|
||||
### 1.1 `train_dormitory_info` (宿舍物理资源表)
|
||||
- **TABLE_EXISTS**: `true`
|
||||
- **ROW_COUNT**: `1`
|
||||
- **KEY_COLUMNS (14列)**:
|
||||
- `dorm_id` (`varchar(32)`, `NOT NULL`, 主键 UUID)
|
||||
- `dorm_building` (`varchar(50)`, `NOT NULL`, 宿舍楼栋)
|
||||
- `dorm_floor` (`varchar(20)`, `NOT NULL`, 所在楼层)
|
||||
- `dorm_room` (`varchar(20)`, `NOT NULL`, 房间号)
|
||||
- `capacity` (`integer`, `NOT NULL`, 核定床位数, 1~8)
|
||||
- `gender_type` (`smallint`, `NOT NULL`, 适用性别: 0不限, 1男, 2女)
|
||||
- `lock_device_sn` (`varchar(64)`, `NULLABLE`, 智能门锁设备硬件序列号)
|
||||
- `dorm_status` (`smallint`, `NOT NULL`, 房间状态: 0禁用, 1可用)
|
||||
- `del_flag` (`bpchar(1)`, `NOT NULL`, 逻辑删除: 0正常, 2删除)
|
||||
- `create_by`, `create_time`, `update_by`, `update_time`, `remark` (标准审计列)
|
||||
- **物理约束**: 唯一约束 `uq_dorm_building_room (dorm_building, dorm_room)`。
|
||||
|
||||
### 1.2 `train_accommodation` (学员住宿安排事实表)
|
||||
- **TABLE_EXISTS**: `true`
|
||||
- **ROW_COUNT**: `0`
|
||||
- **KEY_COLUMNS (11列)**:
|
||||
- `accommodation_id` (`bigint`, `NOT NULL`, 主键 IDENTITY)
|
||||
- `enrollment_id` (`bigint`, `NOT NULL`, 参训档案ID, 唯一约束 `uq_accommodation_enroll`)
|
||||
- `dorm_id` (`varchar(32)`, `NOT NULL`, 分配宿舍ID, 外键 `fk_accomm_dorm` 引用 `train_dormitory_info.dorm_id`)
|
||||
- `bed_no` (`varchar(20)`, `NULLABLE`, 分配床位号)
|
||||
- `assigned_time` (`timestamp`, `NOT NULL`, 分配时间, 默认 `CURRENT_TIMESTAMP`)
|
||||
- `accommodation_status` (`smallint`, `NOT NULL`, 住宿状态: 0预分配, 1已入住, 2已退宿)
|
||||
- `create_by`, `create_time`, `update_by`, `update_time`, `remark` (标准审计列)
|
||||
- **外键约束**:
|
||||
- `fk_accomm_enrollment`: 引用 `train_enrollment(enrollment_id)`
|
||||
- `fk_accomm_dorm`: 引用 `train_dormitory_info(dorm_id)`
|
||||
|
||||
### 1.3 `train_dorm_auth` (门禁同步与下发表 - 数据库 V2 重建版本)
|
||||
- **TABLE_EXISTS**: `true`
|
||||
- **ROW_COUNT**: `0`
|
||||
- **KEY_COLUMNS (14列)**:
|
||||
- `auth_id` (`bigint`, `NOT NULL`, 主键 IDENTITY)
|
||||
- `accommodation_id` (`bigint`, `NOT NULL`, 住宿安排ID, 外键 `fk_dormauth_accomm`, 唯一约束 `uq_dorm_auth_accomm`)
|
||||
- `auth_start` (`timestamp`, `NULLABLE`, 权限生效开始时间)
|
||||
- `auth_end` (`timestamp`, `NULLABLE`, 权限失效结束时间)
|
||||
- `face_sync_status` (`smallint`, `NOT NULL`, 默认 0; 0待下发, 1已下发, 2下发失败, 3已注销)
|
||||
- `retry_count` (`integer`, `NOT NULL`, 默认 0, 同步失败重试次数)
|
||||
- `last_error` (`varchar(500)`, `NULLABLE`, 最后一次下发失败错误信息)
|
||||
- `external_auth_id` (`varchar(100)`, `NULLABLE`, 外部门禁硬件系统下发唯一凭证ID)
|
||||
- `last_sync_time` (`timestamp`, `NULLABLE`, 最近一次同步请求时间)
|
||||
- `create_by`, `create_time`, `update_by`, `update_time`, `remark` (标准审计列)
|
||||
|
||||
### 1.4 其它相关表事实
|
||||
- `bak_v1_train_dorm_auth`: 存在历史 V1 备份表(行数 0),包含废弃的 `student_id`, `plan_id`, `dorm_id`, `bed_no`。
|
||||
- 数据库中**不存在**任何以 `door_*`, `lock_*`, `access_*` 命名的其他物理表。
|
||||
|
||||
---
|
||||
|
||||
## 2. 真实代码层实现现状审计 (Code Implementation Facts)
|
||||
|
||||
| 模块 / 类 | 当前代码存在情况 | 承担职责与现实状态 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`TrainDormitoryInfo`** (Domain/VO/Service/Controller) | **已完整实现** | 负责物理宿舍增删改查。包含 `lockDeviceSn` 单一字符串字段;通过 SQL 实时统计 `train_accommodation` 的 `usedCount` 与计算 `remainingBeds`。 |
|
||||
| **`TrainDormAuth`** (Domain/Mapper/Service/Controller) | **实现脱节 (V1 遗留代码)** | 当前 Java 实体类 `TrainDormAuth.java`、Mapper 与 Controller **依然映射的是 V1 结构**(持有 `studentId`, `planId`, `dormId`, `bedNo`, `faceSyncStatus`),**尚未重构对齐数据库 V2 的 `accommodation_id` 关联结构**! |
|
||||
| **`TrainAccommodation`** (Domain/Service/Controller) | **代码层缺失** | 数据库已存在 `train_accommodation` 物理表,且 `TrainDormitoryInfoMapper.xml` 和 `TrainEnrollmentMapper.xml` 已在子查询中引用该表,但 Java 层尚无对应的 `TrainAccommodation.java` 实体及专用 Service/Controller。 |
|
||||
| **前端 Vue 页面** | **未实现专用页面** | `ruoyi-ui/src/views/` 目录下尚未建立 `train/dormitory` 或 `train/auth` 前端业务界面,目前仅有大盘总览。 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 三大核心业务边界辨析 (Current Business Boundary)
|
||||
|
||||
当前系统必须在概念模型与后续实现中**严格切分以下三个业务对象**,严禁混为一谈:
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------------+
|
||||
| A. 物理宿舍资源 (Dormitory Resource - train_dormitory_info) |
|
||||
| - 物理属性:楼栋、楼层、房间号、核定床位容量、适用性别、房间可用/停用状态 |
|
||||
| - 生命周期:独立于任何培训班和学员,长期相对稳定 |
|
||||
+-----------------------------------------------------------------------------+
|
||||
▲
|
||||
│ 1 间宿舍可容纳多个住宿安排
|
||||
+-----------------------------------------------------------------------------+
|
||||
| B. 住宿业务事实 (Accommodation - train_accommodation) |
|
||||
| - 业务属性:某个学员参训档案 (enrollment_id) 在本次开班中分配到了哪个房间与床位 |
|
||||
| - 状态流转:预分配 (0) -> 已入住 (1) -> 已退宿 (2) |
|
||||
| - 事实归属:RailwaySystem 是本业务的绝对 Source of Truth (事实唯一真源) |
|
||||
+-----------------------------------------------------------------------------+
|
||||
▲
|
||||
│ 1 条住宿事实产生门禁授权要求
|
||||
+-----------------------------------------------------------------------------+
|
||||
| C. 门锁设备授权 (Door Authorization - 门禁授权模型) |
|
||||
| - 设备属性:基于该住宿事实,向 DMS 门锁下发的有效时间区间与电子钥匙凭据 (密码/卡片) |
|
||||
| - 状态流转:待下发 -> 平台已受理 -> 设备执行中 -> 设备生效 -> 撤销中 -> 已注销 |
|
||||
| - 属性特征:依赖外部网络与硬件状态,可异步重试,失败不推翻住宿业务事实 |
|
||||
+-----------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `train_dorm_auth` 当前角色与演进方案比选 (TRAIN_DORM_AUTH_CURRENT_ROLE)
|
||||
|
||||
### 4.1 当前角色定性
|
||||
在当前代码与数据库混合状态下:
|
||||
- **Java 代码层**:`train_dorm_auth` 承担了“住宿分配 + 门锁序列号关联 + 人脸同步标记”的**混合式职责**。
|
||||
- **数据库 V2 迁移设计**:设计意图已将其向“纯门禁授权下发同步表”推进(引入 `accommodation_id`, `retry_count`, `last_error`, `external_auth_id`),但尚未考虑**一个房间多把锁**、**多凭据类型(密码/卡片)**以及 **DMS `operationId` 异步轮询生命周期**。
|
||||
|
||||
### 4.2 三种演进方案详细比选
|
||||
|
||||
| 评估维度 | 方案 A: 沿用并继续扩展 `train_dorm_auth` | 方案 B: 收敛 `train_dorm_auth` + 独立 `train_external_operation` | 方案 C: 新建通用 `train_door_authorization` + `train_external_operation` |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **设计描述** | 在现有的 `train_dorm_auth` 上追加 `lock_id`, `operation_id`, `credential_type`, `credential_value` 等列 | 将 `train_dorm_auth` 代码升级对齐 V2(作为授权事实主表),另建异步操作流水表记录轮询与对账 | 新建专用的门禁授权表与外部操作流水表,彻底废弃/弃用命名有误导的 `train_dorm_auth` |
|
||||
| **职责清晰度** | 差。混合业务授权与设备异步任务,字段臃肿且无法表达多锁与多钥匙 | 良好。主表表达“谁拥有该房间的有效授权”,流水表表达“向设备发送的异步指令状态” | 优秀。命名脱离“人脸/宿舍”局部限制,完全按照通用门禁控制抽象 |
|
||||
| **与 Accommodation 关系** | 外键引用 `accommodation_id`,但多锁时难以保持 1:1 唯一约束 | 主表通过 `accommodation_id` 关联,支持按锁/凭据类型建立多条授权;流水表关联主表 | 清晰明确,一个 Accommodation 可派生多条 Authorization 凭据 |
|
||||
| **DMS Operation 生命周期支持** | 极差。无法保存多次重试、多次轮询的轨迹与错误历史 | 优秀。所有异步 `operationId`、轮询重试、超时记录在流水表完整归档 | 优秀。支持完整的异步生命周期追踪与审计 |
|
||||
| **多锁扩展性** | 无法支持(同一个房间若有外门锁与内门锁,会导致主键或关联冲突) | 良好(主表以 `(accommodation_id, lock_id, credential_type)` 为逻辑单元) | 优秀(原生支持一房多锁与多供应商适配) |
|
||||
| **迁移与代码改动成本** | 表面成本低,但后期技术债务严重 | **适中**。数据库 V2 已就绪 `train_dorm_auth`,只需调整列定义或补充流水表 | 略高。需要新增两张物理表并做数据迁移 |
|
||||
| **审计与故障追溯** | 弱(覆写 `last_error` 导致历史丢失) | **强**(流水表不可变追加,完整记录每一次操作) | **强** |
|
||||
|
||||
### 4.3 演进建议结论
|
||||
1. **本轮 R.3A 不执行任何 DDL**。
|
||||
2. **架构倾向**:推荐采纳 **方案 B/C 的分层解耦思想**。绝不能将 DMS 的 `operationId` 轮询与重试直接塞在单个授权记录中覆写,必须将**“门禁业务授权事实 (Door Authorization)”**与**“外部硬件异步操作记录 (External Operation)”**彻底解耦为两层模型。
|
||||
|
||||
---
|
||||
|
||||
## 5. 本地模型漂移事实与前置对齐要求 (`LOCAL_DOMAIN_ALIGNMENT_REQUIRED`)
|
||||
|
||||
### 5.1 现实漂移事实总结
|
||||
- **`LOCAL_DOMAIN_ALIGNMENT_REQUIRED = YES`**
|
||||
- **当前核心矛盾**:
|
||||
1. **`train_accommodation`**:Kingbase 物理表已在 V2 迁移中正式建立,但 Java 代码层尚未建立对应的实体类、Mapper 与业务 Service,未形成闭环业务层。
|
||||
2. **`train_dorm_auth`**:Kingbase 物理表已在 V2 中升级为以 `accommodation_id` 为外键的门禁授权结构,但 Java 实体类 `TrainDormAuth.java` 与 XML Mapper 依然停留在 V1 旧结构(持有 `student_id`, `plan_id`, `dorm_id`, `bed_no`)。
|
||||
- **执行原则**:
|
||||
在进入 R.3B 真正编写 DMS Adapter 之前,**必须先在 Wave 0 完成本地住宿与门禁授权领域模型对齐**!严禁让外部 DMS 集成适配器直接架设在当前存在严重漂移的本地模型之上。
|
||||
|
||||
### 5.2 Wave 0 Implementation Result (本地模型对齐实施完成)
|
||||
- **完成状态**:已全面对齐并消除结构漂移。
|
||||
- **实体层**:
|
||||
- 新增 `TrainAccommodation.java`,严格对应 Kingbase V2 生产物理表(`accommodation_id`, `enrollment_id`, `dorm_id`, `bed_no`, `assigned_time`, `accommodation_status`, 审计列)。
|
||||
- 重构 `TrainDormAuth.java`,外键核心对齐 `accommodation_id`,持久化字段剔除 V1 遗留字段(`student_id`, `plan_id`, `dorm_id`, `bed_no`, `createUser`),保留为非持久化兼容视图字段以保障只读兼容。
|
||||
- **Mapper/SQL**:
|
||||
- 新增 `TrainAccommodationMapper.java` / `TrainAccommodationMapper.xml`,所有字段显式指定,严禁 `SELECT *`。
|
||||
- 重构 `TrainDormAuthMapper.xml`,INSERT/UPDATE 语句彻底清理 V1 废弃字段写入,通过 LEFT JOIN 视图提供兼容字段。
|
||||
- **业务服务**:
|
||||
- 新增 `ITrainAccommodationService` / `TrainAccommodationServiceImpl`,落地“同一时刻最多1个有效住宿安排”(Duplicate Active Accommodation Guard)、宿舍核定容量边界检查(Capacity Boundary Guard)与换房原子流转(Transfer)。
|
||||
- 升级 `TrainDormAuthServiceImpl`,严格执行“Enrollment -> Accommodation -> Door Authorization”业务单向依赖,禁止绕过住宿事实直接授权。
|
||||
|
||||
### 5.3 Wave 0.1 Accommodation History & 006 Migration (基数模型升级与迁移方案)
|
||||
- **基数语义冻结**:
|
||||
- `Enrollment 1:N Accommodation History`(一个报名记录可有多条历史住宿记录,调房换房产生历史轨迹)
|
||||
- `Enrollment 1:0..1 Active Accommodation`(同一时间一个报名记录最多只能有 1 条有效住宿记录,`accommodation_status IN (0, 1)`)
|
||||
- `Accommodation 1:0..1 DoorAuthorization`(门禁授权强依赖具体的某一次住宿记录)
|
||||
- `TIME_RANGE_SUPPORT = LIMITED`(当前 Kingbase 物理表仅包含 `assigned_time`,无 `start_time`/`end_time`,时间窗口支持为有限支持)
|
||||
- **006 迁移脚本方案 (`006_accommodation_history_constraint.sql`)**:
|
||||
- 脚本路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`
|
||||
- 安全编号:`006`(001-003 为基线,004-005 已被 R.2 分支占用)
|
||||
- 前置检查:Fail-fast,若发现同一 `enrollment_id` 存在多条 active(`accommodation_status IN (0, 1)`)则 `RAISE EXCEPTION` 阻止迁移
|
||||
- 安全 DROP:仅在原约束 `uq_accommodation_enroll` 确认为 `UNIQUE (enrollment_id)` 时安全移除
|
||||
- 局部唯一索引:创建 `uq_accommodation_enroll_active` 并在 `WHERE accommodation_status IN (0, 1)` 生效
|
||||
- 幂等性防护:重复执行不会报错或破坏表结构
|
||||
- 生产执行纪律:本迁移脚本仅完成工程资产化与隔离测试,**严禁在生产数据库执行**
|
||||
- **自动化测试**:
|
||||
- `AccommodationHistoryMigrationContractTest`(契约测试:SQL 语法、前置检查、条件 DROP、局部索引及幂等性)
|
||||
- `AccommodationServiceTest`(10 项测试,包含调房历史与并发唯一性)
|
||||
- `DormAuthAlignmentTest`(7 项测试,包含依赖住宿与换房级联)
|
||||
- 全量测试套件 174 项全部 PASS(0 failures, 0 errors)。
|
||||
@@ -0,0 +1,199 @@
|
||||
# 03 RailwaySystem 与 DMS 领域模型映射体系 (Domain Mapping)
|
||||
|
||||
> **核心原则**:**RailwaySystem 是住宿业务事实的唯一真源 (Source of Truth)**。
|
||||
> **DMS 平台**仅作为外部门禁与物联网设备的执行端与监控端。
|
||||
> **严禁**:以 DMS 房间状态 (`status`) 直接替代 RailwaySystem 住宿占用与床位台账。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心实体对应关系总览 (Mermaid 架构图)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction TB
|
||||
|
||||
class RailwayTrainingPlan {
|
||||
+String planId "内部培训班计划ID"
|
||||
+String planName
|
||||
+Date trainStart
|
||||
+Date trainEnd
|
||||
}
|
||||
|
||||
class RailwayEnrollment {
|
||||
+Long enrollmentId "参训学员档案ID"
|
||||
+String planId
|
||||
+String studentId
|
||||
+Integer reportStatus
|
||||
}
|
||||
|
||||
class RailwayAccommodation {
|
||||
+Long accommodationId "住宿安排事实ID (Source of Truth)"
|
||||
+Long enrollmentId
|
||||
+String dormId
|
||||
+String bedNo
|
||||
+Integer accommodationStatus "0预分配, 1已入住, 2已退宿"
|
||||
}
|
||||
|
||||
class RailwayDormitoryInfo {
|
||||
+String dormId "本地宿舍UUID"
|
||||
+String dormBuilding "楼栋"
|
||||
+String dormFloor "楼层"
|
||||
+String dormRoom "房号"
|
||||
+Integer capacity "床位容量"
|
||||
}
|
||||
|
||||
class ExternalResourceMapping {
|
||||
+Long mappingId
|
||||
+String localDormId "关联本地宿舍"
|
||||
+Long dmsProjectId "DMS租户/物业站点ID"
|
||||
+Long dmsPlaceId "DMS房间ID"
|
||||
+String dmsBuilding
|
||||
+String dmsFloor
|
||||
+String dmsRoom
|
||||
+Integer mappingStatus "1已绑定, 0待校验"
|
||||
}
|
||||
|
||||
class DmsPlaceRoom {
|
||||
<<DMS Remote>>
|
||||
+Long id "DMS placeId"
|
||||
+Long projectId "DMS租户ID (≠ planId)"
|
||||
+String building
|
||||
+String floor
|
||||
+String room
|
||||
}
|
||||
|
||||
class DmsDoorLock {
|
||||
<<DMS Remote>>
|
||||
+Long lockId "DMS锁ID"
|
||||
+String hardwareId "锁硬件SN (如 5C9F539A)"
|
||||
+Long placeId
|
||||
+Integer battery
|
||||
+Integer signal
|
||||
}
|
||||
|
||||
class DoorAuthorization {
|
||||
+Long authId "本地授权凭证主记录"
|
||||
+Long accommodationId
|
||||
+Long dmsLockId
|
||||
+String dmsHardwareId
|
||||
+Long dmsKeyId "DMS返回的钥匙ID"
|
||||
+String credentialType "PASSWORD / CARD"
|
||||
+Timestamp validStart
|
||||
+Timestamp validUntil
|
||||
+Integer authStatus "PENDING / ACTIVE / REVOKED"
|
||||
}
|
||||
|
||||
class ExternalOperationRecord {
|
||||
+Long operationLocalId
|
||||
+Long authId
|
||||
+String dmsOperationId "DMS异步操作流水ID"
|
||||
+Integer requestType "1下发密码, 3删除密码等"
|
||||
+String operationStatus "undone / success / failed / timeout"
|
||||
+Integer pollCount
|
||||
+Timestamp nextPollTime
|
||||
}
|
||||
|
||||
RailwayTrainingPlan "1" --> "0..*" RailwayEnrollment : 包含
|
||||
RailwayEnrollment "1" --> "1" RailwayAccommodation : 产生住宿安排
|
||||
RailwayAccommodation "0..*" --> "1" RailwayDormitoryInfo : 分配房间
|
||||
RailwayDormitoryInfo "1" --> "0..1" ExternalResourceMapping : 稳定映射
|
||||
ExternalResourceMapping "1" ..> "1" DmsPlaceRoom : 对应DMS物理房间
|
||||
DmsPlaceRoom "1" ..> "0..*" DmsDoorLock : 挂载智能门锁 (支持一房多锁)
|
||||
RailwayAccommodation "1" --> "0..*" DoorAuthorization : 派生门锁授权
|
||||
DoorAuthorization "1" --> "0..*" ExternalOperationRecord : 追踪异步执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键实体边界与重大概念澄清
|
||||
|
||||
### 2.1 铁律:`DMS projectId ≠ RailwaySystem Training Plan ID`
|
||||
- **DMS `projectId`**(示例中为 `319402846`、`3837087560`):代表 DMS 物联网平台的**物业项目/租户/公寓站点 ID**。属于系统级或物理站点级的静态上下文配置,由设备供应商分配。
|
||||
- **RailwaySystem `plan_id`**(如 `PLAN20260901`):代表铁路局/段内的**具体教学培训开班批次**。生命周期通常为几天到几周。
|
||||
- **红线**:严禁将 DMS `projectId` 错写成或动态传为学员开班 `planId`!在配置文件中应统一作为 `dms.project-id` 进行站点级静态受控配置。
|
||||
|
||||
### 2.2 铁律:本地住宿事实是唯一真源 (`Local Source of Truth`)
|
||||
- DMS 的 `room.status`(0:空置, 1:已入住, 2:脏房等)仅为其平台或第三方酒店系统的简单标记,**绝对不能用于覆盖或推断 RailwaySystem 的床位与入住状态**。
|
||||
- 只有 RailwaySystem 自身的 `train_accommodation` 与 `train_enrollment` 能决定学员是否在住、床位是否已满、是否已报到。
|
||||
- DMS 门锁授权状态仅是硬件执行层面的技术属性(开门凭据是否已在锁具上成功生效)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 房间与门锁映射策略深度比选 (Mapping Proposals)
|
||||
|
||||
### 3.1 房间映射 (Local Dormitory ↔ DMS Place)
|
||||
DMS 提供了两种查询房间的途径:
|
||||
- 模糊查询列表:`POST /rest/place/room/list`
|
||||
- 精确查询:`POST /rest/place/room/get`(入参:`building`, `floor`, `room`)
|
||||
|
||||
| 方案 | 实现方式 | 优缺点分析 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **方案 1:直接在 `train_dormitory_info` 加列** | 增加 `dms_place_id`, `dms_project_id`, `dms_sync_time` | **优点**:查询直观,单表读取。<br>**缺点**:污染本地物理宿舍模型;若未来接入其他门禁厂商(如海康、大华),列定义难以复用。 | 可作为一期过渡备选 |
|
||||
| **方案 2:独立 `train_external_resource_mapping` 映射表** | 建立独立映射表:`local_resource_type ('DORMITORY')`, `local_id`, `vendor ('DMS')`, `external_id (placeId)`, `metadata_json` | **优点**:模型高度解耦;本地宿舍资源生命周期不受外部影响;天然支持多厂商与资源解绑/重绑。<br>**缺点**:需要多做一次关联查询。 | **推荐采纳** |
|
||||
|
||||
**映射规则明确**:
|
||||
- **严禁**每次下发授权时现场调用 `/rest/place/room/get` 进行文本字符串模糊匹配。网络抖动或楼栋名称微小差异(例如 "1号楼" vs "楼栋1")会导致授权链路阻断。
|
||||
- **必须**在管理端提供“房源映射绑定/同步”功能,预先在数据库中持久化 `localDormId ↔ dmsPlaceId` 的权威主键映射。
|
||||
|
||||
### 3.2 门锁映射:支持“一房多锁”模型 (`One Room to Multi-Locks`)
|
||||
DMS 接口 `POST /rest/doorlock/list`(入参 `placeId`)返回的是一个门锁列表:`rows: [...]`。
|
||||
这证实了物理现实:**一个公寓房间可能没有门锁(0把)、有一把门锁(1把)、或者有多个门锁(例如外防盗门锁 + 内卧室锁,多把锁)**。
|
||||
|
||||
- **错误做法**:在 `train_dormitory_info` 中仅保留单一的 `lock_device_sn` 字符串,假设一房仅有一锁。
|
||||
- **推荐设计**:
|
||||
- 系统必须能够处理一个 `placeId` 对应 `0..N` 把门锁的情况。
|
||||
- 当为学员下发该房间的开门权限时,集成适配器应查询该房间绑定的**所有有效门锁**,并为每把锁分别申请/下发开门凭据,生成对应的 `DoorAuthorization` 记录。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细字段级映射字典 (Field-Level Mapping Dictionary)
|
||||
|
||||
### 4.1 宿舍/房间信息映射
|
||||
| RailwaySystem 本地字段 | DMS 对应字段 | 类型转换 / 映射规则 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `train_dormitory_info.dorm_id` | `mapping.local_dorm_id` | `VARCHAR(32)` (UUID) | 本地主键 |
|
||||
| - | `dms.place.id` / `placeId` | `BIGINT` | DMS 房间全局唯一主键 |
|
||||
| `train_dormitory_info.dorm_building` | `dms.place.building` | 字符串精确比对/绑定 | 宿舍楼栋名 |
|
||||
| `train_dormitory_info.dorm_floor` | `dms.place.floor` | 字符串精确比对/绑定 | 所在楼层 |
|
||||
| `train_dormitory_info.dorm_room` | `dms.place.room` | 字符串精确比对/绑定 | 房间号 |
|
||||
| - | `dms.place.projectId` | `BIGINT` | 系统级受控站点配置 |
|
||||
|
||||
### 4.2 门锁设备信息映射
|
||||
| RailwaySystem 本地概念 | DMS 对应字段 | 类型 / 格式 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `mapping.external_lock_id` | `doorlock.id` | `BIGINT` | DMS 门锁管理 ID |
|
||||
| `train_dormitory_info.lock_device_sn` | `doorlock.hardwareId` | `VARCHAR(64)` (如 `5C9F539A`) | 门锁硬件 SN / 通信 ID |
|
||||
| `device_battery` | `doorlock.battery` | `INT` (0~100, 255为未知) | 门锁电量 |
|
||||
| `device_signal` | `doorlock.signal` | `INT` (-100~0, -128为未知) | 信号强度 (RSSI) |
|
||||
|
||||
### 4.3 门禁授权与钥匙下发映射 (Online Password 场景)
|
||||
| RailwaySystem 本地字段 | DMS 对应入参 / 字段 | 映射规则与约束 |
|
||||
| :--- | :--- | :--- |
|
||||
| `train_accommodation.accommodation_id` | - | 本地业务事实归属 |
|
||||
| `target_lock_id` | `create.lockId` | 目标门锁的 DMS ID |
|
||||
| `auth_start` (配合提早 Buffer) | `create.validStart` | 毫秒级时间戳 (`Long`) |
|
||||
| `auth_end` (配合延后 Buffer) | `create.validUntil` | 毫秒级时间戳 (`Long`) |
|
||||
| `student_name` | `create.holder` | 学员姓名 (可选,建议填写真实姓名) |
|
||||
| - | `create.canOpenAntilock` | 固定设为 `false` (不可开反锁,保障私密安全) |
|
||||
| `phone_country` | `create.phoneCountry` | 默认 `+86` |
|
||||
| `student_phonenumber` | `create.phoneNumber` | 学员手机号 |
|
||||
| `door_password` (一次性生成或安全受控) | `create.password` | 系统可生成4~8位纯数字密码,Pilot建议采用6位限时密码 |
|
||||
| `credential_sub_type` | `create.subType` | 固定为 `4` (限时密码) |
|
||||
| `external_key_id` | 返回的 `data.id` | DMS 钥匙全局 ID (退宿撤权时作为删除入参) |
|
||||
| `dms_operation_id` | 返回的 `data.operationId` | DMS 异步下发任务 ID (用于10秒后状态轮询) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented(外部资源映射模型与外部操作流水持久化模型已交付并验证)。
|
||||
- **资源映射基数与引用模型 (`RESOURCE_MAPPING_REFERENCE_MODEL = DORM_ID_FK`)**:
|
||||
- 本地采用 `dorm_id` 外键强关联 `train_dormitory_info`,确保一期完整性与零类型歧义。
|
||||
- **房间映射基数**:`ROOM_MAPPING_CARDINALITY = Dormitory 1:0..1 Active Room (Dormitory 1:N History)`,同一宿舍在同一 Vendor 下最多只能有 1 个处于 ACTIVE 状态的房间映射(通过局部唯一索引 `uq_ext_res_map_active_room` 严格保障)。
|
||||
- **门锁映射基数**:`LOCK_MAPPING_CARDINALITY = Dormitory 1:0..N DoorLocks`,支持一房多锁(防盗门、内门、套间门),`is_primary` 区分主从门锁。
|
||||
- **外部资源唯一性**:`EXTERNAL_RESOURCE_ACTIVE_UNIQUENESS = Vendor + External Resource ID + Active Unique`,同一外部资源在同一厂商下严禁跨房间重复 ACTIVE 绑定(通过局部唯一索引 `uq_ext_res_map_active_ext_res` 保障)。
|
||||
- **操作流水基数与引用模型 (`OPERATION_REFERENCE_MODEL = DOOR_AUTH_ID_FK`)**:
|
||||
- 本地采用 `door_auth_id` 外键强关联 `train_dorm_auth`,业务类型默认为 `DOOR_AUTH`。
|
||||
- **操作流水基数**:`DOOR_AUTH_OPERATION_CARDINALITY = DoorAuthorization 1:N ExternalOperation`,每次具体下发、重试、撤销均保留独立流水行,禁止直接覆写。
|
||||
- **状态机与幂等**:`OPERATION_STATUS_MODEL` 包含从 `CREATED` 到 `SUCCESS/FAILED/TIMEOUT/RETRY_WAIT` 的合法单向流转(`REVOKED` 属于 `DoorAuthorization` 业务状态,单次注销流水为 `operation_type=REVOKE` 达成 `SUCCESS`),终态不可逆;`idempotency_key` 唯一索引全局防重。
|
||||
- **敏感数据治理**:不存 raw JSON、密码明文、卡号明文、Token、Secret,敏感字段数为 0。
|
||||
- **数据库迁移安全**:`008_external_resource_operation.sql` 已通过静态契约测试与幂等验证,生产库保持零变更。
|
||||
@@ -0,0 +1,153 @@
|
||||
# 04 DMS 认证鉴权、Token 生命周期与高并发策略 (Auth and Token Strategy)
|
||||
|
||||
> **审计基准**:DMS REST API 规范第一节《登录鉴权接口》
|
||||
> **核心风险**:**DMS 后台有限制,多次调用登录接口,只有最后一次生成的 Token 有效!**
|
||||
> **设计目标**:建立零泄露凭据管理、内存安全缓存、防击穿 Single-Flight 并发锁与 401 自愈重试机制。
|
||||
|
||||
---
|
||||
|
||||
## 1. DMS 认证机制技术事实 (`DMS_AUTH_FACTS`)
|
||||
|
||||
基于文档第 3~78 行的逐行事实提取:
|
||||
|
||||
| 维度 | DMS 平台真实实现规则 | 对集成的要求与约束 |
|
||||
| :--- | :--- | :--- |
|
||||
| **登录接口** | `POST https://dms.dmsiot.com/public/rest/third-party-app/token/get` | 请求头 `Content-Type: application/json` |
|
||||
| **凭据载荷** | Body 为 JSON: `{"appid": "...", "appsecret": "..."}` | 必须通过受控配置/环境变量加载,绝不硬编码 |
|
||||
| **Token 有效期** | `expiresIn: 7200`(标准 2 小时,7200 秒) | 本地需跟踪到期时间戳 |
|
||||
| **滑动刷新机制** | **“如果在 token 有效期内成功调用任一平台接口,则会刷新有效期,即从调用接口时间开始,2小时有效期”** | 只要系统保持定期调用或心跳,Token 可实现无感延续,无需反复调用登录接口 |
|
||||
| **单点登录互斥性** | **“注意,后台有限制,多次调用登录接口,只有最后一次的 token 有效”** | **重大并发红线**:若有多个线程或集群节点并发调用登录,后发登录将立即废除前面的 Token,导致正在执行的其他请求瞬间遭遇 401 报错!必须实现单飞互斥锁 |
|
||||
| **Token 传输方式** | 文档声明支持两种方式:<br>1. **Cookie**(放到请求 Cookie 中)<br>2. **URL 参数**(`?token=...`) | **不支持** `Authorization: Bearer <token>`!严禁使用未经文档支持的 Header 格式 |
|
||||
| **401 错误特征** | HTTP 状态码 `401 Unauthorized`,返回 JSON:<br>`{"status": 401, "error": "Unauthorized", "path": "..."}` | 统一由底层 HTTP 客户端拦截捕获,触发自动重登录恢复流 |
|
||||
| **退出登录接口** | `POST https://dms.dmsiot.com/public/rest/security/logout` | 成功调用后该 Token 立即在服务端失效 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 生产凭据存储规范 (`Credential Policy`)
|
||||
|
||||
### 2.1 敏感凭据零泄露规范
|
||||
- **禁止项**:
|
||||
- 严禁将真实 `appid`、`appsecret`、`token` 写入 Git 仓库中的任何文件。
|
||||
- 严禁在 `application.yml` 或 `application-druid.yml` 中保存生产明文。
|
||||
- 严禁在代码异常堆栈、日志打印、HTTP Trace、错误提示信息中输出凭据。
|
||||
- **推荐配置格式**:
|
||||
在 `ruoyi-admin/src/main/resources/application.yml` 中仅保留占位符配置项,真实值由生产环境变量注入:
|
||||
```yaml
|
||||
# DMS 智能门锁外部门禁平台配置
|
||||
dms:
|
||||
base-url: ${DMS_BASE_URL:https://dms.dmsiot.com}
|
||||
app-id: ${DMS_APP_ID:}
|
||||
app-secret: ${DMS_APP_SECRET:}
|
||||
project-id: ${DMS_PROJECT_ID:0}
|
||||
token-expire-margin-seconds: 600 # 提前10分钟预防性刷新
|
||||
connect-timeout-ms: 5000
|
||||
read-timeout-ms: 10000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Token 传输安全策略 (`TOKEN_TRANSPORT_RECOMMENDATION`)
|
||||
|
||||
### 3.1 传输模式比选与推荐
|
||||
- **Cookie 传输(首选推荐)**:
|
||||
- HTTP 请求头:`Cookie: token={token_value}`。
|
||||
- **优势**:Token 位于 HTTP Header,绝不会记录在反向代理(Nginx/网关)的 `access.log` 中,也不会泄露在浏览器历史记录、Referer 标头或请求 URL 监控中。
|
||||
- **URL 传参(降级备选,需日志脱敏)**:
|
||||
- 如果第三方 DMS 平台的某些 API 对 Cookie 解析存在兼容性缺陷,被迫采用 `?token=...`:
|
||||
- **必须实施全局 Log Redaction**:在所有日志拦截器、HttpClient 监控日志中,通过正则表达式 `(?<=token=)[^&]+` 将所有 Token 字符串替换为 `******`。
|
||||
|
||||
---
|
||||
|
||||
## 4. `DmsTokenManager` 高并发防击穿架构 (`TOKEN_CONCURRENCY_STRATEGY`)
|
||||
|
||||
### 4.1 架构设计原理
|
||||
为应对“多次登录导致先前 Token 立即失效”的严苛限制,必须采用 **进程级单例 Token 缓存 + Single-Flight 并发互斥锁 + 提前刷新 + 401 兜底自愈** 架构。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Thread1 as 业务线程 A
|
||||
actor Thread2 as 业务线程 B
|
||||
participant TM as DmsTokenManager
|
||||
participant Cache as 本地 AtomicReference 缓存
|
||||
participant Lock as Single-Flight 并发锁
|
||||
participant DMS as DMS 鉴权服务器
|
||||
|
||||
Thread1->>TM: getToken()
|
||||
Thread2->>TM: getToken()
|
||||
TM->>Cache: 检查当前缓存中的 Token
|
||||
alt 缓存有效 (当前时间 < 到期时间 - 10分钟)
|
||||
Cache-->>TM: 返回有效 Token
|
||||
TM-->>Thread1: 复用有效 Token
|
||||
TM-->>Thread2: 复用有效 Token
|
||||
else 缓存缺失或即将到期
|
||||
TM->>Lock: 尝试获取登录排他锁 (ReentrantLock)
|
||||
activate Lock
|
||||
Note over Lock: 线程 A 成功获取锁,线程 B 阻塞等待
|
||||
Thread1->>Lock: 再次双重检查缓存 (DCL)
|
||||
Thread1->>DMS: POST /public/.../token/get
|
||||
DMS-->>Thread1: 返回 200 OK (新 Token, expiresIn: 7200)
|
||||
Thread1->>Cache: 更新缓存及过期时间戳
|
||||
Thread1-->>Lock: 释放排他锁
|
||||
deactivate Lock
|
||||
Note over Lock: 线程 B 被唤醒,再次检查缓存 (DCL)
|
||||
Thread2->>Cache: 命中线程 A 刚更新的 Token
|
||||
Cache-->>Thread2: 返回最新 Token
|
||||
TM-->>Thread1: 返回新 Token
|
||||
TM-->>Thread2: 复用新 Token (无重复登录!)
|
||||
end
|
||||
```
|
||||
|
||||
### 4.2 核心并发控制机制说明
|
||||
1. **双重检查锁定 (Double-Checked Locking, DCL)**:
|
||||
- 当多个业务请求同时探测到 Token 缺失或过期时,仅有拿到 `ReentrantLock` 的首个线程发起真实 HTTP 登录请求。
|
||||
- 处于等待队列的后续线程在获得锁后,先进行二次检查(DCL);若发现前序线程已刷新成功,则直接读取最新 Token,**绝不发起第二次远程登录**。
|
||||
2. **滑动过期时间更新 (`Sliding Expiry Tracking`)**:
|
||||
- 鉴于 DMS 规定“成功调用任一平台接口会刷新 2 小时有效期”,HttpClient 拦截器在收到任意 200 响应时,通知 `DmsTokenManager.touch()`,将本地缓存的失效时间向后顺延,减少不必要的登录刷新。
|
||||
3. **预防性主动刷新 (`Proactive Refresh`)**:
|
||||
- 设置 `margin = 600秒`(10分钟)。当有效剩余时间不足 10 分钟时,异步触发平滑刷新,杜绝业务请求在临界点遇到 401。
|
||||
|
||||
---
|
||||
|
||||
## 5. 401 自愈与单次安全重试设计 (`DMS 401 Recovery`)
|
||||
|
||||
当由于外部原因(例如运维人员在第三方控制台手动注销、或 DMS 服务端异常重启导致 Session 丢失)出现 `401 Unauthorized` 时的处理规范:
|
||||
|
||||
```
|
||||
业务请求触发调用
|
||||
│
|
||||
▼
|
||||
使用本地 Token 调用 DMS API
|
||||
│
|
||||
├─────────────────────────────────┐
|
||||
▼ [成功 200 OK] ▼ [失败 401 Unauthorized]
|
||||
正常返回业务数据 立即令本地缓存的 Token 失效 (invalidate)
|
||||
通知 TokenManager.touch() 延展寿命 │
|
||||
▼
|
||||
单飞排他锁获取新 Token (Single-Flight Login)
|
||||
│
|
||||
├─────────────────────────┐
|
||||
▼ [重登录成功] ▼ [重登录失败]
|
||||
安全重试原始业务请求 (最多 1 次) 标记操作失败 (FAILED)
|
||||
│ 记录审计日志
|
||||
├──────────────┐ 不影响本地住宿业务事实
|
||||
▼ [重试成功] ▼ [依然401]
|
||||
成功返回 熔断抛出异常
|
||||
```
|
||||
|
||||
### 5.1 恢复红线守则
|
||||
- **单次重试原则**:因 401 触发的自动重试**上限严格为 1 次**。严禁设计任何 `while(true)` 或大于 1 次的递归重试,防止由于网络异常或密码错误引发死循环或轰炸外部服务器。
|
||||
- **业务解耦原则**:若重新登录失败,DmsAdapter 应抛出封装后的 `DmsAuthenticationException`,将门禁任务状态置为 `RETRY_WAIT` 或 `FAILED`,**绝不可向上传播导致本地住宿分配主事务发生回滚**。
|
||||
|
||||
---
|
||||
|
||||
## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
|
||||
- **Token 核心实现**:
|
||||
- `TOKEN_TRANSPORT_MODEL` = `Cookie: token={token}`(严禁使用未经证明的 `Authorization: Bearer`)。
|
||||
- `TOKEN_CACHE_MODEL` = `SINGLE_PROCESS_IN_MEMORY`(全应用单一集中缓存,严禁各个客户端分散维护)。
|
||||
- `TOKEN_SINGLE_FLIGHT_MODEL` = `REENTRANT_LOCK_DCL`(ReentrantLock + 双重检查锁定,20 线程并发获取 Token 实测远程登录仅 1 次)。
|
||||
- `TOKEN_TTL_MODEL` = `7200S_WITH_REFRESH_SKEW`(默认提前 120 秒在滑动窗口内平滑单飞刷新)。
|
||||
- `SUCCESSFUL_API_EXTENDS_LOCAL_TTL` = `YES`(成功调用 DMS API 触发 `tokenManager.markSuccessfulUse()` 顺延本地失效时间戳)。
|
||||
- `AUTH_FAILURE_REFRESH_MODEL` = 401 触发集中 `invalidateToken()`,下一次调用单飞获取新令牌,杜绝 login storm。
|
||||
- `AMBIGUOUS_MUTATION_AUTO_RETRY` = `NO`(写操作读取超时绝对不自动重试,规避重复凭据下发风险)。
|
||||
- **验证结论**:自动化并发测试(20 线程单飞登录、提前刷新单飞、401 自愈)全部通过,仅针对本地 `MockDmsServer` 验证,`LIVE_DMS_REQUEST_COUNT = 0`。
|
||||
@@ -0,0 +1,209 @@
|
||||
# 05 门禁集成适配器架构与厂商无关接口设计 (Adapter Architecture)
|
||||
|
||||
> **设计核心**:**强边界解耦与高内聚设计**。
|
||||
> 住宿业务层(Accommodation Service)对外部物联网厂商的协议细节、Token、URL、整数请求类型完全无感知。
|
||||
> 针对门禁控制定义**厂商中立的抽象网关接口 (`DoorAccessGateway`)**,DMS 专用协议转换完全收敛在 `DmsAccessAdapter`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统分层集成架构图 (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph UI_Layer["前端与接入层 (Web / Controller)"]
|
||||
AC["TrainAccommodationController\n(住宿分配 / 换房 / 退宿)"]
|
||||
DC["TrainDoorAccessController\n(门禁状态查询 / 重试 / 受控应急)"]
|
||||
end
|
||||
|
||||
subgraph Business_Layer["核心业务领域层 (Domain Service)"]
|
||||
AS["TrainAccommodationService\n(维护住宿事实真源 / 状态推进)"]
|
||||
AGS["DoorAccessOrchestrationService\n(授权编排: 提早下发 / 补偿 / 批量处理)"]
|
||||
end
|
||||
|
||||
subgraph Gateway_Boundary["厂商无关抽象边界 (Vendor-Agnostic Gateway)"]
|
||||
DAG["<<interface>>\nDoorAccessGateway\n+grantAccess(GrantCommand): OperationResult\n+revokeAccess(RevokeCommand): OperationResult\n+queryOperation(QueryCommand): OperationStatusResult\n+queryLockHealth(HealthCommand): LockHealthResult\n+remoteUnlock(UnlockCommand): OperationResult"]
|
||||
end
|
||||
|
||||
subgraph Integration_DMS["DMS 专用集成适配层 (ruoyi-train/integration/dms)"]
|
||||
ADAPTER["DmsDoorAccessGateway\n(协议转换 / 参数映射 / 状态归一化)"]
|
||||
|
||||
subgraph DMS_Core["DMS 核心支撑组件"]
|
||||
TM["DmsTokenManager\n(单飞互斥登录 / 缓存 / 401自愈)"]
|
||||
PROP["DmsProperties\n(受控环境变量配置)"]
|
||||
POLLER["DmsOperationPoller\n(Spring/Quartz 10秒对账轮询器)"]
|
||||
end
|
||||
|
||||
subgraph Clients["细分领域 HTTP 客户端 (Feign / RestTemplate)"]
|
||||
PLC["DmsPlaceClient\n(楼栋/楼层/房间查询)"]
|
||||
DLC["DmsDoorLockClient\n(门锁列表/电量信号)"]
|
||||
KC["DmsKeyClient\n(在线密码/在线卡片)"]
|
||||
OPC["DmsOperationClient\n(query-operation-status)"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Remote_DMS["外部物联网云平台 (DMS IoT Platform)"]
|
||||
CLOUD["DMS REST API\n(https://dms.dmsiot.com)"]
|
||||
end
|
||||
|
||||
AC --> AS
|
||||
DC --> AGS
|
||||
AS --> AGS
|
||||
AGS --> DAG
|
||||
DAG <|.. ADAPTER : 实现
|
||||
ADAPTER --> TM
|
||||
ADAPTER --> PROP
|
||||
ADAPTER --> PLC
|
||||
ADAPTER --> DLC
|
||||
ADAPTER --> KC
|
||||
ADAPTER --> OPC
|
||||
POLLER --> DAG
|
||||
PLC --> CLOUD
|
||||
DLC --> CLOUD
|
||||
KC --> CLOUD
|
||||
OPC --> CLOUD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 厂商无关内部标准接口契约 (`Vendor-Agnostic Interface`)
|
||||
|
||||
为确保沈阳房建公寓段学员管理系统未来平滑支持其他设备供应商(如海康威视、大华或铁路专用门禁系统),设计中立门禁网关接口 `DoorAccessGateway`:
|
||||
|
||||
```java
|
||||
package com.ruoyi.train.integration.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.gateway.dto.*;
|
||||
|
||||
/**
|
||||
* 厂商中立门禁系统服务网关接口
|
||||
* 严禁在该接口及方法签名中暴露任何具体厂商(如 DMS)的特定字段、类名或数字枚举。
|
||||
*/
|
||||
public interface DoorAccessGateway {
|
||||
|
||||
/**
|
||||
* 下发门禁通行授权 (支持在线密码、在线卡片等凭据)
|
||||
*
|
||||
* @param command 统一授权下发指令 (包含目标房间、门锁标识、学员信息、时间窗口、凭据要求)
|
||||
* @return 异步操作受理结果 (包含本地操作ID、外部受理ID、初步状态)
|
||||
*/
|
||||
AccessOperationResult grantAccess(GrantAccessCommand command);
|
||||
|
||||
/**
|
||||
* 撤销已生效或下发中的门禁授权 (退宿、退训、换房时调用)
|
||||
*
|
||||
* @param command 统一撤销指令 (包含外部钥匙ID、目标硬件标识等)
|
||||
* @return 异步操作受理结果
|
||||
*/
|
||||
AccessOperationResult revokeAccess(RevokeAccessCommand command);
|
||||
|
||||
/**
|
||||
* 统一查询异步操作执行状态 (对账轮询)
|
||||
*
|
||||
* @param command 操作状态查询指令 (包含 operationId, hardwareId, 原始业务类型)
|
||||
* @return 归一化后的设备执行结果 (SUCCESS, UNDONE, FAILED, TIMEOUT)
|
||||
*/
|
||||
OperationStatusResult queryOperationStatus(QueryOperationCommand command);
|
||||
|
||||
/**
|
||||
* 查询指定房间/门锁的物理运行健康度 (电量、信号、在线状态)
|
||||
*
|
||||
* @param command 查询健康度指令
|
||||
* @return 归一化设备健康度指标
|
||||
*/
|
||||
DeviceHealthResult queryDeviceHealth(QueryHealthCommand command);
|
||||
|
||||
/**
|
||||
* 紧急远程开锁 (受控应急功能,仅限特定权限管理员并记录审计追踪)
|
||||
*
|
||||
* @param command 远程开锁指令 (必须携带操作人、两票审核号、操作原因)
|
||||
* @return 异步操作受理结果
|
||||
*/
|
||||
AccessOperationResult remoteUnlock(RemoteUnlockCommand command);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐工程目录结构规范 (`Package Structure`)
|
||||
|
||||
后续 R.3B 实现时,建议在 `ruoyi-train` 模块中创建如下包结构,本阶段仅冻结设计,**禁止创建 Java 文件**:
|
||||
|
||||
```
|
||||
ruoyi-train
|
||||
└── src/main/java/com/ruoyi/train
|
||||
├── domain # 领域实体 (TrainAccommodation, TrainDormitoryInfo, etc.)
|
||||
├── service # 核心业务服务 (ITrainAccommodationService, etc.)
|
||||
└── integration # 外部集成根包
|
||||
├── gateway # 【厂商无关抽象层】
|
||||
│ ├── DoorAccessGateway.java
|
||||
│ ├── dto # 统一标准入参/出参 DTO
|
||||
│ │ ├── GrantAccessCommand.java
|
||||
│ │ ├── RevokeAccessCommand.java
|
||||
│ │ ├── AccessOperationResult.java
|
||||
│ │ └── OperationStatusResult.java
|
||||
│ └── enums # 标准业务枚举 (CredentialType, NormalizedOpStatus)
|
||||
└── dms # 【DMS 厂商专用集成适配层】
|
||||
├── config # 配置类 (DmsProperties, DmsConfiguration)
|
||||
├── auth # 认证与 Token 管理 (DmsTokenManager, DmsTokenResponse)
|
||||
├── client # 底层 HTTP 交互客户端
|
||||
│ ├── DmsBaseClient.java
|
||||
│ ├── DmsPlaceClient.java
|
||||
│ ├── DmsDoorLockClient.java
|
||||
│ ├── DmsKeyClient.java
|
||||
│ └── DmsOperationClient.java
|
||||
├── dto # DMS 原始报文映射 DTO (只在 integration.dms 内部可见)
|
||||
│ ├── request # DMS 专用请求实体 (如 DmsOnlinePasswordCreateRequest)
|
||||
│ └── response # DMS 专用响应实体 (如 DmsApiResponse, DmsKeyData)
|
||||
├── adapter # 核心适配器实现
|
||||
│ └── DmsDoorAccessGateway.java (实现 DoorAccessGateway 接口)
|
||||
├── scheduler # 异步任务与定时对账
|
||||
│ └── DmsOperationPoller.java (10秒延迟轮询与超时处理)
|
||||
└── util # 专用编码工具 (CardNumberCodec: 读卡器十进制与16进制小端转换)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 适配器边界与职责隔离红线 (`Adapter Boundary Rules`)
|
||||
|
||||
1. **DTO 边界完全隔离**:
|
||||
- DMS 的返回体对象(如 `DmsApiResponse<DmsKeyData>`)的生命周期**绝不允许超出 `com.ruoyi.train.integration.dms` 包范围**。
|
||||
- `DmsDoorAccessGateway` 必须负责将其翻译为中立的 `AccessOperationResult`,禁止向上层抛出 DMS 内部原始 JSON。
|
||||
2. **状态与数字编码归一化**:
|
||||
- DMS 使用数字 `requestType`(1=下发密码, 2=在线卡片, 3=删除密码, 5=远程开锁)。业务 Service **严禁传递数字 1 或 3**,必须传递强类型枚举 `CredentialOperationType.PASSWORD_GRANT`。适配器内部完成枚举到数字 `requestType` 的转换。
|
||||
- DMS 返回的 `operationStatus` 字符串(`"undone"`, `"success"`, `"failed"`, `"timeout"`)由适配器转换为内部标准领域枚举 `NormalizedOperationStatus`。
|
||||
3. **第三方异常吞吐隔离**:
|
||||
- 外部网络超时、401 故障、DMS 内部报错统一由适配器包装为 `IntegrationException` 及其子类,不得泄露第三方平台的底层堆栈至前端。
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **持久化层落地**:
|
||||
- 完成外部资源映射实体 `TrainExternalResourceMapping` 与操作流水实体 `TrainExternalOperation`。
|
||||
- 完成持久化 Mapper 与 Service (`IExternalResourceMappingService`, `IExternalOperationService`)。
|
||||
- 落地 `train_external_resource_mapping`(支持一房多锁、Active 唯一性)与 `train_external_operation`(支持 DoorAuthorization 1:N 流水追踪)。
|
||||
- **零外部耦合红线**:
|
||||
- 本 Wave 严格保持 `DMS_HTTP_CLIENT_COUNT = 0`,`DMS_TOKEN_MANAGER_COUNT = 0`。
|
||||
- 零 RestTemplate / WebClient / HttpClient 网络调用,零明文敏感凭据。
|
||||
- **数据库 Migration**:`008_external_resource_operation.sql` 验证通过,生产库未执行。
|
||||
|
||||
---
|
||||
|
||||
## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
|
||||
- **阶段状态**:Wave 2 implemented against Mock DMS only。
|
||||
- **适配层核心交付清单**:
|
||||
1. `config/DmsProperties`:受控配置中心,`dms.enabled = false`(安全默认),支持参数化超时、提前刷新窗口、Token Cookie 头名称。
|
||||
2. `auth/DmsTokenManager`:全应用唯一集中 Token 缓存,基于 ReentrantLock + DCL 的 Single-Flight 并发互斥锁,支持滑动延展 (`markSuccessfulUse`) 与 401 集中失效自愈。
|
||||
3. `client/DmsHttpClient`:基础底层 HTTP 客户端,统一 Cookie 传输,脱敏日志(密码、Token、Secret 全程保护),写操作读取超时严禁盲目自动重试 (`AMBIGUOUS_MUTATION_AUTO_RETRY = NO`)。
|
||||
4. `client/DmsLocationClient` 与 `gateway/ExternalLocationGateway`:位置资源查询与 Place 空间定位。
|
||||
5. `client/DmsDoorLockClient` 与 `gateway/ExternalDoorLockGateway`:门锁适配器,严格支持 0 把锁(返回空列表,非 NPE)、1 把锁与多把锁(完整返回全部锁)。
|
||||
6. `client/DmsPasswordClient` 与 `gateway/ExternalCredentialGateway`:在线限时密码创建与删除适配器,支持 4~8 位数字,密码明文内存短暂留存,零落库零输出。
|
||||
7. `client/DmsOperationStatusClient` 与 `gateway/DmsOperationStatusMapper`:异步状态查询与集中映射器(success->SUCCESS, undone->PENDING, failed->FAILED, timeout->TIMEOUT, 未知状态->UNKNOWN 严禁判定为 SUCCESS)。
|
||||
8. `poller/DmsOperationPoller`:异步对账轮询调度器,仅在 `dms.enabled=true` 启用,单进程并发防重,终态不可篡改保护,错误分类退避持久化。
|
||||
9. `mock/MockDmsServer`:轻量内嵌测试服务器,精准模拟 DMS 服务端登录互斥、读超时、多锁/零锁与状态流转。
|
||||
- **环境纪律与迁移标记**:
|
||||
- `DMS_DEFAULT_ENABLED = FALSE`
|
||||
- `LIVE_DMS_REQUEST_COUNT = 0`
|
||||
- `FRONTEND_MODIFIED = 0`
|
||||
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
|
||||
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
|
||||
@@ -0,0 +1,178 @@
|
||||
# 06 门锁授权与异步操作状态机设计 (Authorization State Machine)
|
||||
|
||||
> **语义铁律**:**DMS HTTP 200 `success: true` 仅代表“请求已保存/已受理 (REQUEST_ACCEPTED)”,绝不代表“门锁设备执行成功 (DEVICE_SUCCESS)”!**
|
||||
> **门锁生效滞后性**:门锁作为电池供电的无线物联网终端,下发指令需要经过网关转发与微功耗空中唤醒,官方明确要求**调用成功 10 秒后再行查询操作状态**。
|
||||
> **设计目标**:建立双层解耦状态机(业务授权状态 vs 设备操作状态),消除状态混淆。
|
||||
|
||||
---
|
||||
|
||||
## 1. 双层状态体系解耦定义
|
||||
|
||||
必须区分两个不同生命周期的状态机:
|
||||
1. **门禁授权业务状态 (`DoorAuthorizationStatus`)**:站在学员与住宿业务视角,表达该住宿事实当前是否具备开门权限。
|
||||
2. **外部硬件操作状态 (`ExternalOperationStatus`)**:站在异步任务与设备网络视角,表达向 DMS 派发的具体单次指令的执行进展。
|
||||
|
||||
---
|
||||
|
||||
## 2. 状态映射与状态定义字典
|
||||
|
||||
### 2.1 门禁授权业务状态 (`DoorAuthorizationStatus`)
|
||||
| 本地业务状态枚举 | 业务含义 | 允许的操作 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`UNISSUED`** (待下发) | 住宿已确认,尚未向门锁发起授权下发请求 | 下发授权 (`grant`) |
|
||||
| **`ISSUING`** (下发中) | 已向 DMS 发送创建钥匙请求,已获得 `operationId`,正在等待 10 秒后对账确认 | 刷新状态、等待轮询 |
|
||||
| **`ACTIVE`** (已生效/成功) | 轮询确认设备返回 `operationStatus = success`,学员可正常刷卡/输密码开门 | 查看凭据、撤销授权 (`revoke`) |
|
||||
| **`ISSUE_FAILED`** (下发失败) | DMS 或设备明确返回失败,或超时重试达到上限 | 重新下发 (`retry`)、人工改派房间 |
|
||||
| **`REVOKING`** (撤销中) | 学员退宿/调房,已向 DMS 提交删除钥匙请求,正在等待对账确认 | 刷新状态、等待轮询 |
|
||||
| **`REVOKED`** (已注销) | 轮询确认钥匙已被设备成功删除,权限已彻底关闭 | 归档留存、不可逆 |
|
||||
| **`EXPIRED`** (已到达有效期) | 超过 DMS 凭据设定的 `validUntil` 有效期边界(离线过期机制需供应商确认) | 自动归档、主动撤权兜底 |
|
||||
|
||||
### 2.2 外部硬件异步操作状态 (`ExternalOperationStatus`) 与 DMS 映射
|
||||
| 本地操作状态枚举 | 对应 DMS API 原始字段 | 状态说明与处理策略 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`CREATED`** | 内部构建指令,尚未发送 | 准备调用 HTTP 客户端 |
|
||||
| **`REQUESTED`** | HTTP 请求发出,等待响应 | 网络传输中 |
|
||||
| **`ACCEPTED`** | DMS 返回 `success: true`,携带 `operationId` | 记录 `operationId`,调度器挂起 10 秒轮询任务 |
|
||||
| **`PENDING_DEVICE`** | 轮询返回 `operationStatus: "undone"` | 设备尚未完成执行,计算下次退避轮询时间 |
|
||||
| **`SUCCESS`** | 轮询返回 `operationStatus: "success"` | 终态:设备已成功落锁/删除,触发推进业务状态 |
|
||||
| **`FAILED`** | 轮询返回 `operationStatus: "failed"` | 终态:设备返回明确失败,记录 `resultDetails` 与错误原因 |
|
||||
| **`TIMEOUT`** | 轮询返回 `operationStatus: "timeout"` | 终态:DMS 判定设备通信超时(10秒未响应),触发重试或报警 |
|
||||
| **`RETRY_WAIT`** | 遭遇可恢复异常(如网络抖动),等待下一次重试 | 依据退避算法重新放入调度队列 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 门锁授权端到端生命周期状态流转图 (Mermaid)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> UNISSUED : 住宿安排确认 (Accommodation Confirmed)
|
||||
|
||||
state "授权下发阶段 (Grant Flow)" as GrantFlow {
|
||||
UNISSUED --> ISSUING : 触发下发 (报到或临近入住)
|
||||
|
||||
state ISSUING {
|
||||
[*] --> Op_Requested : 调用 POST /online-password-key/create
|
||||
Op_Requested --> Op_Accepted : DMS 返回 200 (获得 operationId)
|
||||
Op_Accepted --> Op_Polling : 延迟等待 10 秒
|
||||
Op_Polling --> Op_Undone : 查询状态返回 undone
|
||||
Op_Undone --> Op_Polling : 间隔 5 秒重试 (最大3次)
|
||||
}
|
||||
|
||||
ISSUING --> ACTIVE : 查询状态返回 success
|
||||
ISSUING --> ISSUE_FAILED : 状态返回 failed 或多次 timeout
|
||||
ISSUE_FAILED --> ISSUING : 管理员手动重试 / 定时补偿重试
|
||||
}
|
||||
|
||||
state "正常使用阶段 (Active Use)" as ActiveUse {
|
||||
ACTIVE --> ACTIVE : 门锁正常开门 / 查询电量信号
|
||||
ACTIVE --> EXPIRED : 超过 validUntil 有效期边界
|
||||
}
|
||||
|
||||
state "权限注销阶段 (Revoke Flow)" as RevokeFlow {
|
||||
ACTIVE --> REVOKING : 学员退宿 / 提前退训 / 换房
|
||||
ISSUE_FAILED --> REVOKING : 换房或作废
|
||||
|
||||
state REVOKING {
|
||||
[*] --> Revoke_Requested : 调用 POST /online-password/delete
|
||||
Revoke_Requested --> Revoke_Accepted : DMS 返回 200 (获得 operationId)
|
||||
Revoke_Accepted --> Revoke_Polling : 延迟等待 10 秒
|
||||
Revoke_Polling --> Revoke_Polling : undone (重试轮询)
|
||||
}
|
||||
|
||||
REVOKING --> REVOKED : 查询状态返回 success
|
||||
REVOKING --> REVOKE_FAILED : 状态返回 failed 或 timeout
|
||||
REVOKE_FAILED --> REVOKING : 紧急人工重试撤销
|
||||
}
|
||||
|
||||
REVOKED --> [*]
|
||||
EXPIRED --> [*]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键流转分支与语义判定细节
|
||||
|
||||
### 4.1 成功下发分支 (Happy Path)
|
||||
1. 触发下发:适配器调用 `/rest/doorlock/online-password-key/create`。
|
||||
2. DMS 同步响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 4945,
|
||||
"placeId": 1320,
|
||||
"lockId": 372,
|
||||
"operationId": "0872887e-464b-4f44-a0cc-057871ba0f65"
|
||||
}
|
||||
}
|
||||
```
|
||||
**本地动作**:持久化 `external_key_id = 4945`,创建外部操作记录(`operationId = 0872887e...`, `status = ACCEPTED`),授权主表推进为 `ISSUING`。
|
||||
3. 轮询调度器等待 10 秒:调用 `/rest/doorlock/query-operation-status`(入参 `requestType = 1`, `operationId = ...`)。
|
||||
4. 轮询响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"operationStatus": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
**本地动作**:更新操作记录为 `SUCCESS`,授权主表推进为 `ACTIVE`(生效可用)。
|
||||
|
||||
### 4.2 设备超时分支 (`Timeout Handling`)
|
||||
- 若 10 秒后轮询返回:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"operationStatus": "timeout",
|
||||
"message": "Device response timeout"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **判定规则**:门锁可能休眠过深、网关信号不佳或网关离线。
|
||||
- **系统动作**:
|
||||
1. 不得无限循环调用。
|
||||
2. 若配置了自动补偿,可追加 1 次重试查询;若依然超时,将操作置为 `TIMEOUT`,授权主表置为 `ISSUE_FAILED`。
|
||||
3. 页面以警告色徽标展示“设备下发超时”,提供【重新下发】按钮供管理员现场排障。
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **状态模型与流转控制 (`OPERATION_STATUS_MODEL`)**:
|
||||
- 流转闭环:`CREATED` -> `REQUESTED` -> `ACCEPTED` / `PENDING` -> `SUCCESS` / `FAILED` / `TIMEOUT` / `RETRY_WAIT`(`REVOKED` 归属于 `DoorAuthorization` 业务授权主状态,注销调用以 `operation_type=REVOKE` 发起并达成 `status=SUCCESS`)。
|
||||
- **终态保护**:`SUCCESS` 终态达成后禁止任何逆向跳转;`FAILED` / `TIMEOUT` 终态达成后禁止原记录重置为 `CREATED`。
|
||||
- **重试审计保持**:重试调用通过 `createRetryOperation` 创建自增 `attempt_count` 的全新流水记录,历史调用事实不可篡改。
|
||||
- **轮询检索接口 (`PENDING_OPERATION_QUERY`)**:
|
||||
- 交付 `selectPendingPollOperations`,检索处于 `ACCEPTED`、`PENDING`、`RETRY_WAIT` 且 `next_poll_time <= NOW()` 的操作记录。
|
||||
- 为 Wave 2 调度器提供完整数据准备,本 Wave 保持 0 调度器运行、0 HTTP 调用。
|
||||
|
||||
---
|
||||
|
||||
## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
|
||||
- **阶段状态**:Wave 2 implemented against Mock DMS only。
|
||||
- **状态机与对账调度实现**:
|
||||
- `DmsOperationStatusMapper`:集中映射供应商返回的设备操作状态(`success` -> `SUCCESS`, `undone` -> `PENDING`, `failed` -> `FAILED`, `timeout` -> `TIMEOUT`),未知供应商状态一律归为 `UNKNOWN`,严禁判定为 `SUCCESS`。
|
||||
- `ACCEPTED != SUCCESS` 原则强制落地:下发/删除密码接口 HTTP 200 响应仅代表平台受理并取得 `operationId`(本地状态记录为 `ACCEPTED`),设备执行事实必须由后续 Poller 轮询确认达成 `SUCCESS`。
|
||||
- `DmsOperationPoller`:自动从 `listPendingPollOperations` 检索任务,支持 10 秒初始延迟,单进程并发防重,终态保护不可篡改,网络错误转入 `RETRY_WAIT` 退避。
|
||||
- **迁移标记**:
|
||||
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
|
||||
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
|
||||
|
||||
---
|
||||
|
||||
## 7. Wave 3A Implementation Result (Wave 3A implemented against Mock DMS only)
|
||||
- **阶段状态**:Wave 3A Access Authorization Orchestration Dry-Run implemented against Mock DMS only。
|
||||
- **厂商中立业务授权状态模型 (`AUTHORIZATION_STATUS_MODEL`)**:
|
||||
- 完整定义细粒度业务授权状态枚举:`PENDING_GRANT`(待下发) -> `GRANTING`(下发中) -> `ACTIVE`(已生效) -> `PENDING_REVOKE`(待注销) -> `REVOKING`(注销中) -> `REVOKED`(已注销),异常收敛为 `ERROR`。
|
||||
- 业务授权生命周期与单次外部操作流水(`ExternalOperation.operation_status`)严格解耦:
|
||||
- `ExternalOperation` 达成 `ACCEPTED` 时,`DoorAuthorization` 保持 `GRANTING` / `REVOKING`,**严禁直接标记为 ACTIVE / REVOKED**。
|
||||
- `ExternalOperation` 经由异步轮询器对账达成 `SUCCESS` 时,方才正式推进 `DoorAuthorization` 为 `ACTIVE` 或 `REVOKED`。
|
||||
- `ExternalOperation` 失败或超时(`FAILED` / `TIMEOUT`)时,`DoorAuthorization` 置为 `ERROR` 并保留原业务上下文以备后续幂等重试。
|
||||
- **防迟到覆盖保护 (`STALE_OPERATION_RESULT_GUARD`)**:
|
||||
- 授权主表建立 `latest_operation_id` 与 `operation_version` 防护机制。
|
||||
- 当轮询器处理旧操作流水结果时,必须强校验当前授权记录的 `latest_operation_id` 是否依然与该流水匹配。
|
||||
- 若已发起后续新操作,迟到的旧操作结果(如旧 GRANT 迟到的 FAILED 或 SUCCESS)被静默忽略,绝不覆盖新授权状态。
|
||||
- **向后兼容与双向状态映射**:
|
||||
- `TrainDormAuth` 内置 `authStatus` 与旧版 `faceSyncStatus` 双向同步转换,在 009 Migration 未在生产执行前无缝兼容现有 MyBatis 映射。
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user