新增门禁:实现适配器、令牌与异步操作集成

This commit is contained in:
wangzhibiao committed 2026-09-16 10:58:29 +08:00
1 parent 1332b45fae
commit bb16b0b875
54 files changed
+4215 -1

No files matched your search

@@ -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,90 @@
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)
*/
public List<DmsRoomDto> listRooms(Long floorId) {
Map<String, Object> req = new HashMap<>();
req.put("floorId", floorId);
JSONObject resp = httpClient.postQuery("/rest/place/room/list", req, JSONObject.class);
return extractList(resp, DmsRoomDto.class);
}
/**
* 获取单个房间详情 (POST /rest/place/room/get)
*/
public DmsRoomDto getRoom(Long roomId) {
Map<String, Object> req = new HashMap<>();
req.put("id", roomId);
JSONObject resp = httpClient.postQuery("/rest/place/room/get", req, JSONObject.class);
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,77 @@
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 lockId;
private String hardwareId;
private String lockName;
private Long placeId;
private Integer onlineStatus;
private Integer battery;
public Long getLockId() {
return lockId;
}
public void setLockId(Long lockId) {
this.lockId = 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{" +
"lockId=" + lockId +
", 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,77 @@
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;
}
public void setId(Long id) {
this.id = id;
}
public Long getPlaceId() {
return placeId;
}
public void setPlaceId(Long placeId) {
this.placeId = 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,273 @@
package com.ruoyi.train.integration.dms.poller;
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.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 Set<Long> inFlightOperationIds = ConcurrentHashMap.newKeySet();
private Clock clock = Clock.systemUTC();
public DmsOperationPoller(DmsProperties dmsProperties,
IExternalOperationService externalOperationService,
DmsOperationStatusClient operationStatusClient) {
this.dmsProperties = dmsProperties;
this.externalOperationService = externalOperationService;
this.operationStatusClient = operationStatusClient;
}
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"
);
} 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"
);
} 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"
);
} 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"
);
} 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 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");
}
}
@@ -107,4 +107,25 @@ public interface IExternalOperationService
* @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);
}
@@ -238,4 +238,58 @@ public class ExternalOperationServiceImpl implements IExternalOperationService
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,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,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,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;
}
);
}
}
@@ -67,3 +67,19 @@
- **远程开锁 (`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`。
@@ -138,3 +138,16 @@ sequenceDiagram
### 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`。
@@ -186,3 +186,24 @@ ruoyi-train
- 本 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`
@@ -147,3 +147,15 @@ stateDiagram-v2
- **轮询检索接口 (`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`。
@@ -164,3 +164,15 @@ sequenceDiagram
- **重试审计与不可变性**:
- 终态记录不可覆写:每次重试调用通过 `createRetryOperation` 独立创建自增 `attempt_count` 的新记录。
- 保留完整排障轨迹:`last_error_code` 与 `last_error_message` 详细保留每次失败现场,满足段级安全审计与对账需求。
---
## 7. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
- **阶段状态**:Wave 2 implemented against Mock DMS only。
- **重试与幂等安全落地**:
- `AMBIGUOUS_MUTATION_AUTO_RETRY = NO`:写操作(如密码创建)发生网络读取超时(Read Timeout)时,底层 `DmsHttpClient` 坚决抑制自动重试,`passwordCreatePostCounter = 1`,避免在门锁端产生重复凭据。
- 轮询指数退避:`DmsOperationPoller` 在遭遇网络抖动时将状态置为 `RETRY_WAIT`,基于 `Math.pow(2, attemptCount) * 5` 计算下次轮询时间并递增 `attempt_count`,防止高频风暴轮询。
- 401 集中重试:仅在明确认证失败且为安全查询时才发起 1 次 Token 刷新与重试;写操作 401 则集中失效并向外抛错,由上层流水对账接管。
- **迁移标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
@@ -115,3 +115,15 @@
### 5.2 响应数据持久化策略 (`Response Sanitization Policy`)
- **禁止项**:绝不无脑把外部 DMS 返回的几千字节原始 JSON 直接整体塞入数据库的文本列中。
- **要求**:仅抽取核心对账字段进行结构化持久化;若出于排障目的保留原始请求与响应报文快照,**必须先经过脱敏过滤器(剔除明文密码与密钥)**后再写入 `sanitized_response_payload`。
---
## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
- **阶段状态**:Wave 2 implemented against Mock DMS only。
- **安全红线核验结论**:
- `PLAINTEXT_PASSWORD_PERSISTENCE_COUNT = 0`:反射测试断言 `TrainExternalOperation` 与 `TrainDormAuth` 绝无密码明文属性,实体中仅保留 `external_key_id`。
- `SECRET_LOGGING_COUNT = 0`:`DmsHttpClient` 与 `DmsOperationPoller` 全局应用 `sanitize` 过滤器,请求头与请求体中的 `password`、`token`、`appsecret` 均被强制替换为 `[PROTECTED]`。
- `LIVE_DMS_REQUEST_COUNT = 0`:开发与自动化测试期间严格仅使用内嵌 `MockDmsServer`,真实 DMS 地址仅保留于默认配置占位符,未发起任何公网请求。
- **迁移标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
@@ -115,3 +115,15 @@
[写入 train_external_resource_mapping, 完成两套系统物理基线的稳定锁定]
```
- **核心保障**:同步操作仅建立与更新映射表,**绝不自动删除或篡改本地 `train_dormitory_info` 的基础房源台账**。本地房源依然是学员管理系统的独立主数据。
---
## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
- **阶段状态**:Wave 2 implemented against Mock DMS only。
- **Controller 与 UI 边界红线**:
- `CONTROLLER_COUNT` = 0(本 Wave 严禁新增任何 `/train/dms/**` 控制器,所有适配层仅供 Service/Test 内部调用)。
- `FRONTEND_MODIFIED` = 0(前端与 `build_ui` 修改文件数严格为 0)。
- `REMOTE_UNLOCK_CONTROLLER` = 0(保持 P2/FUTURE 决策,不提供任何远程开锁入口)。
- **迁移标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
@@ -135,3 +135,52 @@ public void testConcurrentLoginDeduplication() throws Exception {
- `008_CONTRACT_TEST = PASS`
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
- 明确声明:008 迁移脚本已通过静态语法与结构契约测试(Contract Test PASS),未在实际生产库或非隔离库执行;在投产前必须在隔离 Kingbase 实例执行双跑准入门禁。
---
## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only)
- **阶段状态**:Wave 2 implemented against Mock DMS only。
- **新增自动化测试清单 (26 项)**:
1. `DmsTokenManagerTest.java` (4 项):
- `testTokenConcurrency_SingleFlightLogin` (20 线程并发获取 Token,单飞登录仅 1 次,所有线程获取相同 Token)。
- `testTokenRefresh_SingleFlightRefresh` (推进时间至提前刷新窗口,20 线程并发单飞刷新,旧 Token 废弃)。
- `testSuccessfulApiExtendsLocalTtl` (成功调用 API 触发滑动延展本地有效期限)。
- `testAuthFailureRecovery_NoLoginStorm` (401 集中失效自愈,无并发 login storm)。
2. `DmsHttpClientTest.java` (3 项):
- `testMutationAmbiguity_NoAutoRetryOnTimeout` (写操作读取超时坚决不自动重试,`passwordCreatePostCounter = 1`)。
- `testTokenTransport_CookieHeader` (验证请求注入 `Cookie: token=...`,严禁 `Bearer`)。
- `testSensitiveDataRedaction` (密码明文、Token、Secret 全局脱敏断言)。
3. `DmsLocationAdapterTest.java` (1 项):
- `testLocationLookup_FullHierarchy` (建筑、楼层、房间完整层级查询与 placeId 解析)。
4. `DmsDoorLockAdapterTest.java` (2 项):
- `testZeroLock_ReturnsEmptyList` (0 把锁场景正常返回空列表,非 NPE,非假锁)。
- `testMultiLock_ReturnsAllLocks` (多把锁场景完整返回全部 3 把门锁)。
5. `DmsPasswordAdapterTest.java` (4 项):
- `testCreateOnlinePassword_Success` (正常下发 6 位限时密码,获得 keyId 与 operationId)。
- `testCreateOnlinePassword_ValidRanges` (支持 4~8 位范围内的合法密码)。
- `testCreateOnlinePassword_InvalidFormatsRejected` (非法格式参数拦截)。
- `testDeleteOnlinePassword_Success` (删除在线密码成功返回 operationId)。
6. `DmsOperationStatusAdapterTest.java` (4 项):
- `testQueryOperationStatus_FromMock` (查询门锁操作状态)。
- `testStatusMapper_AllKnownTransitions` (success->SUCCESS, undone->PENDING, failed->FAILED, timeout->TIMEOUT)。
- `testStatusMapper_UnknownStatusNeverSuccess` (未知状态映射为 UNKNOWN,绝非 SUCCESS)。
- `testStatusMapper_TerminalStatusIdentification` (终态识别判定)。
7. `DmsOperationPollerTest.java` (6 项):
- `testSafeDefaults_DisabledPollerDoesNothing` (安全默认:enabled=false 绝不执行轮询)。
- `testPollFlow_ReachesSuccess` (轮询流转到 SUCCESS 终态)。
- `testPollFlow_ReachesFailed` (轮询流转到 FAILED 终态)。
- `testPollFlow_ReachesTimeout` (轮询流转到 TIMEOUT 终态)。
- `testPollFlow_PendingUndoneRetained` (设备 undone 保持 PENDING 并延展时间)。
- `testTerminalStateProtection_NeverRewritten` (终态记录不可篡改)。
8. `DmsAsyncLifecycleAndSecurityTest.java` (2 项):
- `testAcceptedNotEqualsSuccess_LifecycleVerification` (核心验证:HTTP 200 仅为 ACCEPTED,轮询成功后才为 SUCCESS)。
- `testCredentialDataLeak_ZeroPlaintextStorage` (反射断言持久化实体绝无密码明文属性)。
- **全量测试指标**:
- 基线测试数:203 项。
- 新增测试数:26 项。
- 当前测试总数:229 项(ruoyi-train: 201 项,ruoyi-admin: 28 项),Failures = 0, Errors = 0, Skipped = 0。
- Maven `clean test` 与 `clean package` 顺利构建成功。
- **环境纪律与迁移标记**:
- `LIVE_DMS_REQUEST_COUNT = 0`
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
@@ -188,3 +188,30 @@ flowchart TD
- `008_CONTRACT_TEST = PASS`
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
- 严禁将 Contract Test PASS 描述为实际 Kingbase 双执行 PASS。
### 3.4 Wave 2 Implementation Result (DMS 适配层 / Token / HTTP / Poller / Mock 集成 - Wave 2 implemented)
- **阶段目标**:DMS Adapter / Token / HTTP / Poller / Mock Integration against Mock DMS only
- **执行状态**:Wave 2 implemented
- **核心交付清单**:
1. 基础配置:`DmsProperties.java`(受控配置,`enabled=false` 安全默认,参数化超时与刷新窗口)。
2. 令牌管理:`DmsTokenManager.java`(进程级单例缓存,ReentrantLock + DCL 单飞互斥防击穿,滑动顺延,401 集中失效)。
3. 通信客户端:`DmsHttpClient.java`(Cookie 头鉴权,脱敏日志,写操作读取超时严禁自动重发 `AMBIGUOUS_MUTATION_AUTO_RETRY = NO`)。
4. 位置适配器:`DmsLocationClient.java`、`ExternalLocationGateway.java`、`DmsLocationGatewayImpl.java`(层级空间查询与 placeId 解析)。
5. 门锁适配器:`DmsDoorLockClient.java`、`ExternalDoorLockGateway.java`、`DmsDoorLockGatewayImpl.java`(支持 0、1、多把智能门锁)。
6. 密码凭据适配器:`DmsPasswordClient.java`、`ExternalCredentialGateway.java`、`DmsCredentialGatewayImpl.java`(下发 4~8 位限时密码、删除密码,密码明文内存短暂留存)。
7. 状态查询与映射:`DmsOperationStatusClient.java`、`DmsOperationStatusMapper.java`(集中统一映射,未知状态判定为 UNKNOWN 严禁判定为 SUCCESS)。
8. 异步轮询器:`DmsOperationPoller.java`(条件化启动,单进程并发防重,终态不可篡改保护,退避重试)。
9. 测试基础设施:`MockDmsServer.java`(内嵌纯 JDK HttpServer,完全模拟真实 DMS 平台登录互斥、短 TTL、多锁/零锁与状态流转)。
10. 自动化测试套件:`DmsTokenManagerTest` (4项)、`DmsHttpClientTest` (3项)、`DmsLocationAdapterTest` (1项)、`DmsDoorLockAdapterTest` (2项)、`DmsPasswordAdapterTest` (4项)、`DmsOperationStatusAdapterTest` (4项)、`DmsOperationPollerTest` (6项)、`DmsAsyncLifecycleAndSecurityTest` (2项)。
- **全量构建与回归验收**:
- `ruoyi-train`: 201 tests
- `ruoyi-admin`: 28 tests
- 总计:229 tests, Failures = 0, Errors = 0, Skipped = 0,全部通过。
- Maven `clean test` 与 `clean package` 构建成功。
- 生产数据库变更:`PRODUCTION_DB_MODIFIED? NO`。
- 外部通信真实调用:`LIVE_DMS_REQUEST_COUNT = 0`。
- 控制器与前端改动:`CONTROLLER_COUNT = 0`,`FRONTEND_MODIFIED = 0`。
- **Migration 标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`
- `008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
@@ -46,3 +46,12 @@
| **Q-NON-04** | **开门密码学员触达渠道**:密码如何安全告知学员? | 推荐学员在移动端个人中心经二次身份验证后查看 30 秒倒计时明文,节省短信资费。 |
| **Q-NON-05** | **远程紧急开锁 (Remote Unlock)**:一期是否需要上线远程开锁? | 属于高危控制操作,一期严格排除;仅设计两票制受控应急方案备查。 |
| **Q-NON-06** | **门锁电量与信号监控**:是否需要专门的设备运行状态大盘? | 一期在宿舍列表表格中轻量展示电量与信号徽标,辅助宿管员排查低电量门锁。 |
---
## 4. Wave 2 架构中对未决问题的设计守则 (`Wave 2 Open Question Guards`)
在 Wave 2 适配层开发中,上述问题被严格保留为待确认事项,绝不在代码与文档中伪称已解决:
1. **门锁离线与 RTC 漂移语义**:依然待供应商提供技术规格,系统在 Adapter 与 Poller 中保持以主动撤权为主、有效期为辅的双重安全设计。
2. **真实环境 Sandbox 账号**:本 Wave 严格使用 `MockDmsServer` 动态模拟,不依赖真实 DMS 沙箱。
3. **真实错误码全集**:通过 `DmsOperationPoller.ERROR_CATEGORY_*` 抽象通用错误分类(AUTH_ERROR, NETWORK_ERROR, VENDOR_BUSINESS_ERROR, DEVICE_FAILED, DEVICE_TIMEOUT),兼容未知供应商错误。
4. **Token Cookie 头名称**:在 `DmsProperties.tokenCookieName` 中参数化配置(默认 "token"),杜绝写死猜测。
+40 -1
View File
@@ -154,4 +154,43 @@
- Maven 构建:`clean test` 与 `clean package` 均 `SUCCESS`。
- 代码格式校验:`git diff --check = PASS`。
- 阶段评审状态:`R3_WAVE1_REVIEW = PASS`。
- 提交状态:`R3_WAVE1_COMMIT = READY`(执行 Checkpoint Commit,绝不 PUSH,绝不 DEPLOY)。
- 提交状态:`R3_WAVE1_COMMIT = COMMITTED` (SHA: 22bcd1bacb66826e8e911454e9c01b54c8b64b32)。
### 6.4 Wave 2 DMS Adapter, Token, Poller & Mock Integration (Wave 2 implemented against Mock DMS only)
- **阶段状态**:`R3_WAVE2_STATUS = PASS`。
- **配置与安全默认**:
- `DMS_DEFAULT_ENABLED = FALSE`:默认不开启 DMS 通信与轮询。
- `DMS_PROPERTIES`:完整实现受控配置,零硬编码密钥,参数化超时与刷新窗口。
- **令牌与通信架构**:
- `TOKEN_TRANSPORT_MODEL` = `Cookie: token={token}`(严禁使用未核实的 `Authorization: Bearer`)。
- `TOKEN_CACHE_MODEL` = `SINGLE_PROCESS_IN_MEMORY`(全应用单一集中缓存)。
- `TOKEN_SINGLE_FLIGHT_MODEL` = `REENTRANT_LOCK_DCL`(高并发过期单飞防击穿,实测 20 线程并发登录仅 1 次)。
- `TOKEN_TTL_MODEL` = `7200S_WITH_REFRESH_SKEW`(滑动窗口提前 120 秒平滑单飞刷新)。
- `SUCCESSFUL_API_EXTENDS_LOCAL_TTL` = `YES`(成功调用 DMS API 顺延本地失效时间戳)。
- `AUTH_FAILURE_REFRESH_MODEL` = `INVALIDATE_THEN_SINGLE_FLIGHT`(401 集中失效并在下一次请求单飞获取新 Token,无 login storm)。
- `AMBIGUOUS_MUTATION_AUTO_RETRY` = `NO`(写操作读取超时坚决抑制自动重发,防止重复生成门锁凭据)。
- `DMS_HTTP_CLIENT` = `DmsHttpClient` 统一底层通信与全局脱敏日志(零明文密码/Token/Secret 泄露)。
- **设备与凭据适配能力**:
- `LOCATION_ADAPTER` = `DmsLocationClient` / `ExternalLocationGateway`(Building, Floor, Room 空间定位与 placeId 解析)。
- `DOORLOCK_ADAPTER` = `DmsDoorLockClient` / `ExternalDoorLockGateway`(支持 0 把锁空列表与多把锁完整返回)。
- `ONLINE_PASSWORD_CREATE` = `DmsPasswordClient` / `ExternalCredentialGateway`(支持 4~8 位数字限时密码下发,密码零落库)。
- `ONLINE_PASSWORD_DELETE` = `DmsPasswordClient` / `ExternalCredentialGateway`(注销并返回 operationId)。
- `OPERATION_STATUS_QUERY` = `DmsOperationStatusClient`。
- `VENDOR_STATUS_MAPPING` = `DmsOperationStatusMapper`(集中统一映射,未知状态判定为 UNKNOWN,绝非 SUCCESS)。
- **异步轮询与测试模拟**:
- `POLLER_IMPLEMENTED` = `DmsOperationPoller`(条件化启动,单进程并发防重,终态不可篡改保护,指数退避重试)。
- `POLLER_INITIAL_DELAY` = 10s。
- `MULTI_INSTANCE_POLLER_LOCK` = `FUTURE_GATE`。
- `MOCK_DMS_IMPLEMENTED` = `MockDmsServer`(纯轻量 JDK 原生模拟,登录互斥、短 TTL、多锁/零锁与状态流转)。
- **测试与质量指标**:
- `OLD_TEST_COUNT = 203`
- `NEW_TEST_COUNT = 26`
- `TOTAL_TEST_COUNT = 229` (ruoyi-train: 201, ruoyi-admin: 28),Failures = 0, Errors = 0, Skipped = 0。
- `MAVEN_TEST = SUCCESS`,`MAVEN_PACKAGE = SUCCESS`。
- `git diff --check = PASS`。
- **环境纪律与迁移标记**:
- `LIVE_DMS_REQUEST_COUNT = 0`(全生命周期未对公网真实 DMS 发起任何请求)。
- `FRONTEND_MODIFIED = 0`,`CONTROLLER_COUNT = 0`。
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
- `COMMIT? NO`,`PUSH? NO`,`DEPLOY? NO`(严格 HOLD 等待审查)。