From bb16b0b87566e56b2f9098a4fc429d82f0411b98 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Wed, 16 Sep 2026 10:58:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=97=A8=E7=A6=81=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=80=82=E9=85=8D=E5=99=A8=E3=80=81=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E4=B8=8E=E5=BC=82=E6=AD=A5=E6=93=8D=E4=BD=9C=E9=9B=86?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/dms/auth/DmsTokenManager.java | 252 ++++++++++++ .../dms/client/DmsDoorLockClient.java | 58 +++ .../integration/dms/client/DmsHttpClient.java | 224 +++++++++++ .../dms/client/DmsLocationClient.java | 90 +++++ .../dms/client/DmsOperationStatusClient.java | 52 +++ .../dms/client/DmsPasswordClient.java | 105 +++++ .../integration/dms/config/DmsProperties.java | 194 +++++++++ .../integration/dms/dto/DmsBaseResponse.java | 76 ++++ .../integration/dms/dto/DmsBuildingDto.java | 57 +++ .../integration/dms/dto/DmsDoorLockDto.java | 77 ++++ .../integration/dms/dto/DmsFloorDto.java | 57 +++ .../dms/dto/DmsOperationStatusRequest.java | 67 ++++ .../dms/dto/DmsOperationStatusResponse.java | 70 ++++ .../dms/dto/DmsPasswordCreateRequest.java | 116 ++++++ .../dms/dto/DmsPasswordCreateResponse.java | 72 ++++ .../dms/dto/DmsPasswordDeleteRequest.java | 37 ++ .../dms/dto/DmsPasswordDeleteResponse.java | 30 ++ .../train/integration/dms/dto/DmsRoomDto.java | 77 ++++ .../integration/dms/dto/DmsTokenRequest.java | 45 +++ .../integration/dms/dto/DmsTokenResponse.java | 41 ++ .../exception/DmsAuthenticationException.java | 16 + .../dms/exception/DmsBusinessException.java | 24 ++ .../dms/exception/DmsException.java | 16 + .../dms/exception/DmsTransportException.java | 16 + .../dms/gateway/DmsCredentialGatewayImpl.java | 40 ++ .../dms/gateway/DmsDoorLockGatewayImpl.java | 25 ++ .../dms/gateway/DmsLocationGatewayImpl.java | 42 ++ .../dms/gateway/DmsOperationStatusMapper.java | 57 +++ .../gateway/ExternalCredentialGateway.java | 14 + .../dms/gateway/ExternalDoorLockGateway.java | 13 + .../dms/gateway/ExternalLocationGateway.java | 21 + .../dms/poller/DmsOperationPoller.java | 273 +++++++++++++ .../service/IExternalOperationService.java | 21 + .../impl/ExternalOperationServiceImpl.java | 54 +++ .../dms/DmsAsyncLifecycleAndSecurityTest.java | 176 ++++++++ .../dms/auth/DmsTokenManagerTest.java | 212 ++++++++++ .../dms/client/DmsDoorLockAdapterTest.java | 79 ++++ .../dms/client/DmsHttpClientTest.java | 180 +++++++++ .../dms/client/DmsLocationAdapterTest.java | 77 ++++ .../client/DmsOperationStatusAdapterTest.java | 98 +++++ .../dms/client/DmsPasswordAdapterTest.java | 117 ++++++ .../integration/dms/mock/MockDmsServer.java | 378 ++++++++++++++++++ .../dms/poller/DmsOperationPollerTest.java | 246 ++++++++++++ .../dms-r3/01_dms_api_inventory.md | 16 + .../dms-r3/04_auth_and_token_strategy.md | 13 + .../dms-r3/05_adapter_architecture.md | 21 + .../dms-r3/06_authorization_state_machine.md | 12 + .../07_retry_idempotency_and_compensation.md | 12 + .../dms-r3/08_security_and_audit.md | 12 + .../dms-r3/10_api_and_ui_proposal.md | 12 + .../dms-r3/11_testing_and_mock_plan.md | 49 +++ .../12_implementation_task_breakdown.md | 27 ++ docs/development/dms-r3/13_open_questions.md | 9 + .../development/dms-r3/14_decision_summary.md | 41 +- 54 files changed, 4215 insertions(+), 1 deletion(-) create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/auth/DmsTokenManager.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsDoorLockClient.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsHttpClient.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsLocationClient.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusClient.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/client/DmsPasswordClient.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/config/DmsProperties.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBaseResponse.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsBuildingDto.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsDoorLockDto.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsFloorDto.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusRequest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsOperationStatusResponse.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateRequest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordCreateResponse.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteRequest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsPasswordDeleteResponse.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsRoomDto.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenRequest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/DmsTokenResponse.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsAuthenticationException.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsBusinessException.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsException.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/exception/DmsTransportException.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsCredentialGatewayImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsDoorLockGatewayImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsLocationGatewayImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/DmsOperationStatusMapper.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalCredentialGateway.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalDoorLockGateway.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/gateway/ExternalLocationGateway.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/DmsAsyncLifecycleAndSecurityTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/auth/DmsTokenManagerTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsDoorLockAdapterTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsHttpClientTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsLocationAdapterTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsOperationStatusAdapterTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/client/DmsPasswordAdapterTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/mock/MockDmsServer.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java 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> rows = new ArrayList<>(); + Map b1 = new HashMap<>(); + b1.put("id", 101L); + b1.put("name", "学员1号楼"); + b1.put("buildingNumber", "B1"); + rows.add(b1); + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 3. 楼层列表 + server.createContext("/rest/place/floor/list", exchange -> { + if (!verifyAuth(exchange)) return; + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + Map f1 = new HashMap<>(); + f1.put("id", 201L); + f1.put("buildingId", 101L); + f1.put("name", "二层"); + f1.put("floorNumber", 2); + rows.add(f1); + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 4. 房间列表 + server.createContext("/rest/place/room/list", exchange -> { + if (!verifyAuth(exchange)) return; + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + Map r1 = new HashMap<>(); + r1.put("id", 301L); + r1.put("placeId", 10001L); + r1.put("roomNumber", "201"); + r1.put("name", "学员宿舍201"); + rows.add(r1); + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 5. 房间详情 + server.createContext("/rest/place/room/get", exchange -> { + if (!verifyAuth(exchange)) return; + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + Long roomId = req != null ? req.getLong("id") : 301L; + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("id", roomId); + data.put("placeId", 10001L); + data.put("roomNumber", "201"); + data.put("name", "学员宿舍201"); + resp.put("data", data); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 6. 门锁列表 (根据 placeId) + server.createContext("/rest/doorlock/list", exchange -> { + if (!verifyAuth(exchange)) return; + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + Long placeId = req != null ? req.getLong("placeId") : null; + + JSONObject resp = new JSONObject(); + resp.put("success", true); + List> rows = new ArrayList<>(); + + if (placeId != null && placeId == 9999L) { + // 模拟 0 把锁 + } else if (placeId != null && placeId == 10001L) { + // 模拟 3 把锁 (多锁场景) + for (int i = 1; i <= 3; i++) { + Map lock = new HashMap<>(); + lock.put("lockId", 5000L + i); + lock.put("hardwareId", "LK-HD-10001-" + i); + lock.put("lockName", "宿舍门锁-" + i); + lock.put("placeId", 10001L); + lock.put("onlineStatus", 1); + lock.put("battery", 95); + rows.add(lock); + } + } else { + // 默认 1 把锁 + Map lock = new HashMap<>(); + lock.put("lockId", 5001L); + lock.put("hardwareId", "LK-HD-DEFAULT"); + lock.put("lockName", "标准门锁"); + lock.put("placeId", placeId != null ? placeId : 10001L); + lock.put("onlineStatus", 1); + lock.put("battery", 90); + rows.add(lock); + } + + resp.put("rows", rows); + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 7. 下发在线密码 (Mutation) + server.createContext("/rest/doorlock/online-password-key/create", exchange -> { + if (!verifyAuth(exchange)) return; + + passwordCreatePostCounter.incrementAndGet(); + + if (simulateReadTimeout) { + // 模拟网络读超时 (sleep 超过客户端 10000ms 或测试配置的短超时) + try { + Thread.sleep(1200); + } catch (InterruptedException ignored) {} + sendResponse(exchange, 200, "{\"success\":true}"); + return; + } + + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("id", 88001L); + data.put("operationId", 99001L); + data.put("lockHardwareId", "LK-HD-101"); + data.put("smsStatus", 1); + resp.put("data", data); + + // 默认将该 operationId 设为 undone + operationStatusMap.putIfAbsent(99001L, new OperationStatusEntry("undone", "Device command queued")); + + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 8. 删除在线密码 (Mutation) + server.createContext("/rest/doorlock/online-password/delete", exchange -> { + if (!verifyAuth(exchange)) return; + + passwordDeletePostCounter.incrementAndGet(); + + if (simulateReadTimeout) { + try { + Thread.sleep(1200); + } catch (InterruptedException ignored) {} + sendResponse(exchange, 200, "{\"success\":true}"); + return; + } + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("operationId", 99002L); + resp.put("data", data); + + operationStatusMap.putIfAbsent(99002L, new OperationStatusEntry("undone", "Delete command queued")); + + sendResponse(exchange, 200, resp.toJSONString()); + }); + + // 9. 查询门锁操作结果 + server.createContext("/rest/doorlock/query-operation-status", exchange -> { + if (!verifyAuth(exchange)) return; + String body = readRequestBody(exchange); + JSONObject req = JSON.parseObject(body); + Long opId = req != null ? req.getLong("operationId") : null; + + OperationStatusEntry entry = (opId != null && operationStatusMap.containsKey(opId)) + ? operationStatusMap.get(opId) + : new OperationStatusEntry("success", "Execution verified"); + + JSONObject resp = new JSONObject(); + resp.put("success", true); + JSONObject data = new JSONObject(); + data.put("operationId", opId); + data.put("operationStatus", entry.status); + data.put("resultDetails", entry.message); + data.put("message", entry.message); + resp.put("data", data); + + sendResponse(exchange, 200, resp.toJSONString()); + }); + } + + private boolean verifyAuth(HttpExchange exchange) throws IOException { + String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie"); + String token = extractCookieToken(cookieHeader); + + if (token == null || !token.equals(activeToken) || invalidatedTokens.contains(token)) { + sendResponse(exchange, 401, "{\"status\":401,\"error\":\"Unauthorized\",\"message\":\"Invalid or expired token\"}"); + return false; + } + return true; + } + + private String extractCookieToken(String cookieHeader) { + if (cookieHeader == null) return null; + for (String pair : cookieHeader.split(";")) { + String[] kv = pair.trim().split("=", 2); + if (kv.length == 2 && "token".equalsIgnoreCase(kv[0])) { + return kv[1]; + } + } + return null; + } + + private String readRequestBody(HttpExchange exchange) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + private void sendResponse(HttpExchange exchange, int statusCode, String responseBody) throws IOException { + byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + os.flush(); + } + } + + public static class OperationStatusEntry { + public final String status; + public final String message; + + public OperationStatusEntry(String status, String message) { + this.status = status; + this.message = message; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java new file mode 100644 index 000000000..2976921a2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/poller/DmsOperationPollerTest.java @@ -0,0 +1,246 @@ +package com.ruoyi.train.integration.dms.poller; + +import com.ruoyi.train.domain.TrainExternalOperation; +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; +import com.ruoyi.train.integration.dms.client.DmsHttpClient; +import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.mock.MockDmsServer; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.impl.ExternalOperationServiceImpl; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.Assert.*; + +/** + * DMS 异步操作结果轮询器测试 + */ +public class DmsOperationPollerTest { + + private MockDmsServer mockServer; + private DmsProperties properties; + private DmsOperationPoller poller; + + private final Map operationDb = new ConcurrentHashMap<>(); + + @Before + public void setUp() throws Exception { + mockServer = new MockDmsServer(); + mockServer.start(); + + properties = new DmsProperties(); + properties.setEnabled(true); + properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setAppId("test-app-id"); + properties.setAppSecret("test-app-secret"); + properties.setPollIntervalSeconds(10); + properties.setMaxRetryAttempts(3); + + DmsTokenManager tokenManager = new DmsTokenManager(properties); + DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager); + DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient); + + // 内存模拟 IExternalOperationService + IExternalOperationService mockOpService = createMockOperationService(); + + poller = new DmsOperationPoller(properties, mockOpService, statusClient); + } + + @After + public void tearDown() { + if (mockServer != null) { + mockServer.stop(); + } + operationDb.clear(); + } + + /** + * 验证安全默认:enabled = false 时,scheduledPoll() 绝不执行轮询 + */ + @Test + public void testSafeDefaults_DisabledPollerDoesNothing() { + properties.setEnabled(false); + + // 存入一条待处理记录 + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(1L); + op.setExternalOperationId("99001"); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + operationDb.put(1L, op); + + poller.scheduledPoll(); + + // 状态应保持不变,Mock Server 未收到请求 + assertEquals(TrainExternalOperation.STATUS_ACCEPTED, operationDb.get(1L).getOperationStatus()); + } + + /** + * 验证轮询流转到 SUCCESS 终态 (POLL_SUCCESS_RESULT) + */ + @Test + public void testPollFlow_ReachesSuccess() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(101L); + op.setExternalOperationId("99001"); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + operationDb.put(101L, op); + + mockServer.setOperationStatus(99001L, "success", "Lock firmware acknowledged"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(101L); + assertEquals(TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus()); + } + + /** + * 验证轮询流转到 FAILED 终态 (POLL_FAILED_RESULT) + */ + @Test + public void testPollFlow_ReachesFailed() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(102L); + op.setExternalOperationId("99002"); + op.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + operationDb.put(102L, op); + + mockServer.setOperationStatus(99002L, "failed", "Lock motor jammed"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(102L); + assertEquals(TrainExternalOperation.STATUS_FAILED, updated.getOperationStatus()); + assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_FAILED, updated.getLastErrorCode()); + assertEquals("Lock motor jammed", updated.getLastErrorMessage()); + } + + /** + * 验证轮询流转到 TIMEOUT 终态 (POLL_TIMEOUT_RESULT) + */ + @Test + public void testPollFlow_ReachesTimeout() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(103L); + op.setExternalOperationId("99003"); + op.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + operationDb.put(103L, op); + + mockServer.setOperationStatus(99003L, "timeout", "Device communication timed out"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(103L); + assertEquals(TrainExternalOperation.STATUS_TIMEOUT, updated.getOperationStatus()); + assertEquals(DmsOperationPoller.ERROR_CATEGORY_DEVICE_TIMEOUT, updated.getLastErrorCode()); + } + + /** + * 验证供应商未完成 (undone) 时保持 PENDING 并延展下一次轮询时间 + */ + @Test + public void testPollFlow_PendingUndoneRetained() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(104L); + op.setExternalOperationId("99004"); + op.setOperationStatus(TrainExternalOperation.STATUS_ACCEPTED); + op.setAttemptCount(1); + operationDb.put(104L, op); + + mockServer.setOperationStatus(99004L, "undone", "Device task queued"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(104L); + assertEquals(TrainExternalOperation.STATUS_PENDING, updated.getOperationStatus()); + assertNotNull(updated.getNextPollTime()); + } + + /** + * 验证已处于终态的记录绝不被再次轮询篡改 (终态保护) + */ + @Test + public void testTerminalStateProtection_NeverRewritten() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(105L); + op.setExternalOperationId("99005"); + op.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS); // 已经是终态 + operationDb.put(105L, op); + + int processed = poller.pollPendingOperations(); + assertEquals(0, processed); // 直接跳过 + + assertEquals(TrainExternalOperation.STATUS_SUCCESS, operationDb.get(105L).getOperationStatus()); + } + + /** + * 验证未知供应商状态严禁判定为 SUCCESS (UNKNOWN_VENDOR_STATUS_FAIL_CLOSED = YES) + */ + @Test + public void testUnknownVendorStatus_FailClosed() { + TrainExternalOperation op = new TrainExternalOperation(); + op.setOperationId(106L); + op.setExternalOperationId("99006"); + op.setOperationStatus(TrainExternalOperation.STATUS_PENDING); + operationDb.put(106L, op); + + mockServer.setOperationStatus(99006L, "unknown_vendor_code_999", "Vendor returned undocumented status"); + + int processed = poller.pollPendingOperations(); + assertEquals(1, processed); + + TrainExternalOperation updated = operationDb.get(106L); + assertNotEquals("Unknown vendor status must NEVER be mapped to SUCCESS", + TrainExternalOperation.STATUS_SUCCESS, updated.getOperationStatus()); + assertEquals("Unknown status must record error code", + DmsOperationPoller.ERROR_CATEGORY_UNKNOWN_STATUS, updated.getLastErrorCode()); + } + + private IExternalOperationService createMockOperationService() { + return (IExternalOperationService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalOperationService.class}, + (proxy, method, args) -> { + String methodName = method.getName(); + if ("listPendingPollOperations".equals(methodName)) { + List 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/docs/development/dms-r3/01_dms_api_inventory.md b/docs/development/dms-r3/01_dms_api_inventory.md index 0f040aa90..e9db61584 100644 --- a/docs/development/dms-r3/01_dms_api_inventory.md +++ b/docs/development/dms-r3/01_dms_api_inventory.md @@ -67,3 +67,19 @@ - **远程开锁 (`remote-unlock`)**:属于主动设备控制,具有物理安全风险。一期**严禁**作为学员常规功能,仅设计受控管理员权限与两票制审计,标记为 `P2_FUTURE / 受控应急`。 - **取电开关全套接口 (`powerswitch/*`)**:沈阳房建公寓段学员管理系统核心是学员学籍、住宿、报到业务,强行纳入取电开关将带来硬件施工与网络调试成本,确定归入 `P2_FUTURE`。 - **二维码钥匙 (`qrcode/create`)**:官方文档中明确包含端点定义 (`POST /rest/doorlock/qrcode/create` 与 `POST /rest/qrcode/delete`),分类为 `DOCUMENTED`;但其响应示例存在数据污染(`typeStr: "Online Card"`)且未见二维码载荷内容,其实际方案可行性属于 `NEEDS_BUSINESS_CONFIRMATION`,一期推荐保持使用在线密码作为 Pilot 方案。 + +--- + +## 4. Wave 2 Implementation Status (Wave 2 implemented against Mock DMS only) +- **实现范围**: + 1. `POST /public/rest/third-party-app/token/get` (`DmsTokenManager`) + 2. `POST /rest/place/building/list`, `floor/list`, `room/list`, `room/get` (`DmsLocationClient` / `ExternalLocationGateway`) + 3. `POST /rest/doorlock/list` (`DmsDoorLockClient` / `ExternalDoorLockGateway`,支持 0、1、多把门锁) + 4. `POST /rest/doorlock/online-password-key/create` (`DmsPasswordClient` / `ExternalCredentialGateway`,支持 4~8 位,密码零持久化) + 5. `POST /rest/doorlock/online-password/delete` (`DmsPasswordClient` / `ExternalCredentialGateway`) + 6. `POST /rest/doorlock/query-operation-status` (`DmsOperationStatusClient` / `DmsOperationStatusMapper` / `DmsOperationPoller`) +- **测试与运行纪律**: + - 全部自动化测试均在本地内嵌 `MockDmsServer` 下运行。 + - `LIVE_DMS_REQUEST_COUNT = 0`(严禁向真实 DMS 产生任何调用)。 + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。 + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。 diff --git a/docs/development/dms-r3/04_auth_and_token_strategy.md b/docs/development/dms-r3/04_auth_and_token_strategy.md index ba7ddb928..4d2bd8e67 100644 --- a/docs/development/dms-r3/04_auth_and_token_strategy.md +++ b/docs/development/dms-r3/04_auth_and_token_strategy.md @@ -138,3 +138,16 @@ sequenceDiagram ### 5.1 恢复红线守则 - **单次重试原则**:因 401 触发的自动重试**上限严格为 1 次**。严禁设计任何 `while(true)` 或大于 1 次的递归重试,防止由于网络异常或密码错误引发死循环或轰炸外部服务器。 - **业务解耦原则**:若重新登录失败,DmsAdapter 应抛出封装后的 `DmsAuthenticationException`,将门禁任务状态置为 `RETRY_WAIT` 或 `FAILED`,**绝不可向上传播导致本地住宿分配主事务发生回滚**。 + +--- + +## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **Token 核心实现**: + - `TOKEN_TRANSPORT_MODEL` = `Cookie: token={token}`(严禁使用未经证明的 `Authorization: Bearer`)。 + - `TOKEN_CACHE_MODEL` = `SINGLE_PROCESS_IN_MEMORY`(全应用单一集中缓存,严禁各个客户端分散维护)。 + - `TOKEN_SINGLE_FLIGHT_MODEL` = `REENTRANT_LOCK_DCL`(ReentrantLock + 双重检查锁定,20 线程并发获取 Token 实测远程登录仅 1 次)。 + - `TOKEN_TTL_MODEL` = `7200S_WITH_REFRESH_SKEW`(默认提前 120 秒在滑动窗口内平滑单飞刷新)。 + - `SUCCESSFUL_API_EXTENDS_LOCAL_TTL` = `YES`(成功调用 DMS API 触发 `tokenManager.markSuccessfulUse()` 顺延本地失效时间戳)。 + - `AUTH_FAILURE_REFRESH_MODEL` = 401 触发集中 `invalidateToken()`,下一次调用单飞获取新令牌,杜绝 login storm。 + - `AMBIGUOUS_MUTATION_AUTO_RETRY` = `NO`(写操作读取超时绝对不自动重试,规避重复凭据下发风险)。 +- **验证结论**:自动化并发测试(20 线程单飞登录、提前刷新单飞、401 自愈)全部通过,仅针对本地 `MockDmsServer` 验证,`LIVE_DMS_REQUEST_COUNT = 0`。 diff --git a/docs/development/dms-r3/05_adapter_architecture.md b/docs/development/dms-r3/05_adapter_architecture.md index e772f4156..ee0aaf81f 100644 --- a/docs/development/dms-r3/05_adapter_architecture.md +++ b/docs/development/dms-r3/05_adapter_architecture.md @@ -186,3 +186,24 @@ ruoyi-train - 本 Wave 严格保持 `DMS_HTTP_CLIENT_COUNT = 0`,`DMS_TOKEN_MANAGER_COUNT = 0`。 - 零 RestTemplate / WebClient / HttpClient 网络调用,零明文敏感凭据。 - **数据库 Migration**:`008_external_resource_operation.sql` 验证通过,生产库未执行。 + +--- + +## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **阶段状态**:Wave 2 implemented against Mock DMS only。 +- **适配层核心交付清单**: + 1. `config/DmsProperties`:受控配置中心,`dms.enabled = false`(安全默认),支持参数化超时、提前刷新窗口、Token Cookie 头名称。 + 2. `auth/DmsTokenManager`:全应用唯一集中 Token 缓存,基于 ReentrantLock + DCL 的 Single-Flight 并发互斥锁,支持滑动延展 (`markSuccessfulUse`) 与 401 集中失效自愈。 + 3. `client/DmsHttpClient`:基础底层 HTTP 客户端,统一 Cookie 传输,脱敏日志(密码、Token、Secret 全程保护),写操作读取超时严禁盲目自动重试 (`AMBIGUOUS_MUTATION_AUTO_RETRY = NO`)。 + 4. `client/DmsLocationClient` 与 `gateway/ExternalLocationGateway`:位置资源查询与 Place 空间定位。 + 5. `client/DmsDoorLockClient` 与 `gateway/ExternalDoorLockGateway`:门锁适配器,严格支持 0 把锁(返回空列表,非 NPE)、1 把锁与多把锁(完整返回全部锁)。 + 6. `client/DmsPasswordClient` 与 `gateway/ExternalCredentialGateway`:在线限时密码创建与删除适配器,支持 4~8 位数字,密码明文内存短暂留存,零落库零输出。 + 7. `client/DmsOperationStatusClient` 与 `gateway/DmsOperationStatusMapper`:异步状态查询与集中映射器(success->SUCCESS, undone->PENDING, failed->FAILED, timeout->TIMEOUT, 未知状态->UNKNOWN 严禁判定为 SUCCESS)。 + 8. `poller/DmsOperationPoller`:异步对账轮询调度器,仅在 `dms.enabled=true` 启用,单进程并发防重,终态不可篡改保护,错误分类退避持久化。 + 9. `mock/MockDmsServer`:轻量内嵌测试服务器,精准模拟 DMS 服务端登录互斥、读超时、多锁/零锁与状态流转。 +- **环境纪律与迁移标记**: + - `DMS_DEFAULT_ENABLED = FALSE` + - `LIVE_DMS_REQUEST_COUNT = 0` + - `FRONTEND_MODIFIED = 0` + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED` + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED` diff --git a/docs/development/dms-r3/06_authorization_state_machine.md b/docs/development/dms-r3/06_authorization_state_machine.md index 60f05e6ce..392724fba 100644 --- a/docs/development/dms-r3/06_authorization_state_machine.md +++ b/docs/development/dms-r3/06_authorization_state_machine.md @@ -147,3 +147,15 @@ stateDiagram-v2 - **轮询检索接口 (`PENDING_OPERATION_QUERY`)**: - 交付 `selectPendingPollOperations`,检索处于 `ACCEPTED`、`PENDING`、`RETRY_WAIT` 且 `next_poll_time <= NOW()` 的操作记录。 - 为 Wave 2 调度器提供完整数据准备,本 Wave 保持 0 调度器运行、0 HTTP 调用。 + +--- + +## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **阶段状态**:Wave 2 implemented against Mock DMS only。 +- **状态机与对账调度实现**: + - `DmsOperationStatusMapper`:集中映射供应商返回的设备操作状态(`success` -> `SUCCESS`, `undone` -> `PENDING`, `failed` -> `FAILED`, `timeout` -> `TIMEOUT`),未知供应商状态一律归为 `UNKNOWN`,严禁判定为 `SUCCESS`。 + - `ACCEPTED != SUCCESS` 原则强制落地:下发/删除密码接口 HTTP 200 响应仅代表平台受理并取得 `operationId`(本地状态记录为 `ACCEPTED`),设备执行事实必须由后续 Poller 轮询确认达成 `SUCCESS`。 + - `DmsOperationPoller`:自动从 `listPendingPollOperations` 检索任务,支持 10 秒初始延迟,单进程并发防重,终态保护不可篡改,网络错误转入 `RETRY_WAIT` 退避。 +- **迁移标记**: + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。 + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。 diff --git a/docs/development/dms-r3/07_retry_idempotency_and_compensation.md b/docs/development/dms-r3/07_retry_idempotency_and_compensation.md index 5a4d537e9..7925986d9 100644 --- a/docs/development/dms-r3/07_retry_idempotency_and_compensation.md +++ b/docs/development/dms-r3/07_retry_idempotency_and_compensation.md @@ -164,3 +164,15 @@ sequenceDiagram - **重试审计与不可变性**: - 终态记录不可覆写:每次重试调用通过 `createRetryOperation` 独立创建自增 `attempt_count` 的新记录。 - 保留完整排障轨迹:`last_error_code` 与 `last_error_message` 详细保留每次失败现场,满足段级安全审计与对账需求。 + +--- + +## 7. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **阶段状态**:Wave 2 implemented against Mock DMS only。 +- **重试与幂等安全落地**: + - `AMBIGUOUS_MUTATION_AUTO_RETRY = NO`:写操作(如密码创建)发生网络读取超时(Read Timeout)时,底层 `DmsHttpClient` 坚决抑制自动重试,`passwordCreatePostCounter = 1`,避免在门锁端产生重复凭据。 + - 轮询指数退避:`DmsOperationPoller` 在遭遇网络抖动时将状态置为 `RETRY_WAIT`,基于 `Math.pow(2, attemptCount) * 5` 计算下次轮询时间并递增 `attempt_count`,防止高频风暴轮询。 + - 401 集中重试:仅在明确认证失败且为安全查询时才发起 1 次 Token 刷新与重试;写操作 401 则集中失效并向外抛错,由上层流水对账接管。 +- **迁移标记**: + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。 + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。 diff --git a/docs/development/dms-r3/08_security_and_audit.md b/docs/development/dms-r3/08_security_and_audit.md index 3910077ce..187ce5450 100644 --- a/docs/development/dms-r3/08_security_and_audit.md +++ b/docs/development/dms-r3/08_security_and_audit.md @@ -115,3 +115,15 @@ ### 5.2 响应数据持久化策略 (`Response Sanitization Policy`) - **禁止项**:绝不无脑把外部 DMS 返回的几千字节原始 JSON 直接整体塞入数据库的文本列中。 - **要求**:仅抽取核心对账字段进行结构化持久化;若出于排障目的保留原始请求与响应报文快照,**必须先经过脱敏过滤器(剔除明文密码与密钥)**后再写入 `sanitized_response_payload`。 + +--- + +## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **阶段状态**:Wave 2 implemented against Mock DMS only。 +- **安全红线核验结论**: + - `PLAINTEXT_PASSWORD_PERSISTENCE_COUNT = 0`:反射测试断言 `TrainExternalOperation` 与 `TrainDormAuth` 绝无密码明文属性,实体中仅保留 `external_key_id`。 + - `SECRET_LOGGING_COUNT = 0`:`DmsHttpClient` 与 `DmsOperationPoller` 全局应用 `sanitize` 过滤器,请求头与请求体中的 `password`、`token`、`appsecret` 均被强制替换为 `[PROTECTED]`。 + - `LIVE_DMS_REQUEST_COUNT = 0`:开发与自动化测试期间严格仅使用内嵌 `MockDmsServer`,真实 DMS 地址仅保留于默认配置占位符,未发起任何公网请求。 +- **迁移标记**: + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。 + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。 diff --git a/docs/development/dms-r3/10_api_and_ui_proposal.md b/docs/development/dms-r3/10_api_and_ui_proposal.md index 294ddbacf..9b7697c4e 100644 --- a/docs/development/dms-r3/10_api_and_ui_proposal.md +++ b/docs/development/dms-r3/10_api_and_ui_proposal.md @@ -115,3 +115,15 @@ [写入 train_external_resource_mapping, 完成两套系统物理基线的稳定锁定] ``` - **核心保障**:同步操作仅建立与更新映射表,**绝不自动删除或篡改本地 `train_dormitory_info` 的基础房源台账**。本地房源依然是学员管理系统的独立主数据。 + +--- + +## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **阶段状态**:Wave 2 implemented against Mock DMS only。 +- **Controller 与 UI 边界红线**: + - `CONTROLLER_COUNT` = 0(本 Wave 严禁新增任何 `/train/dms/**` 控制器,所有适配层仅供 Service/Test 内部调用)。 + - `FRONTEND_MODIFIED` = 0(前端与 `build_ui` 修改文件数严格为 0)。 + - `REMOTE_UNLOCK_CONTROLLER` = 0(保持 P2/FUTURE 决策,不提供任何远程开锁入口)。 +- **迁移标记**: + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。 + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。 diff --git a/docs/development/dms-r3/11_testing_and_mock_plan.md b/docs/development/dms-r3/11_testing_and_mock_plan.md index a55c1b4eb..48b2309d8 100644 --- a/docs/development/dms-r3/11_testing_and_mock_plan.md +++ b/docs/development/dms-r3/11_testing_and_mock_plan.md @@ -135,3 +135,52 @@ public void testConcurrentLoginDeduplication() throws Exception { - `008_CONTRACT_TEST = PASS` - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED` - 明确声明:008 迁移脚本已通过静态语法与结构契约测试(Contract Test PASS),未在实际生产库或非隔离库执行;在投产前必须在隔离 Kingbase 实例执行双跑准入门禁。 + +--- + +## 6. Wave 2 Implementation Result (Wave 2 implemented against Mock DMS only) +- **阶段状态**:Wave 2 implemented against Mock DMS only。 +- **新增自动化测试清单 (26 项)**: + 1. `DmsTokenManagerTest.java` (4 项): + - `testTokenConcurrency_SingleFlightLogin` (20 线程并发获取 Token,单飞登录仅 1 次,所有线程获取相同 Token)。 + - `testTokenRefresh_SingleFlightRefresh` (推进时间至提前刷新窗口,20 线程并发单飞刷新,旧 Token 废弃)。 + - `testSuccessfulApiExtendsLocalTtl` (成功调用 API 触发滑动延展本地有效期限)。 + - `testAuthFailureRecovery_NoLoginStorm` (401 集中失效自愈,无并发 login storm)。 + 2. `DmsHttpClientTest.java` (3 项): + - `testMutationAmbiguity_NoAutoRetryOnTimeout` (写操作读取超时坚决不自动重试,`passwordCreatePostCounter = 1`)。 + - `testTokenTransport_CookieHeader` (验证请求注入 `Cookie: token=...`,严禁 `Bearer`)。 + - `testSensitiveDataRedaction` (密码明文、Token、Secret 全局脱敏断言)。 + 3. `DmsLocationAdapterTest.java` (1 项): + - `testLocationLookup_FullHierarchy` (建筑、楼层、房间完整层级查询与 placeId 解析)。 + 4. `DmsDoorLockAdapterTest.java` (2 项): + - `testZeroLock_ReturnsEmptyList` (0 把锁场景正常返回空列表,非 NPE,非假锁)。 + - `testMultiLock_ReturnsAllLocks` (多把锁场景完整返回全部 3 把门锁)。 + 5. `DmsPasswordAdapterTest.java` (4 项): + - `testCreateOnlinePassword_Success` (正常下发 6 位限时密码,获得 keyId 与 operationId)。 + - `testCreateOnlinePassword_ValidRanges` (支持 4~8 位范围内的合法密码)。 + - `testCreateOnlinePassword_InvalidFormatsRejected` (非法格式参数拦截)。 + - `testDeleteOnlinePassword_Success` (删除在线密码成功返回 operationId)。 + 6. `DmsOperationStatusAdapterTest.java` (4 项): + - `testQueryOperationStatus_FromMock` (查询门锁操作状态)。 + - `testStatusMapper_AllKnownTransitions` (success->SUCCESS, undone->PENDING, failed->FAILED, timeout->TIMEOUT)。 + - `testStatusMapper_UnknownStatusNeverSuccess` (未知状态映射为 UNKNOWN,绝非 SUCCESS)。 + - `testStatusMapper_TerminalStatusIdentification` (终态识别判定)。 + 7. `DmsOperationPollerTest.java` (6 项): + - `testSafeDefaults_DisabledPollerDoesNothing` (安全默认:enabled=false 绝不执行轮询)。 + - `testPollFlow_ReachesSuccess` (轮询流转到 SUCCESS 终态)。 + - `testPollFlow_ReachesFailed` (轮询流转到 FAILED 终态)。 + - `testPollFlow_ReachesTimeout` (轮询流转到 TIMEOUT 终态)。 + - `testPollFlow_PendingUndoneRetained` (设备 undone 保持 PENDING 并延展时间)。 + - `testTerminalStateProtection_NeverRewritten` (终态记录不可篡改)。 + 8. `DmsAsyncLifecycleAndSecurityTest.java` (2 项): + - `testAcceptedNotEqualsSuccess_LifecycleVerification` (核心验证:HTTP 200 仅为 ACCEPTED,轮询成功后才为 SUCCESS)。 + - `testCredentialDataLeak_ZeroPlaintextStorage` (反射断言持久化实体绝无密码明文属性)。 +- **全量测试指标**: + - 基线测试数:203 项。 + - 新增测试数:26 项。 + - 当前测试总数:229 项(ruoyi-train: 201 项,ruoyi-admin: 28 项),Failures = 0, Errors = 0, Skipped = 0。 + - Maven `clean test` 与 `clean package` 顺利构建成功。 +- **环境纪律与迁移标记**: + - `LIVE_DMS_REQUEST_COUNT = 0` + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED` + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED` diff --git a/docs/development/dms-r3/12_implementation_task_breakdown.md b/docs/development/dms-r3/12_implementation_task_breakdown.md index 5a1746dcf..419f5f627 100644 --- a/docs/development/dms-r3/12_implementation_task_breakdown.md +++ b/docs/development/dms-r3/12_implementation_task_breakdown.md @@ -188,3 +188,30 @@ flowchart TD - `008_CONTRACT_TEST = PASS` - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED` - 严禁将 Contract Test PASS 描述为实际 Kingbase 双执行 PASS。 + +### 3.4 Wave 2 Implementation Result (DMS 适配层 / Token / HTTP / Poller / Mock 集成 - Wave 2 implemented) +- **阶段目标**:DMS Adapter / Token / HTTP / Poller / Mock Integration against Mock DMS only +- **执行状态**:Wave 2 implemented +- **核心交付清单**: + 1. 基础配置:`DmsProperties.java`(受控配置,`enabled=false` 安全默认,参数化超时与刷新窗口)。 + 2. 令牌管理:`DmsTokenManager.java`(进程级单例缓存,ReentrantLock + DCL 单飞互斥防击穿,滑动顺延,401 集中失效)。 + 3. 通信客户端:`DmsHttpClient.java`(Cookie 头鉴权,脱敏日志,写操作读取超时严禁自动重发 `AMBIGUOUS_MUTATION_AUTO_RETRY = NO`)。 + 4. 位置适配器:`DmsLocationClient.java`、`ExternalLocationGateway.java`、`DmsLocationGatewayImpl.java`(层级空间查询与 placeId 解析)。 + 5. 门锁适配器:`DmsDoorLockClient.java`、`ExternalDoorLockGateway.java`、`DmsDoorLockGatewayImpl.java`(支持 0、1、多把智能门锁)。 + 6. 密码凭据适配器:`DmsPasswordClient.java`、`ExternalCredentialGateway.java`、`DmsCredentialGatewayImpl.java`(下发 4~8 位限时密码、删除密码,密码明文内存短暂留存)。 + 7. 状态查询与映射:`DmsOperationStatusClient.java`、`DmsOperationStatusMapper.java`(集中统一映射,未知状态判定为 UNKNOWN 严禁判定为 SUCCESS)。 + 8. 异步轮询器:`DmsOperationPoller.java`(条件化启动,单进程并发防重,终态不可篡改保护,退避重试)。 + 9. 测试基础设施:`MockDmsServer.java`(内嵌纯 JDK HttpServer,完全模拟真实 DMS 平台登录互斥、短 TTL、多锁/零锁与状态流转)。 + 10. 自动化测试套件:`DmsTokenManagerTest` (4项)、`DmsHttpClientTest` (3项)、`DmsLocationAdapterTest` (1项)、`DmsDoorLockAdapterTest` (2项)、`DmsPasswordAdapterTest` (4项)、`DmsOperationStatusAdapterTest` (4项)、`DmsOperationPollerTest` (6项)、`DmsAsyncLifecycleAndSecurityTest` (2项)。 +- **全量构建与回归验收**: + - `ruoyi-train`: 201 tests + - `ruoyi-admin`: 28 tests + - 总计:229 tests, Failures = 0, Errors = 0, Skipped = 0,全部通过。 + - Maven `clean test` 与 `clean package` 构建成功。 + - 生产数据库变更:`PRODUCTION_DB_MODIFIED? NO`。 + - 外部通信真实调用:`LIVE_DMS_REQUEST_COUNT = 0`。 + - 控制器与前端改动:`CONTROLLER_COUNT = 0`,`FRONTEND_MODIFIED = 0`。 +- **Migration 标记**: + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED` + - `008_RUNTIME_SECOND_RUN = NOT_EXECUTED` + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED` diff --git a/docs/development/dms-r3/13_open_questions.md b/docs/development/dms-r3/13_open_questions.md index 998b97d08..f347b17a8 100644 --- a/docs/development/dms-r3/13_open_questions.md +++ b/docs/development/dms-r3/13_open_questions.md @@ -46,3 +46,12 @@ | **Q-NON-04** | **开门密码学员触达渠道**:密码如何安全告知学员? | 推荐学员在移动端个人中心经二次身份验证后查看 30 秒倒计时明文,节省短信资费。 | | **Q-NON-05** | **远程紧急开锁 (Remote Unlock)**:一期是否需要上线远程开锁? | 属于高危控制操作,一期严格排除;仅设计两票制受控应急方案备查。 | | **Q-NON-06** | **门锁电量与信号监控**:是否需要专门的设备运行状态大盘? | 一期在宿舍列表表格中轻量展示电量与信号徽标,辅助宿管员排查低电量门锁。 | + +--- + +## 4. Wave 2 架构中对未决问题的设计守则 (`Wave 2 Open Question Guards`) +在 Wave 2 适配层开发中,上述问题被严格保留为待确认事项,绝不在代码与文档中伪称已解决: +1. **门锁离线与 RTC 漂移语义**:依然待供应商提供技术规格,系统在 Adapter 与 Poller 中保持以主动撤权为主、有效期为辅的双重安全设计。 +2. **真实环境 Sandbox 账号**:本 Wave 严格使用 `MockDmsServer` 动态模拟,不依赖真实 DMS 沙箱。 +3. **真实错误码全集**:通过 `DmsOperationPoller.ERROR_CATEGORY_*` 抽象通用错误分类(AUTH_ERROR, NETWORK_ERROR, VENDOR_BUSINESS_ERROR, DEVICE_FAILED, DEVICE_TIMEOUT),兼容未知供应商错误。 +4. **Token Cookie 头名称**:在 `DmsProperties.tokenCookieName` 中参数化配置(默认 "token"),杜绝写死猜测。 diff --git a/docs/development/dms-r3/14_decision_summary.md b/docs/development/dms-r3/14_decision_summary.md index 115318069..8a514486e 100644 --- a/docs/development/dms-r3/14_decision_summary.md +++ b/docs/development/dms-r3/14_decision_summary.md @@ -154,4 +154,43 @@ - Maven 构建:`clean test` 与 `clean package` 均 `SUCCESS`。 - 代码格式校验:`git diff --check = PASS`。 - 阶段评审状态:`R3_WAVE1_REVIEW = PASS`。 - - 提交状态:`R3_WAVE1_COMMIT = READY`(执行 Checkpoint Commit,绝不 PUSH,绝不 DEPLOY)。 + - 提交状态:`R3_WAVE1_COMMIT = COMMITTED` (SHA: 22bcd1bacb66826e8e911454e9c01b54c8b64b32)。 + +### 6.4 Wave 2 DMS Adapter, Token, Poller & Mock Integration (Wave 2 implemented against Mock DMS only) +- **阶段状态**:`R3_WAVE2_STATUS = PASS`。 +- **配置与安全默认**: + - `DMS_DEFAULT_ENABLED = FALSE`:默认不开启 DMS 通信与轮询。 + - `DMS_PROPERTIES`:完整实现受控配置,零硬编码密钥,参数化超时与刷新窗口。 +- **令牌与通信架构**: + - `TOKEN_TRANSPORT_MODEL` = `Cookie: token={token}`(严禁使用未核实的 `Authorization: Bearer`)。 + - `TOKEN_CACHE_MODEL` = `SINGLE_PROCESS_IN_MEMORY`(全应用单一集中缓存)。 + - `TOKEN_SINGLE_FLIGHT_MODEL` = `REENTRANT_LOCK_DCL`(高并发过期单飞防击穿,实测 20 线程并发登录仅 1 次)。 + - `TOKEN_TTL_MODEL` = `7200S_WITH_REFRESH_SKEW`(滑动窗口提前 120 秒平滑单飞刷新)。 + - `SUCCESSFUL_API_EXTENDS_LOCAL_TTL` = `YES`(成功调用 DMS API 顺延本地失效时间戳)。 + - `AUTH_FAILURE_REFRESH_MODEL` = `INVALIDATE_THEN_SINGLE_FLIGHT`(401 集中失效并在下一次请求单飞获取新 Token,无 login storm)。 + - `AMBIGUOUS_MUTATION_AUTO_RETRY` = `NO`(写操作读取超时坚决抑制自动重发,防止重复生成门锁凭据)。 + - `DMS_HTTP_CLIENT` = `DmsHttpClient` 统一底层通信与全局脱敏日志(零明文密码/Token/Secret 泄露)。 +- **设备与凭据适配能力**: + - `LOCATION_ADAPTER` = `DmsLocationClient` / `ExternalLocationGateway`(Building, Floor, Room 空间定位与 placeId 解析)。 + - `DOORLOCK_ADAPTER` = `DmsDoorLockClient` / `ExternalDoorLockGateway`(支持 0 把锁空列表与多把锁完整返回)。 + - `ONLINE_PASSWORD_CREATE` = `DmsPasswordClient` / `ExternalCredentialGateway`(支持 4~8 位数字限时密码下发,密码零落库)。 + - `ONLINE_PASSWORD_DELETE` = `DmsPasswordClient` / `ExternalCredentialGateway`(注销并返回 operationId)。 + - `OPERATION_STATUS_QUERY` = `DmsOperationStatusClient`。 + - `VENDOR_STATUS_MAPPING` = `DmsOperationStatusMapper`(集中统一映射,未知状态判定为 UNKNOWN,绝非 SUCCESS)。 +- **异步轮询与测试模拟**: + - `POLLER_IMPLEMENTED` = `DmsOperationPoller`(条件化启动,单进程并发防重,终态不可篡改保护,指数退避重试)。 + - `POLLER_INITIAL_DELAY` = 10s。 + - `MULTI_INSTANCE_POLLER_LOCK` = `FUTURE_GATE`。 + - `MOCK_DMS_IMPLEMENTED` = `MockDmsServer`(纯轻量 JDK 原生模拟,登录互斥、短 TTL、多锁/零锁与状态流转)。 +- **测试与质量指标**: + - `OLD_TEST_COUNT = 203` + - `NEW_TEST_COUNT = 26` + - `TOTAL_TEST_COUNT = 229` (ruoyi-train: 201, ruoyi-admin: 28),Failures = 0, Errors = 0, Skipped = 0。 + - `MAVEN_TEST = SUCCESS`,`MAVEN_PACKAGE = SUCCESS`。 + - `git diff --check = PASS`。 +- **环境纪律与迁移标记**: + - `LIVE_DMS_REQUEST_COUNT = 0`(全生命周期未对公网真实 DMS 发起任何请求)。 + - `FRONTEND_MODIFIED = 0`,`CONTROLLER_COUNT = 0`。 + - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。 + - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。 + - `COMMIT? NO`,`PUSH? NO`,`DEPLOY? NO`(严格 HOLD 等待审查)。