新增门禁:实现访问管理界面与Mock端到端测试

This commit is contained in:
wangzhibiao committed 2026-09-16 11:43:58 +08:00
1 parent 20c5d494cf
commit d976fcfb4d
29 files changed
+2423 -281

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,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,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);
}
}