diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java
new file mode 100644
index 000000000..7c0ca2ea9
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java
@@ -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 访问令牌集中管理器
+ *
+ * 核心并发架构与安全保证:
+ * 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 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;
+ }
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java
new file mode 100644
index 000000000..0d78807e6
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java
@@ -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 智能门锁适配客户端
+ *
+ * 遵循 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 listDoorLocksByPlaceId(Long placeId) {
+ if (placeId == null) {
+ return Collections.emptyList();
+ }
+
+ Map 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();
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java
new file mode 100644
index 000000000..67345330a
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java
@@ -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 客户端
+ *
+ * 核心设计准则:
+ * 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 postQuery(String path, Object requestBody, Class responseClass) {
+ return executePost(path, requestBody, responseClass, false, false);
+ }
+
+ /**
+ * 发送 POST 写操作请求 (涉及设备凭据变更,isMutation = true,读取超时绝不自动重试)
+ */
+ public T postMutation(String path, Object requestBody, Class responseClass) {
+ return executePost(path, requestBody, responseClass, true, false);
+ }
+
+ /**
+ * 发送 POST 请求并反序列化指定对象
+ */
+ public T post(String path, Object requestBody, Class 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 executePost(String path, Object requestBody, Class 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");
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java
new file mode 100644
index 000000000..1798a0d55
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java
@@ -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 listBuildings(Long projectId) {
+ Map 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 listFloors(Long buildingId) {
+ Map 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 listRooms(Long floorId) {
+ Map 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 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 List extractList(JSONObject resp, Class 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();
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java
new file mode 100644
index 000000000..a8ddf52ba
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java
@@ -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;
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java
new file mode 100644
index 000000000..4e88a65ce
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java
@@ -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)
+ *
+ * 核心设计准则:
+ * 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 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;
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java
new file mode 100644
index 000000000..0450024fe
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java
@@ -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;
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java
new file mode 100644
index 000000000..188af302e
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java
@@ -0,0 +1,76 @@
+package com.ruoyi.train.integration.dms.dto;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * DMS 响应包装信封
+ */
+public class DmsBaseResponse implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Boolean success;
+ private String code;
+ private String message;
+ private T data;
+ private List 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 getRows() {
+ return rows;
+ }
+
+ public void setRows(List 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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java
new file mode 100644
index 000000000..10135df36
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java
@@ -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 + '\'' +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java
new file mode 100644
index 000000000..631f2b574
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java
new file mode 100644
index 000000000..9b76ce49d
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java
new file mode 100644
index 000000000..708e9400f
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java
@@ -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 + '\'' +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java
new file mode 100644
index 000000000..50236ff35
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java
@@ -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 + '\'' +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java
new file mode 100644
index 000000000..399a28943
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java
new file mode 100644
index 000000000..929545194
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java
new file mode 100644
index 000000000..c4a71e1cc
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java
new file mode 100644
index 000000000..af3255df7
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java
new file mode 100644
index 000000000..30dfcf13d
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java
@@ -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 + '\'' +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java
new file mode 100644
index 000000000..49d30dab9
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java
@@ -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]'" +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java
new file mode 100644
index 000000000..2aa50136a
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java
@@ -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 +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java
new file mode 100644
index 000000000..7dacb7acb
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java
@@ -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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java
new file mode 100644
index 000000000..82cbda428
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java
@@ -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;
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java
new file mode 100644
index 000000000..196b11077
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java
@@ -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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java
new file mode 100644
index 000000000..51d5f4e52
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java
@@ -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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java
new file mode 100644
index 000000000..00c7a74f4
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java
@@ -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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java
new file mode 100644
index 000000000..507f4efcb
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java
@@ -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 listDoorLocksByPlaceId(Long placeId) {
+ return doorLockClient.listDoorLocksByPlaceId(placeId);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java
new file mode 100644
index 000000000..709db7ecd
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java
@@ -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 listBuildings(Long projectId) {
+ return locationClient.listBuildings(projectId);
+ }
+
+ @Override
+ public List listFloors(Long buildingId) {
+ return locationClient.listFloors(buildingId);
+ }
+
+ @Override
+ public List listRooms(Long floorId) {
+ return locationClient.listRooms(floorId);
+ }
+
+ @Override
+ public DmsRoomDto getRoom(Long roomId) {
+ return locationClient.getRoom(roomId);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java
new file mode 100644
index 000000000..874e8abc4
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java
@@ -0,0 +1,57 @@
+package com.ruoyi.train.integration.dms.gateway;
+
+import com.ruoyi.train.domain.TrainExternalOperation;
+
+/**
+ * DMS 外部操作状态集中映射器
+ *
+ * 将 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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java
new file mode 100644
index 000000000..439918532
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java
@@ -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);
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java
new file mode 100644
index 000000000..f2913532f
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java
@@ -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 listDoorLocksByPlaceId(Long placeId);
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java
new file mode 100644
index 000000000..2448a9c37
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java
@@ -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 listBuildings(Long projectId);
+
+ List listFloors(Long buildingId);
+
+ List listRooms(Long floorId);
+
+ DmsRoomDto getRoom(Long roomId);
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java
new file mode 100644
index 000000000..e3af485b6
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java
@@ -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 异步设备操作结果轮询对账调度器
+ *
+ * 核心设计准则:
+ * 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 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 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");
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java
index b0c1fc2cc..153288193 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java
@@ -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);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java
index fa0e11275..5dc50d0c1 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java
@@ -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;
+ }
}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java
new file mode 100644
index 000000000..6727763ef
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java
@@ -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 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 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;
+ }
+ );
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java
new file mode 100644
index 000000000..bcfb63b90
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java
@@ -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 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 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 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());
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java
new file mode 100644
index 000000000..fa3ba8899
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java
@@ -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 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 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());
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java
new file mode 100644
index 000000000..b1bbc3f32
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java
@@ -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());
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java
new file mode 100644
index 000000000..d0f3537d7
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java
@@ -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 buildings = locationGateway.listBuildings(1L);
+ assertNotNull(buildings);
+ assertFalse(buildings.isEmpty());
+ assertEquals("学员1号楼", buildings.get(0).getName());
+
+ // 2. 查询楼层
+ List floors = locationGateway.listFloors(buildings.get(0).getId());
+ assertNotNull(floors);
+ assertFalse(floors.isEmpty());
+ assertEquals("二层", floors.get(0).getName());
+
+ // 3. 查询房间
+ List 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());
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java
new file mode 100644
index 000000000..bcec2aa99
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java
@@ -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"));
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java
new file mode 100644
index 000000000..75c9cc1fe
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java
@@ -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());
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java
new file mode 100644
index 000000000..fea6c4e13
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java
@@ -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 服务端
+ *
+ * 遵循官方 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 invalidatedTokens = ConcurrentHashMap.newKeySet();
+
+ private volatile boolean simulateReadTimeout = false;
+ private volatile int tokenTtlSeconds = 7200;
+
+ private final Map 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