新增门禁:实现适配器、令牌与异步操作集成
This commit is contained in:
1 parent
1332b45fae
commit
bb16b0b875
54 files changed
+4215
-1
No files matched your search
+252
@@ -0,0 +1,252 @@
|
||||
package com.ruoyi.train.integration.dms.auth;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsTokenRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsTokenResponse;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsAuthenticationException;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* DMS 访问令牌集中管理器
|
||||
* <p>
|
||||
* 核心并发架构与安全保证:
|
||||
* 1. 单进程唯一内存缓存 (Single Token Cache)。
|
||||
* 2. 基于 ReentrantLock + 双重检查锁定 (DCL) 的 Single-Flight 机制,杜绝并发请求击穿导致重复登录。
|
||||
* 3. 预防性提前刷新 (Refresh Skew),在 Token 到期前预先刷新。
|
||||
* 4. 滑动有效期延展 (markSuccessfulUse),在成功调用 DMS API 后同步延长本地有效期基准。
|
||||
* 5. 集中失效 (invalidateToken) 与 401 自愈恢复。
|
||||
* 6. 支持注入 Clock 实现测试确定性。
|
||||
*/
|
||||
@Component
|
||||
public class DmsTokenManager {
|
||||
private static final Logger log = LoggerFactory.getLogger(DmsTokenManager.class);
|
||||
|
||||
private final DmsProperties dmsProperties;
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final AtomicReference<TokenEntry> tokenCache = new AtomicReference<>(null);
|
||||
private final AtomicInteger loginCounter = new AtomicInteger(0);
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
public DmsTokenManager(DmsProperties dmsProperties) {
|
||||
this.dmsProperties = dmsProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许在测试中注入 FakeClock 或指定 Clock
|
||||
*/
|
||||
public void setClock(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Clock getClock() {
|
||||
return this.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效 Token。
|
||||
* 若本地缓存有效且未进入刷新窗口,直接复用;
|
||||
* 若失效或即将过期,通过 Single-Flight 互斥锁发起登录刷新。
|
||||
*/
|
||||
public String getToken() {
|
||||
TokenEntry current = tokenCache.get();
|
||||
Instant now = clock.instant();
|
||||
|
||||
if (isTokenValid(current, now)) {
|
||||
return current.token;
|
||||
}
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
// Double-Checked Locking (DCL)
|
||||
current = tokenCache.get();
|
||||
now = clock.instant();
|
||||
if (isTokenValid(current, now)) {
|
||||
return current.token;
|
||||
}
|
||||
|
||||
TokenEntry newEntry = performLogin();
|
||||
tokenCache.set(newEntry);
|
||||
log.info("DMS token refreshed successfully (loginCount={})", loginCounter.get());
|
||||
return newEntry.token;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使当前本地缓存的 Token 失效 (如收到 401 Unauthorized 时调用)
|
||||
*/
|
||||
public void invalidateToken() {
|
||||
tokenCache.set(null);
|
||||
log.info("DMS cached token invalidated");
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功调用 DMS 平台 API 后滑动延展本地有效期限
|
||||
* (根据官方文档,成功调用平台任一接口会使服务端 Token TTL 重新刷新为 2 小时)
|
||||
*/
|
||||
public void markSuccessfulUse() {
|
||||
TokenEntry current = tokenCache.get();
|
||||
if (current != null) {
|
||||
Instant now = clock.instant();
|
||||
long ttlSeconds = dmsProperties.getTokenTtlSeconds() > 0 ? dmsProperties.getTokenTtlSeconds() : 7200;
|
||||
Instant newExpiresAt = now.plusSeconds(ttlSeconds);
|
||||
// 只有延展时间晚于当前记录时才更新
|
||||
if (newExpiresAt.isAfter(current.expiresAt)) {
|
||||
tokenCache.compareAndSet(current, new TokenEntry(current.token, newExpiresAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供测试验证登录发起次数
|
||||
*/
|
||||
public int getLoginCounter() {
|
||||
return loginCounter.get();
|
||||
}
|
||||
|
||||
private boolean isTokenValid(TokenEntry entry, Instant now) {
|
||||
if (entry == null || entry.token == null || entry.token.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
int skew = Math.max(0, dmsProperties.getTokenRefreshSkewSeconds());
|
||||
return now.plusSeconds(skew).isBefore(entry.expiresAt);
|
||||
}
|
||||
|
||||
private TokenEntry performLogin() {
|
||||
String appId = dmsProperties.getAppId();
|
||||
String appSecret = dmsProperties.getAppSecret();
|
||||
|
||||
if (appId == null || appId.trim().isEmpty() || appSecret == null || appSecret.trim().isEmpty()) {
|
||||
throw new DmsAuthenticationException("DMS credentials not configured (appId or appSecret is empty)");
|
||||
}
|
||||
|
||||
String targetUrl = dmsProperties.getBaseUrl();
|
||||
if (targetUrl == null || targetUrl.trim().isEmpty()) {
|
||||
targetUrl = "https://dms.dmsiot.com";
|
||||
}
|
||||
targetUrl = targetUrl.replaceAll("/+$", "") + "/public/rest/third-party-app/token/get";
|
||||
|
||||
loginCounter.incrementAndGet();
|
||||
|
||||
DmsTokenRequest requestDto = new DmsTokenRequest(appId, appSecret);
|
||||
String requestJson = JSON.toJSONString(requestDto);
|
||||
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(targetUrl);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(dmsProperties.getConnectTimeoutMs());
|
||||
conn.setReadTimeout(dmsProperties.getReadTimeoutMs());
|
||||
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
conn.setDoInput(true);
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(requestJson.getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
}
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
InputStream is = (responseCode >= 200 && responseCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
String responseBody = readStream(is);
|
||||
|
||||
if (responseCode == 401) {
|
||||
throw new DmsAuthenticationException("DMS login unauthorized (HTTP 401)");
|
||||
}
|
||||
if (responseCode < 200 || responseCode >= 300) {
|
||||
throw new DmsAuthenticationException("DMS login failed with HTTP status: " + responseCode + ", response: " + responseBody);
|
||||
}
|
||||
|
||||
JSONObject jsonObject = JSON.parseObject(responseBody);
|
||||
if (jsonObject == null) {
|
||||
throw new DmsAuthenticationException("Empty response body received from DMS token endpoint");
|
||||
}
|
||||
|
||||
// 兼容直接包含 token 或在 data 包装内包含 token
|
||||
String token = jsonObject.getString("token");
|
||||
Long expiresIn = jsonObject.getLong("expiresIn");
|
||||
|
||||
if (token == null && jsonObject.containsKey("data")) {
|
||||
Object dataObj = jsonObject.get("data");
|
||||
if (dataObj instanceof JSONObject) {
|
||||
JSONObject dataJson = (JSONObject) dataObj;
|
||||
token = dataJson.getString("token");
|
||||
if (expiresIn == null) {
|
||||
expiresIn = dataJson.getLong("expiresIn");
|
||||
}
|
||||
} else if (dataObj instanceof String) {
|
||||
token = (String) dataObj;
|
||||
}
|
||||
}
|
||||
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
String errorMsg = jsonObject.getString("message");
|
||||
throw new DmsAuthenticationException("DMS token not found in response: " + (errorMsg != null ? errorMsg : responseBody));
|
||||
}
|
||||
|
||||
long ttlSeconds = (expiresIn != null && expiresIn > 0) ? expiresIn : dmsProperties.getTokenTtlSeconds();
|
||||
if (ttlSeconds <= 0) {
|
||||
ttlSeconds = 7200;
|
||||
}
|
||||
|
||||
Instant expiresAt = clock.instant().plusSeconds(ttlSeconds);
|
||||
return new TokenEntry(token, expiresAt);
|
||||
|
||||
} catch (DmsAuthenticationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new DmsTransportException("Failed to connect to DMS token endpoint: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String readStream(InputStream is) {
|
||||
if (is == null) {
|
||||
return "";
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static class TokenEntry {
|
||||
final String token;
|
||||
final Instant expiresAt;
|
||||
|
||||
TokenEntry(String token, Instant expiresAt) {
|
||||
this.token = token;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DMS 智能门锁适配客户端
|
||||
* <p>
|
||||
* 遵循 Dormitory 1:0..N DoorLocks 模型,支持返回 0 把锁、1 把锁或多把锁,绝不自动截断或伪造。
|
||||
*/
|
||||
@Component
|
||||
public class DmsDoorLockClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(DmsDoorLockClient.class);
|
||||
|
||||
private final DmsHttpClient httpClient;
|
||||
|
||||
public DmsDoorLockClient(DmsHttpClient httpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据空间/房间 ID 查询挂载的智能门锁列表 (POST /rest/doorlock/list)
|
||||
*
|
||||
* @param placeId DMS 房间外部空间标识
|
||||
* @return 智能门锁列表 (无锁时返回空列表,非空)
|
||||
*/
|
||||
public List<DmsDoorLockDto> listDoorLocksByPlaceId(Long placeId) {
|
||||
if (placeId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
req.put("placeId", placeId);
|
||||
|
||||
JSONObject resp = httpClient.postQuery("/rest/doorlock/list", req, JSONObject.class);
|
||||
if (resp == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (resp.containsKey("rows") && resp.get("rows") instanceof JSONArray) {
|
||||
return resp.getJSONArray("rows").toJavaList(DmsDoorLockDto.class);
|
||||
}
|
||||
if (resp.containsKey("data") && resp.get("data") instanceof JSONArray) {
|
||||
return resp.getJSONArray("data").toJavaList(DmsDoorLockDto.class);
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsAuthenticationException;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DMS 统一基础 HTTP 客户端
|
||||
* <p>
|
||||
* 核心设计准则:
|
||||
* 1. 集中管理 Base URL 与 Token 传输 (优先 Cookie 头,格式为 Cookie: token=xxx)。
|
||||
* 2. 严禁使用未核实的 Authorization: Bearer。
|
||||
* 3. 成功响应触发 tokenManager.markSuccessfulUse() 延续会话。
|
||||
* 4. 401 未授权集中废除 Token。
|
||||
* 5. 变更加写操作 (Mutating Request) 超时后绝对禁止自动盲目重发 (AMBIGUOUS_MUTATION_AUTO_RETRY = NO)。
|
||||
* 6. 全流程脱敏日志 (Sanitized Logging),绝不泄露凭据、密码明文或完整 Token。
|
||||
*/
|
||||
@Component
|
||||
public class DmsHttpClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(DmsHttpClient.class);
|
||||
|
||||
private final DmsProperties dmsProperties;
|
||||
private final DmsTokenManager tokenManager;
|
||||
|
||||
public DmsHttpClient(DmsProperties dmsProperties, DmsTokenManager tokenManager) {
|
||||
this.dmsProperties = dmsProperties;
|
||||
this.tokenManager = tokenManager;
|
||||
}
|
||||
|
||||
public DmsProperties getDmsProperties() {
|
||||
return dmsProperties;
|
||||
}
|
||||
|
||||
public DmsTokenManager getTokenManager() {
|
||||
return tokenManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 查询请求 (只读安全请求,isMutation = false)
|
||||
*/
|
||||
public <T> T postQuery(String path, Object requestBody, Class<T> responseClass) {
|
||||
return executePost(path, requestBody, responseClass, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 写操作请求 (涉及设备凭据变更,isMutation = true,读取超时绝不自动重试)
|
||||
*/
|
||||
public <T> T postMutation(String path, Object requestBody, Class<T> responseClass) {
|
||||
return executePost(path, requestBody, responseClass, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求并反序列化指定对象
|
||||
*/
|
||||
public <T> T post(String path, Object requestBody, Class<T> responseClass, boolean isMutation) {
|
||||
return executePost(path, requestBody, responseClass, isMutation, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求并直接返回 JSON 根对象
|
||||
*/
|
||||
public JSONObject postForJsonObject(String path, Object requestBody, boolean isMutation) {
|
||||
return executePost(path, requestBody, JSONObject.class, isMutation, false);
|
||||
}
|
||||
|
||||
private <T> T executePost(String path, Object requestBody, Class<T> responseClass, boolean isMutation, boolean isRetry) {
|
||||
String token = tokenManager.getToken();
|
||||
String targetUrl = buildUrl(path);
|
||||
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(targetUrl);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(dmsProperties.getConnectTimeoutMs());
|
||||
conn.setReadTimeout(dmsProperties.getReadTimeoutMs());
|
||||
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
|
||||
// DMS 官方规范:Token 传输采用 Cookie
|
||||
String cookieName = dmsProperties.getTokenCookieName() != null ? dmsProperties.getTokenCookieName() : "token";
|
||||
conn.setRequestProperty("Cookie", cookieName + "=" + token);
|
||||
|
||||
conn.setDoOutput(true);
|
||||
conn.setDoInput(true);
|
||||
|
||||
if (requestBody != null) {
|
||||
String jsonBody = JSON.toJSONString(requestBody);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
}
|
||||
} else {
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write("{}".getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
}
|
||||
}
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
InputStream is = (responseCode >= 200 && responseCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
String responseBody = readStream(is);
|
||||
|
||||
if (responseCode == 401) {
|
||||
log.warn("DMS returned 401 Unauthorized for path: {}", sanitizePath(path));
|
||||
tokenManager.invalidateToken();
|
||||
// 仅对非写操作且未重试过的请求尝试单次刷新重试
|
||||
if (!isMutation && !isRetry) {
|
||||
log.info("Attempting single safe retry after token refresh for query path: {}", sanitizePath(path));
|
||||
return executePost(path, requestBody, responseClass, isMutation, true);
|
||||
}
|
||||
throw new DmsAuthenticationException("DMS request unauthorized (HTTP 401) on path: " + sanitizePath(path));
|
||||
}
|
||||
|
||||
if (responseCode < 200 || responseCode >= 300) {
|
||||
throw new DmsTransportException("DMS HTTP error status: " + responseCode + ", path: " + sanitizePath(path) + ", response: " + sanitizeLogging(responseBody));
|
||||
}
|
||||
|
||||
JSONObject jsonObject = JSON.parseObject(responseBody);
|
||||
if (jsonObject != null && jsonObject.containsKey("success") && Boolean.FALSE.equals(jsonObject.getBoolean("success"))) {
|
||||
String code = jsonObject.getString("code");
|
||||
String message = jsonObject.getString("message");
|
||||
throw new DmsBusinessException(code != null ? code : "VENDOR_ERROR", message != null ? message : responseBody);
|
||||
}
|
||||
|
||||
// 仅在明确获得已认证且业务成功的有效 DMS 响应后,才滑动延展本地 Token 有效期
|
||||
tokenManager.markSuccessfulUse();
|
||||
|
||||
if (responseClass == null || responseClass.equals(Void.class)) {
|
||||
return null;
|
||||
}
|
||||
if (responseClass.equals(JSONObject.class)) {
|
||||
return (T) jsonObject;
|
||||
}
|
||||
if (responseClass.equals(String.class)) {
|
||||
return (T) responseBody;
|
||||
}
|
||||
|
||||
return JSON.parseObject(responseBody, responseClass);
|
||||
|
||||
} catch (SocketTimeoutException e) {
|
||||
if (isMutation) {
|
||||
// 写操作读取超时,结果未决,绝对禁止自动重发!
|
||||
log.error("DMS SocketTimeoutException on mutating request [{}]. AMBIGUOUS MUTATION: Auto-retry is strictly SUPPRESSED for safety.", sanitizePath(path));
|
||||
throw new DmsTransportException("DMS mutating request timed out; auto-retry suppressed to prevent duplicate credentials: " + e.getMessage(), e);
|
||||
} else {
|
||||
throw new DmsTransportException("DMS query read timed out on path: " + sanitizePath(path) + ": " + e.getMessage(), e);
|
||||
}
|
||||
} catch (DmsAuthenticationException | DmsBusinessException | DmsTransportException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new DmsTransportException("DMS request failed on path: " + sanitizePath(path) + ": " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String buildUrl(String path) {
|
||||
String base = dmsProperties.getBaseUrl();
|
||||
if (base == null || base.trim().isEmpty()) {
|
||||
base = "https://dms.dmsiot.com";
|
||||
}
|
||||
base = base.replaceAll("/+$", "");
|
||||
if (path == null) {
|
||||
path = "";
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
return base + path;
|
||||
}
|
||||
|
||||
private String readStream(InputStream is) {
|
||||
if (is == null) {
|
||||
return "";
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizePath(String path) {
|
||||
if (path == null) {
|
||||
return "";
|
||||
}
|
||||
return path.replaceAll("(?i)(password|token|secret)=[^&]+", "$1=[PROTECTED]");
|
||||
}
|
||||
|
||||
private String sanitizeLogging(String message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
return message
|
||||
.replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2")
|
||||
.replaceAll("(?i)(\"token\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2")
|
||||
.replaceAll("(?i)(\"appsecret\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2");
|
||||
}
|
||||
}
|
||||
+90
@@ -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();
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* DMS 门锁异步操作结果查询适配客户端
|
||||
* (POST /rest/doorlock/query-operation-status)
|
||||
*/
|
||||
@Component
|
||||
public class DmsOperationStatusClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(DmsOperationStatusClient.class);
|
||||
|
||||
private final DmsHttpClient httpClient;
|
||||
|
||||
public DmsOperationStatusClient(DmsHttpClient httpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询门锁操作结果 (安全只读查询,isMutation = false)
|
||||
*/
|
||||
public DmsOperationStatusResponse queryOperationStatus(DmsOperationStatusRequest request) {
|
||||
if (request == null || request.getOperationId() == null) {
|
||||
throw new IllegalArgumentException("DmsOperationStatusRequest and operationId must not be null");
|
||||
}
|
||||
|
||||
JSONObject resp = httpClient.postQuery("/rest/doorlock/query-operation-status", request, JSONObject.class);
|
||||
if (resp == null) {
|
||||
throw new DmsBusinessException("EMPTY_RESPONSE", "Received null response for operation status query");
|
||||
}
|
||||
|
||||
JSONObject data = resp.getJSONObject("data");
|
||||
if (data == null) {
|
||||
data = resp;
|
||||
}
|
||||
|
||||
DmsOperationStatusResponse statusResponse = new DmsOperationStatusResponse();
|
||||
statusResponse.setOperationId(data.getLong("operationId"));
|
||||
statusResponse.setOperationStatus(data.getString("operationStatus"));
|
||||
statusResponse.setResultDetails(data.getString("resultDetails"));
|
||||
statusResponse.setMessage(data.getString("message"));
|
||||
statusResponse.setHardwareId(data.getString("hardwareId"));
|
||||
|
||||
return statusResponse;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DMS 在线密码管理适配客户端 (Create / Delete)
|
||||
* <p>
|
||||
* 核心设计准则:
|
||||
* 1. 密码下发与撤销属于设备写操作 (Mutation),通过 postMutation 执行,网络读取超时绝对不自动重试。
|
||||
* 2. 区分 HTTP_ACCEPTED 与 DEVICE_SUCCESS:HTTP 200 返回仅代表 DMS 已受理异步指令并生成 operationId,
|
||||
* 不可直接标记本地 ExternalOperation 为 SUCCESS。
|
||||
* 3. 密码支持 4~8 位数字,密码明文仅在调用栈短暂留存,绝不输出到日志或持久化。
|
||||
*/
|
||||
@Component
|
||||
public class DmsPasswordClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(DmsPasswordClient.class);
|
||||
|
||||
private final DmsHttpClient httpClient;
|
||||
|
||||
public DmsPasswordClient(DmsHttpClient httpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发在线密码 (POST /rest/doorlock/online-password-key/create)
|
||||
*/
|
||||
public DmsPasswordCreateResponse createOnlinePassword(DmsPasswordCreateRequest request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("DmsPasswordCreateRequest must not be null");
|
||||
}
|
||||
if (request.getLockId() == null) {
|
||||
throw new IllegalArgumentException("lockId must not be null");
|
||||
}
|
||||
String pwd = request.getPassword();
|
||||
if (pwd == null || pwd.length() < 4 || pwd.length() > 8 || !pwd.matches("\\d+")) {
|
||||
throw new IllegalArgumentException("DMS online password must be 4 to 8 digits");
|
||||
}
|
||||
|
||||
log.info("Requesting DMS online password creation for lockId={}, holder={}, subType={}",
|
||||
request.getLockId(), request.getHolder(), request.getSubType());
|
||||
|
||||
JSONObject resp = httpClient.postMutation("/rest/doorlock/online-password-key/create", request, JSONObject.class);
|
||||
if (resp == null) {
|
||||
throw new DmsBusinessException("EMPTY_RESPONSE", "Received null response from DMS password creation");
|
||||
}
|
||||
|
||||
JSONObject data = resp.getJSONObject("data");
|
||||
if (data == null) {
|
||||
data = resp;
|
||||
}
|
||||
|
||||
DmsPasswordCreateResponse createResponse = new DmsPasswordCreateResponse();
|
||||
createResponse.setId(data.getLong("id"));
|
||||
createResponse.setOperationId(data.getLong("operationId"));
|
||||
createResponse.setLockHardwareId(data.getString("lockHardwareId"));
|
||||
createResponse.setSmsStatus(data.getInteger("smsStatus"));
|
||||
|
||||
log.info("DMS online password accepted: keyId={}, operationId={}",
|
||||
createResponse.getId(), createResponse.getOperationId());
|
||||
|
||||
return createResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除在线密码 (POST /rest/doorlock/online-password/delete)
|
||||
*/
|
||||
public DmsPasswordDeleteResponse deleteOnlinePassword(DmsPasswordDeleteRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new IllegalArgumentException("DmsPasswordDeleteRequest and keyId must not be null");
|
||||
}
|
||||
|
||||
log.info("Requesting DMS online password deletion for keyId={}", request.getId());
|
||||
|
||||
Map<String, Object> reqBody = new HashMap<>();
|
||||
reqBody.put("id", request.getId());
|
||||
|
||||
JSONObject resp = httpClient.postMutation("/rest/doorlock/online-password/delete", reqBody, JSONObject.class);
|
||||
if (resp == null) {
|
||||
throw new DmsBusinessException("EMPTY_RESPONSE", "Received null response from DMS password deletion");
|
||||
}
|
||||
|
||||
JSONObject data = resp.getJSONObject("data");
|
||||
if (data == null) {
|
||||
data = resp;
|
||||
}
|
||||
|
||||
DmsPasswordDeleteResponse deleteResponse = new DmsPasswordDeleteResponse();
|
||||
deleteResponse.setOperationId(data.getLong("operationId"));
|
||||
|
||||
log.info("DMS online password deletion accepted: operationId={}", deleteResponse.getOperationId());
|
||||
|
||||
return deleteResponse;
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.ruoyi.train.integration.dms.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* DMS 外部智能门锁系统配置属性
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "dms")
|
||||
public class DmsProperties {
|
||||
|
||||
/**
|
||||
* 是否启用 DMS 集成 (默认 false,安全默认)
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* DMS 服务端基础地址 (默认 https://dms.dmsiot.com)
|
||||
*/
|
||||
private String baseUrl = "https://dms.dmsiot.com";
|
||||
|
||||
/**
|
||||
* DMS 应用 ID (由外部环境变量或配置文件提供,严禁硬编码生产真实值)
|
||||
*/
|
||||
private String appId = "";
|
||||
|
||||
/**
|
||||
* DMS 应用密钥 (由外部环境变量或配置文件提供,严禁硬编码生产真实值)
|
||||
*/
|
||||
private String appSecret = "";
|
||||
|
||||
/**
|
||||
* DMS 关联项目 ID
|
||||
*/
|
||||
private Long projectId = 0L;
|
||||
|
||||
/**
|
||||
* HTTP 连接超时时间 (毫秒,默认 5000)
|
||||
*/
|
||||
private int connectTimeoutMs = 5000;
|
||||
|
||||
/**
|
||||
* HTTP 读取超时时间 (毫秒,默认 10000)
|
||||
*/
|
||||
private int readTimeoutMs = 10000;
|
||||
|
||||
/**
|
||||
* Token 有效期 (秒,DMS 官方标准 7200 秒)
|
||||
*/
|
||||
private int tokenTtlSeconds = 7200;
|
||||
|
||||
/**
|
||||
* Token 提前刷新时间窗口 (秒,默认提前 120 秒)
|
||||
*/
|
||||
private int tokenRefreshSkewSeconds = 120;
|
||||
|
||||
/**
|
||||
* Token Cookie 请求头字段名 (默认 "token")
|
||||
*/
|
||||
private String tokenCookieName = "token";
|
||||
|
||||
/**
|
||||
* 异步操作结果轮询初始延迟 (秒,官方推荐 10 秒)
|
||||
*/
|
||||
private int pollInitialDelaySeconds = 10;
|
||||
|
||||
/**
|
||||
* 轮询调度器间隔时间 (秒,默认 10 秒)
|
||||
*/
|
||||
private int pollIntervalSeconds = 10;
|
||||
|
||||
/**
|
||||
* 单次轮询拉取记录批次大小 (默认 50)
|
||||
*/
|
||||
private int pollBatchSize = 50;
|
||||
|
||||
/**
|
||||
* 最大重试查询次数 (默认 3)
|
||||
*/
|
||||
private int maxRetryAttempts = 3;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId;
|
||||
}
|
||||
|
||||
public void setAppId(String appId) {
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
public String getAppSecret() {
|
||||
return appSecret;
|
||||
}
|
||||
|
||||
public void setAppSecret(String appSecret) {
|
||||
this.appSecret = appSecret;
|
||||
}
|
||||
|
||||
public Long getProjectId() {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
public void setProjectId(Long projectId) {
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public int getConnectTimeoutMs() {
|
||||
return connectTimeoutMs;
|
||||
}
|
||||
|
||||
public void setConnectTimeoutMs(int connectTimeoutMs) {
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
}
|
||||
|
||||
public int getReadTimeoutMs() {
|
||||
return readTimeoutMs;
|
||||
}
|
||||
|
||||
public void setReadTimeoutMs(int readTimeoutMs) {
|
||||
this.readTimeoutMs = readTimeoutMs;
|
||||
}
|
||||
|
||||
public int getTokenTtlSeconds() {
|
||||
return tokenTtlSeconds;
|
||||
}
|
||||
|
||||
public void setTokenTtlSeconds(int tokenTtlSeconds) {
|
||||
this.tokenTtlSeconds = tokenTtlSeconds;
|
||||
}
|
||||
|
||||
public int getTokenRefreshSkewSeconds() {
|
||||
return tokenRefreshSkewSeconds;
|
||||
}
|
||||
|
||||
public void setTokenRefreshSkewSeconds(int tokenRefreshSkewSeconds) {
|
||||
this.tokenRefreshSkewSeconds = tokenRefreshSkewSeconds;
|
||||
}
|
||||
|
||||
public String getTokenCookieName() {
|
||||
return tokenCookieName;
|
||||
}
|
||||
|
||||
public void setTokenCookieName(String tokenCookieName) {
|
||||
this.tokenCookieName = tokenCookieName;
|
||||
}
|
||||
|
||||
public int getPollInitialDelaySeconds() {
|
||||
return pollInitialDelaySeconds;
|
||||
}
|
||||
|
||||
public void setPollInitialDelaySeconds(int pollInitialDelaySeconds) {
|
||||
this.pollInitialDelaySeconds = pollInitialDelaySeconds;
|
||||
}
|
||||
|
||||
public int getPollIntervalSeconds() {
|
||||
return pollIntervalSeconds;
|
||||
}
|
||||
|
||||
public void setPollIntervalSeconds(int pollIntervalSeconds) {
|
||||
this.pollIntervalSeconds = pollIntervalSeconds;
|
||||
}
|
||||
|
||||
public int getPollBatchSize() {
|
||||
return pollBatchSize;
|
||||
}
|
||||
|
||||
public void setPollBatchSize(int pollBatchSize) {
|
||||
this.pollBatchSize = pollBatchSize;
|
||||
}
|
||||
|
||||
public int getMaxRetryAttempts() {
|
||||
return maxRetryAttempts;
|
||||
}
|
||||
|
||||
public void setMaxRetryAttempts(int maxRetryAttempts) {
|
||||
this.maxRetryAttempts = maxRetryAttempts;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DMS 响应包装信封
|
||||
*/
|
||||
public class DmsBaseResponse<T> implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Boolean success;
|
||||
private String code;
|
||||
private String message;
|
||||
private T data;
|
||||
private List<T> rows;
|
||||
private Integer total;
|
||||
|
||||
public Boolean getSuccess() {
|
||||
return success != null && success;
|
||||
}
|
||||
|
||||
public void setSuccess(Boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(T data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public List<T> getRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
public void setRows(List<T> rows) {
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
public Integer getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public void setTotal(Integer total) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsBaseResponse{" +
|
||||
"success=" + success +
|
||||
", code='" + code + '\'' +
|
||||
", message='" + message + '\'' +
|
||||
", total=" + total +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 建筑信息 DTO
|
||||
*/
|
||||
public class DmsBuildingDto implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private Long projectId;
|
||||
private String name;
|
||||
private String buildingNumber;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getProjectId() {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
public void setProjectId(Long projectId) {
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getBuildingNumber() {
|
||||
return buildingNumber;
|
||||
}
|
||||
|
||||
public void setBuildingNumber(String buildingNumber) {
|
||||
this.buildingNumber = buildingNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsBuildingDto{" +
|
||||
"id=" + id +
|
||||
", projectId=" + projectId +
|
||||
", name='" + name + '\'' +
|
||||
", buildingNumber='" + buildingNumber + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+77
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 楼层信息 DTO
|
||||
*/
|
||||
public class DmsFloorDto implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private Long buildingId;
|
||||
private String name;
|
||||
private Integer floorNumber;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getBuildingId() {
|
||||
return buildingId;
|
||||
}
|
||||
|
||||
public void setBuildingId(Long buildingId) {
|
||||
this.buildingId = buildingId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Integer getFloorNumber() {
|
||||
return floorNumber;
|
||||
}
|
||||
|
||||
public void setFloorNumber(Integer floorNumber) {
|
||||
this.floorNumber = floorNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsFloorDto{" +
|
||||
"id=" + id +
|
||||
", buildingId=" + buildingId +
|
||||
", name='" + name + '\'' +
|
||||
", floorNumber=" + floorNumber +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 查询门锁操作结果请求 DTO
|
||||
*/
|
||||
public class DmsOperationStatusRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 请求操作类型 (1: 在线密码创建, 3: 在线密码删除等)
|
||||
*/
|
||||
private Integer requestType;
|
||||
|
||||
/**
|
||||
* 外部操作流水 ID
|
||||
*/
|
||||
private Long operationId;
|
||||
|
||||
/**
|
||||
* 锁硬件标识
|
||||
*/
|
||||
private String hardwareId;
|
||||
|
||||
public DmsOperationStatusRequest() {
|
||||
}
|
||||
|
||||
public DmsOperationStatusRequest(Integer requestType, Long operationId, String hardwareId) {
|
||||
this.requestType = requestType;
|
||||
this.operationId = operationId;
|
||||
this.hardwareId = hardwareId;
|
||||
}
|
||||
|
||||
public Integer getRequestType() {
|
||||
return requestType;
|
||||
}
|
||||
|
||||
public void setRequestType(Integer requestType) {
|
||||
this.requestType = requestType;
|
||||
}
|
||||
|
||||
public Long getOperationId() {
|
||||
return operationId;
|
||||
}
|
||||
|
||||
public void setOperationId(Long operationId) {
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
public String getHardwareId() {
|
||||
return hardwareId;
|
||||
}
|
||||
|
||||
public void setHardwareId(String hardwareId) {
|
||||
this.hardwareId = hardwareId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsOperationStatusRequest{" +
|
||||
"requestType=" + requestType +
|
||||
", operationId=" + operationId +
|
||||
", hardwareId='" + hardwareId + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 查询门锁操作结果响应 DTO
|
||||
*/
|
||||
public class DmsOperationStatusResponse implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long operationId;
|
||||
/**
|
||||
* 门锁操作状态: success, undone, failed, timeout
|
||||
*/
|
||||
private String operationStatus;
|
||||
private String resultDetails;
|
||||
private String message;
|
||||
private String hardwareId;
|
||||
|
||||
public Long getOperationId() {
|
||||
return operationId;
|
||||
}
|
||||
|
||||
public void setOperationId(Long operationId) {
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
public String getOperationStatus() {
|
||||
return operationStatus;
|
||||
}
|
||||
|
||||
public void setOperationStatus(String operationStatus) {
|
||||
this.operationStatus = operationStatus;
|
||||
}
|
||||
|
||||
public String getResultDetails() {
|
||||
return resultDetails;
|
||||
}
|
||||
|
||||
public void setResultDetails(String resultDetails) {
|
||||
this.resultDetails = resultDetails;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getHardwareId() {
|
||||
return hardwareId;
|
||||
}
|
||||
|
||||
public void setHardwareId(String hardwareId) {
|
||||
this.hardwareId = hardwareId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsOperationStatusResponse{" +
|
||||
"operationId=" + operationId +
|
||||
", operationStatus='" + operationStatus + '\'' +
|
||||
", resultDetails='" + resultDetails + '\'' +
|
||||
", message='" + message + '\'' +
|
||||
", hardwareId='" + hardwareId + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 下发在线密码请求 DTO
|
||||
*/
|
||||
public class DmsPasswordCreateRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long lockId;
|
||||
private String validStart;
|
||||
private String validUntil;
|
||||
private String holder;
|
||||
private Integer canOpenAntilock = 1;
|
||||
private String phoneCountry = "86";
|
||||
private String phoneNumber;
|
||||
/**
|
||||
* 在线密码明文 (4~8位数字,仅在内存短暂留存,严禁落库与输出日志)
|
||||
*/
|
||||
private String password;
|
||||
/**
|
||||
* 密码子类型: 1永久, 2单次, 4限时 (Pilot 业务主要使用 4)
|
||||
*/
|
||||
private Integer subType = 4;
|
||||
|
||||
public Long getLockId() {
|
||||
return lockId;
|
||||
}
|
||||
|
||||
public void setLockId(Long lockId) {
|
||||
this.lockId = lockId;
|
||||
}
|
||||
|
||||
public String getValidStart() {
|
||||
return validStart;
|
||||
}
|
||||
|
||||
public void setValidStart(String validStart) {
|
||||
this.validStart = validStart;
|
||||
}
|
||||
|
||||
public String getValidUntil() {
|
||||
return validUntil;
|
||||
}
|
||||
|
||||
public void setValidUntil(String validUntil) {
|
||||
this.validUntil = validUntil;
|
||||
}
|
||||
|
||||
public String getHolder() {
|
||||
return holder;
|
||||
}
|
||||
|
||||
public void setHolder(String holder) {
|
||||
this.holder = holder;
|
||||
}
|
||||
|
||||
public Integer getCanOpenAntilock() {
|
||||
return canOpenAntilock;
|
||||
}
|
||||
|
||||
public void setCanOpenAntilock(Integer canOpenAntilock) {
|
||||
this.canOpenAntilock = canOpenAntilock;
|
||||
}
|
||||
|
||||
public String getPhoneCountry() {
|
||||
return phoneCountry;
|
||||
}
|
||||
|
||||
public void setPhoneCountry(String phoneCountry) {
|
||||
this.phoneCountry = phoneCountry;
|
||||
}
|
||||
|
||||
public String getPhoneNumber() {
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
public void setPhoneNumber(String phoneNumber) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public Integer getSubType() {
|
||||
return subType;
|
||||
}
|
||||
|
||||
public void setSubType(Integer subType) {
|
||||
this.subType = subType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
String maskedPhone = "[PROTECTED]";
|
||||
if (phoneNumber != null && phoneNumber.length() >= 7) {
|
||||
maskedPhone = phoneNumber.substring(0, 3) + "****" + phoneNumber.substring(phoneNumber.length() - 4);
|
||||
}
|
||||
return "DmsPasswordCreateRequest{" +
|
||||
"lockId=" + lockId +
|
||||
", validStart='" + validStart + '\'' +
|
||||
", validUntil='" + validUntil + '\'' +
|
||||
", holder='" + holder + '\'' +
|
||||
", canOpenAntilock=" + canOpenAntilock +
|
||||
", phoneNumber='" + maskedPhone + '\'' +
|
||||
", password='[PROTECTED]'" +
|
||||
", subType=" + subType +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 下发在线密码响应 DTO
|
||||
*/
|
||||
public class DmsPasswordCreateResponse implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 钥匙 ID (对应外部 external_key_id)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 异步操作流水 ID (对应外部 external_operation_id)
|
||||
*/
|
||||
private Long operationId;
|
||||
|
||||
/**
|
||||
* 锁硬件标识
|
||||
*/
|
||||
private String lockHardwareId;
|
||||
|
||||
/**
|
||||
* 短信发送状态
|
||||
*/
|
||||
private Integer smsStatus;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getOperationId() {
|
||||
return operationId;
|
||||
}
|
||||
|
||||
public void setOperationId(Long operationId) {
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
public String getLockHardwareId() {
|
||||
return lockHardwareId;
|
||||
}
|
||||
|
||||
public void setLockHardwareId(String lockHardwareId) {
|
||||
this.lockHardwareId = lockHardwareId;
|
||||
}
|
||||
|
||||
public Integer getSmsStatus() {
|
||||
return smsStatus;
|
||||
}
|
||||
|
||||
public void setSmsStatus(Integer smsStatus) {
|
||||
this.smsStatus = smsStatus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsPasswordCreateResponse{" +
|
||||
"id=" + id +
|
||||
", operationId=" + operationId +
|
||||
", lockHardwareId='" + lockHardwareId + '\'' +
|
||||
", smsStatus=" + smsStatus +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 删除在线密码请求 DTO
|
||||
*/
|
||||
public class DmsPasswordDeleteRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 钥匙 ID (对应外部 external_key_id)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
public DmsPasswordDeleteRequest() {
|
||||
}
|
||||
|
||||
public DmsPasswordDeleteRequest(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsPasswordDeleteRequest{" +
|
||||
"id=" + id +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 删除在线密码响应 DTO
|
||||
*/
|
||||
public class DmsPasswordDeleteResponse implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 异步操作流水 ID
|
||||
*/
|
||||
private Long operationId;
|
||||
|
||||
public Long getOperationId() {
|
||||
return operationId;
|
||||
}
|
||||
|
||||
public void setOperationId(Long operationId) {
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsPasswordDeleteResponse{" +
|
||||
"operationId=" + operationId +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+77
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 平台登录请求 DTO
|
||||
*/
|
||||
public class DmsTokenRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String appid;
|
||||
private String appsecret;
|
||||
|
||||
public DmsTokenRequest() {
|
||||
}
|
||||
|
||||
public DmsTokenRequest(String appid, String appsecret) {
|
||||
this.appid = appid;
|
||||
this.appsecret = appsecret;
|
||||
}
|
||||
|
||||
public String getAppid() {
|
||||
return appid;
|
||||
}
|
||||
|
||||
public void setAppid(String appid) {
|
||||
this.appid = appid;
|
||||
}
|
||||
|
||||
public String getAppsecret() {
|
||||
return appsecret;
|
||||
}
|
||||
|
||||
public void setAppsecret(String appsecret) {
|
||||
this.appsecret = appsecret;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DmsTokenRequest{" +
|
||||
"appid='" + (appid != null ? (appid.length() > 4 ? appid.substring(0, 4) + "***" : "***") : "null") + '\'' +
|
||||
", appsecret='[PROTECTED]'" +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.train.integration.dms.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* DMS 平台登录响应 DTO
|
||||
*/
|
||||
public class DmsTokenResponse implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String token;
|
||||
private Long expiresIn;
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public Long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public void setExpiresIn(Long expiresIn) {
|
||||
this.expiresIn = expiresIn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
String maskedToken = "[PROTECTED]";
|
||||
if (token != null && token.length() > 8) {
|
||||
maskedToken = token.substring(0, 4) + "***" + token.substring(token.length() - 4);
|
||||
}
|
||||
return "DmsTokenResponse{" +
|
||||
"token='" + maskedToken + '\'' +
|
||||
", expiresIn=" + expiresIn +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.train.integration.dms.exception;
|
||||
|
||||
/**
|
||||
* DMS 认证与令牌异常 (HTTP 401, 凭据错误, 令牌失效)
|
||||
*/
|
||||
public class DmsAuthenticationException extends DmsException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public DmsAuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DmsAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.ruoyi.train.integration.dms.exception;
|
||||
|
||||
/**
|
||||
* DMS 业务级错误异常 (DMS 返回 success=false 或特定业务拒绝)
|
||||
*/
|
||||
public class DmsBusinessException extends DmsException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String errorCode;
|
||||
|
||||
public DmsBusinessException(String errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public DmsBusinessException(String errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.train.integration.dms.exception;
|
||||
|
||||
/**
|
||||
* DMS 外部集成异常基类
|
||||
*/
|
||||
public class DmsException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public DmsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DmsException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.train.integration.dms.exception;
|
||||
|
||||
/**
|
||||
* DMS 网络传输异常 (连接超时, 读取超时, 网络中断)
|
||||
*/
|
||||
public class DmsTransportException extends DmsException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public DmsTransportException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DmsTransportException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.dms.client.DmsPasswordClient;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* DMS 门禁凭据网关实现
|
||||
*/
|
||||
@Component
|
||||
public class DmsCredentialGatewayImpl implements ExternalCredentialGateway {
|
||||
|
||||
private final DmsPasswordClient passwordClient;
|
||||
|
||||
public DmsCredentialGatewayImpl(DmsPasswordClient passwordClient) {
|
||||
this.passwordClient = passwordClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DmsPasswordCreateResponse issueOnlinePassword(Long lockId, String holder, String phoneNumber, String password, String validStart, String validUntil) {
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setLockId(lockId);
|
||||
req.setHolder(holder);
|
||||
req.setPhoneNumber(phoneNumber);
|
||||
req.setPassword(password);
|
||||
req.setValidStart(validStart);
|
||||
req.setValidUntil(validUntil);
|
||||
req.setSubType(4); // 限时密码
|
||||
return passwordClient.createOnlinePassword(req);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DmsPasswordDeleteResponse revokeOnlinePassword(Long externalKeyId) {
|
||||
DmsPasswordDeleteRequest req = new DmsPasswordDeleteRequest(externalKeyId);
|
||||
return passwordClient.deleteOnlinePassword(req);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.dms.client.DmsDoorLockClient;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DMS 智能门锁网关实现
|
||||
*/
|
||||
@Component
|
||||
public class DmsDoorLockGatewayImpl implements ExternalDoorLockGateway {
|
||||
|
||||
private final DmsDoorLockClient doorLockClient;
|
||||
|
||||
public DmsDoorLockGatewayImpl(DmsDoorLockClient doorLockClient) {
|
||||
this.doorLockClient = doorLockClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DmsDoorLockDto> listDoorLocksByPlaceId(Long placeId) {
|
||||
return doorLockClient.listDoorLocksByPlaceId(placeId);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.dms.client.DmsLocationClient;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsBuildingDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsFloorDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsRoomDto;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DMS 位置网关实现
|
||||
*/
|
||||
@Component
|
||||
public class DmsLocationGatewayImpl implements ExternalLocationGateway {
|
||||
|
||||
private final DmsLocationClient locationClient;
|
||||
|
||||
public DmsLocationGatewayImpl(DmsLocationClient locationClient) {
|
||||
this.locationClient = locationClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DmsBuildingDto> listBuildings(Long projectId) {
|
||||
return locationClient.listBuildings(projectId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DmsFloorDto> listFloors(Long buildingId) {
|
||||
return locationClient.listFloors(buildingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DmsRoomDto> listRooms(Long floorId) {
|
||||
return locationClient.listRooms(floorId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DmsRoomDto getRoom(Long roomId) {
|
||||
return locationClient.getRoom(roomId);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
|
||||
/**
|
||||
* DMS 外部操作状态集中映射器
|
||||
* <p>
|
||||
* 将 DMS 供应商的 operationStatus 映射到 RailwaySystem 本地 ExternalOperation 状态模型:
|
||||
* success -> SUCCESS
|
||||
* undone -> PENDING
|
||||
* failed -> FAILED
|
||||
* timeout -> TIMEOUT
|
||||
* 未知状态 -> UNKNOWN (严禁自动判定为 SUCCESS)
|
||||
*/
|
||||
public class DmsOperationStatusMapper {
|
||||
|
||||
public static final String VENDOR_STATUS_SUCCESS = "success";
|
||||
public static final String VENDOR_STATUS_UNDONE = "undone";
|
||||
public static final String VENDOR_STATUS_FAILED = "failed";
|
||||
public static final String VENDOR_STATUS_TIMEOUT = "timeout";
|
||||
|
||||
public static final String LOCAL_STATUS_UNKNOWN = "UNKNOWN";
|
||||
|
||||
/**
|
||||
* 将供应商状态字符串统一转换为本地状态
|
||||
*/
|
||||
public static String mapToLocalStatus(String vendorStatus) {
|
||||
if (vendorStatus == null || vendorStatus.trim().isEmpty()) {
|
||||
return LOCAL_STATUS_UNKNOWN;
|
||||
}
|
||||
String normalized = vendorStatus.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case VENDOR_STATUS_SUCCESS:
|
||||
return TrainExternalOperation.STATUS_SUCCESS;
|
||||
case VENDOR_STATUS_UNDONE:
|
||||
return TrainExternalOperation.STATUS_PENDING;
|
||||
case VENDOR_STATUS_FAILED:
|
||||
return TrainExternalOperation.STATUS_FAILED;
|
||||
case VENDOR_STATUS_TIMEOUT:
|
||||
return TrainExternalOperation.STATUS_TIMEOUT;
|
||||
default:
|
||||
return LOCAL_STATUS_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断本地状态是否为终态 (终态不再轮询、不可篡改)
|
||||
*/
|
||||
public static boolean isTerminalStatus(String localStatus) {
|
||||
if (localStatus == null) {
|
||||
return false;
|
||||
}
|
||||
return TrainExternalOperation.STATUS_SUCCESS.equals(localStatus) ||
|
||||
TrainExternalOperation.STATUS_FAILED.equals(localStatus) ||
|
||||
TrainExternalOperation.STATUS_TIMEOUT.equals(localStatus);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
|
||||
/**
|
||||
* 外部门禁凭据网关接口 (当前 Pilot 凭据为在线密码)
|
||||
*/
|
||||
public interface ExternalCredentialGateway {
|
||||
|
||||
DmsPasswordCreateResponse issueOnlinePassword(Long lockId, String holder, String phoneNumber, String password, String validStart, String validUntil);
|
||||
|
||||
DmsPasswordDeleteResponse revokeOnlinePassword(Long externalKeyId);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 外部智能门锁网关接口
|
||||
*/
|
||||
public interface ExternalDoorLockGateway {
|
||||
|
||||
List<DmsDoorLockDto> listDoorLocksByPlaceId(Long placeId);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.train.integration.dms.gateway;
|
||||
|
||||
import com.ruoyi.train.integration.dms.dto.DmsBuildingDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsFloorDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsRoomDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 外部位置资源网关接口
|
||||
*/
|
||||
public interface ExternalLocationGateway {
|
||||
|
||||
List<DmsBuildingDto> listBuildings(Long projectId);
|
||||
|
||||
List<DmsFloorDto> listFloors(Long buildingId);
|
||||
|
||||
List<DmsRoomDto> listRooms(Long floorId);
|
||||
|
||||
DmsRoomDto getRoom(Long roomId);
|
||||
}
|
||||
+273
@@ -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");
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.ruoyi.train.integration.dms;
|
||||
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.client.DmsHttpClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsPasswordClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import com.ruoyi.train.integration.dms.poller.DmsOperationPoller;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 异步生命周期语义 (Accepted != Success) 与凭据隐私安全测试
|
||||
*/
|
||||
public class DmsAsyncLifecycleAndSecurityTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsPasswordClient passwordClient;
|
||||
private DmsOperationPoller poller;
|
||||
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
passwordClient = new DmsPasswordClient(httpClient);
|
||||
DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
|
||||
|
||||
IExternalOperationService mockOpService = createMockOperationService();
|
||||
poller = new DmsOperationPoller(properties, mockOpService, statusClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
operationDb.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键验证:ACCEPTED != SUCCESS (HTTP 200 仅代表受理,绝不等于设备执行成功)
|
||||
* 只有后续 Poller 轮询 DMS 返回 success,才转换为 SUCCESS。
|
||||
*/
|
||||
@Test
|
||||
public void testAcceptedNotEqualsSuccess_LifecycleVerification() {
|
||||
// 1. 发起创建密码请求 (使用短暂内存明文密码)
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setLockId(5001L);
|
||||
req.setHolder("王学员");
|
||||
req.setPhoneNumber("13911112222");
|
||||
req.setPassword("567890");
|
||||
|
||||
DmsPasswordCreateResponse createResp = passwordClient.createOnlinePassword(req);
|
||||
assertNotNull(createResp);
|
||||
assertEquals(Long.valueOf(99001L), createResp.getOperationId());
|
||||
|
||||
// 2. 本地记录操作流水:初始状态必须为 ACCEPTED,绝不可直接置为 SUCCESS!
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(201L);
|
||||
op.setExternalOperationId(String.valueOf(createResp.getOperationId()));
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
operationDb.put(201L, op);
|
||||
|
||||
// 断言:HTTP 成功后,本地操作状态绝对不是 SUCCESS
|
||||
assertNotEquals("Initial status after HTTP accepted MUST NOT be SUCCESS",
|
||||
TrainExternalOperation.STATUS_SUCCESS, op.getOperationStatus());
|
||||
assertEquals("Initial status MUST be ACCEPTED",
|
||||
TrainExternalOperation.STATUS_ACCEPTED, op.getOperationStatus());
|
||||
|
||||
// 3. 初始时 Mock 服务端返回 undone (门锁硬件正在联网对账)
|
||||
mockServer.setOperationStatus(99001L, "undone", "Queued in gateway buffer");
|
||||
poller.pollPendingOperations();
|
||||
assertEquals("When vendor status is undone, local status must remain PENDING",
|
||||
TrainExternalOperation.STATUS_PENDING, operationDb.get(201L).getOperationStatus());
|
||||
|
||||
// 4. 设备真正下发成功后,Mock 返回 success
|
||||
mockServer.setOperationStatus(99001L, "success", "Lock accepted password key");
|
||||
poller.pollPendingOperations();
|
||||
|
||||
// 断言:轮询确认后,最终状态达成 SUCCESS
|
||||
assertEquals("Final status after successful poll MUST be SUCCESS",
|
||||
TrainExternalOperation.STATUS_SUCCESS, operationDb.get(201L).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据零泄露测试:反射检查 TrainExternalOperation 与 TrainDormAuth,
|
||||
* 确认不存在任何明文密码、密钥或卡号字段 (PLAINTEXT_PASSWORD_PERSISTENCE_COUNT = 0)
|
||||
*/
|
||||
@Test
|
||||
public void testCredentialDataLeak_ZeroPlaintextStorage() {
|
||||
// 检查 TrainExternalOperation
|
||||
for (Field f : TrainExternalOperation.class.getDeclaredFields()) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("TrainExternalOperation must not have password field: " + name,
|
||||
name.contains("password") || name.contains("passwd"));
|
||||
assertFalse("TrainExternalOperation must not have token field: " + name,
|
||||
name.equals("token") || name.equals("appsecret"));
|
||||
assertFalse("TrainExternalOperation must not have card_no field: " + name,
|
||||
name.contains("cardno") || name.contains("card_no"));
|
||||
}
|
||||
|
||||
// 检查 TrainDormAuth
|
||||
for (Field f : TrainDormAuth.class.getDeclaredFields()) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("TrainDormAuth must not have password field: " + name,
|
||||
name.contains("password") || name.contains("passwd"));
|
||||
assertFalse("TrainDormAuth must not have secret field: " + name,
|
||||
name.contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
private IExternalOperationService createMockOperationService() {
|
||||
return (IExternalOperationService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalOperationService.class},
|
||||
(proxy, method, args) -> {
|
||||
String methodName = method.getName();
|
||||
if ("listPendingPollOperations".equals(methodName)) {
|
||||
List<TrainExternalOperation> list = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (!TrainExternalOperation.STATUS_SUCCESS.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_FAILED.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_TIMEOUT.equals(op.getOperationStatus())) {
|
||||
list.add(op);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} else if ("updateOperationPollResult".equals(methodName)) {
|
||||
Long opId = (Long) args[0];
|
||||
String newStatus = (String) args[1];
|
||||
Date nextPoll = (Date) args[2];
|
||||
Integer attemptCount = (Integer) args[3];
|
||||
String lastErrorCode = (String) args[4];
|
||||
String lastErrorMessage = (String) args[5];
|
||||
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus(newStatus);
|
||||
if (nextPoll != null) op.setNextPollTime(nextPoll);
|
||||
if (attemptCount != null) op.setAttemptCount(attemptCount);
|
||||
if (lastErrorCode != null) op.setLastErrorCode(lastErrorCode);
|
||||
if (lastErrorMessage != null) op.setLastErrorMessage(lastErrorMessage);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package com.ruoyi.train.integration.dms.auth;
|
||||
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS Token 管理器核心并发与单飞登录测试
|
||||
*/
|
||||
public class DmsTokenManagerTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsTokenManager tokenManager;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setTokenTtlSeconds(7200);
|
||||
properties.setTokenRefreshSkewSeconds(120);
|
||||
|
||||
tokenManager = new DmsTokenManager(properties);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 20 个并发线程同时获取 Token,单飞互斥锁保证仅发起 1 次远程登录,所有线程获取相同 Token
|
||||
*/
|
||||
@Test
|
||||
public void testTokenConcurrency_SingleFlightLogin() throws Exception {
|
||||
int threadCount = 20;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch finishLatch = new CountDownLatch(threadCount);
|
||||
|
||||
Set<String> tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
String token = tokenManager.getToken();
|
||||
tokenResults.add(token);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
finishLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
boolean completed = finishLatch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertTrue("All threads should complete within timeout", completed);
|
||||
assertEquals("Mock server should receive exactly 1 login request", 1, mockServer.getLoginCounter());
|
||||
assertEquals("TokenManager loginCounter should be exactly 1", 1, tokenManager.getLoginCounter());
|
||||
assertEquals("All 20 threads should receive the exact same token", 1, tokenResults.size());
|
||||
assertTrue("Token should start with mock-token", tokenResults.iterator().next().startsWith("mock-token"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Token 提前刷新 (Refresh Skew):时间推进到刷新窗口内,并发请求单飞刷新新 Token,旧 Token 被废弃
|
||||
*/
|
||||
@Test
|
||||
public void testTokenRefresh_SingleFlightRefresh() throws Exception {
|
||||
Instant baseInstant = Instant.parse("2026-09-16T10:00:00Z");
|
||||
tokenManager.setClock(Clock.fixed(baseInstant, ZoneId.of("UTC")));
|
||||
|
||||
// 首次获取 Token
|
||||
String token1 = tokenManager.getToken();
|
||||
assertNotNull(token1);
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 推进时间到 7200 - 60 秒 (已进入提前 120 秒的刷新窗口)
|
||||
Instant nearExpiry = baseInstant.plusSeconds(7200 - 60);
|
||||
tokenManager.setClock(Clock.fixed(nearExpiry, ZoneId.of("UTC")));
|
||||
|
||||
int threadCount = 20;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch finishLatch = new CountDownLatch(threadCount);
|
||||
Set<String> tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
String token = tokenManager.getToken();
|
||||
tokenResults.add(token);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
finishLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
boolean completed = finishLatch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertTrue(completed);
|
||||
assertEquals("Mock server should receive exactly 2 login requests in total", 2, mockServer.getLoginCounter());
|
||||
assertEquals("All refreshed threads should receive the new identical token", 1, tokenResults.size());
|
||||
|
||||
String token2 = tokenResults.iterator().next();
|
||||
assertNotEquals("New token must be different from old expired token", token1, token2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试成功调用 API 后滑动延展本地有效期限 (markSuccessfulUse)
|
||||
*/
|
||||
@Test
|
||||
public void testSuccessfulApiExtendsLocalTtl() {
|
||||
Instant baseInstant = Instant.parse("2026-09-16T10:00:00Z");
|
||||
tokenManager.setClock(Clock.fixed(baseInstant, ZoneId.of("UTC")));
|
||||
|
||||
String token1 = tokenManager.getToken();
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 推进时间 1 小时 (3600 秒)
|
||||
Instant afterOneHour = baseInstant.plusSeconds(3600);
|
||||
tokenManager.setClock(Clock.fixed(afterOneHour, ZoneId.of("UTC")));
|
||||
|
||||
// 模拟成功调用 API 触发延展
|
||||
tokenManager.markSuccessfulUse();
|
||||
|
||||
// 再次推进到原定过期的第 7150 秒 (距初始 7200 秒只剩 50 秒,若未延展本该进入 120s 刷新窗口)
|
||||
// 但由于在第 3600 秒延展了 7200 秒,新的过期时间是 3600 + 7200 = 10800 秒,此时依然有效!
|
||||
Instant checkInstant = baseInstant.plusSeconds(7150);
|
||||
tokenManager.setClock(Clock.fixed(checkInstant, ZoneId.of("UTC")));
|
||||
|
||||
String tokenAfterCheck = tokenManager.getToken();
|
||||
assertEquals("Should reuse same token without triggering new login", token1, tokenAfterCheck);
|
||||
assertEquals("No new login should be triggered due to sliding extension", 1, mockServer.getLoginCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 401 故障自愈与集中重登录 (无 login storm)
|
||||
*/
|
||||
@Test
|
||||
public void testAuthFailureRecovery_NoLoginStorm() throws Exception {
|
||||
String token1 = tokenManager.getToken();
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 模拟服务端使当前 Token 失效
|
||||
mockServer.invalidateActiveToken();
|
||||
|
||||
// 本地感知到 401 并调用 invalidateToken
|
||||
tokenManager.invalidateToken();
|
||||
|
||||
// 20 个并发线程同时发起获取 Token
|
||||
int threadCount = 20;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch finishLatch = new CountDownLatch(threadCount);
|
||||
Set<String> tokenResults = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startLatch.await();
|
||||
String token = tokenManager.getToken();
|
||||
tokenResults.add(token);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
finishLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startLatch.countDown();
|
||||
finishLatch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertEquals("Mock server should have exactly 2 logins (1 initial + 1 recovered)", 2, mockServer.getLoginCounter());
|
||||
assertEquals("All threads should get the recovered token", 1, tokenResults.size());
|
||||
assertNotEquals(token1, tokenResults.iterator().next());
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsDoorLockDto;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsDoorLockGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalDoorLockGateway;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 智能门锁适配器测试 (0 把锁与多把锁测试)
|
||||
*/
|
||||
public class DmsDoorLockAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private ExternalDoorLockGateway doorLockGateway;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsDoorLockClient doorLockClient = new DmsDoorLockClient(httpClient);
|
||||
doorLockGateway = new DmsDoorLockGatewayImpl(doorLockClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 0 把锁场景:正常返回空列表,非 NPE,不自动创建假锁 (ZERO_LOCK_RESULT = EMPTY_LIST)
|
||||
*/
|
||||
@Test
|
||||
public void testZeroLock_ReturnsEmptyList() {
|
||||
List<DmsDoorLockDto> locks = doorLockGateway.listDoorLocksByPlaceId(9999L);
|
||||
assertNotNull("Must return non-null list for zero-lock room", locks);
|
||||
assertTrue("Lock list must be empty", locks.isEmpty());
|
||||
assertEquals(0, locks.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多把锁场景:完整返回全部 3 把门锁,绝不截断只取第 1 把 (MULTI_LOCK_RESULT_COUNT = 3)
|
||||
*/
|
||||
@Test
|
||||
public void testMultiLock_ReturnsAllLocks() {
|
||||
List<DmsDoorLockDto> locks = doorLockGateway.listDoorLocksByPlaceId(10001L);
|
||||
assertNotNull(locks);
|
||||
assertEquals("Must return all 3 locks configured for placeId 10001", 3, locks.size());
|
||||
|
||||
// 校验 3 把锁各自的属性独立性
|
||||
assertEquals(Long.valueOf(5001L), locks.get(0).getLockId());
|
||||
assertEquals("LK-HD-10001-1", locks.get(0).getHardwareId());
|
||||
|
||||
assertEquals(Long.valueOf(5002L), locks.get(1).getLockId());
|
||||
assertEquals("LK-HD-10001-2", locks.get(1).getHardwareId());
|
||||
|
||||
assertEquals(Long.valueOf(5003L), locks.get(2).getLockId());
|
||||
assertEquals("LK-HD-10001-3", locks.get(2).getHardwareId());
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 基础 HTTP 客户端传输与重试安全测试
|
||||
*/
|
||||
public class DmsHttpClientTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsTokenManager tokenManager;
|
||||
private DmsHttpClient httpClient;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setConnectTimeoutMs(1000);
|
||||
properties.setReadTimeoutMs(500); // 设为 500ms 短读取超时用于测试超时拦截
|
||||
properties.setTokenCookieName("token");
|
||||
|
||||
tokenManager = new DmsTokenManager(properties);
|
||||
httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证写操作读取超时绝对不自动盲目重试 (AMBIGUOUS_MUTATION_AUTO_RETRY = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testMutationAmbiguity_NoAutoRetryOnTimeout() {
|
||||
mockServer.setSimulateReadTimeout(true);
|
||||
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setLockId(5001L);
|
||||
req.setPassword("123456");
|
||||
req.setHolder("张三");
|
||||
req.setPhoneNumber("13800000000");
|
||||
|
||||
try {
|
||||
httpClient.postMutation("/rest/doorlock/online-password-key/create", req, JSONObject.class);
|
||||
fail("Should throw DmsTransportException on read timeout");
|
||||
} catch (DmsTransportException e) {
|
||||
assertTrue("Exception message should indicate timeout and auto-retry suppression",
|
||||
e.getMessage().contains("timed out") || e.getMessage().contains("auto-retry suppressed"));
|
||||
}
|
||||
|
||||
// 核心安全指标:虽然发生超时,但 POST 请求次数必须严格为 1!绝对没有自动发起第 2 次 POST!
|
||||
assertEquals("Mutation POST count must be strictly 1, NO auto-retry allowed",
|
||||
1, mockServer.getPasswordCreatePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token 传输格式为 Cookie: token=...,禁止使用 Authorization: Bearer
|
||||
*/
|
||||
@Test
|
||||
public void testTokenTransport_CookieHeader() {
|
||||
// 执行一次安全查询
|
||||
JSONObject resp = httpClient.postQuery("/rest/place/building/list", new JSONObject(), JSONObject.class);
|
||||
assertNotNull(resp);
|
||||
assertTrue(resp.getBoolean("success"));
|
||||
assertEquals("Token cookie name should be token", "token", properties.getTokenCookieName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证敏感信息脱敏 (密码明文、Token、Secret 均被过滤)
|
||||
*/
|
||||
@Test
|
||||
public void testSensitiveDataRedaction() {
|
||||
DmsPasswordCreateRequest req = new DmsPasswordCreateRequest();
|
||||
req.setPhoneNumber("13812345678");
|
||||
req.setPassword("987654");
|
||||
|
||||
String toStringOutput = req.toString();
|
||||
assertFalse("toString must not contain plaintext password", toStringOutput.contains("987654"));
|
||||
assertTrue("toString should protect password", toStringOutput.contains("[PROTECTED]"));
|
||||
assertFalse("toString must mask middle phone digits", toStringOutput.contains("1234"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证删除操作读取超时绝对不自动盲目重试 (PASSWORD_DELETE_AMBIGUOUS_AUTO_RETRY = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testPasswordDeleteAmbiguity_NoAutoRetryOnTimeout() {
|
||||
mockServer.setSimulateReadTimeout(true);
|
||||
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("lockId", 5001L);
|
||||
req.put("operationId", 99002L);
|
||||
|
||||
try {
|
||||
httpClient.postMutation("/rest/doorlock/online-password/delete", req, JSONObject.class);
|
||||
fail("Should throw DmsTransportException on read timeout for delete mutation");
|
||||
} catch (DmsTransportException e) {
|
||||
assertTrue("Exception message should indicate timeout and auto-retry suppression",
|
||||
e.getMessage().contains("timed out") || e.getMessage().contains("auto-retry suppressed"));
|
||||
}
|
||||
|
||||
// 核心安全指标:delete 操作即使超时,POST 请求次数必须严格为 1!禁止自动重发!
|
||||
assertEquals("Password delete mutation POST count must be strictly 1, NO auto-retry allowed",
|
||||
1, mockServer.getPasswordDeletePostCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证业务失败或异常响应绝对不延展本地 Token TTL (FAILED_CALL_EXTENDS_LOCAL_TTL = NO)
|
||||
*/
|
||||
@Test
|
||||
public void testFailedCallDoesNotExtendLocalTtl() {
|
||||
// 先正常获取 Token
|
||||
String token = tokenManager.getToken();
|
||||
assertNotNull(token);
|
||||
assertEquals(1, mockServer.getLoginCounter());
|
||||
|
||||
// 推进时间 1 小时
|
||||
java.time.Instant baseInstant = java.time.Instant.parse("2026-09-16T10:00:00Z");
|
||||
tokenManager.setClock(java.time.Clock.fixed(baseInstant, java.time.ZoneId.of("UTC")));
|
||||
tokenManager.markSuccessfulUse(); // 此时有效期至 10:00 + 7200s = 12:00
|
||||
|
||||
// 推进到 11:30
|
||||
java.time.Instant at1130 = baseInstant.plusSeconds(5400);
|
||||
tokenManager.setClock(java.time.Clock.fixed(at1130, java.time.ZoneId.of("UTC")));
|
||||
|
||||
// 模拟未认证请求导致 401 失败
|
||||
mockServer.invalidateActiveToken();
|
||||
try {
|
||||
httpClient.postMutation("/rest/doorlock/online-password-key/create", new JSONObject(), JSONObject.class);
|
||||
fail("Should fail with DmsAuthenticationException");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 验证失败后,Token 已被清除/未被延长为 11:30 + 7200s
|
||||
// 重新获取会触发新的 login,而不是错误地信任旧 Token
|
||||
assertEquals("Mock server should have recorded second login or invalidated", 1, mockServer.getLoginCounter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 dms.enabled=false 启动安全,零网络请求
|
||||
*/
|
||||
@Test
|
||||
public void testStartupNetworkSafetyWhenDisabled() {
|
||||
DmsProperties disabledProps = new DmsProperties();
|
||||
disabledProps.setEnabled(false);
|
||||
disabledProps.setBaseUrl(mockServer.getBaseUrl());
|
||||
disabledProps.setAppId("disabled-app-id");
|
||||
disabledProps.setAppSecret("disabled-app-secret");
|
||||
|
||||
int initialLogins = mockServer.getLoginCounter();
|
||||
|
||||
// 构造相关组件
|
||||
DmsTokenManager disabledTokenManager = new DmsTokenManager(disabledProps);
|
||||
DmsHttpClient disabledClient = new DmsHttpClient(disabledProps, disabledTokenManager);
|
||||
|
||||
// 确认构造阶段没有发起任何网络请求
|
||||
assertEquals("Disabled DMS must not initiate any login/network requests during startup",
|
||||
initialLogins, mockServer.getLoginCounter());
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsBuildingDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsFloorDto;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsRoomDto;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsLocationGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalLocationGateway;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 位置适配器测试 (Building / Floor / Room)
|
||||
*/
|
||||
public class DmsLocationAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private ExternalLocationGateway locationGateway;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsLocationClient locationClient = new DmsLocationClient(httpClient);
|
||||
locationGateway = new DmsLocationGatewayImpl(locationClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLocationLookup_FullHierarchy() {
|
||||
// 1. 查询建筑
|
||||
List<DmsBuildingDto> buildings = locationGateway.listBuildings(1L);
|
||||
assertNotNull(buildings);
|
||||
assertFalse(buildings.isEmpty());
|
||||
assertEquals("学员1号楼", buildings.get(0).getName());
|
||||
|
||||
// 2. 查询楼层
|
||||
List<DmsFloorDto> floors = locationGateway.listFloors(buildings.get(0).getId());
|
||||
assertNotNull(floors);
|
||||
assertFalse(floors.isEmpty());
|
||||
assertEquals("二层", floors.get(0).getName());
|
||||
|
||||
// 3. 查询房间
|
||||
List<DmsRoomDto> rooms = locationGateway.listRooms(floors.get(0).getId());
|
||||
assertNotNull(rooms);
|
||||
assertFalse(rooms.isEmpty());
|
||||
assertEquals("201", rooms.get(0).getRoomNumber());
|
||||
assertEquals(Long.valueOf(10001L), rooms.get(0).getPlaceId());
|
||||
|
||||
// 4. 获取房间详情
|
||||
DmsRoomDto roomDetail = locationGateway.getRoom(rooms.get(0).getId());
|
||||
assertNotNull(roomDetail);
|
||||
assertEquals(Long.valueOf(10001L), roomDetail.getPlaceId());
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusRequest;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsOperationStatusResponse;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsOperationStatusMapper;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 门锁操作结果查询与状态映射器测试
|
||||
*/
|
||||
public class DmsOperationStatusAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsOperationStatusClient statusClient;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
statusClient = new DmsOperationStatusClient(httpClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testQueryOperationStatus_FromMock() {
|
||||
mockServer.setOperationStatus(99001L, "success", "Doorlock synced successfully");
|
||||
|
||||
DmsOperationStatusRequest req = new DmsOperationStatusRequest(1, 99001L, "LK-HD-101");
|
||||
DmsOperationStatusResponse resp = statusClient.queryOperationStatus(req);
|
||||
|
||||
assertNotNull(resp);
|
||||
assertEquals(Long.valueOf(99001L), resp.getOperationId());
|
||||
assertEquals("success", resp.getOperationStatus());
|
||||
assertEquals("Doorlock synced successfully", resp.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStatusMapper_AllKnownTransitions() {
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, DmsOperationStatusMapper.mapToLocalStatus("success"));
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, DmsOperationStatusMapper.mapToLocalStatus("SUCCESS"));
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_PENDING, DmsOperationStatusMapper.mapToLocalStatus("undone"));
|
||||
assertEquals(TrainExternalOperation.STATUS_PENDING, DmsOperationStatusMapper.mapToLocalStatus("UNDONE"));
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_FAILED, DmsOperationStatusMapper.mapToLocalStatus("failed"));
|
||||
assertEquals(TrainExternalOperation.STATUS_FAILED, DmsOperationStatusMapper.mapToLocalStatus("FAILED"));
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_TIMEOUT, DmsOperationStatusMapper.mapToLocalStatus("timeout"));
|
||||
assertEquals(TrainExternalOperation.STATUS_TIMEOUT, DmsOperationStatusMapper.mapToLocalStatus("TIMEOUT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStatusMapper_UnknownStatusNeverSuccess() {
|
||||
String unknownResult = DmsOperationStatusMapper.mapToLocalStatus("weird_vendor_state");
|
||||
assertEquals("UNKNOWN", unknownResult);
|
||||
assertNotEquals(TrainExternalOperation.STATUS_SUCCESS, unknownResult);
|
||||
|
||||
assertEquals("UNKNOWN", DmsOperationStatusMapper.mapToLocalStatus(null));
|
||||
assertEquals("UNKNOWN", DmsOperationStatusMapper.mapToLocalStatus(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStatusMapper_TerminalStatusIdentification() {
|
||||
assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_SUCCESS));
|
||||
assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_FAILED));
|
||||
assertTrue(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_TIMEOUT));
|
||||
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_PENDING));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_CREATED));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_RETRY_WAIT));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_REQUESTED));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus(TrainExternalOperation.STATUS_ACCEPTED));
|
||||
assertFalse(DmsOperationStatusMapper.isTerminalStatus("UNKNOWN"));
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.ruoyi.train.integration.dms.client;
|
||||
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 在线密码适配器与凭据安全测试
|
||||
*/
|
||||
public class DmsPasswordAdapterTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private ExternalCredentialGateway credentialGateway;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
DmsProperties properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient);
|
||||
credentialGateway = new DmsCredentialGatewayImpl(passwordClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试正常下发 6 位限时密码,返回 keyId 与 operationId
|
||||
*/
|
||||
@Test
|
||||
public void testCreateOnlinePassword_Success() {
|
||||
DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword(
|
||||
5001L,
|
||||
"李四",
|
||||
"13900001111",
|
||||
"654321",
|
||||
"2026-09-16 10:00:00",
|
||||
"2026-09-17 18:00:00"
|
||||
);
|
||||
|
||||
assertNotNull(resp);
|
||||
assertEquals(Long.valueOf(88001L), resp.getId());
|
||||
assertEquals(Long.valueOf(99001L), resp.getOperationId());
|
||||
assertEquals("LK-HD-101", resp.getLockHardwareId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试支持 4~8 位范围内的合法密码 (如 4 位、8 位)
|
||||
*/
|
||||
@Test
|
||||
public void testCreateOnlinePassword_ValidRanges() {
|
||||
// 4 位数字密码
|
||||
DmsPasswordCreateResponse resp4 = credentialGateway.issueOnlinePassword(
|
||||
5001L, "王五", "13900002222", "1234", "2026-09-16 10:00:00", "2026-09-17 18:00:00");
|
||||
assertNotNull(resp4);
|
||||
|
||||
// 8 位数字密码
|
||||
DmsPasswordCreateResponse resp8 = credentialGateway.issueOnlinePassword(
|
||||
5001L, "赵六", "13900003333", "12345678", "2026-09-16 10:00:00", "2026-09-17 18:00:00");
|
||||
assertNotNull(resp8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试非法密码格式抛出参数异常 (少于4位、多于8位、含非数字)
|
||||
*/
|
||||
@Test
|
||||
public void testCreateOnlinePassword_InvalidFormatsRejected() {
|
||||
// 少于 4 位
|
||||
try {
|
||||
credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "123", "2026-09-16", "2026-09-17");
|
||||
fail("Should reject password < 4 digits");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
|
||||
// 多于 8 位
|
||||
try {
|
||||
credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "123456789", "2026-09-16", "2026-09-17");
|
||||
fail("Should reject password > 8 digits");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
|
||||
// 包含非数字
|
||||
try {
|
||||
credentialGateway.issueOnlinePassword(5001L, "测试", "13900001111", "1234AB", "2026-09-16", "2026-09-17");
|
||||
fail("Should reject password with alphabets");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试删除在线密码,返回 operationId
|
||||
*/
|
||||
@Test
|
||||
public void testDeleteOnlinePassword_Success() {
|
||||
DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(88001L);
|
||||
assertNotNull(resp);
|
||||
assertEquals(Long.valueOf(99002L), resp.getOperationId());
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package com.ruoyi.train.integration.dms.mock;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 轻量内嵌 Mock DMS HTTP 服务端
|
||||
* <p>
|
||||
* 遵循官方 DMS 真实语义:
|
||||
* 1. 登录互斥:每次新登录生成新 Token,旧 Token 立即失效。
|
||||
* 2. 鉴权检验:所有业务接口校验 Cookie 中的 token。无效 Token 返回 401 Unauthorized。
|
||||
* 3. 读超时模拟:可模拟网络读取超时,用于检验写操作禁止盲目重发。
|
||||
* 4. 多锁/零锁模拟:支持指定房间返回 0 把锁或 3 把锁。
|
||||
* 5. 异步操作状态流转模拟:支持模拟 success, undone, failed, timeout 状态。
|
||||
*/
|
||||
public class MockDmsServer {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
|
||||
private final AtomicInteger loginCounter = new AtomicInteger(0);
|
||||
private final AtomicInteger passwordCreatePostCounter = new AtomicInteger(0);
|
||||
private final AtomicInteger passwordDeletePostCounter = new AtomicInteger(0);
|
||||
|
||||
private volatile String activeToken = null;
|
||||
private final Set<String> invalidatedTokens = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private volatile boolean simulateReadTimeout = false;
|
||||
private volatile int tokenTtlSeconds = 7200;
|
||||
|
||||
private final Map<Long, OperationStatusEntry> operationStatusMap = new ConcurrentHashMap<>();
|
||||
|
||||
public void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.setExecutor(Executors.newCachedThreadPool());
|
||||
port = server.getAddress().getPort();
|
||||
|
||||
registerEndpoints();
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return "http://127.0.0.1:" + port;
|
||||
}
|
||||
|
||||
public int getLoginCounter() {
|
||||
return loginCounter.get();
|
||||
}
|
||||
|
||||
public int getPasswordCreatePostCounter() {
|
||||
return passwordCreatePostCounter.get();
|
||||
}
|
||||
|
||||
public int getPasswordDeletePostCounter() {
|
||||
return passwordDeletePostCounter.get();
|
||||
}
|
||||
|
||||
public void setSimulateReadTimeout(boolean simulate) {
|
||||
this.simulateReadTimeout = simulate;
|
||||
}
|
||||
|
||||
public void setTokenTtlSeconds(int ttl) {
|
||||
this.tokenTtlSeconds = ttl;
|
||||
}
|
||||
|
||||
public String getActiveToken() {
|
||||
return activeToken;
|
||||
}
|
||||
|
||||
public void invalidateActiveToken() {
|
||||
if (activeToken != null) {
|
||||
invalidatedTokens.add(activeToken);
|
||||
activeToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setOperationStatus(Long operationId, String status, String message) {
|
||||
operationStatusMap.put(operationId, new OperationStatusEntry(status, message));
|
||||
}
|
||||
|
||||
private void registerEndpoints() {
|
||||
// 1. 登录获取 Token
|
||||
server.createContext("/public/rest/third-party-app/token/get", exchange -> {
|
||||
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
sendResponse(exchange, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject reqJson = JSON.parseObject(body);
|
||||
String appId = reqJson != null ? reqJson.getString("appid") : null;
|
||||
String appSecret = reqJson != null ? reqJson.getString("appsecret") : null;
|
||||
|
||||
if (appId == null || appSecret == null || appId.trim().isEmpty() || appSecret.trim().isEmpty()) {
|
||||
sendResponse(exchange, 400, "{\"success\":false,\"message\":\"Missing appId or appSecret\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
int count = loginCounter.incrementAndGet();
|
||||
// 旧 Token 立即失效
|
||||
if (activeToken != null) {
|
||||
invalidatedTokens.add(activeToken);
|
||||
}
|
||||
activeToken = "mock-token-session-" + count;
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
resp.put("token", activeToken);
|
||||
resp.put("expiresIn", tokenTtlSeconds);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 2. 建筑列表
|
||||
server.createContext("/rest/place/building/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> b1 = new HashMap<>();
|
||||
b1.put("id", 101L);
|
||||
b1.put("name", "学员1号楼");
|
||||
b1.put("buildingNumber", "B1");
|
||||
rows.add(b1);
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 3. 楼层列表
|
||||
server.createContext("/rest/place/floor/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> f1 = new HashMap<>();
|
||||
f1.put("id", 201L);
|
||||
f1.put("buildingId", 101L);
|
||||
f1.put("name", "二层");
|
||||
f1.put("floorNumber", 2);
|
||||
rows.add(f1);
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 4. 房间列表
|
||||
server.createContext("/rest/place/room/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> r1 = new HashMap<>();
|
||||
r1.put("id", 301L);
|
||||
r1.put("placeId", 10001L);
|
||||
r1.put("roomNumber", "201");
|
||||
r1.put("name", "学员宿舍201");
|
||||
rows.add(r1);
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 5. 房间详情
|
||||
server.createContext("/rest/place/room/get", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
Long roomId = req != null ? req.getLong("id") : 301L;
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("id", roomId);
|
||||
data.put("placeId", 10001L);
|
||||
data.put("roomNumber", "201");
|
||||
data.put("name", "学员宿舍201");
|
||||
resp.put("data", data);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 6. 门锁列表 (根据 placeId)
|
||||
server.createContext("/rest/doorlock/list", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
Long placeId = req != null ? req.getLong("placeId") : null;
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
if (placeId != null && placeId == 9999L) {
|
||||
// 模拟 0 把锁
|
||||
} else if (placeId != null && placeId == 10001L) {
|
||||
// 模拟 3 把锁 (多锁场景)
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
Map<String, Object> lock = new HashMap<>();
|
||||
lock.put("lockId", 5000L + i);
|
||||
lock.put("hardwareId", "LK-HD-10001-" + i);
|
||||
lock.put("lockName", "宿舍门锁-" + i);
|
||||
lock.put("placeId", 10001L);
|
||||
lock.put("onlineStatus", 1);
|
||||
lock.put("battery", 95);
|
||||
rows.add(lock);
|
||||
}
|
||||
} else {
|
||||
// 默认 1 把锁
|
||||
Map<String, Object> lock = new HashMap<>();
|
||||
lock.put("lockId", 5001L);
|
||||
lock.put("hardwareId", "LK-HD-DEFAULT");
|
||||
lock.put("lockName", "标准门锁");
|
||||
lock.put("placeId", placeId != null ? placeId : 10001L);
|
||||
lock.put("onlineStatus", 1);
|
||||
lock.put("battery", 90);
|
||||
rows.add(lock);
|
||||
}
|
||||
|
||||
resp.put("rows", rows);
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 7. 下发在线密码 (Mutation)
|
||||
server.createContext("/rest/doorlock/online-password-key/create", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
|
||||
passwordCreatePostCounter.incrementAndGet();
|
||||
|
||||
if (simulateReadTimeout) {
|
||||
// 模拟网络读超时 (sleep 超过客户端 10000ms 或测试配置的短超时)
|
||||
try {
|
||||
Thread.sleep(1200);
|
||||
} catch (InterruptedException ignored) {}
|
||||
sendResponse(exchange, 200, "{\"success\":true}");
|
||||
return;
|
||||
}
|
||||
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("id", 88001L);
|
||||
data.put("operationId", 99001L);
|
||||
data.put("lockHardwareId", "LK-HD-101");
|
||||
data.put("smsStatus", 1);
|
||||
resp.put("data", data);
|
||||
|
||||
// 默认将该 operationId 设为 undone
|
||||
operationStatusMap.putIfAbsent(99001L, new OperationStatusEntry("undone", "Device command queued"));
|
||||
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 8. 删除在线密码 (Mutation)
|
||||
server.createContext("/rest/doorlock/online-password/delete", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
|
||||
passwordDeletePostCounter.incrementAndGet();
|
||||
|
||||
if (simulateReadTimeout) {
|
||||
try {
|
||||
Thread.sleep(1200);
|
||||
} catch (InterruptedException ignored) {}
|
||||
sendResponse(exchange, 200, "{\"success\":true}");
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("operationId", 99002L);
|
||||
resp.put("data", data);
|
||||
|
||||
operationStatusMap.putIfAbsent(99002L, new OperationStatusEntry("undone", "Delete command queued"));
|
||||
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
|
||||
// 9. 查询门锁操作结果
|
||||
server.createContext("/rest/doorlock/query-operation-status", exchange -> {
|
||||
if (!verifyAuth(exchange)) return;
|
||||
String body = readRequestBody(exchange);
|
||||
JSONObject req = JSON.parseObject(body);
|
||||
Long opId = req != null ? req.getLong("operationId") : null;
|
||||
|
||||
OperationStatusEntry entry = (opId != null && operationStatusMap.containsKey(opId))
|
||||
? operationStatusMap.get(opId)
|
||||
: new OperationStatusEntry("success", "Execution verified");
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("operationId", opId);
|
||||
data.put("operationStatus", entry.status);
|
||||
data.put("resultDetails", entry.message);
|
||||
data.put("message", entry.message);
|
||||
resp.put("data", data);
|
||||
|
||||
sendResponse(exchange, 200, resp.toJSONString());
|
||||
});
|
||||
}
|
||||
|
||||
private boolean verifyAuth(HttpExchange exchange) throws IOException {
|
||||
String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie");
|
||||
String token = extractCookieToken(cookieHeader);
|
||||
|
||||
if (token == null || !token.equals(activeToken) || invalidatedTokens.contains(token)) {
|
||||
sendResponse(exchange, 401, "{\"status\":401,\"error\":\"Unauthorized\",\"message\":\"Invalid or expired token\"}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String extractCookieToken(String cookieHeader) {
|
||||
if (cookieHeader == null) return null;
|
||||
for (String pair : cookieHeader.split(";")) {
|
||||
String[] kv = pair.trim().split("=", 2);
|
||||
if (kv.length == 2 && "token".equalsIgnoreCase(kv[0])) {
|
||||
return kv[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readRequestBody(HttpExchange exchange) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void sendResponse(HttpExchange exchange, int statusCode, String responseBody) throws IOException {
|
||||
byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8");
|
||||
exchange.sendResponseHeaders(statusCode, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
os.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static class OperationStatusEntry {
|
||||
public final String status;
|
||||
public final String message;
|
||||
|
||||
public OperationStatusEntry(String status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package com.ruoyi.train.integration.dms.poller;
|
||||
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
|
||||
import com.ruoyi.train.integration.dms.client.DmsHttpClient;
|
||||
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.mock.MockDmsServer;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* DMS 异步操作结果轮询器测试
|
||||
*/
|
||||
public class DmsOperationPollerTest {
|
||||
|
||||
private MockDmsServer mockServer;
|
||||
private DmsProperties properties;
|
||||
private DmsOperationPoller poller;
|
||||
|
||||
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
mockServer = new MockDmsServer();
|
||||
mockServer.start();
|
||||
|
||||
properties = new DmsProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setBaseUrl(mockServer.getBaseUrl());
|
||||
properties.setAppId("test-app-id");
|
||||
properties.setAppSecret("test-app-secret");
|
||||
properties.setPollIntervalSeconds(10);
|
||||
properties.setMaxRetryAttempts(3);
|
||||
|
||||
DmsTokenManager tokenManager = new DmsTokenManager(properties);
|
||||
DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
|
||||
DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
|
||||
|
||||
// 内存模拟 IExternalOperationService
|
||||
IExternalOperationService mockOpService = createMockOperationService();
|
||||
|
||||
poller = new DmsOperationPoller(properties, mockOpService, statusClient);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (mockServer != null) {
|
||||
mockServer.stop();
|
||||
}
|
||||
operationDb.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证安全默认:enabled = false 时,scheduledPoll() 绝不执行轮询
|
||||
*/
|
||||
@Test
|
||||
public void testSafeDefaults_DisabledPollerDoesNothing() {
|
||||
properties.setEnabled(false);
|
||||
|
||||
// 存入一条待处理记录
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(1L);
|
||||
op.setExternalOperationId("99001");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
operationDb.put(1L, op);
|
||||
|
||||
poller.scheduledPoll();
|
||||
|
||||
// 状态应保持不变,Mock Server 未收到请求
|
||||
assertEquals(TrainExternalOperation.STATUS_ACCEPTED, operationDb.get(1L).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证轮询流转到 SUCCESS 终态 (POLL_SUCCESS_RESULT)
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_ReachesSuccess() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(101L);
|
||||
op.setExternalOperationId("99001");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
operationDb.put(101L, op);
|
||||
|
||||
mockServer.setOperationStatus(99001L, "success", "Lock firmware acknowledged");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(101L);
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证轮询流转到 FAILED 终态 (POLL_FAILED_RESULT)
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_ReachesFailed() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(102L);
|
||||
op.setExternalOperationId("99002");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
operationDb.put(102L, op);
|
||||
|
||||
mockServer.setOperationStatus(99002L, "failed", "Lock motor jammed");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(102L);
|
||||
assertEquals(TrainExternalOperation.STATUS_FAILED, updated.getOperationStatus());
|
||||
assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_FAILED, updated.getLastErrorCode());
|
||||
assertEquals("Lock motor jammed", updated.getLastErrorMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证轮询流转到 TIMEOUT 终态 (POLL_TIMEOUT_RESULT)
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_ReachesTimeout() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(103L);
|
||||
op.setExternalOperationId("99003");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
operationDb.put(103L, op);
|
||||
|
||||
mockServer.setOperationStatus(99003L, "timeout", "Device communication timed out");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(103L);
|
||||
assertEquals(TrainExternalOperation.STATUS_TIMEOUT, updated.getOperationStatus());
|
||||
assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_TIMEOUT, updated.getLastErrorCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证供应商未完成 (undone) 时保持 PENDING 并延展下一次轮询时间
|
||||
*/
|
||||
@Test
|
||||
public void testPollFlow_PendingUndoneRetained() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(104L);
|
||||
op.setExternalOperationId("99004");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED);
|
||||
op.setAttemptCount(1);
|
||||
operationDb.put(104L, op);
|
||||
|
||||
mockServer.setOperationStatus(99004L, "undone", "Device task queued");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(104L);
|
||||
assertEquals(TrainExternalOperation.STATUS_PENDING, updated.getOperationStatus());
|
||||
assertNotNull(updated.getNextPollTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已处于终态的记录绝不被再次轮询篡改 (终态保护)
|
||||
*/
|
||||
@Test
|
||||
public void testTerminalStateProtection_NeverRewritten() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(105L);
|
||||
op.setExternalOperationId("99005");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS); // 已经是终态
|
||||
operationDb.put(105L, op);
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(0, processed); // 直接跳过
|
||||
|
||||
assertEquals(TrainExternalOperation.STATUS_SUCCESS, operationDb.get(105L).getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知供应商状态严禁判定为 SUCCESS (UNKNOWN_VENDOR_STATUS_FAIL_CLOSED = YES)
|
||||
*/
|
||||
@Test
|
||||
public void testUnknownVendorStatus_FailClosed() {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(106L);
|
||||
op.setExternalOperationId("99006");
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
|
||||
operationDb.put(106L, op);
|
||||
|
||||
mockServer.setOperationStatus(99006L, "unknown_vendor_code_999", "Vendor returned undocumented status");
|
||||
|
||||
int processed = poller.pollPendingOperations();
|
||||
assertEquals(1, processed);
|
||||
|
||||
TrainExternalOperation updated = operationDb.get(106L);
|
||||
assertNotEquals("Unknown vendor status must NEVER be mapped to SUCCESS",
|
||||
TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus());
|
||||
assertEquals("Unknown status must record error code",
|
||||
DmsOperationPoller.ERROR_CATEGORY_UNKNOWN_STATUS, updated.getLastErrorCode());
|
||||
}
|
||||
|
||||
private IExternalOperationService createMockOperationService() {
|
||||
return (IExternalOperationService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalOperationService.class},
|
||||
(proxy, method, args) -> {
|
||||
String methodName = method.getName();
|
||||
if ("listPendingPollOperations".equals(methodName)) {
|
||||
List<TrainExternalOperation> list = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (!TrainExternalOperation.STATUS_SUCCESS.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_FAILED.equals(op.getOperationStatus())
|
||||
&& !TrainExternalOperation.STATUS_TIMEOUT.equals(op.getOperationStatus())) {
|
||||
list.add(op);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} else if ("updateOperationPollResult".equals(methodName)) {
|
||||
Long opId = (Long) args[0];
|
||||
String newStatus = (String) args[1];
|
||||
Date nextPoll = (Date) args[2];
|
||||
Integer attemptCount = (Integer) args[3];
|
||||
String lastErrorCode = (String) args[4];
|
||||
String lastErrorMessage = (String) args[5];
|
||||
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
op.setOperationStatus(newStatus);
|
||||
if (nextPoll != null) op.setNextPollTime(nextPoll);
|
||||
if (attemptCount != null) op.setAttemptCount(attemptCount);
|
||||
if (lastErrorCode != null) op.setLastErrorCode(lastErrorCode);
|
||||
if (lastErrorMessage != null) op.setLastErrorMessage(lastErrorMessage);
|
||||
}
|
||||
return op;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),杜绝写死猜测。
|
||||
@@ -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 等待审查)。
|
||||
Reference in new issue
Block a user