合并门禁:将DMS真实接口探测结果集成至质量验证基线

This commit is contained in:
wangzhibiao committed 2026-09-16 15:31:49 +08:00
commit 758fd79e16
139 files changed
+18058 -483

No files matched your search

@@ -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";
}
}
}
@@ -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";
}
}
}
@@ -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";
}
}
}
@@ -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());
}
}
@@ -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));
}
}
}
@@ -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());
}
}
@@ -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));
}
}
}
@@ -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"));
}
}
@@ -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"));
}
}
@@ -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();
@@ -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 + '\'' +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 +
'}';
}
}
@@ -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 + '\'' +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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 + '\'' +
'}';
}
}
@@ -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 + '\'' +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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 + '\'' +
'}';
}
}
@@ -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]'" +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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]");
}
}
@@ -0,0 +1,26 @@
package com.ruoyi.train.integration.dms.orchestration;
/**
* 门禁权限对账决策枚举 (厂商中立)
*/
public enum ReconciliationDecision {
/**
* 无需操作 (当前状态已与业务事实对齐)
*/
NO_ACTION,
/**
* 需要发放权限 (满足所有前置业务条件,待下发凭据)
*/
GRANT_REQUIRED,
/**
* 需要注销权限 (学员退训/结训或调房退宿,需收回凭据)
*/
REVOKE_REQUIRED,
/**
* 等待前置业务条件 (未报到、未分房、无门锁映射等)
*/
WAITING_FOR_PREREQUISITE
}
@@ -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") +
'}';
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
@@ -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());
}
}
}
}
@@ -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;
}
}
@@ -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() + "],禁止跨房间重复绑定");
}
}
}
}
@@ -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);
}
}
@@ -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 &gt;= #{authStart}
</if>
<if test="authEnd != null">
AND a.auth_end &lt;= #{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>
@@ -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 &lt;= #{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>
@@ -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>
@@ -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;
}
);
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
);
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
@@ -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;
+68
View File
@@ -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>
+162 -269
View File
@@ -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