From 0c1d97a6f08219d2009b5a1fc27a0d18629cbe0a Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Wed, 14 Oct 2026 10:52:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=89=A9=E8=81=94=E5=AE=89?= =?UTF-8?q?=E5=85=A8=EF=BC=9A=E6=8C=81=E4=B9=85=E5=8C=96=E6=92=A4=E6=9D=83?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=B9=B6=E7=BB=9F=E4=B8=80=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=A4=96=E5=8F=91=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tool/QaMockMqttApiController.java | 14 +- .../web/controller/tool/TestController.java | 5 +- .../src/main/resources/application-druid.yml | 8 +- .../src/main/resources/application.yml | 21 +- RuoYi-Vue/ruoyi-framework/pom.xml | 3 +- .../config/DruidConsoleSecurityGuard.java | 26 + .../framework/web/service/TokenService.java | 15 +- .../Round5sSecretConfigurationTest.java | 43 + RuoYi-Vue/ruoyi-iot/pom.xml | 2 + .../iot/config/FaceExtractProperties.java | 17 +- .../com/ruoyi/iot/config/IotMqttConfig.java | 5 +- .../ruoyi/iot/config/IotMqttProperties.java | 15 +- .../iot/controller/IotMqttController.java | 2 + .../com/ruoyi/iot/core/MqttClientManager.java | 80 +- .../lock/controller/LockAuthController.java | 3 +- .../iot/lock/domain/ExecutionRecord.java | 52 ++ .../com/ruoyi/iot/lock/domain/LockAuth.java | 2 +- .../ruoyi/iot/lock/domain/LockCommand.java | 9 +- .../ruoyi/iot/lock/mapper/LockAuthMapper.java | 3 + .../iot/lock/mapper/LockCommandMapper.java | 3 + .../iot/lock/mqtt/LockMessageListener.java | 9 +- .../lock/protocol/LockAuthPayloadFactory.java | 46 ++ .../ruoyi/iot/lock/protocol/YdlinkCodec.java | 6 +- .../iot/lock/remote/FaceExtractClient.java | 46 +- .../iot/lock/security/ExecutionStates.java | 18 + .../lock/security/RealIotSecurityGate.java | 258 +++--- .../service/LockExecutionTransactions.java | 31 + .../service/impl/LockAuthServiceImpl.java | 741 +++--------------- .../service/impl/LockCommandServiceImpl.java | 601 +++++--------- .../service/impl/LockDeviceServiceImpl.java | 24 + .../service/impl/LockFaceServiceImpl.java | 35 +- .../service/impl/LockGatewayServiceImpl.java | 4 + .../iot/lock/task/LockCommandRetryTask.java | 2 +- .../controller/OpenAuthApiController.java | 11 +- .../iot/openapi/filter/OpenApiAuthFilter.java | 26 +- .../com/ruoyi/iot/service/IMqttService.java | 4 + .../iot/service/impl/MqttServiceImpl.java | 5 + .../resources/mapper/lock/LockAuthMapper.xml | 28 +- .../mapper/lock/LockCommandMapper.xml | 26 +- .../remote/FaceExtractClientMockTest.java | 15 +- .../security/RealIotSecurityGateTest.java | 125 +-- .../security/Round5sEgressIsolationTest.java | 125 +++ .../Round5sExecutionIntegrationTest.java | 328 ++++++++ .../resources/round5s-before-migration.sql | 21 + .../integration/dms/auth/DmsTokenManager.java | 2 + .../integration/dms/client/DmsHttpClient.java | 2 + .../integration/dms/config/DmsProperties.java | 12 + .../TrainIotLockAuthBridgeServiceImpl.java | 82 +- .../impl/TrainAccommodationServiceImpl.java | 16 +- .../dms/DmsAsyncLifecycleAndSecurityTest.java | 1 + .../dms/Round5sDmsBoundaryTest.java | 23 + .../dms/auth/DmsTokenManagerTest.java | 1 + .../dms/client/DmsDoorLockAdapterTest.java | 1 + .../dms/client/DmsHttpClientTest.java | 2 + .../dms/client/DmsLocationAdapterTest.java | 1 + .../client/DmsOperationStatusAdapterTest.java | 1 + .../dms/client/DmsPasswordAdapterTest.java | 1 + .../AccessAuthorizationOrchestrationTest.java | 1 + .../DmsMockE2EScenariosSuiteTest.java | 1 + .../dms/poller/DmsOperationPollerTest.java | 1 + .../lock/TrainIotLockAuthBridgeMockTest.java | 50 +- .../sql/iot/005_round5s_execution_safety.sql | 26 + build_ui/src/utils/iotExecutionState.js | 13 + build_ui/src/views/iot/auth/index.vue | 13 +- build_ui/src/views/iot/command/index.vue | 12 +- build_ui/src/views/lock.vue | 2 +- docs/iot/ROUND5S_SAFETY_HARDENING.md | 121 +++ qa-bootstrap/manifest.md | 4 +- qa-bootstrap/sql/01_system_foundation.sql | 11 +- qa-bootstrap/sql/05_synthetic_demo_data.sql | 13 +- 70 files changed, 1817 insertions(+), 1459 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConsoleSecurityGuard.java create mode 100644 RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5sSecretConfigurationTest.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/ExecutionRecord.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/LockAuthPayloadFactory.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/ExecutionStates.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/LockExecutionTransactions.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java create mode 100644 RuoYi-Vue/ruoyi-iot/src/test/resources/round5s-before-migration.sql create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/Round5sDmsBoundaryTest.java create mode 100644 RuoYi-Vue/sql/iot/005_round5s_execution_safety.sql create mode 100644 build_ui/src/utils/iotExecutionState.js create mode 100644 docs/iot/ROUND5S_SAFETY_HARDENING.md diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java index 9008def9a..8c9331138 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java @@ -26,13 +26,16 @@ import com.ruoyi.iot.service.IMqttService; */ @RestController @RequestMapping("/qa/mock") -@ConditionalOnProperty(name = "iot.mqtt.mock", havingValue = "true") +@ConditionalOnProperty(name = {"iot.mqtt.mock", "iot.qa-api-enabled"}, havingValue = "true") @PreAuthorize("@ss.hasRole('admin') or @ss.hasPermi('iot:command:edit')") public class QaMockMqttApiController { @Autowired private IMqttService mqttService; + @Autowired + private com.ruoyi.iot.lock.service.ILockCommandService commandService; + /** * 获取所有拦截到的下行指令 */ @@ -83,7 +86,14 @@ public class QaMockMqttApiController YdlinkFrameDTO frame = new YdlinkFrameDTO(); frame.setDevId(groupCode); frame.setSeq(commandNo); - frame.setOp(op); + com.ruoyi.iot.lock.domain.LockCommand command = commandService.selectCommandList(new com.ruoyi.iot.lock.domain.LockCommand()) + .stream().filter(c -> commandNo.equals(c.getCommandNo())).findFirst().orElseThrow(); + com.ruoyi.iot.lock.protocol.YdlinkFrameDTO original = com.ruoyi.iot.lock.protocol.YdlinkCodec.fromJson(command.getPayload()); + if (original == null) return AjaxResult.error("Command payload is invalid"); + groupCode = original.getDevId(); deviceSn = command.getDeviceSn(); + op = String.valueOf(original.getParams().get(0)); + topic = command.getTopic() + "_report"; + frame.setDevId(groupCode); frame.setOp(original.getOp()); frame.setAt(String.valueOf(System.currentTimeMillis() / 1000L)); List records = new ArrayList<>(); diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/TestController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/TestController.java index b56a09766..075c2aa85 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/TestController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/TestController.java @@ -25,14 +25,15 @@ import io.swagger.v3.oas.annotations.tags.Tag; * @author ruoyi */ @Tag(name = "用户信息管理") +@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = "iot.qa-api-enabled", havingValue = "true") +@org.springframework.security.access.prepost.PreAuthorize("@ss.hasRole('admin')") @RestController @RequestMapping("/test/user") public class TestController extends BaseController { private final static Map users = new LinkedHashMap(); { - users.put(1, new UserEntity(1, "admin", "admin123", "15888888888")); - users.put(2, new UserEntity(2, "ry", "admin123", "15666666666")); + users.put(1, new UserEntity(1, "synthetic-user", "", "")); } @Operation(summary = "获取用户列表") diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml index 324fc96c9..36856144a 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application-druid.yml @@ -48,13 +48,13 @@ spring: webStatFilter: enabled: true statViewServlet: - enabled: true + enabled: ${DRUID_CONSOLE_ENABLED:false} # 设置白名单,不填则允许所有访问 allow: url-pattern: /druid/* # 控制台管理用户名和密码 - login-username: ruoyi - login-password: 123456 + login-username: ${DRUID_CONSOLE_USERNAME:} + login-password: ${DRUID_CONSOLE_PASSWORD:} filter: stat: enabled: true @@ -66,4 +66,4 @@ spring: # 若 Druid 无法自动识别 KingbaseES,取消下一行注释显式指定数据库类型 # db-type: kingbase config: - multi-statement-allow: true \ No newline at end of file + multi-statement-allow: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml index 3d2ae19af..2dad1da29 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml @@ -107,7 +107,7 @@ token: # 令牌自定义标识 header: Authorization # 令牌密钥 - secret: abcdefghijklmnopqrstuvwxyz + secret: ${TOKEN_SECRET:} # 令牌有效期(默认30分钟) expireTime: 30 @@ -160,14 +160,19 @@ xss: # IOT设备接入(Mock 模式,拦截外部真实硬件) iot: # 人脸特征提取服务 (Mock 拦截,绝不向外部真实接口发请求) + qa-api-enabled: ${IOT_QA_API_ENABLED:false} face: - endpoint: mock://face-feature-extract - mock: true + endpoint: ${IOT_FACE_ENDPOINT:} + mock: ${IOT_FACE_MOCK:true} + enabled: ${IOT_FACE_ENABLED:false} + real-http-enabled: ${IOT_FACE_REAL_HTTP_ENABLED:false} + environment-id: ${IOT_ENVIRONMENT_ID:local-mock} + allowed-endpoints: [] timeout: 3000 # 第三方开放接口:关闭时接口返回 404;启用后调用方需带请求头 X-API-Key openapi: - enabled: true - api-key: qa-fusion-test-api-key + enabled: ${IOT_OPENAPI_ENABLED:false} + api-key: ${IOT_OPENAPI_KEY:} path-prefix: /openapi/iot mqtt: # 彻底禁用真实 MQTT 连接,启用 Mock 替身模式 @@ -180,7 +185,9 @@ iot: # 历史指令防重放保护(禁止向真实硬件重放系统启动前遗留的历史待发送指令) replay-protection-enabled: true # 定时补偿任务开关 - retry-task-enabled: true + retry-task-enabled: ${IOT_RETRY_TASK_ENABLED:false} + environment-id: ${IOT_ENVIRONMENT_ID:local-mock} + protocol-verified: false broker-url: ${IOT_MQTT_BROKER_URL:tcp://127.0.0.1:1883} # 客户端ID,同一服务器下必须唯一;为空时自动生成 client-id: @@ -191,7 +198,7 @@ iot: connection-timeout: 30 keep-alive-interval: 60 # 断线自动重连 - automatic-reconnect: true + automatic-reconnect: false max-reconnect-delay: 30000 # 连接健康检查周期(毫秒),0 表示不做定时重连 health-check-interval: 30000 diff --git a/RuoYi-Vue/ruoyi-framework/pom.xml b/RuoYi-Vue/ruoyi-framework/pom.xml index 5926470d7..1690edb52 100644 --- a/RuoYi-Vue/ruoyi-framework/pom.xml +++ b/RuoYi-Vue/ruoyi-framework/pom.xml @@ -16,6 +16,7 @@ + junitjunit4.13.2test @@ -61,4 +62,4 @@ - \ No newline at end of file + diff --git a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConsoleSecurityGuard.java b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConsoleSecurityGuard.java new file mode 100644 index 000000000..ed8606ced --- /dev/null +++ b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConsoleSecurityGuard.java @@ -0,0 +1,26 @@ +package com.ruoyi.framework.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +/** Prevent enabling an anonymous Druid console via an environment override. */ +@Component +@ConfigurationProperties(prefix = "spring.datasource.druid.stat-view-servlet") +public class DruidConsoleSecurityGuard { + private boolean enabled; + private String loginUsername; + private String loginPassword; + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean value) { enabled = value; } + public String getLoginUsername() { return loginUsername; } + public void setLoginUsername(String value) { loginUsername = value; } + public String getLoginPassword() { return loginPassword; } + public void setLoginPassword(String value) { loginPassword = value; } + @PostConstruct public void validate() { + if (enabled && (loginUsername == null || loginUsername.isBlank() + || loginPassword == null || loginPassword.length() < 20)) { + throw new IllegalStateException("Enabled Druid console requires injected administrator credentials"); + } + } +} diff --git a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java index 0acae076f..9aedaac1d 100644 --- a/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java +++ b/RuoYi-Vue/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java @@ -39,13 +39,23 @@ public class TokenService private String header; // 令牌秘钥 - @Value("${token.secret}") + @Value("${token.secret:}") private String secret; // 令牌有效期(默认30分钟) @Value("${token.expireTime}") private int expireTime; + private void requireSigningSecret() { + try { + if (secret == null || java.util.Base64.getDecoder().decode(secret).length < 64) { + throw new IllegalArgumentException(); + } + } catch (IllegalArgumentException invalid) { + throw new IllegalStateException("TOKEN_SECRET must be an injected Base64 key of at least 64 random bytes"); + } + } + protected static final long MILLIS_SECOND = 1000; protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND; @@ -114,6 +124,7 @@ public class TokenService */ public String createToken(LoginUser loginUser) { + requireSigningSecret(); String token = IdUtils.fastUUID(); loginUser.setToken(token); setUserAgent(loginUser); @@ -178,6 +189,7 @@ public class TokenService */ private String createToken(Map claims) { + requireSigningSecret(); String token = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret).compact(); @@ -192,6 +204,7 @@ public class TokenService */ private Claims parseToken(String token) { + requireSigningSecret(); return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) diff --git a/RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5sSecretConfigurationTest.java b/RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5sSecretConfigurationTest.java new file mode 100644 index 000000000..65e63455e --- /dev/null +++ b/RuoYi-Vue/ruoyi-framework/src/test/java/com/ruoyi/framework/security/Round5sSecretConfigurationTest.java @@ -0,0 +1,43 @@ +package com.ruoyi.framework.security; + +import static org.junit.Assert.*; +import java.lang.reflect.*; +import java.util.*; +import org.junit.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.env.*; +import com.ruoyi.framework.config.DruidConsoleSecurityGuard; +import com.ruoyi.framework.web.service.TokenService; + +public class Round5sSecretConfigurationTest { + private void setKey(TokenService service,String key)throws Exception { + Field field=TokenService.class.getDeclaredField("secret");field.setAccessible(true);field.set(service,key); + } + @Test public void missingOrWeakJwtKeyCannotCreateTokenOrTouchLoginState()throws Exception { + for(String key:Arrays.asList("", "not-a-key", Base64.getEncoder().encodeToString(new byte[16]))) { + TokenService service=new TokenService();setKey(service,key); + try {service.createToken((com.ruoyi.common.core.domain.model.LoginUser)null);fail();} + catch(IllegalStateException expected) {assertTrue(expected.getMessage().contains("TOKEN_SECRET"));} + } + } + @Test public void injectedStrongJwtKeySignsAndVerifies()throws Exception { + byte[] bytes=new byte[64];new java.security.SecureRandom().nextBytes(bytes); + TokenService service=new TokenService();setKey(service,Base64.getEncoder().encodeToString(bytes)); + Method sign=TokenService.class.getDeclaredMethod("createToken",Map.class);sign.setAccessible(true); + String token=(String)sign.invoke(service,Map.of("subject","isolated-test")); + Method parse=TokenService.class.getDeclaredMethod("parseToken",String.class);parse.setAccessible(true); + io.jsonwebtoken.Claims claims=(io.jsonwebtoken.Claims)parse.invoke(service,token); + assertEquals("isolated-test",claims.get("subject")); // Token/key are never printed or persisted. + } + @Test public void druidDefaultOffAndMissingCredentialsFailClosed() { + DruidConsoleSecurityGuard guard=new DruidConsoleSecurityGuard();guard.validate();guard.setEnabled(true); + try {guard.validate();fail();}catch(IllegalStateException expected){} + } + @Test public void druidRelaxedYamlBindingEnforcesInjectedCredentials() { + StandardEnvironment env=new StandardEnvironment();env.getPropertySources().addFirst(new MapPropertySource("test", + Map.of("spring.datasource.druid.statViewServlet.enabled","true"))); + DruidConsoleSecurityGuard guard=Binder.get(env).bind("spring.datasource.druid.stat-view-servlet",DruidConsoleSecurityGuard.class).get(); + assertTrue(guard.isEnabled());try{guard.validate();fail();}catch(IllegalStateException expected){} + guard.setLoginUsername("test-administrator");guard.setLoginPassword(UUID.randomUUID().toString());guard.validate(); + } +} diff --git a/RuoYi-Vue/ruoyi-iot/pom.xml b/RuoYi-Vue/ruoyi-iot/pom.xml index f43ca24f1..a622866ef 100644 --- a/RuoYi-Vue/ruoyi-iot/pom.xml +++ b/RuoYi-Vue/ruoyi-iot/pom.xml @@ -28,6 +28,8 @@ + com.h2databaseh2test + diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java index 1ace3751b..00645102f 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/FaceExtractProperties.java @@ -17,7 +17,22 @@ import org.springframework.stereotype.Component; public class FaceExtractProperties { /** 特征提取服务地址(未配置 application.yml 时的兜底值) */ - private String endpoint = "http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData"; + private String endpoint = ""; + private boolean mock = true; + private boolean enabled = false; + private boolean realHttpEnabled = false; + private String environmentId = "local-mock"; + private java.util.List allowedEndpoints = new java.util.ArrayList<>(); + public boolean isMock() { return mock; } + public void setMock(boolean value) { mock = value; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean value) { enabled = value; } + public boolean isRealHttpEnabled() { return realHttpEnabled; } + public void setRealHttpEnabled(boolean value) { realHttpEnabled = value; } + public String getEnvironmentId() { return environmentId; } + public void setEnvironmentId(String value) { environmentId = value; } + public java.util.List getAllowedEndpoints() { return allowedEndpoints; } + public void setAllowedEndpoints(java.util.List value) { allowedEndpoints = value; } /** 调用超时(毫秒) */ private int timeout = 10000; diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java index 549751c06..98ccc95ee 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java @@ -37,9 +37,10 @@ public class IotMqttConfig * 客户端管理器,随容器启动建立连接、销毁时释放资源 */ @Bean(initMethod = "start", destroyMethod = "stop") - public MqttClientManager mqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher) + public MqttClientManager mqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher, + com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate) { - return new MqttClientManager(properties, dispatcher); + return new MqttClientManager(properties, dispatcher, securityGate); } /** diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java index 69304884a..76e76d5a4 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java @@ -43,7 +43,7 @@ public class IotMqttProperties implements Serializable private int keepAliveInterval = 60; /** 是否开启Paho内置自动重连 */ - private boolean automaticReconnect = true; + private boolean automaticReconnect = false; /** Paho内置重连的最大退避时间(毫秒) */ private int maxReconnectDelay = 30000; @@ -75,11 +75,18 @@ public class IotMqttProperties implements Serializable /** 真实设备 SN 白名单(仅在白名单内的设备才允许下发控制指令) */ private List deviceWhitelist = new ArrayList(); - /** 是否启用历史命令防重放保护(默认为 true,禁止启动后重放启动前的历史未完成命令) */ + /** Compatibility property only. Provenance/version isolation is mandatory and cannot be disabled. */ private boolean replayProtectionEnabled = true; - /** 定时补偿任务开关(默认开启,但在保护模式下仅补偿启动后产生的命令) */ - private boolean retryTaskEnabled = true; + /** Default off; only current-origin READY outbox entries can be recovered when explicitly enabled. */ + private boolean retryTaskEnabled = false; + + private String environmentId = "local-mock"; + private boolean protocolVerified = false; + public String getEnvironmentId() { return environmentId; } + public void setEnvironmentId(String value) { environmentId = value; } + public boolean isProtocolVerified() { return protocolVerified; } + public void setProtocolVerified(boolean value) { protocolVerified = value; } public boolean isEnabled() { diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java index b9b555369..180b1d25b 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java @@ -22,6 +22,8 @@ import com.ruoyi.iot.service.IMqttService; * * @author ruoyi */ +@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = {"iot.mqtt.mock", "iot.qa-api-enabled"}, havingValue = "true") +@org.springframework.security.access.prepost.PreAuthorize("@ss.hasPermi('iot:mqtt:manage')") @RestController @RequestMapping("/iot/mqtt") public class IotMqttController diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java index fd9bd7716..069dc940a 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java @@ -48,6 +48,7 @@ public class MqttClientManager implements MqttConnectionCallback private final Object connectLock = new Object(); private volatile MqttClient client; + private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; private volatile String runtimeClientId; @@ -56,6 +57,11 @@ public class MqttClientManager implements MqttConnectionCallback /** Mock 模式下记录的已下发报文 */ private final java.util.List> mockPublishedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + public MqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher, + com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate) { + this(properties, dispatcher); this.securityGate = securityGate; + } + public MqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher) { this.properties = properties; @@ -126,6 +132,7 @@ public class MqttClientManager implements MqttConnectionCallback */ public void connect() { + if (properties.isMock() || !properties.isEnabled()) throw new SecurityException("Real MQTT networking is disabled"); synchronized (connectLock) { MqttClient current = this.client; @@ -179,6 +186,7 @@ public class MqttClientManager implements MqttConnectionCallback */ public void reconnect() { + if (properties.isMock() || !properties.isEnabled()) throw new SecurityException("Real MQTT reconnect is disabled"); synchronized (connectLock) { MqttClient current = this.client; @@ -264,46 +272,23 @@ public class MqttClientManager implements MqttConnectionCallback */ public void publish(String topic, Object payload, Integer qos, Boolean retained) { - if (StringUtils.isEmpty(topic)) - { - throw new IllegalArgumentException("发布主题不能为空"); - } - if (properties.isMock()) - { - java.util.Map msg = new java.util.LinkedHashMap<>(); - msg.put("topic", topic); - msg.put("payload", payload); - msg.put("qos", qos == null ? properties.getDefaultQos() : qos); - msg.put("time", new java.util.Date()); - mockPublishedMessages.add(msg); - log.info("[MOCK-MQTT] 拦截下行消息: topic={}, payload={}", topic, payload); - return; - } - // 硬性安全门禁:realDeviceWriteEnabled 未显式开启前,严禁向真实硬件下发任何控制指令 - if (!properties.isRealDeviceWriteEnabled()) - { - log.warn("[REAL-IOT-SECURITY-GATE] 真实硬件写入已全局禁用(realDeviceWriteEnabled=false),安全拦截下行消息: topic={}, payload={}", - topic, payload); - throw new SecurityException("真实硬件写操作已全局禁用 (REAL_DEVICE_WRITE_ENABLED=false)"); - } - MqttMessage message = new MqttMessage(toBytes(payload)); - message.setQos(qos == null ? properties.getDefaultQos() : qos); - message.setRetained(retained == null ? properties.isDefaultRetained() : retained); - try - { - requireClient().publish(topic, message); - log.info("MQTT 消息发布成功, topic={}, qos={}", topic, message.getQos()); - } - catch (MqttPersistenceException e) - { - log.error("MQTT 消息持久化失败, topic={}", topic, e); - throw new IllegalStateException("MQTT消息持久化失败:" + e.getMessage(), e); - } - catch (MqttException e) - { - log.error("MQTT 消息发布失败, topic={}", topic, e); - throw new IllegalStateException("MQTT消息发布失败:" + e.getMessage(), e); - } + if (!properties.isMock()) throw new SecurityException("Raw MQTT publication is disabled outside Mock mode"); + if (StringUtils.isEmpty(topic)) throw new IllegalArgumentException("Topic required"); + java.util.Map message = new java.util.LinkedHashMap<>(); + message.put("topic", topic); message.put("payload", payload); message.put("qos", qos); + message.put("time", new java.util.Date()); mockPublishedMessages.add(message); + } + + public void publishCommand(com.ruoyi.iot.lock.domain.LockCommand command) + { + if (securityGate == null) throw new SecurityException("Command security policy is unavailable"); + securityGate.requirePersistedDispatch(command); + if (properties.isMock()) { publish(command.getTopic(), command.getPayload(), 0, false); return; } + MqttMessage message = new MqttMessage(toBytes(command.getPayload())); + // At-most-once control transport. Unknown outcomes require review, never a hidden QoS retry. + message.setQos(0); message.setRetained(false); + try { requireClient().publish(command.getTopic(), message); } + catch (MqttException error) { throw new IllegalStateException("MQTT outcome unknown; manual reconciliation required"); } } /** @@ -459,17 +444,13 @@ public class MqttClientManager implements MqttConnectionCallback { options.setPassword(properties.getPassword().toCharArray()); } - options.setCleanSession(properties.isCleanSession()); + options.setCleanSession(true); options.setConnectionTimeout(properties.getConnectionTimeout()); options.setKeepAliveInterval(properties.getKeepAliveInterval()); - options.setAutomaticReconnect(properties.isAutomaticReconnect()); + options.setAutomaticReconnect(false); options.setMaxReconnectDelay(properties.getMaxReconnectDelay()); - IotMqttProperties.MqttWill will = properties.getWill(); - if (will != null && StringUtils.isNotEmpty(will.getTopic())) - { - byte[] payload = will.getPayload() == null ? new byte[0] - : will.getPayload().getBytes(StandardCharsets.UTF_8); - options.setWill(will.getTopic(), payload, will.getQos(), will.isRetained()); + if (properties.getWill() != null) { + throw new SecurityException("MQTT will messages are disabled: they bypass the command outbox"); } return options; } @@ -481,7 +462,7 @@ public class MqttClientManager implements MqttConnectionCallback { if (StringUtils.isNotEmpty(properties.getPersistenceDirectory())) { - return new MqttDefaultFilePersistence(properties.getPersistenceDirectory()); + throw new SecurityException("Persisted MQTT packet replay is disabled; recovery belongs to the command outbox"); } return new MemoryPersistence(); } @@ -561,6 +542,7 @@ public class MqttClientManager implements MqttConnectionCallback */ public void mockReceive(String topic, String payload) { + if (!properties.isMock()) throw new SecurityException("Mock receipt injection is disabled in real mode"); log.info("[MOCK-MQTT] 模拟接收设备上行回执: topic={}, payload={}", topic, payload); com.ruoyi.iot.domain.MqttMessageDTO dto = new com.ruoyi.iot.domain.MqttMessageDTO(topic, payload); dto.setTimestamp(new java.util.Date()); diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java index cc07d9200..3c91de4cf 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockAuthController.java @@ -76,7 +76,8 @@ public class LockAuthController extends BaseController @DeleteMapping("/{authIds}") public AjaxResult remove(@PathVariable Long[] authIds) { - return toAjax(lockAuthService.deleteAuthByIds(authIds)); + int removed = lockAuthService.deleteAuthByIds(authIds); + return AjaxResult.success(removed == authIds.length ? "已删除已确认撤权的记录" : "撤权请求已记录,设备确认前保留记录"); } @PreAuthorize("@ss.hasPermi('lock:auth:push')") diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/ExecutionRecord.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/ExecutionRecord.java new file mode 100644 index 000000000..3bd08b9ca --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/ExecutionRecord.java @@ -0,0 +1,52 @@ +package com.ruoyi.iot.lock.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.ruoyi.common.core.domain.BaseEntity; + +/** Server-owned provenance. HTTP callers cannot promote legacy/Mock data. */ +public class ExecutionRecord extends BaseEntity { + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String executionMode; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String environmentId; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String intentKey; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Long intentVersion; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String executionState; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String intentAction; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date intentAt; + @JsonIgnore + private Long previousIntentVersion; + @JsonIgnore + private String previousExecutionState; + + public String getExecutionMode() { return executionMode; } + public void setExecutionMode(String value) { executionMode = value; } + public String getEnvironmentId() { return environmentId; } + public void setEnvironmentId(String value) { environmentId = value; } + public String getIntentKey() { return intentKey; } + public void setIntentKey(String value) { intentKey = value; } + public Long getIntentVersion() { return intentVersion; } + public void setIntentVersion(Long value) { intentVersion = value; } + public String getExecutionState() { return executionState; } + public void setExecutionState(String value) { executionState = value; } + public String getIntentAction() { return intentAction; } + public void setIntentAction(String value) { intentAction = value; } + public Date getIntentAt() { return intentAt; } + public void setIntentAt(Date value) { intentAt = value; } + public Long getPreviousIntentVersion() { return previousIntentVersion; } + public void setPreviousIntentVersion(Long value) { previousIntentVersion = value; } + public String getPreviousExecutionState() { return previousExecutionState; } + public void setPreviousExecutionState(String value) { previousExecutionState = value; } + public void copyProvenance(ExecutionRecord from) { + executionMode = from.executionMode; environmentId = from.environmentId; + intentKey = from.intentKey; intentVersion = from.intentVersion; + intentAction = from.intentAction; intentAt = from.intentAt; + } +} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java index 8fe914e8f..c8cf1fc2d 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockAuth.java @@ -12,7 +12,7 @@ import com.ruoyi.common.core.domain.BaseEntity; * * @author ruoyi */ -public class LockAuth extends BaseEntity +public class LockAuth extends ExecutionRecord { private static final long serialVersionUID = 1L; diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockCommand.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockCommand.java index 514940592..fe3db305c 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockCommand.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockCommand.java @@ -12,7 +12,7 @@ import com.ruoyi.common.core.domain.BaseEntity; * * @author ruoyi */ -public class LockCommand extends BaseEntity +public class LockCommand extends ExecutionRecord { private static final long serialVersionUID = 1L; @@ -86,6 +86,13 @@ public class LockCommand extends BaseEntity /** 失败原因 */ private String errorMsg; + private String deviceSn; + private Date attemptStartedAt; + public String getDeviceSn() { return deviceSn; } + public void setDeviceSn(String value) { deviceSn = value; } + public Date getAttemptStartedAt() { return attemptStartedAt; } + public void setAttemptStartedAt(Date value) { attemptStartedAt = value; } + public Long getCommandId() { return commandId; diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java index 3fc3f2c73..7e63e2ecc 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockAuthMapper.java @@ -11,6 +11,9 @@ import com.ruoyi.iot.lock.domain.LockAuth; */ public interface LockAuthMapper { + LockAuth selectAuthByIdForUpdate(Long authId); + List selectReadyIntents(); + int updateExecution(LockAuth auth); List selectAuthList(LockAuth auth); LockAuth selectAuthById(Long authId); diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java index 639195023..82aa9ce7a 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockCommandMapper.java @@ -11,6 +11,9 @@ import com.ruoyi.iot.lock.domain.LockCommand; */ public interface LockCommandMapper { + LockCommand selectCommandByIdForUpdate(Long commandId); + LockCommand selectCommandByIntentKey(String intentKey); + List selectReadyCommands(); List selectCommandList(LockCommand command); LockCommand selectCommandById(Long commandId); diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java index 563384af6..86c8653d4 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java @@ -76,6 +76,13 @@ public class LockMessageListener implements IMqttMessageListener return; } String result; + if (message.isRetained() && (topic.getTopicType() == com.ruoyi.iot.lock.protocol.YdlinkTopicType.GATEWAY_PROPERTY_SET_RESP + || topic.getTopicType() == com.ruoyi.iot.lock.protocol.YdlinkTopicType.GROUP_ACTION_EXECUTE_RESP + || (topic.getTopicType() == com.ruoyi.iot.lock.protocol.YdlinkTopicType.GROUP_DATA_BATCH_REPORT + && !YdlinkOp.LOCK_RSSI.equals(frame.getOp())))) { + recordLog(topic, frame, message, LockConstants.HANDLE_IGNORE, "RETAINED_ACK_REJECTED", cost(start)); + return; + } String status = LockConstants.HANDLE_SUCCESS; try { @@ -148,7 +155,7 @@ public class LockMessageListener implements IMqttMessageListener } logEntity.setQos(message.getQos()); logEntity.setRetained(message.isRetained() ? "1" : "0"); - logEntity.setPayload(message.getPayload()); + logEntity.setPayload("[redacted]"); logEntity.setHandleStatus(handleStatus); logEntity.setHandleMsg(handleMsg); logEntity.setCostTime(costTime); diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/LockAuthPayloadFactory.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/LockAuthPayloadFactory.java new file mode 100644 index 000000000..0a0539529 --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/LockAuthPayloadFactory.java @@ -0,0 +1,46 @@ +package com.ruoyi.iot.lock.protocol; + +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.*; + +/** Existing Ydlink face/password layouts; no new wire fields or idempotency assumptions. */ +public final class LockAuthPayloadFactory { + private LockAuthPayloadFactory() { } + public static String op(String type) { + if (LockConstants.AUTH_FACE.equals(type)) return YdlinkOp.FACE_FEATURE; + if (LockConstants.AUTH_PASSWORD.equals(type)) return YdlinkOp.PASSWORD; + throw new IllegalArgumentException("Unconfirmed authorization protocol type"); + } + public static YdlinkFrameDTO build(LockAuth auth, LockDevice device, LockGroup group, boolean grant) { + if (device == null || group == null || blank(device.getDeviceSn()) || blank(group.getGroupCode()) + || !device.getDeviceSn().equals(auth.getDeviceSn()) || !validPerson(auth.getPersonNo()) + || blank(device.getDevicePassword()) || blank(device.getDevVer()) || blank(device.getDevType())) { + throw new IllegalArgumentException("Missing or inconsistent authorization protocol identity"); + } + String op = op(auth.getAuthType()); + YdlinkFrameDTO frame = YdlinkCodec.buildDownFrame(op, group.getGroupCode(), device.getDevicePassword(), + device.getDevVer(), device.getDevType()); + if (!grant) { + frame.setFlatParams(Arrays.asList("delete", auth.getPersonNo(), device.getDeviceSn(), device.getDevicePassword())); + } else { + if (blank(auth.getAuthValue()) || auth.getBeginTime() == null || auth.getEndTime() == null + || !auth.getEndTime().after(auth.getBeginTime())) { + throw new IllegalArgumentException("Missing credential or invalid authorization validity window"); + } + if (YdlinkOp.FACE_FEATURE.equals(op)) { + frame.setFlatParams(Arrays.asList("add", auth.getPersonNo(), auth.getAuthValue(), auth.getPersonNo(), + format(auth.getBeginTime()), format(auth.getEndTime()), device.getDeviceSn(), device.getDevicePassword())); + } else { + frame.setFlatParams(Arrays.asList("add", auth.getPersonNo(), auth.getAuthValue(), + format(auth.getBeginTime()), format(auth.getEndTime()), device.getDeviceSn(), device.getDevicePassword())); + } + } + return frame; + } + public static boolean validPerson(String value) { return value != null && value.matches("[A-Za-z0-9]{8}"); } + private static boolean blank(String value) { return value == null || value.trim().isEmpty(); } + private static String format(Date date) { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); } +} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkCodec.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkCodec.java index 5e3164337..639b28f45 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkCodec.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/protocol/YdlinkCodec.java @@ -37,11 +37,7 @@ public class YdlinkCodec */ public static String newSeq() { - long now = System.currentTimeMillis(); - long last = SEQ_LAST.get(); - long value = now > last ? now : last + 1; - SEQ_LAST.set(value); - return String.valueOf(value); + return String.valueOf(SEQ_LAST.updateAndGet(last -> Math.max(System.currentTimeMillis(), last + 1))); } /** diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/remote/FaceExtractClient.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/remote/FaceExtractClient.java index 012244368..625fbcb1c 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/remote/FaceExtractClient.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/remote/FaceExtractClient.java @@ -49,6 +49,16 @@ public class FaceExtractClient @Autowired private FaceExtractProperties properties; + @Autowired + private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; + + public boolean isMock() { return properties.isMock(); } + + public void authorize(String deviceSn) { + if (!properties.isEnabled() || !properties.isRealHttpEnabled()) throw new SecurityException("Real face HTTP is disabled"); + securityGate.requireFaceIdentity(deviceSn, properties.getEnvironmentId()); + } + /** * 调用特征提取服务 * @@ -58,24 +68,30 @@ public class FaceExtractClient * @return 特征值(resultData),失败返回 null */ public String extract(byte[] imageBytes, String fileName, String algorithmVersion) + { + if (!properties.isMock()) throw new SecurityException("Explicit face target identity required"); + return extract(imageBytes, fileName, algorithmVersion, null); + } + + public String extract(byte[] imageBytes, String fileName, String algorithmVersion, String deviceSn) { if (imageBytes == null || imageBytes.length == 0) { return null; } - JSONObject config = configService.loadConfigValue(ILockServiceConfigService.CONFIG_FACE); - // 数据库配置优先,其次取 application.yml 的 iot.face.* - String endpoint = config != null && StringUtils.isNotEmpty(config.getString("endpoint")) - ? config.getString("endpoint") - : properties.getEndpoint(); - - // [QA安全拦截] 拦截外部真实人脸服务请求,返回合成测试特征 - if (Boolean.getBoolean("iot.face.mock") || (endpoint != null && (endpoint.startsWith("mock:") || endpoint.contains("mock")))) - { - log.info("[MOCK-FACE] 拦截真实人脸特征提取请求,返回合成测试特征数据, fileName={}, algo={}", fileName, algorithmVersion); + if (properties.isMock()) { return "mock_face_feature_base64_" + Integer.toHexString(java.util.Arrays.hashCode(imageBytes)); } - + authorize(deviceSn); + JSONObject config = configService.loadConfigValue(ILockServiceConfigService.CONFIG_FACE); + String endpoint = config != null && StringUtils.isNotEmpty(config.getString("endpoint")) + ? config.getString("endpoint") : properties.getEndpoint(); + URI allowed = URI.create(endpoint); + if (properties.getAllowedEndpoints() == null || !properties.getAllowedEndpoints().contains(endpoint) + || allowed.getHost() == null || allowed.getUserInfo() != null || allowed.getFragment() != null + || allowed.getQuery() != null || !("http".equals(allowed.getScheme()) || "https".equals(allowed.getScheme()))) { + throw new SecurityException("Face endpoint is not explicitly authorized"); + } int timeout = config != null && config.getIntValue("timeout") > 0 ? config.getIntValue("timeout") : properties.getTimeout(); @@ -89,7 +105,7 @@ public class FaceExtractClient String requestUrl = appendAlgorithmVersion(endpoint, algorithmVersion); String boundary = "----RuoYiIotBoundary" + System.currentTimeMillis(); byte[] body = buildMultipartBody(boundary, fileName, imageBytes); - HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeout)).build(); + HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).connectTimeout(Duration.ofMillis(timeout)).build(); HttpRequest request = HttpRequest.newBuilder(URI.create(requestUrl)) .timeout(Duration.ofMillis(timeout * 3L)) .header("accept", "*/*") @@ -106,7 +122,7 @@ public class FaceExtractClient JSONObject result = JSON.parseObject(response.body()); if (result == null) { - log.error("特征提取服务返回内容无法解析: {}", response.body()); + log.error("Face service returned an invalid response"); return null; } boolean functionSuccess = result.getBooleanValue("functionSuccess"); @@ -118,12 +134,12 @@ public class FaceExtractClient return null; } String feature = result.getString("resultData"); - log.info("特征提取成功, msg={}, 耗时={}ms", result.getString("msg"), System.currentTimeMillis() - begin); + log.info("Face extraction completed"); return feature; } catch (Exception e) { - log.error("调用特征提取服务失败, endpoint={}, 耗时={}ms", endpoint, System.currentTimeMillis() - begin, e); + log.error("Face HTTP failed; response and endpoint details redacted"); return null; } } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/ExecutionStates.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/ExecutionStates.java new file mode 100644 index 000000000..cc70f061f --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/ExecutionStates.java @@ -0,0 +1,18 @@ +package com.ruoyi.iot.lock.security; + +public final class ExecutionStates { + public static final String READY = "READY"; + public static final String DISPATCHING = "DISPATCHING"; + public static final String AWAITING_ACK = "AWAITING_ACK"; + public static final String CONFIRMED = "CONFIRMED"; + public static final String REJECTED = "REJECTED"; + public static final String MANUAL_REVIEW = "MANUAL_REVIEW"; + public static final String QUARANTINED = "QUARANTINED"; + public static final String CANCELED = "CANCELED"; + public static final String GRANT = "GRANT"; + public static final String REVOKE = "REVOKE"; + private ExecutionStates() { } + public static boolean uncertain(String state) { + return DISPATCHING.equals(state) || AWAITING_ACK.equals(state) || MANUAL_REVIEW.equals(state); + } +} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java index 68a226275..f1e727fc1 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java @@ -1,130 +1,162 @@ package com.ruoyi.iot.lock.security; import java.util.Date; -import java.util.List; -import java.util.regex.Pattern; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.Objects; +import java.util.UUID; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import com.ruoyi.common.utils.StringUtils; import com.ruoyi.iot.config.IotMqttProperties; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.*; +import com.ruoyi.iot.lock.mapper.*; +import com.ruoyi.iot.lock.protocol.*; -/** - * 真实 IoT 硬件接入安全门禁 - *

- * 安全设计职责: - * 1. 真实硬件写操作全局硬开关 (realDeviceWriteEnabled,默认严禁开启) - * 2. 真实设备 SN 白名单校验 (deviceWhitelist) - * 3. 历史指令防重放保护 (replayProtection,系统启动前产生的历史待发指令绝不重放至真实网络) - * 4. 人员门禁编号 8 位硬件规格校验 (personNoValidation) - * 5. 全链路拦截审计日志 - * - * @author ruoyi - */ +/** Fail-closed policy shared by business, recovery and final transport boundaries. */ @Component -public class RealIotSecurityGate -{ - private static final Logger log = LoggerFactory.getLogger(RealIotSecurityGate.class); - - /** 系统启动时间戳,用于防历史指令重放判定 */ - private final Date systemStartupTime = new Date(); - - /** 真实门锁固件及LoRa网关规格要求:人员编号必须严格为 8 位字符(如 AA000001, BA000001) */ - private static final Pattern PERSON_NO_PATTERN = Pattern.compile("^[A-Za-z0-9]{8}$"); - - @Autowired(required = false) - private IotMqttProperties properties; - - public RealIotSecurityGate() - { +public class RealIotSecurityGate { + @Autowired private IotMqttProperties properties; + @Autowired private LockDeviceMapper deviceMapper; + @Autowired private LockGatewayMapper gatewayMapper; + @Autowired private LockGroupMapper groupMapper; + @Autowired private LockAuthMapper authMapper; + @Autowired private LockCommandMapper commandMapper; + public RealIotSecurityGate() { } + public RealIotSecurityGate(IotMqttProperties properties) { this.properties = properties; } + public boolean isMock() { return properties != null && properties.isMock(); } + public boolean isValidPersonNo(String value) { return LockAuthPayloadFactory.validPerson(value); } + public void bindAuthorizationIdentity(LockAuth auth) { + LockDevice device = auth.getDeviceId() == null ? null : deviceMapper.selectDeviceById(auth.getDeviceId()); + LockGateway gateway = device == null || device.getGatewayId() == null ? null : gatewayMapper.selectGatewayById(device.getGatewayId()); + if (device == null || gateway == null || gateway.getGroupId() == null + || (auth.getDeviceSn() != null && !Objects.equals(auth.getDeviceSn(), device.getDeviceSn()))) { + throw new SecurityException("Explicit device/gateway identity required"); + } + auth.setDeviceSn(device.getDeviceSn()); auth.setGatewayId(device.getGatewayId()); auth.setGroupId(gateway.getGroupId()); } - - public RealIotSecurityGate(IotMqttProperties properties) - { - this.properties = properties; + public void requireFaceIdentity(String deviceSn, String environmentId) { + if (isMock() || !validEnvironment() || !Objects.equals(properties.getEnvironmentId(), environmentId) + || !checkDeviceWriteAllowed(deviceSn, "FACE_HTTP")) { + throw new SecurityException("Face HTTP environment/device authorization denied"); + } } - - public void setProperties(IotMqttProperties properties) - { - this.properties = properties; + public boolean checkDeviceWriteAllowed(String sn, String operation) { + if (isMock()) return true; // In-memory transport only; never grants real networking. + return properties != null && properties.isEnabled() && properties.isRealDeviceWriteEnabled() + && sn != null && !sn.trim().isEmpty() && sn.equals(sn.trim()) + && properties.getDeviceWhitelist() != null && properties.getDeviceWhitelist().contains(sn); } - - public Date getSystemStartupTime() - { - return systemStartupTime; + public boolean sameOrigin(ExecutionRecord record) { + return properties != null && record != null && record.getIntentVersion() != null + && record.getIntentVersion() > 0 && record.getIntentKey() != null + && record.getIntentKey().matches("[a-f0-9-]{36}") + && (isMock() ? "MOCK" : "REAL").equals(record.getExecutionMode()) + && validEnvironment() && Objects.equals(properties.getEnvironmentId(), record.getEnvironmentId()); } - - /** - * 校验是否允许向指定设备下发控制指令 - * - * @param deviceSn 设备序列号 - * @param opOrType 操作类型或命令类型 - * @return true 允许下发;false 安全拦截 - */ - public boolean checkDeviceWriteAllowed(String deviceSn, String opOrType) - { - if (properties == null || properties.isMock()) - { - // Mock 模式由本地替身处理,不接触真实硬件 - return true; - } - if (!properties.isRealDeviceWriteEnabled()) - { - log.warn("[REAL-IOT-SECURITY-GATE] 拦截原因: realDeviceWriteEnabled=false (真实硬件写操作已全局禁用), deviceSn={}, op={}", - deviceSn, opOrType); - return false; - } - List whitelist = properties.getDeviceWhitelist(); - if (whitelist != null && !whitelist.isEmpty()) - { - if (StringUtils.isEmpty(deviceSn) || !whitelist.contains(deviceSn.trim())) - { - log.warn("[REAL-IOT-SECURITY-GATE] 拦截原因: 设备SN不在白名单内, deviceSn={}, whitelist={}, op={}", - deviceSn, whitelist, opOrType); - return false; - } - } - return true; + private boolean validEnvironment() { + String env = properties == null ? null : properties.getEnvironmentId(); + return env != null && env.matches("[A-Za-z0-9][A-Za-z0-9_.-]{2,127}") + && (isMock() || !env.toLowerCase(java.util.Locale.ROOT).contains("mock")); } - - /** - * 校验待重试指令是否允许重放(防重放:系统启动前的历史遗留待发指令坚决不向真实网络重发) - * - * @param commandReqTime 指令原始请求/落库时间 - * @return true 允许重试;false 判定为历史脏数据予以拦截 - */ - public boolean checkReplayAllowed(Date commandReqTime) - { - if (properties == null || properties.isMock()) - { - return true; - } - if (!properties.isReplayProtectionEnabled()) - { - return true; - } - if (commandReqTime == null || commandReqTime.before(systemStartupTime)) - { - log.warn("[REAL-IOT-SECURITY-GATE] 拦截历史指令重放: reqTime={} 早于当前节点启动时间 startupTime={}, 放弃重发", - commandReqTime, systemStartupTime); - return false; - } - return true; + public void initializeIntent(ExecutionRecord record, String action) { + if (!validEnvironment()) throw new SecurityException("An explicit environment identity is required"); + record.setExecutionMode(isMock() ? "MOCK" : "REAL"); + record.setEnvironmentId(properties.getEnvironmentId()); + record.setIntentVersion(1L); record.setIntentKey(UUID.randomUUID().toString()); + record.setIntentAction(action); record.setIntentAt(new Date()); + record.setExecutionState(ExecutionStates.READY); } - - /** - * 校验人员编号是否符合真实门锁规范 (严格 8 位字母数字) - * - * @param personNo 人员编号 - * @return true 格式符合规范;false 不符合规范 - */ - public boolean isValidPersonNo(String personNo) - { - if (StringUtils.isEmpty(personNo)) - { - return false; + public void advanceIntent(LockAuth auth, String action) { + if (!sameOrigin(auth)) throw new SecurityException("Historical provenance requires explicit offline reconciliation"); + boolean uncertain = ExecutionStates.uncertain(auth.getExecutionState()); + if (uncertain && ExecutionStates.GRANT.equals(action)) { + throw new SecurityException("Previous device outcome requires manual reconciliation before a new grant"); + } + auth.setPreviousIntentVersion(auth.getIntentVersion()); + auth.setPreviousExecutionState(auth.getExecutionState()); + auth.setIntentVersion(Math.addExact(auth.getIntentVersion(), 1)); + auth.setIntentKey(UUID.randomUUID().toString()); auth.setIntentAt(new Date()); + auth.setIntentAction(action); + auth.setExecutionState(uncertain ? ExecutionStates.MANUAL_REVIEW : ExecutionStates.READY); + } + public static boolean sameIntent(ExecutionRecord a, ExecutionRecord b) { + return a != null && b != null && Objects.equals(a.getIntentKey(), b.getIntentKey()) + && a.getIntentKey() != null && Objects.equals(a.getIntentVersion(), b.getIntentVersion()) + && Objects.equals(a.getExecutionMode(), b.getExecutionMode()) + && Objects.equals(a.getEnvironmentId(), b.getEnvironmentId()); + } + public void requirePayloadAndIdentity(LockCommand command) { + if (!sameOrigin(command)) throw new SecurityException("Untrusted command provenance"); + YdlinkFrameDTO frame = YdlinkCodec.fromJson(command.getPayload()); + if (frame == null || !Objects.equals(command.getCommandNo(), frame.getSeq()) + || !YdlinkCodec.MODE_S2D.equals(frame.getMode()) || !"req".equals(frame.getAc()) + || frame.getParams() == null || frame.getParams().isEmpty()) { + throw new IllegalArgumentException("Invalid or empty Ydlink command payload"); + } + boolean authCommand = LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) + || LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()); + if (!authCommand && !isMock()) { + // Gateway-wide controls have no proven device-scoped contract. + throw new SecurityException("Real control is restricted to reviewed device-scoped authorization commands"); + } + if (!authCommand) return; + LockAuth auth = command.getBizId() == null ? null : authMapper.selectAuthById(command.getBizId()); + if (!sameIntent(auth, command) || !sameOrigin(auth) || !"0".equals(auth.getDelFlag()) + || !Objects.equals(command.getDeviceSn(), auth.getDeviceSn())) { + throw new SecurityException("Command does not match the current persisted business intent"); + } + boolean grant = LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()); + if (!(grant ? ExecutionStates.GRANT : ExecutionStates.REVOKE).equals(auth.getIntentAction()) + || !(grant ? "0" : "1").equals(auth.getStatus())) { + throw new SecurityException("Business intent direction mismatch"); + } + LockDevice device = deviceMapper.selectDeviceById(auth.getDeviceId()); + LockGateway gateway = device == null || device.getGatewayId() == null ? null + : gatewayMapper.selectGatewayById(device.getGatewayId()); + LockGroup group = gateway == null || gateway.getGroupId() == null ? null + : groupMapper.selectGroupById(gateway.getGroupId()); + if (device == null || gateway == null || group == null + || !Objects.equals(device.getGatewayId(), auth.getGatewayId()) + || !Objects.equals(gateway.getGroupId(), auth.getGroupId()) + || !Objects.equals(command.getDeviceSn(), device.getDeviceSn()) + || !LockConstants.TARGET_GROUP.equals(command.getTargetType()) + || !Objects.equals(command.getTargetId(), group.getGroupId()) + || !Objects.equals(command.getTargetCode(), group.getGroupCode()) + || !Objects.equals(command.getTopic(), YdlinkTopicBuilder.groupDataBatch(group.getGroupCode()))) { + throw new SecurityException("Device/gateway/group routing snapshot no longer matches"); + } + YdlinkFrameDTO expected = LockAuthPayloadFactory.build(auth, device, group, grant); + if (!isMock() && grant && auth.getAuthValue() != null + && auth.getAuthValue().toLowerCase(java.util.Locale.ROOT).startsWith("mock")) { + throw new SecurityException("Synthetic credentials cannot enter real commands"); + } + if (!Objects.equals(expected.getParams(), frame.getParams()) || !Objects.equals(expected.getOp(), frame.getOp()) + || !Objects.equals(expected.getDevId(), frame.getDevId()) + || !Objects.equals(expected.getDevPsw(), frame.getDevPsw()) + || !Objects.equals(expected.getDevVer(), frame.getDevVer()) + || !Objects.equals(expected.getDevType(), frame.getDevType()) + || !Objects.equals(expected.getChksum(), frame.getChksum()) + || frame.getAt() == null || !frame.getAt().matches("[0-9]+")) { + throw new IllegalArgumentException("Payload does not match the persisted authorization snapshot"); + } + } + public void requireDispatchAllowed(LockCommand command) { + requirePayloadAndIdentity(command); + if (!checkDeviceWriteAllowed(command.getDeviceSn(), command.getCommandType())) { + throw new SecurityException("Real network/write authorization or device allowlist denied"); + } + if (!isMock() && !properties.isProtocolVerified()) { + throw new SecurityException("Teacher protocol evidence must be approved before real transmission"); + } + } + public void requirePersistedDispatch(LockCommand command) { + requireDispatchAllowed(command); + LockCommand stored = command.getCommandId() == null ? null : commandMapper.selectCommandById(command.getCommandId()); + if (!sameIntent(stored, command) || !ExecutionStates.DISPATCHING.equals(stored.getExecutionState()) + || stored.getAttemptStartedAt() == null || !Objects.equals(stored.getPayload(), command.getPayload()) + || !Objects.equals(stored.getTopic(), command.getTopic()) + || !Objects.equals(stored.getCommandNo(), command.getCommandNo()) + || !Objects.equals(stored.getDeviceSn(), command.getDeviceSn())) { + throw new SecurityException("Transport requires a committed, immutable dispatch claim"); } - return PERSON_NO_PATTERN.matcher(personNo.trim()).matches(); } } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/LockExecutionTransactions.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/LockExecutionTransactions.java new file mode 100644 index 000000000..6253ded9c --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/LockExecutionTransactions.java @@ -0,0 +1,31 @@ +package com.ruoyi.iot.lock.service; + +import java.util.function.Supplier; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** A committed dispatch claim must exist before any network side effect. */ +@Component +public class LockExecutionTransactions { + private final TransactionTemplate required; + private final TransactionTemplate independent; + public LockExecutionTransactions(PlatformTransactionManager manager) { + required = new TransactionTemplate(manager); + independent = new TransactionTemplate(manager); + independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + public T required(Supplier work) { return required.execute(status -> work.get()); } + public T committed(Supplier work) { return independent.execute(status -> work.get()); } + public void afterCommit(Runnable work) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Command enqueue requires an active transaction"); + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { work.run(); } + }); + } +} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java index e6fc589ee..9eaf49644 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java @@ -1,639 +1,156 @@ package com.ruoyi.iot.lock.service.impl; -import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Date; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.ruoyi.common.utils.StringUtils; import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.LockAuth; -import com.ruoyi.iot.lock.domain.LockCommand; -import com.ruoyi.iot.lock.domain.LockDevice; -import com.ruoyi.iot.lock.domain.LockGateway; -import com.ruoyi.iot.lock.domain.LockGroup; -import com.ruoyi.iot.lock.mapper.LockAuthMapper; -import com.ruoyi.iot.lock.mapper.LockCommandMapper; -import com.ruoyi.iot.lock.mapper.LockDeviceMapper; -import com.ruoyi.iot.lock.mapper.LockGatewayMapper; -import com.ruoyi.iot.lock.mapper.LockGroupMapper; -import com.ruoyi.iot.lock.protocol.YdlinkCodec; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkOp; -import com.ruoyi.iot.lock.protocol.YdlinkTopicBuilder; -import com.ruoyi.iot.lock.service.ILockAuthService; -import com.ruoyi.iot.lock.service.ILockCommandService; +import com.ruoyi.iot.lock.domain.*; +import com.ruoyi.iot.lock.mapper.*; +import com.ruoyi.iot.lock.protocol.*; +import com.ruoyi.iot.lock.security.*; +import com.ruoyi.iot.lock.service.*; -/** - * 授权服务实现 - * - * @author ruoyi - */ +/** Persisted desired intent is independent from transport and device confirmation. */ @Service -public class LockAuthServiceImpl implements ILockAuthService -{ - private static final Logger log = LoggerFactory.getLogger(LockAuthServiceImpl.class); +public class LockAuthServiceImpl implements ILockAuthService { + @Autowired private LockAuthMapper authMapper; + @Autowired private LockDeviceMapper deviceMapper; + @Autowired private LockGatewayMapper gatewayMapper; + @Autowired private LockGroupMapper groupMapper; + @Autowired private LockCommandMapper commandMapper; + @Autowired private ILockCommandService commandService; + @Autowired private RealIotSecurityGate securityGate; + @Autowired private LockExecutionTransactions transactions; - private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + @Override public List selectAuthList(LockAuth query) { return authMapper.selectAuthList(query); } + @Override public LockAuth selectAuthById(Long id) { return authMapper.selectAuthById(id); } + @Override public List selectAuthsByDeviceId(Long id) { return authMapper.selectAuthsByDeviceId(id); } - @Autowired - private LockAuthMapper authMapper; - - @Autowired - private LockDeviceMapper deviceMapper; - - @Autowired - private LockGatewayMapper gatewayMapper; - - @Autowired - private LockGroupMapper groupMapper; - - @Autowired - private LockCommandMapper commandMapper; - - @Autowired - private ILockCommandService commandService; - - @Autowired(required = false) - private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; - - @Override - public List selectAuthList(LockAuth auth) - { - return authMapper.selectAuthList(auth); + @Override public int insertAuth(LockAuth auth) { + return transactions.required(() -> { + fillIdentity(auth); + securityGate.initializeIntent(auth, ExecutionStates.GRANT); + auth.setAuthNo("AUTH" + UUID.randomUUID().toString().replace("-", "")); + auth.setStatus("0"); auth.setDelFlag("0"); auth.setAuthTime(new Date()); + auth.setSyncStatus(LockConstants.SYNC_PENDING); + int rows = authMapper.insertAuth(auth); + enqueue(auth); + return rows; + }); } - - @Override - public LockAuth selectAuthById(Long authId) - { - return authMapper.selectAuthById(authId); - } - - @Override - public List selectAuthsByDeviceId(Long deviceId) - { - return authMapper.selectAuthsByDeviceId(deviceId); - } - - @Override - @Transactional - public int insertAuth(LockAuth auth) - { - fillAuth(auth); - int rows = authMapper.insertAuth(auth); - pushInternal(auth, true); - return rows; - } - - @Override - @Transactional - public int updateAuth(LockAuth auth) - { - fillAuth(auth); - int rows = authMapper.updateAuth(auth); - pushInternal(auth, true); - return rows; - } - - @Override - @Transactional - public int deleteAuthByIds(Long[] authIds) - { - for (Long authId : authIds) - { - LockAuth auth = authMapper.selectAuthById(authId); - if (auth != null) - { - pushClear(auth); + @Override public int updateAuth(LockAuth input) { + return transactions.required(() -> { + LockAuth auth = requireAuth(input.getAuthId()); + if (!securityGate.sameOrigin(auth)) throw new SecurityException("Historical authorization cannot be promoted"); + if ((input.getDeviceId() != null && !Objects.equals(input.getDeviceId(), auth.getDeviceId())) + || (input.getPersonNo() != null && !Objects.equals(input.getPersonNo(), auth.getPersonNo()))) { + throw new SecurityException("Revoke the previous identity before creating another device/person grant"); } - } - return authMapper.deleteAuthByIds(authIds); + if (input.getAuthType() != null) auth.setAuthType(input.getAuthType()); + if (input.getAuthValue() != null) auth.setAuthValue(input.getAuthValue()); + if (input.getBeginTime() != null) auth.setBeginTime(input.getBeginTime()); + if (input.getEndTime() != null) auth.setEndTime(input.getEndTime()); + if (input.getPersonName() != null) auth.setPersonName(input.getPersonName()); + auth.setUpdateBy(input.getUpdateBy()); + fillIdentity(auth); securityGate.advanceIntent(auth, ExecutionStates.GRANT); + auth.setStatus("0"); auth.setSyncStatus(LockConstants.SYNC_PENDING); + if (authMapper.updateAuth(auth) != 1) throw new IllegalStateException("Concurrent intent update"); + enqueue(auth); return 1; + }); } - - @Override - public void push(Long authId) - { - LockAuth auth = authMapper.selectAuthById(authId); - if (auth == null) - { - throw new IllegalArgumentException("授权不存在"); - } - pushInternal(auth, true); + private LockAuth requireAuth(Long id) { + LockAuth auth = authMapper.selectAuthByIdForUpdate(id); + if (auth == null || !"0".equals(auth.getDelFlag())) throw new IllegalArgumentException("Authorization not found"); + return auth; } - - @Override - public void batchPush(Long[] authIds) - { - for (Long authId : authIds) - { - try - { - push(authId); + private void fillIdentity(LockAuth auth) { + if (!securityGate.isValidPersonNo(auth.getPersonNo())) throw new IllegalArgumentException("Person number must be 8 alphanumeric characters"); + LockDevice device = auth.getDeviceId() == null ? null : deviceMapper.selectDeviceById(auth.getDeviceId()); + LockGateway gateway = device == null || device.getGatewayId() == null ? null : gatewayMapper.selectGatewayById(device.getGatewayId()); + if (device == null || gateway == null || gateway.getGroupId() == null) throw new IllegalArgumentException("Explicit device/gateway/group binding required"); + auth.setDeviceSn(device.getDeviceSn()); auth.setGatewayId(device.getGatewayId()); auth.setGroupId(gateway.getGroupId()); + } + @Override public void push(Long id) { + transactions.required(() -> { + LockAuth auth = requireAuth(id); + if (!"0".equals(auth.getStatus()) || !ExecutionStates.GRANT.equals(auth.getIntentAction())) { + throw new SecurityException("A revoked or historical authorization cannot be repushed"); } - catch (Exception e) - { - log.warn("授权下发失败, authId={}, msg={}", authId, e.getMessage()); - } - } + enqueue(auth); return null; + }); } - - @Override - public void clear(Long authId) - { - LockAuth auth = authMapper.selectAuthById(authId); - if (auth == null) - { - throw new IllegalArgumentException("授权不存在"); - } - // 核心安全门禁:将授权状态明确置为停用/撤销中 (status='1'),从源头杜绝被建权调度误扫 - auth.setStatus("1"); - auth.setRemark(StringUtils.defaultString(auth.getRemark()) + " [已进入撤权流程]"); + @Override public void clear(Long id) { + transactions.required(() -> { + LockAuth auth = requireAuth(id); + if (!securityGate.sameOrigin(auth)) { + auth.setStatus("1"); auth.setIntentAction(ExecutionStates.REVOKE); + quarantine(auth, "UNPROVEN_REVOKE_REQUIRES_OFFLINE_RECONCILIATION"); return null; + } + if (!ExecutionStates.REVOKE.equals(auth.getIntentAction())) { + securityGate.advanceIntent(auth, ExecutionStates.REVOKE); + auth.setStatus("1"); auth.setSyncStatus(LockConstants.SYNC_PENDING); + if (authMapper.updateAuth(auth) != 1) throw new IllegalStateException("Concurrent revoke intent update"); + } + enqueue(auth); return null; + }); + } + private void quarantine(LockAuth auth, String reason) { + auth.setExecutionState(ExecutionStates.QUARANTINED); + auth.setSyncStatus(LockConstants.SYNC_FAIL); auth.setRemark(reason); authMapper.updateAuth(auth); - pushClear(auth); } - - @Override - public void batchClear(Long[] authIds) - { - for (Long authId : authIds) - { - try - { - clear(authId); - } - catch (Exception e) - { - log.warn("授权清除失败, authId={}, msg={}", authId, e.getMessage()); - } + private boolean enqueue(LockAuth auth) { + if (!securityGate.sameOrigin(auth)) { quarantine(auth, "PROVENANCE_MISMATCH"); return false; } + if (!ExecutionStates.READY.equals(auth.getExecutionState())) return false; + LockCommand existing = commandMapper.selectCommandByIntentKey(auth.getIntentKey()); + if (existing != null) return false; // READY command recovery belongs to the command outbox, never create a duplicate. + try { + boolean grant = ExecutionStates.GRANT.equals(auth.getIntentAction()); + if (!grant && !ExecutionStates.REVOKE.equals(auth.getIntentAction())) throw new IllegalArgumentException("Unknown intent direction"); + LockDevice device = deviceMapper.selectDeviceById(auth.getDeviceId()); + LockGroup group = groupMapper.selectGroupById(auth.getGroupId()); + YdlinkFrameDTO frame = LockAuthPayloadFactory.build(auth, device, group, grant); + LockCommand command = new LockCommand(); command.copyProvenance(auth); + command.setDeviceSn(auth.getDeviceSn()); command.setCommandNo(frame.getSeq()); + command.setCommandType(grant ? LockConstants.CMD_AUTH_PUSH : LockConstants.CMD_AUTH_CLEAR); + command.setTargetType(LockConstants.TARGET_GROUP); command.setTargetId(auth.getGroupId()); + command.setTargetCode(group.getGroupCode()); command.setBizId(auth.getAuthId()); + command.setTopic(YdlinkTopicBuilder.groupDataBatch(group.getGroupCode())); + command.setPayload(YdlinkCodec.toJson(frame)); + securityGate.requirePayloadAndIdentity(command); + commandService.send(command); + return true; + } catch (IllegalArgumentException | SecurityException invalid) { + auth.setExecutionState(ExecutionStates.MANUAL_REVIEW); + auth.setSyncStatus(LockConstants.SYNC_FAIL); auth.setSyncMsg("INVALID_MAPPING_OR_PROTOCOL_REQUIRES_REVIEW"); + authMapper.updateExecution(auth); return false; } } - - @Override - @Transactional - public void handleResult(String seq, boolean success, String message) - { - if (StringUtils.isEmpty(seq)) - { - return; - } - LockCommand command = commandMapper.selectCommandByNo(seq); - if (command == null || command.getBizId() == null) - { - return; - } - if (!LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) - && !LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType())) - { - // 仅授权类命令回写授权同步状态,避免 bizId 与门锁等其它业务ID撞号误更新 - return; - } - LockAuth auth = authMapper.selectAuthById(command.getBizId()); - if (auth == null) - { - return; - } - - // 核心安全防御 1:若命令已被取消/作废 (STATUS_CANCELED),属于旧周期的已作废命令,坚决丢弃晚到回执 - if (LockConstants.STATUS_CANCELED.equals(command.getCommandStatus())) - { - log.warn("命令已被作废取消(seq={}),丢弃晚到回执,不改写授权状态", seq); - return; - } - - // 核心安全防御 2:若回执属于旧撤权命令 (CMD_AUTH_CLEAR),但此时授权已被重新入住激活为正常在用 (status='0') - if (LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()) && "0".equals(auth.getStatus())) - { - log.warn("收到旧撤权命令晚到回执(seq={}),但授权 authId={} 已被重新激活(status=0),坚决丢弃,防止错误清除新权限!", - seq, auth.getAuthId()); - return; - } - - // 核心安全防御 3:若回执属于旧建权命令 (CMD_AUTH_PUSH),但此时授权已处于退宿撤权流程 (status='1') - if (LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) && "1".equals(auth.getStatus())) - { - log.warn("收到旧建权命令晚到回执(seq={}),但授权 authId={} 已处于退宿撤权流程(status=1),坚决丢弃,防止篡改撤权状态!", - seq, auth.getAuthId()); - return; - } - - if (LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType())) - { - if (success) - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_CLEARED, "撤权成功"); - } - else - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, "撤权失败: " + StringUtils.substring(message, 0, 480)); - } - } - else - { - String syncStatus = success ? LockConstants.SYNC_SUCCESS : LockConstants.SYNC_FAIL; - authMapper.updateSyncStatus(auth.getAuthId(), syncStatus, StringUtils.substring(message, 0, 500)); - } - } - - @Override - public int retryPending() - { + @Override public int retryPending() { int count = 0; - - // 1. 待建权补偿(status = '0') - List pendingGrants = authMapper.selectPendingAuths(null); - if (pendingGrants != null) - { - for (LockAuth auth : pendingGrants) - { - // 硬门禁 1:停用/已撤销状态 (status != '0') 严禁反向执行建权下发! - if (!"0".equals(auth.getStatus())) - { - log.warn("授权处于停用/待撤销状态(status={}),严禁反向执行建权下发: authId={}", - auth.getStatus(), auth.getAuthId()); - continue; - } - - // 硬门禁 2:检查是否已有活跃指令正在执行或待重试,杜绝与 commandService.retryPending 重复下发 - if (hasActiveCommand(auth.getAuthId())) - { - log.info("授权 authId={} 已存在活跃命令在执行/重试中,跳过重复创建", auth.getAuthId()); - continue; - } - - // 硬门禁 2.5:防历史重放检查(系统启动前的历史数据不向真实设备重放) - if (securityGate != null && !securityGate.checkReplayAllowed(auth.getAuthTime() != null ? auth.getAuthTime() : auth.getCreateTime())) - { - log.info("授权 authId={} 属于系统启动前历史数据,防重放机制阻止补偿重推", auth.getAuthId()); - continue; - } - - // 经过双重门禁校验后,才安全执行建权补偿下发 - try - { - pushInternal(auth, true); - count++; - } - catch (Exception e) - { - log.warn("授权补偿下发失败, authId={}, msg={}", auth.getAuthId(), e.getMessage()); - } - } + for (LockAuth snapshot : authMapper.selectReadyIntents()) { + if (transactions.required(() -> enqueue(requireAuth(snapshot.getAuthId())))) count++; } - - // 2. 待撤权补偿(status = '1',意图已持久化但 AUTH_CLEAR 指令未完成下发/未终态) - List pendingRevokes = authMapper.selectPendingRevocations(null); - if (pendingRevokes != null) - { - for (LockAuth auth : pendingRevokes) - { - // 硬门禁 3:必须处于停用/待撤权状态 (status == '1') - if (!"1".equals(auth.getStatus())) - { - continue; - } - - // 若已处于彻底清除终态,跳过 - if (LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus())) - { - continue; - } - - // 硬门禁 4:检查是否已有未终结的活跃撤权指令在执行/重试中,防止重复生成有效指令 - if (hasActiveCommand(auth.getAuthId())) - { - log.info("撤权记录 authId={} 已存在活跃命令在执行/重试中,跳过重复创建", auth.getAuthId()); - continue; - } - - // 硬门禁 4.5:防历史重放检查 - if (securityGate != null && !securityGate.checkReplayAllowed(auth.getAuthTime() != null ? auth.getAuthTime() : auth.getCreateTime())) - { - log.info("撤权记录 authId={} 属于系统启动前历史数据,防重放机制阻止补偿重推", auth.getAuthId()); - continue; - } - - // 补建 AUTH_CLEAR 撤权指令 - try - { - log.info("发现待撤权意图记录 authId={}, 补发 AUTH_CLEAR 撤权下行指令", auth.getAuthId()); - pushClear(auth); - count++; - } - catch (Exception e) - { - log.warn("撤权补偿下发失败, authId={}, msg={}", auth.getAuthId(), e.getMessage()); - } - } - } - return count; } - - /** - * 检查该授权在下行命令记录表中是否存在未终结命令 (待发送、已发送、重试中) - */ - private boolean hasActiveCommand(Long authId) - { - if (authId == null || commandMapper == null) - { - return false; - } - LockCommand query = new LockCommand(); - query.setBizId(authId); - List list = commandMapper.selectCommandList(query); - if (list != null) - { - for (LockCommand cmd : list) - { - if (LockConstants.STATUS_PENDING.equals(cmd.getCommandStatus()) - || LockConstants.STATUS_SENT.equals(cmd.getCommandStatus()) - || LockConstants.STATUS_FAIL.equals(cmd.getCommandStatus())) - { - return true; - } + @Override public int deleteAuthByIds(Long[] ids) { + return transactions.required(() -> { + int deleted = 0; + for (Long id : ids) { + LockAuth auth = requireAuth(id); + if (ExecutionStates.REVOKE.equals(auth.getIntentAction()) + && ExecutionStates.CONFIRMED.equals(auth.getExecutionState()) + && LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus())) { + deleted += authMapper.deleteAuthByIds(new Long[]{id}); + } else clear(id); // Retain unresolved revocation intent; do not hide it with del_flag. } - } - return false; + return deleted; + }); } - - /** - * 冗余字段填充:从门锁与人员信息补齐下发所需快照 - */ - private void fillAuth(LockAuth auth) - { - if (StringUtils.isEmpty(auth.getAuthNo())) - { - auth.setAuthNo("AUTH" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) - + StringUtils.substring(com.ruoyi.common.utils.uuid.IdUtils.fastSimpleUUID(), 0, 6).toUpperCase()); - } - if (auth.getDeviceId() != null) - { - LockDevice device = deviceMapper.selectDeviceById(auth.getDeviceId()); - if (device != null) - { - auth.setDeviceSn(device.getDeviceSn()); - auth.setGatewayId(device.getGatewayId()); - auth.setGroupId(resolveGroupId(device)); - } - } - if (auth.getAuthTime() == null) - { - auth.setAuthTime(new Date()); - } - } - - /** - * 下发授权:ydlink/{groupCode}/thing/data/batch - */ - private void pushInternal(LockAuth auth, boolean add) - { - String failReason = resolvePushFailReason(auth); - if (failReason != null) - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, failReason); - log.warn("授权下发跳过, authId={}, reason={}", auth.getAuthId(), failReason); - return; - } - if (securityGate != null && !securityGate.checkDeviceWriteAllowed(auth.getDeviceSn(), add ? "AUTH_PUSH" : "AUTH_CLEAR")) - { - String reason = "[REAL-IOT-SECURITY-GATE] 拦截下发: 真实硬件写已全局禁用或设备SN不在白名单: " + auth.getDeviceSn(); - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, reason); - log.warn(reason + ", authId={}", auth.getAuthId()); - return; - } - LockGroup group = groupMapper.selectGroupById(auth.getGroupId()); - if (add && StringUtils.isEmpty(auth.getAuthValue())) - { - // 授权内容(人脸特征/密码/卡号)为空时下发无意义,设备会拒绝 - String reason = "授权内容为空,未获取到有效特征数据,authType=" + auth.getAuthType(); - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, reason); - log.warn("授权下发跳过, authId={}, reason={}", auth.getAuthId(), reason); - return; - } - YdlinkFrameDTO frame = buildAuthFrame(auth, add); - LockCommand command = new LockCommand(); - command.setCommandNo(frame.getSeq()); - command.setCommandType(add ? LockConstants.CMD_AUTH_PUSH : LockConstants.CMD_AUTH_CLEAR); - command.setTargetType(LockConstants.TARGET_GROUP); - command.setTargetId(auth.getGroupId()); - command.setTargetCode(group.getGroupCode()); - command.setBizId(auth.getAuthId()); - command.setTopic(YdlinkTopicBuilder.groupDataBatch(group.getGroupCode())); - LockCommand sentCommand = commandService.send(command); - if (sentCommand != null && LockConstants.STATUS_FAIL.equals(sentCommand.getCommandStatus())) - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, sentCommand.getErrorMsg()); - } - else - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_SENT, null); - } - } - - /** - * 取分组编号作为下发信封的 devId(协议 devId 即组ID) - */ - private String resolveGroupCode(Long groupId) - { - if (groupId == null) - { - return null; - } - LockGroup group = groupMapper.selectGroupById(groupId); - return group == null ? null : group.getGroupCode(); - } - - /** - * 解析备用 devId:门锁报文经网关中转,取网关SN;网关缺失时返回 null - */ - private String resolveGatewaySn(LockAuth auth, LockDevice device) - { - Long gatewayId = auth.getGatewayId() != null ? auth.getGatewayId() : (device == null ? null : device.getGatewayId()); - if (gatewayId != null) - { - LockGateway gateway = gatewayMapper.selectGatewayById(gatewayId); - if (gateway != null && StringUtils.isNotEmpty(gateway.getGatewaySn())) - { - return gateway.getGatewaySn(); - } - } - return null; - } - - /** - * 确定授权所属分组 - *

- * 分组关系为「门锁 → 网关 → 分组」,统一由门锁所属网关推导,不取门锁的冗余分组字段, - * 避免两者不一致。下发主题为 ydlink/{groupCode}/thing/data/batch,分组缺失会导致指令无法下发。 - */ - private Long resolveGroupId(LockDevice device) - { - if (device.getGatewayId() == null) - { - return null; - } - LockGateway gateway = gatewayMapper.selectGatewayById(device.getGatewayId()); - return gateway == null ? null : gateway.getGroupId(); - } - - /** - * 下发前置校验,返回可读的失败原因;通过校验返回 null - */ - private String resolvePushFailReason(LockAuth auth) - { - if (auth.getDeviceId() == null) - { - return "授权未关联门锁,无法下发"; - } - if (auth.getGroupId() == null) - { - return "门锁未绑定网关(或网关未归属分组),请先绑定网关后再下发"; - } - LockGroup group = groupMapper.selectGroupById(auth.getGroupId()); - if (group == null) - { - return "所属分组不存在,分组ID=" + auth.getGroupId(); - } - if (StringUtils.isEmpty(group.getGroupCode())) - { - return "所属分组未配置分组编号,无法拼装下发主题"; - } - return null; - } - - /** - * 组装授权下发/清除报文 - *

- * 协议格式(一维 params,门锁报文由网关订阅后转发): - *

-     * {"devId":"网关SN","devPsw":"门锁密码","devVer":"11","devType":"19","ac":"req",
-     *  "at":"1790909167","seq":"1790909167589","op":"facefeature","mode":"s2d","chksum":"xx",
-     *  "params":["add","人员编号","授权内容","授权锁标识","开始时间","结束时间","门锁SN","门锁密码"]}
-     * 
- * - * @param add true 下发授权(add 动作),false 清除授权(delete 动作) - */ - private YdlinkFrameDTO buildAuthFrame(LockAuth auth, boolean add) - { - LockDevice device = auth.getDeviceId() == null ? null : deviceMapper.selectDeviceById(auth.getDeviceId()); - String deviceSn = auth.getDeviceSn(); - String devicePsw = ""; - String devVer = ""; - String devType = ""; - if (device != null) - { - if (StringUtils.isNotEmpty(device.getDeviceSn())) - { - deviceSn = device.getDeviceSn(); - } - devicePsw = StringUtils.defaultString(device.getDevicePassword()); - devVer = StringUtils.defaultString(device.getDevVer()); - devType = StringUtils.defaultString(device.getDevType()); - } - deviceSn = StringUtils.defaultString(deviceSn); - // devId 为分组编号(组ID);分组编号缺失时依次回退网关SN、门锁SN - String devId = resolveGroupCode(auth.getGroupId()); - if (StringUtils.isEmpty(devId)) - { - devId = resolveGatewaySn(auth, device); - } - if (StringUtils.isEmpty(devId)) - { - devId = deviceSn; - } - String op = resolveOp(auth.getAuthType()); - YdlinkFrameDTO frame = YdlinkCodec.buildDownFrame(op, devId, devicePsw, devVer, devType); - String personNo = StringUtils.defaultString(auth.getPersonNo()); - String authValue = StringUtils.defaultString(auth.getAuthValue()); - // 人脸帧比密码等帧多一个「授权锁标识」位(与人员编号同值),长度不同不可混用 - boolean face = YdlinkOp.FACE_FEATURE.equals(op); - if (add) - { - if (face) - { - // [动作, 人员编号, 人脸特征, 授权锁标识, 开始时间, 结束时间, 门锁SN, 门锁密码] - frame.setFlatParams(Arrays.asList("add", personNo, authValue, personNo, - format(auth.getBeginTime()), format(auth.getEndTime()), deviceSn, devicePsw)); - } - else - { - // 密码/指纹/IC卡:[动作, 人员编号, 授权内容, 开始时间, 结束时间, 门锁SN, 门锁密码] - frame.setFlatParams(Arrays.asList("add", personNo, authValue, - format(auth.getBeginTime()), format(auth.getEndTime()), deviceSn, devicePsw)); - } - } - else - { - // 删除帧只需定位「删除谁 + 哪把锁」:["delete", 人员编号, 门锁SN, 门锁密码] - // 实测人脸删除报文即此 4 项,不携带特征值与时间 - frame.setFlatParams(Arrays.asList("delete", personNo, deviceSn, devicePsw)); - } - return frame; - } - - /** - * 清除授权:与下发授权同格式,仅 params 首项为 delete - */ - private void pushClear(LockAuth auth) - { - LockGroup group = auth.getGroupId() == null ? null : groupMapper.selectGroupById(auth.getGroupId()); - if (group == null || StringUtils.isEmpty(group.getGroupCode())) - { - return; - } - if (securityGate != null && !securityGate.checkDeviceWriteAllowed(auth.getDeviceSn(), "AUTH_CLEAR")) - { - String reason = "[REAL-IOT-SECURITY-GATE] 拦截撤权下发: 真实硬件写已全局禁用或设备SN不在白名单: " + auth.getDeviceSn(); - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, reason); - log.warn(reason + ", authId={}", auth.getAuthId()); - return; - } - YdlinkFrameDTO frame = buildAuthFrame(auth, false); - LockCommand command = new LockCommand(); - command.setCommandNo(frame.getSeq()); - command.setCommandType(LockConstants.CMD_AUTH_CLEAR); - command.setTargetType(LockConstants.TARGET_GROUP); - command.setTargetId(auth.getGroupId()); - command.setTargetCode(group.getGroupCode()); - command.setBizId(auth.getAuthId()); - command.setTopic(YdlinkTopicBuilder.groupDataBatch(group.getGroupCode())); - LockCommand sentCommand = commandService.send(command); - if (sentCommand != null && LockConstants.STATUS_FAIL.equals(sentCommand.getCommandStatus())) - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_FAIL, "清除指令发送失败: " + sentCommand.getErrorMsg()); - } - else - { - authMapper.updateSyncStatus(auth.getAuthId(), LockConstants.SYNC_SENT, "清除指令已下发"); - } - } - - /** - * 授权类型转协议 op - */ - private String resolveOp(String authType) - { - if (LockConstants.AUTH_PASSWORD.equals(authType)) - { - return YdlinkOp.PASSWORD; - } - if (LockConstants.AUTH_FINGERPRINT.equals(authType)) - { - return YdlinkOp.FINGERPRINT; - } - if (LockConstants.AUTH_IC_CARD.equals(authType)) - { - return YdlinkOp.IC_CARD; - } - return YdlinkOp.FACE_FEATURE; - } - - private String format(Date date) - { - return date == null ? "" : new SimpleDateFormat(TIME_PATTERN).format(date); + @Override public void batchPush(Long[] ids) { for (Long id : ids) push(id); } + @Override public void batchClear(Long[] ids) { for (Long id : ids) clear(id); } + @Override public void handleResult(String seq, boolean success, String message) { + // The command service persists command ACK + version-checked business projection in one transaction. + // Do not trust a caller-supplied boolean to independently rewrite authorization state. } } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java index fd0b1c146..fb1f1b882 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java @@ -1,412 +1,243 @@ package com.ruoyi.iot.lock.service.impl; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.LockCommand; -import com.ruoyi.iot.lock.domain.LockMqttLog; -import com.ruoyi.iot.lock.mapper.LockCommandMapper; -import com.ruoyi.iot.lock.mapper.LockMqttLogMapper; -import com.ruoyi.iot.lock.service.ILockCommandService; +import com.ruoyi.iot.lock.domain.*; +import com.ruoyi.iot.lock.mapper.*; +import com.ruoyi.iot.lock.protocol.*; +import com.ruoyi.iot.lock.security.*; +import com.ruoyi.iot.lock.service.*; import com.ruoyi.iot.service.IMqttService; -/** - * 下行命令服务实现 - * - * @author ruoyi - */ +/** Transactional outbox: enqueue -> committed claim -> one transport attempt -> confirmed ACK or review. */ @Service -public class LockCommandServiceImpl implements ILockCommandService -{ - private static final Logger log = LoggerFactory.getLogger(LockCommandServiceImpl.class); +public class LockCommandServiceImpl implements ILockCommandService { + @Autowired private LockCommandMapper commandMapper; + @Autowired private LockAuthMapper authMapper; + @Autowired private LockExecutionTransactions transactions; + @Autowired private RealIotSecurityGate securityGate; + @Lazy @Autowired private IMqttService mqttService; - /** 默认最大重试次数 */ - private static final int DEFAULT_MAX_RETRY = 3; - - /** 默认命令超时时间(秒) */ - private static final int DEFAULT_TIMEOUT_SECONDS = 30; - - /** 失败后不做自动重发的命令类型:时间同步、远程开关锁(人工操作,重发无意义且可能重复执行) */ - private static final Set NO_RETRY_COMMAND_TYPES = new HashSet( - Arrays.asList(LockConstants.CMD_SYNC_TIME, LockConstants.CMD_SWITCH)); - - @Autowired - private LockCommandMapper commandMapper; - - @Autowired - private LockMqttLogMapper mqttLogMapper; - - /** - * 通道层服务:业务监听器会被分发器拉起,存在形成依赖环的可能, - * 这里延迟解析,确保容器创建顺序不受影响。 - */ - @Lazy - @Autowired - private IMqttService mqttService; - - @Autowired(required = false) - private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; - - @Override - @Transactional - public LockCommand send(LockCommand command) - { - if (command == null || StringUtils.isEmpty(command.getTopic())) - { - throw new IllegalArgumentException("命令主题不能为空"); - } - if (StringUtils.isEmpty(command.getCommandNo())) - { - command.setCommandNo(IdUtils.fastSimpleUUID().toUpperCase()); - } - command.setCommandStatus(LockConstants.STATUS_PENDING); - command.setRetryCount(0); - if (command.getMaxRetry() == null) - { - command.setMaxRetry(DEFAULT_MAX_RETRY); - } - command.setReqTime(new Date()); - commandMapper.insertCommand(command); - // 报文留痕(下行) - recordLog(command); - if (securityGate != null && !securityGate.checkDeviceWriteAllowed(command.getTargetCode(), command.getCommandType())) - { - String reason = "[REAL-IOT-SECURITY-GATE] 拦截指令下发: 真实硬件写已全局禁用或目标SN不在白名单: " + command.getTargetCode(); - command.setCommandStatus(LockConstants.STATUS_FAIL); - command.setErrorMsg(reason); - commandMapper.updateCommand(command); - log.warn(reason + ", commandNo={}", command.getCommandNo()); + @Override public LockCommand send(LockCommand command) { + return transactions.required(() -> { + LockAuth auth = lockAuth(command); + if (auth != null && !ExecutionStates.READY.equals(auth.getExecutionState())) { + throw new SecurityException("Intent is not ready for a new command"); + } + securityGate.requirePayloadAndIdentity(command); + LockCommand existing = commandMapper.selectCommandByIntentKey(command.getIntentKey()); + if (existing != null) return existing; + command.setExecutionState(ExecutionStates.READY); + command.setCommandStatus(LockConstants.STATUS_PENDING); + command.setRetryCount(0); command.setMaxRetry(0); + command.setReqTime(new Date()); + commandMapper.insertCommand(command); + transactions.afterCommit(() -> dispatch(command.getCommandId())); return command; - } - if (!mqttService.isConnected()) - { - command.setCommandStatus(LockConstants.STATUS_FAIL); - command.setErrorMsg("MQTT未连接,命令已进入重试队列"); - command.setNextRetryTime(nextRetryTime(1)); - commandMapper.updateCommand(command); - log.warn("MQTT未连接,命令落库待重试, commandNo={}", command.getCommandNo()); + }); + } + + private boolean authCommand(LockCommand command) { + return LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) + || LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()); + } + // Always acquire the authorization lock before the command lock. + private LockAuth lockAuth(LockCommand command) { + return authCommand(command) && command.getBizId() != null + ? authMapper.selectAuthByIdForUpdate(command.getBizId()) : null; + } + private void project(LockCommand command, String state, String syncStatus, String reason) { + if (!authCommand(command)) return; + LockAuth update = new LockAuth(); update.setAuthId(command.getBizId()); + update.copyProvenance(command); update.setExecutionState(state); + update.setSyncStatus(syncStatus); update.setSyncMsg(reason); + authMapper.updateExecution(update); + } + private void transition(LockCommand command, String state, String status, String reason) { + command.setPreviousExecutionState(command.getExecutionState()); + command.setExecutionState(state); command.setCommandStatus(status); + command.setErrorMsg(reason); + if (commandMapper.updateCommand(command) != 1) throw new IllegalStateException("Concurrent command state change"); + command.setPreviousExecutionState(null); + } + + /** No surrounding business transaction may remain uncommitted when this method publishes. */ + public boolean dispatch(Long commandId) { + LockCommand claimed = transactions.committed(() -> { + LockCommand snapshot = commandMapper.selectCommandById(commandId); + if (snapshot == null) return null; + LockAuth auth = lockAuth(snapshot); + LockCommand command = commandMapper.selectCommandByIdForUpdate(commandId); + if (!ExecutionStates.READY.equals(command.getExecutionState())) return null; + if (command.getAttemptStartedAt() != null) { + transition(command, ExecutionStates.MANUAL_REVIEW, LockConstants.STATUS_TIMEOUT, "PREVIOUS_ATTEMPT_REQUIRES_REVIEW"); + project(command, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "PREVIOUS_ATTEMPT_REQUIRES_REVIEW"); + return null; + } + if (!securityGate.sameOrigin(command) || (authCommand(command) && !RealIotSecurityGate.sameIntent(auth, command))) { + transition(command, ExecutionStates.QUARANTINED, LockConstants.STATUS_FAIL, "PROVENANCE_OR_VERSION_MISMATCH"); + project(command, ExecutionStates.QUARANTINED, LockConstants.SYNC_FAIL, "PROVENANCE_OR_VERSION_MISMATCH"); + return null; + } + try { + securityGate.requirePayloadAndIdentity(command); + } catch (RuntimeException invalid) { + transition(command, ExecutionStates.QUARANTINED, LockConstants.STATUS_FAIL, "INVALID_IDENTITY_OR_PAYLOAD"); + project(command, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "INVALID_IDENTITY_OR_PAYLOAD"); + return null; + } + if (auth != null && !ExecutionStates.READY.equals(auth.getExecutionState())) return null; + try { + securityGate.requireDispatchAllowed(command); + } catch (SecurityException blocked) { + command.setErrorMsg("POLICY_BLOCKED_NOT_ATTEMPTED"); commandMapper.updateCommand(command); + return null; + } + if (!mqttService.isConnected()) { + command.setCommandStatus(LockConstants.STATUS_FAIL); + command.setErrorMsg("TRANSPORT_DISCONNECTED_NOT_ATTEMPTED"); + commandMapper.updateCommand(command); return null; + } + command.setAttemptStartedAt(new Date()); + transition(command, ExecutionStates.DISPATCHING, LockConstants.STATUS_PENDING, "DISPATCH_CLAIM_COMMITTED"); + project(command, ExecutionStates.DISPATCHING, LockConstants.SYNC_PENDING, "DISPATCH_CLAIM_COMMITTED"); return command; + }); + if (claimed == null) return false; + try { + mqttService.publishCommand(claimed); + transactions.committed(() -> { + lockAuth(claimed); + LockCommand current = commandMapper.selectCommandByIdForUpdate(commandId); + if (ExecutionStates.DISPATCHING.equals(current.getExecutionState())) { + transition(current, ExecutionStates.AWAITING_ACK, LockConstants.STATUS_SENT, "AWAITING_DEVICE_CONFIRMATION"); + project(current, ExecutionStates.AWAITING_ACK, LockConstants.SYNC_SENT, "AWAITING_DEVICE_CONFIRMATION"); + } + return null; + }); + } catch (RuntimeException uncertain) { + transactions.committed(() -> { + lockAuth(claimed); + LockCommand current = commandMapper.selectCommandByIdForUpdate(commandId); + if (ExecutionStates.DISPATCHING.equals(current.getExecutionState())) { + transition(current, ExecutionStates.MANUAL_REVIEW, LockConstants.STATUS_TIMEOUT, "TRANSPORT_OUTCOME_UNKNOWN_NO_AUTOREPLAY"); + project(current, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "TRANSPORT_OUTCOME_UNKNOWN_NO_AUTOREPLAY"); + } + return null; + }); } - try - { - mqttService.publish(command.getTopic(), command.getPayload(), 1, false); - command.setCommandStatus(LockConstants.STATUS_SENT); - command.setNextRetryTime(nextRetryTime(1)); - commandMapper.updateCommand(command); - } - catch (Exception e) - { - log.error("命令下发失败, commandNo={}", command.getCommandNo(), e); - command.setCommandStatus(LockConstants.STATUS_FAIL); - command.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 500)); - command.setNextRetryTime(nextRetryTime(1)); - commandMapper.updateCommand(command); - } - return command; + return true; } - @Override - @Transactional - public String handleResponse(String topic, String seq, String payload, boolean success, String message) - { - if (StringUtils.isEmpty(seq)) - { - return null; - } - LockCommand command = commandMapper.selectCommandByNo(seq); - if (command == null) - { - log.warn("未匹配到命令, seq={}, topic={}", seq, topic); - return null; - } - if (isFinished(command)) - { - // 已终结的命令不再被后续回执改写 - return null; - } - command.setRespTopic(topic); - command.setRespPayload(payload); - command.setRespTime(new Date()); - command.setDeviceAck(LockConstants.ACK_YES); - command.setDeviceResult(success ? LockConstants.DEVICE_RESULT_SUCCESS : LockConstants.DEVICE_RESULT_FAIL); - // 门锁已明确失败时无需再等网关回执;否则需网关回执到齐才判定成功 - if (!success) - { - return finish(command, false, message); - } - if (LockConstants.ACK_YES.equals(command.getGatewayAck())) - { - return finish(command, true, message); - } - commandMapper.updateCommand(command); - log.info("已收到门锁回执,等待网关回执, seq={}, commandType={}", seq, command.getCommandType()); - return null; + @Override public int retryPending() { + int sent = 0; + for (LockCommand command : commandMapper.selectReadyCommands()) if (dispatch(command.getCommandId())) sent++; + return sent; } - - @Override - @Transactional - public String handleGatewayAck(String topic, String seq, String payload, String message) - { - if (StringUtils.isEmpty(seq)) - { - return null; - } - LockCommand command = commandMapper.selectCommandByNo(seq); - if (command == null) - { - log.warn("未匹配到命令, seq={}, topic={}", seq, topic); - return null; - } - if (isFinished(command)) - { - return null; - } - command.setRespTopic(topic); - command.setRespPayload(payload); - command.setRespTime(new Date()); - command.setGatewayAck(LockConstants.ACK_YES); - if (LockConstants.ACK_YES.equals(command.getDeviceAck())) - { - // 双回执齐备,以门锁结果为准 - boolean success = LockConstants.DEVICE_RESULT_SUCCESS.equals(command.getDeviceResult()); - return finish(command, success, message); - } - commandMapper.updateCommand(command); - log.info("已收到网关回执,等待门锁回执, seq={}, commandType={}", seq, command.getCommandType()); - return null; - } - - /** - * 该命令类型是否禁止自动重发 - */ - private boolean isRetryDisabled(String commandType) - { - return commandType != null && NO_RETRY_COMMAND_TYPES.contains(commandType); - } - - /** - * 命令是否已有终态结果 - */ - private boolean isFinished(LockCommand command) - { - return LockConstants.STATUS_SUCCESS.equals(command.getCommandStatus()) - || LockConstants.STATUS_FAIL.equals(command.getCommandStatus()) - || LockConstants.STATUS_TIMEOUT.equals(command.getCommandStatus()) - || LockConstants.STATUS_CANCELED.equals(command.getCommandStatus()); - } - - /** - * 写入终态并落库 - * - * @return 终态状态 - */ - private String finish(LockCommand command, boolean success, String message) - { - String status = success ? LockConstants.STATUS_SUCCESS : LockConstants.STATUS_FAIL; - command.setCommandStatus(status); - command.setErrorMsg(success ? null : StringUtils.substring(message, 0, 500)); - commandMapper.updateCommand(command); - log.info("命令终态判定, seq={}, commandType={}, status={}", command.getCommandNo(), - command.getCommandType(), status); - return status; - } - - @Override - public int markTimeout(int timeoutSeconds) - { - int seconds = timeoutSeconds <= 0 ? DEFAULT_TIMEOUT_SECONDS : timeoutSeconds; - Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.SECOND, -seconds); - List commands = commandMapper.selectTimeoutCommands(calendar.getTime()); - int count = 0; - for (LockCommand command : commands) - { - Integer maxRetry = command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry(); - Integer retryCount = command.getRetryCount() == null ? 0 : command.getRetryCount(); - LockCommand update = new LockCommand(); - update.setCommandId(command.getCommandId()); - if (isRetryDisabled(command.getCommandType())) - { - // 该类命令不自动重发,超时即终态 - update.setCommandStatus(LockConstants.STATUS_TIMEOUT); - update.setErrorMsg("等待响应超时(该命令不自动重发)"); - } - else if (retryCount >= maxRetry) - { - update.setCommandStatus(LockConstants.STATUS_TIMEOUT); - update.setErrorMsg("超过最大重试次数仍未收到响应"); - } - else - { - update.setCommandStatus(LockConstants.STATUS_FAIL); - update.setRetryCount(retryCount + 1); - update.setNextRetryTime(nextRetryTime(retryCount + 1)); - update.setErrorMsg("等待响应超时"); - } - commandMapper.updateCommand(update); - count++; - } - return count; - } - - @Override - public int retryPending() - { - LockCommand query = new LockCommand(); - query.setCommandStatus(LockConstants.STATUS_FAIL); - List commands = commandMapper.selectCommandList(query); - int count = 0; - Date now = new Date(); - for (LockCommand command : commands) - { - if (command.getNextRetryTime() == null || command.getNextRetryTime().after(now)) - { - continue; - } - if (securityGate != null && !securityGate.checkReplayAllowed(command.getReqTime())) - { - log.info("[REAL-IOT-SECURITY-GATE] 历史待发指令防重放跳过: commandId={}, reqTime={}", - command.getCommandId(), command.getReqTime()); - continue; - } - if (securityGate != null && !securityGate.checkDeviceWriteAllowed(command.getTargetCode(), command.getCommandType())) - { - continue; - } - if (isRetryDisabled(command.getCommandType())) - { - // 时间同步、远程开关锁失败后不自动重发 - continue; - } - Integer maxRetry = command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry(); - Integer retryCount = command.getRetryCount() == null ? 0 : command.getRetryCount(); - if (retryCount >= maxRetry) - { - continue; - } - LockCommand update = new LockCommand(); - update.setCommandId(command.getCommandId()); - update.setRetryCount(retryCount + 1); - update.setNextRetryTime(nextRetryTime(retryCount + 1)); - try - { - mqttService.publish(command.getTopic(), command.getPayload(), 1, false); - update.setCommandStatus(LockConstants.STATUS_SENT); - update.setReqTime(now); - count++; - } - catch (Exception e) - { - update.setCommandStatus(LockConstants.STATUS_FAIL); - update.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 500)); - } - commandMapper.updateCommand(update); - } - return count; - } - - @Override - @Transactional - public void retry(Long commandId) - { + @Override public void retry(Long commandId) { LockCommand command = commandMapper.selectCommandById(commandId); - if (command == null) - { - throw new IllegalArgumentException("命令不存在"); + if (command == null || !ExecutionStates.READY.equals(command.getExecutionState()) + || !securityGate.sameOrigin(command) || command.getAttemptStartedAt() != null) { + throw new SecurityException("Only proven, never-attempted commands may be retried; other outcomes need review"); } - if (securityGate != null && !securityGate.checkReplayAllowed(command.getReqTime())) - { - throw new SecurityException("[REAL-IOT-SECURITY-GATE] 该指令属于系统启动前的历史数据,防重放机制阻止手动重试"); + dispatch(commandId); + } + @Override public int markTimeout(int timeoutSeconds) { + Date before = new Date(System.currentTimeMillis() - Math.max(30, timeoutSeconds) * 1000L); + int count = 0; + for (LockCommand snapshot : commandMapper.selectTimeoutCommands(before)) { + count += transactions.committed(() -> { + lockAuth(snapshot); + LockCommand command = commandMapper.selectCommandByIdForUpdate(snapshot.getCommandId()); + if (!ExecutionStates.DISPATCHING.equals(command.getExecutionState()) + && !ExecutionStates.AWAITING_ACK.equals(command.getExecutionState())) return 0; + transition(command, ExecutionStates.MANUAL_REVIEW, LockConstants.STATUS_TIMEOUT, "ACK_OR_DISPATCH_OUTCOME_UNKNOWN"); + project(command, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "ACK_OR_DISPATCH_OUTCOME_UNKNOWN"); + return 1; + }); } - if (securityGate != null && !securityGate.checkDeviceWriteAllowed(command.getTargetCode(), command.getCommandType())) - { - throw new SecurityException("[REAL-IOT-SECURITY-GATE] 真实硬件写已全局禁用或设备SN不在白名单: " + command.getTargetCode()); - } - LockCommand update = new LockCommand(); - update.setCommandId(commandId); - Integer retryCount = command.getRetryCount() == null ? 0 : command.getRetryCount(); - update.setRetryCount(retryCount + 1); - update.setNextRetryTime(nextRetryTime(retryCount + 1)); - try - { - mqttService.publish(command.getTopic(), command.getPayload(), 1, false); - update.setCommandStatus(LockConstants.STATUS_SENT); - update.setReqTime(new Date()); - } - catch (Exception e) - { - update.setCommandStatus(LockConstants.STATUS_FAIL); - update.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 500)); - } - commandMapper.updateCommand(update); + return count; + } + @Override public void cancel(Long commandId) { + transactions.required(() -> { + LockCommand snapshot = commandMapper.selectCommandById(commandId); + if (snapshot == null) throw new IllegalArgumentException("Command does not exist"); + lockAuth(snapshot); + LockCommand command = commandMapper.selectCommandByIdForUpdate(commandId); + if (!ExecutionStates.READY.equals(command.getExecutionState()) || command.getAttemptStartedAt() != null) { + throw new SecurityException("An uncertain device outcome cannot be canceled locally"); + } + transition(command, ExecutionStates.CANCELED, LockConstants.STATUS_CANCELED, "OPERATOR_CANCELED_NOT_ATTEMPTED"); + project(command, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "INTENT_REQUIRES_OPERATOR_RECONCILIATION"); + return null; + }); } - @Override - @Transactional - public void cancel(Long commandId) - { - LockCommand update = new LockCommand(); - update.setCommandId(commandId); - update.setCommandStatus(LockConstants.STATUS_CANCELED); - commandMapper.updateCommand(update); + @Override public String handleResponse(String topic, String seq, String payload, boolean success, String message) { + return receive(topic, seq, payload, false, success); } - - @Override - public List selectCommandList(LockCommand command) - { - return commandMapper.selectCommandList(command); + @Override public String handleGatewayAck(String topic, String seq, String payload, String message) { + return receive(topic, seq, payload, true, false); } - - @Override - public LockCommand selectCommandById(Long commandId) - { - return commandMapper.selectCommandById(commandId); + private String receive(String topic, String seq, String payload, boolean gateway, boolean success) { + return transactions.required(() -> { + LockCommand snapshot = commandMapper.selectCommandByNo(seq); + if (snapshot == null) return null; + LockAuth auth = lockAuth(snapshot); + LockCommand command = commandMapper.selectCommandByIdForUpdate(snapshot.getCommandId()); + if (!securityGate.sameOrigin(command) || command.getAttemptStartedAt() == null + || !(ExecutionStates.DISPATCHING.equals(command.getExecutionState()) + || ExecutionStates.AWAITING_ACK.equals(command.getExecutionState()) + || ExecutionStates.MANUAL_REVIEW.equals(command.getExecutionState())) + || (authCommand(command) && !RealIotSecurityGate.sameIntent(auth, command)) + || !receiptMatches(command, topic, seq, payload, gateway, success)) return null; + command.setRespTopic(topic); command.setRespTime(new Date()); + // Receipt contents can contain personal data; persist outcome and identity, not arbitrary raw text. + command.setRespPayload("[redacted]"); + if (gateway) command.setGatewayAck(LockConstants.ACK_YES); + else { + command.setDeviceAck(LockConstants.ACK_YES); + command.setDeviceResult(success ? LockConstants.DEVICE_RESULT_SUCCESS : LockConstants.DEVICE_RESULT_FAIL); + } + boolean failed = !gateway && !success; + boolean complete = LockConstants.ACK_YES.equals(command.getGatewayAck()) + && LockConstants.ACK_YES.equals(command.getDeviceAck()); + if (failed || complete) { + boolean ok = !failed && LockConstants.DEVICE_RESULT_SUCCESS.equals(command.getDeviceResult()); + String status = ok ? LockConstants.STATUS_SUCCESS : LockConstants.STATUS_FAIL; + transition(command, ok ? ExecutionStates.CONFIRMED : ExecutionStates.REJECTED, status, + ok ? "DEVICE_AND_GATEWAY_CONFIRMED" : "DEVICE_REJECTED_MANUAL_REVIEW"); + project(command, ok ? ExecutionStates.CONFIRMED : ExecutionStates.MANUAL_REVIEW, + ok ? (LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()) ? LockConstants.SYNC_CLEARED : LockConstants.SYNC_SUCCESS) + : LockConstants.SYNC_FAIL, + ok ? "DEVICE_AND_GATEWAY_CONFIRMED" : "DEVICE_REJECTED_MANUAL_REVIEW"); + return status; + } + commandMapper.updateCommand(command); + return null; + }); } - - /** - * 下行报文留痕 - */ - private void recordLog(LockCommand command) - { - try - { - LockMqttLog logEntity = new LockMqttLog(); - logEntity.setDirection(LockConstants.DIRECTION_DOWN); - logEntity.setTopic(command.getTopic()); - logEntity.setPayload(command.getPayload()); - logEntity.setSeq(command.getCommandNo()); - logEntity.setRecvTime(command.getReqTime()); - logEntity.setHandleStatus(StringUtils.equals(command.getCommandStatus(), LockConstants.STATUS_FAIL) - ? LockConstants.HANDLE_FAIL - : LockConstants.HANDLE_SUCCESS); - mqttLogMapper.insertMqttLog(logEntity); - } - catch (Exception e) - { - log.warn("下行报文留痕失败, commandNo={}", command.getCommandNo()); + private boolean receiptMatches(LockCommand command, String topic, String seq, String payload, boolean gateway, boolean success) { + YdlinkFrameDTO request = YdlinkCodec.fromJson(command.getPayload()); + YdlinkFrameDTO response = YdlinkCodec.fromJson(payload); + if (request == null || response == null || !Objects.equals(seq, response.getSeq()) + || !Objects.equals(request.getOp(), response.getOp())) return false; + String expectedTopic = command.getTopic() + (authCommand(command) ? "_report" : "_response"); + if (!Objects.equals(expectedTopic, topic)) return false; + List> records = YdlinkCodec.toRecordList(response.getParams()); + if (records.size() != 1) return false; + List row = records.get(0); + if (authCommand(command)) { + if (row.size() < 3 || !Objects.equals(request.getParams().get(0), row.get(0)) + || !Objects.equals(command.getDeviceSn(), row.get(1))) return false; + String code = row.get(2); + return gateway ? "10".equals(code) : (!"10".equals(code) && !code.isEmpty() && success == "0".equals(code)); } + return securityGate.isMock() && row.size() >= 2; } - - /** - * 指数退避:第 n 次重试延迟 2^n 分钟,最长 30 分钟 - */ - private Date nextRetryTime(int retryCount) - { - int minutes = Math.min((int) Math.pow(2, Math.min(retryCount, 5)), 30); - Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.MINUTE, minutes); - return calendar.getTime(); - } + @Override public List selectCommandList(LockCommand query) { return commandMapper.selectCommandList(query); } + @Override public LockCommand selectCommandById(Long id) { return commandMapper.selectCommandById(id); } } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java index ed57f2d02..447cebe64 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java @@ -49,6 +49,9 @@ public class LockDeviceServiceImpl implements ILockDeviceService @Autowired private LockDeviceMapper deviceMapper; + @Autowired + private com.ruoyi.iot.lock.mapper.LockAuthMapper authMapper; + @Autowired private LockGatewayMapper gatewayMapper; @@ -58,6 +61,9 @@ public class LockDeviceServiceImpl implements ILockDeviceService @Autowired private ILockCommandService commandService; + @Autowired + private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; + @Override public List selectDeviceList(LockDevice device) { @@ -92,12 +98,18 @@ public class LockDeviceServiceImpl implements ILockDeviceService @Override public int updateDevice(LockDevice device) { + LockDevice previous = deviceMapper.selectDeviceById(device.getDeviceId()); + if (previous != null && ((device.getDeviceSn() != null && !java.util.Objects.equals(device.getDeviceSn(), previous.getDeviceSn())) + || (device.getGatewayId() != null && !java.util.Objects.equals(device.getGatewayId(), previous.getGatewayId())))) { + requireNoUnresolvedAuthorizations(device.getDeviceId()); + } return deviceMapper.updateDevice(device); } @Override public int deleteDeviceByIds(Long[] deviceIds) { + for (Long id : deviceIds) requireNoUnresolvedAuthorizations(id); return deviceMapper.deleteDeviceByIds(deviceIds); } @@ -212,6 +224,7 @@ public class LockDeviceServiceImpl implements ILockDeviceService command.setTopic(topic); command.setPayload(YdlinkCodec.toJson(frame)); command.setRemark(open ? "远程开锁" : "远程关锁"); + securityGate.initializeIntent(command, "CONTROL"); commandService.send(command); log.info("远程开关锁指令已下发, deviceSn={}, action={}, groupCode={}", device.getDeviceSn(), action, group.getGroupCode()); @@ -285,6 +298,7 @@ public class LockDeviceServiceImpl implements ILockDeviceService command.setBizId(bizId); command.setTopic(YdlinkTopicBuilder.gatewayPropertySet(gateway.getGatewaySn())); command.setPayload(YdlinkCodec.toJson(frame)); + securityGate.initializeIntent(command, "CONTROL"); commandService.send(command); } @@ -315,4 +329,14 @@ public class LockDeviceServiceImpl implements ILockDeviceService LockGroup group = groupMapper.selectGroupById(groupId); return group == null ? null : group.getGroupCode(); } + private void requireNoUnresolvedAuthorizations(Long deviceId) { + for (com.ruoyi.iot.lock.domain.LockAuth auth : authMapper.selectAuthsByDeviceId(deviceId)) { + if (!com.ruoyi.iot.lock.security.ExecutionStates.REVOKE.equals(auth.getIntentAction()) + || !com.ruoyi.iot.lock.security.ExecutionStates.CONFIRMED.equals(auth.getExecutionState()) + || !LockConstants.SYNC_CLEARED.equals(auth.getSyncStatus())) { + throw new SecurityException("Resolve device authorizations before removing or rebinding the device"); + } + } + } + } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java index e0ff8ebe2..79c9bb173 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockFaceServiceImpl.java @@ -94,6 +94,11 @@ public class LockFaceServiceImpl implements ILockFaceService { throw new IllegalArgumentException("人脸数据不存在"); } + LockDevice explicitDevice = deviceId == null ? null : deviceMapper.selectDeviceById(deviceId); + if (!faceExtractClient.isMock()) { + if (explicitDevice == null) throw new SecurityException("An explicit face extraction device is required"); + faceExtractClient.authorize(explicitDevice.getDeviceSn()); + } // algorithm_version 由门锁维护,优先取指定门锁,其次按该人员的授权推断 String algoVersion = resolveAlgoVersion(face.getPersonNo(), deviceId); if (StringUtils.isEmpty(algoVersion)) @@ -112,7 +117,7 @@ public class LockFaceServiceImpl implements ILockFaceService return null; } // photoUrl 存的是图片地址,特征提取服务按文件接收,这里读取图片内容 - byte[] imageBytes = loadImageBytes(face.getPhotoUrl()); + byte[] imageBytes = faceExtractClient.isMock() ? new byte[]{0} : loadImageBytes(face.getPhotoUrl()); if (imageBytes == null || imageBytes.length == 0) { update.setExtractStatus("2"); @@ -121,7 +126,7 @@ public class LockFaceServiceImpl implements ILockFaceService log.warn("人脸图片读取失败, faceId={}, photoUrl={}", faceId, face.getPhotoUrl()); return null; } - String feature = faceExtractClient.extract(imageBytes, "face_" + faceId + ".jpg", algoVersion); + String feature = faceExtractClient.extract(imageBytes, "face.jpg", algoVersion, explicitDevice == null ? null : explicitDevice.getDeviceSn()); if (StringUtils.isEmpty(feature)) { update.setExtractStatus("2"); @@ -180,30 +185,20 @@ public class LockFaceServiceImpl implements ILockFaceService { try { - if (photoUrl.startsWith("http")) - { - HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); - HttpRequest request = HttpRequest.newBuilder(URI.create(photoUrl)) - .timeout(Duration.ofSeconds(30)).GET().build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); - if (response.statusCode() != 200) - { - return null; - } - return response.body(); + if (!photoUrl.startsWith(Constants.RESOURCE_PREFIX + "/")) { + throw new SecurityException("Only local approved profile images are accepted"); } - String profile = RuoYiConfig.getProfile(); - String relative = StringUtils.substringAfter(photoUrl, Constants.RESOURCE_PREFIX); - Path path = Paths.get(profile + relative); - if (!Files.exists(path)) - { - return null; + Path profile = Paths.get(RuoYiConfig.getProfile()).toRealPath(); + String relative = photoUrl.substring((Constants.RESOURCE_PREFIX + "/").length()); + Path path = profile.resolve(relative).toRealPath(); + if (!path.startsWith(profile) || !Files.isRegularFile(path) || Files.size(path) > 5 * 1024 * 1024) { + throw new SecurityException("Image path or size denied"); } return Files.readAllBytes(path); } catch (Exception e) { - log.warn("人脸图片读取失败, photoUrl={}", photoUrl, e); + log.warn("Face image read denied or unavailable"); return null; } } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java index 3622c4d07..0b07d915e 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java @@ -70,6 +70,9 @@ public class LockGatewayServiceImpl implements ILockGatewayService @Autowired private ILockCommandService commandService; + @Autowired + private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; + @Override public List selectGatewayList(LockGateway gateway) { @@ -333,6 +336,7 @@ public class LockGatewayServiceImpl implements ILockGatewayService command.setBizId(bizId); command.setTopic(YdlinkTopicBuilder.gatewayPropertySet(gateway.getGatewaySn())); command.setPayload(YdlinkCodec.toJson(frame)); + securityGate.initializeIntent(command, "CONTROL"); commandService.send(command); log.info("网关命令已下发, gatewaySn={}, commandType={}", gateway.getGatewaySn(), commandType); } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java index 0e727f639..9909ea94d 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java @@ -33,7 +33,7 @@ public class LockCommandRetryTask @Scheduled(initialDelay = 30000L, fixedDelay = 60000L) public void execute() { - if (properties != null && !properties.isRetryTaskEnabled()) + if (properties == null || !properties.isRetryTaskEnabled()) { return; } diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java index d790237e6..cd65433a2 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/controller/OpenAuthApiController.java @@ -131,9 +131,10 @@ public class OpenAuthApiController } authService.insertAuth(auth); - AjaxResult result = AjaxResult.success("授权已新增并下发"); + AjaxResult result = AjaxResult.success("授权意图已记录,设备结果以确认状态为准"); result.put("authId", auth.getAuthId()); result.put("syncStatus", auth.getSyncStatus()); + result.put("executionState", authService.selectAuthById(auth.getAuthId()).getExecutionState()); return result; } @@ -149,7 +150,7 @@ public class OpenAuthApiController return AjaxResult.error("授权不存在,authId=" + authId); } authService.push(authId); - return AjaxResult.success("下发指令已提交"); + return AjaxResult.success("已处理下发请求,请查询设备确认状态"); } /** @@ -164,7 +165,7 @@ public class OpenAuthApiController return AjaxResult.error("授权不存在,authId=" + authId); } authService.clear(authId); - return AjaxResult.success("取消指令已提交"); + return AjaxResult.success("撤权意图已受理,尚不代表设备权限已清除"); } /** @@ -178,8 +179,8 @@ public class OpenAuthApiController { return AjaxResult.error("授权不存在,authId=" + authId); } - authService.deleteAuthByIds(new Long[] { authId }); - return AjaxResult.success("删除成功"); + int removed = authService.deleteAuthByIds(new Long[] { authId }); + return AjaxResult.success(removed > 0 ? "已删除已确认撤权的记录" : "撤权意图已记录,设备确认前保留记录"); } /** diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/filter/OpenApiAuthFilter.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/filter/OpenApiAuthFilter.java index a3c2faf17..2241fe4af 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/filter/OpenApiAuthFilter.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/filter/OpenApiAuthFilter.java @@ -39,8 +39,27 @@ public class OpenApiAuthFilter implements Filter { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; - String uri = req.getRequestURI(); - if (uri == null || !uri.startsWith(properties.getPathPrefix() + "/")) + String uri = req.getServletPath(); + if (uri == null || uri.isEmpty()) { + uri = req.getRequestURI(); + if (uri != null && !req.getContextPath().isEmpty()) uri = uri.substring(req.getContextPath().length()); + } else if (req.getPathInfo() != null) { + uri += req.getPathInfo(); + } + try { + if (uri != null) { + uri = org.springframework.web.util.UriUtils.decode(uri, java.nio.charset.StandardCharsets.UTF_8); + if (uri.contains("//") || uri.contains("/../") || uri.contains("/./")) { + reject(resp, 400, 400, "Non-canonical request path"); return; + } + // MVC can remove matrix parameters when matching a Controller path. + uri = java.util.Arrays.stream(uri.split("/", -1)) + .map(segment -> segment.split(";", 2)[0]).collect(java.util.stream.Collectors.joining("/")); + } + } catch (IllegalArgumentException invalid) { + reject(resp, 400, 400, "Invalid request path"); return; + } + if (uri == null || !(uri.equals("/openapi/iot") || uri.startsWith("/openapi/iot/"))) { // 非开放接口直接放行 chain.doFilter(request, response); @@ -52,7 +71,8 @@ public class OpenApiAuthFilter implements Filter return; } String apiKey = req.getHeader(HEADER_API_KEY); - if (StringUtils.isEmpty(properties.getApiKey()) || !properties.getApiKey().equals(apiKey)) + if (properties.getApiKey() == null || properties.getApiKey().length() < 32 || apiKey == null + || !java.security.MessageDigest.isEqual(properties.getApiKey().getBytes(java.nio.charset.StandardCharsets.UTF_8), apiKey.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { reject(resp, HttpServletResponse.SC_UNAUTHORIZED, 401, "API Key 无效或缺失"); return; diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java index 207333ab5..4d6f1ef3b 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java @@ -9,6 +9,10 @@ import java.util.Map; */ public interface IMqttService { + /** Only a persisted, claimed business command may enter real transport. */ + default void publishCommand(com.ruoyi.iot.lock.domain.LockCommand command) { + throw new SecurityException("Command-aware transport is required"); + } /** * 客户端是否已连接 */ diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java index af1571c80..6864877e2 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java @@ -26,6 +26,11 @@ public class MqttServiceImpl implements IMqttService return clientManager.isConnected(); } + @Override + public void publishCommand(com.ruoyi.iot.lock.domain.LockCommand command) { + clientManager.publishCommand(command); + } + @Override public void publish(String topic, String payload) { diff --git a/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml b/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml index 5e3859c21..05f13cd4a 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml +++ b/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockAuthMapper.xml @@ -30,10 +30,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + - select auth_id, auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id, + select execution_mode, environment_id, intent_key, intent_version, execution_state, intent_action, intent_at, auth_id, auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id, auth_type, auth_value, auth_value_md5, begin_time, end_time, auth_time, sync_status, sync_time, sync_msg, retry_count, status, del_flag, create_by, create_time, update_by, update_time, remark from lock_auth @@ -123,10 +130,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" insert into lock_auth ( + execution_mode, environment_id, intent_key, intent_version, execution_state, intent_action, intent_at, auth_no, person_no, person_name, device_id, device_sn, gateway_id, group_id, auth_type, auth_value, auth_value_md5, begin_time, end_time, auth_time, sync_status, retry_count, status, del_flag, create_by, create_time, remark ) values ( + #{executionMode}, #{environmentId}, #{intentKey}, #{intentVersion}, #{executionState}, #{intentAction}, #{intentAt}, #{authNo}, #{personNo}, #{personName}, #{deviceId}, #{deviceSn}, #{gatewayId}, #{groupId}, #{authType}, #{authValue}, #{authValueMd5}, #{beginTime}, #{endTime}, #{authTime}, '0', 0, #{status}, '0', #{createBy}, CURRENT_TIMESTAMP, #{remark} @@ -150,9 +159,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" auth_time = #{authTime}, status = #{status}, remark = #{remark}, + execution_mode = #{executionMode}, + environment_id = #{environmentId}, + intent_key = #{intentKey}, + intent_version = #{intentVersion}, + execution_state = #{executionState}, + intent_action = #{intentAction}, + intent_at = #{intentAt}, + sync_status = #{syncStatus}, update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP where auth_id = #{authId} + and intent_version = #{previousIntentVersion} and execution_state = #{previousExecutionState} @@ -172,4 +190,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from lock_auth where device_id = #{deviceId} + + + + update lock_auth set execution_state = #{executionState}, sync_msg = #{syncMsg}, sync_time = CURRENT_TIMESTAMP + , sync_status = #{syncStatus} + where auth_id = #{authId} and intent_key = #{intentKey} and intent_version = #{intentVersion} + + diff --git a/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockCommandMapper.xml b/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockCommandMapper.xml index 720f28a3e..dc67d7cdf 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockCommandMapper.xml +++ b/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockCommandMapper.xml @@ -31,10 +31,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + + - select command_id, command_no, command_type, target_type, target_id, target_code, biz_id, topic, + select execution_mode, environment_id, intent_key, intent_version, execution_state, intent_action, intent_at, device_sn, attempt_started_at, command_id, command_no, command_type, target_type, target_id, target_code, biz_id, topic, payload, req_time, resp_topic, resp_payload, resp_time, command_status, retry_count, max_retry, next_retry_time, gateway_ack, device_ack, device_result, error_msg, create_by, create_time, update_by, update_time, remark @@ -93,16 +102,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" insert into lock_command ( + execution_mode, environment_id, intent_key, intent_version, execution_state, intent_action, intent_at, device_sn, attempt_started_at, command_no, command_type, target_type, target_id, target_code, biz_id, topic, payload, req_time, command_status, retry_count, max_retry, next_retry_time, gateway_ack, device_ack, device_result, error_msg, create_by, create_time, remark ) values ( + #{executionMode}, #{environmentId}, #{intentKey}, #{intentVersion}, #{executionState}, #{intentAction}, #{intentAt}, #{deviceSn}, #{attemptStartedAt}, #{commandNo}, #{commandType}, #{targetType}, #{targetId}, #{targetCode}, #{bizId}, #{topic}, #{payload}, #{reqTime}, #{commandStatus}, #{retryCount}, #{maxRetry}, #{nextRetryTime}, '0', '0', #{deviceResult}, @@ -113,9 +124,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update lock_command - command_type = #{commandType}, - topic = #{topic}, - payload = #{payload}, req_time = #{reqTime}, resp_topic = #{respTopic}, resp_payload = #{respPayload}, @@ -128,9 +136,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" device_ack = #{deviceAck}, device_result = #{deviceResult}, error_msg = #{errorMsg}, + execution_state = #{executionState}, + attempt_started_at = #{attemptStartedAt}, update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP where command_id = #{commandId} + and execution_state = #{previousExecutionState} + and intent_key = #{intentKey} and intent_version = #{intentVersion} @@ -140,4 +152,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java index a02c12a59..90e7a7763 100644 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java +++ b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java @@ -19,12 +19,13 @@ import org.junit.Test; public class FaceExtractClientMockTest { private static HttpServer mockServer; - private static int mockPort = 18999; + private static int mockPort; @BeforeClass public static void startMockServer() throws Exception { - mockServer = HttpServer.create(new InetSocketAddress(mockPort), 0); + mockServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + mockPort = mockServer.getAddress().getPort(); mockServer.createContext("/api/face/extract", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws java.io.IOException { @@ -61,6 +62,14 @@ public class FaceExtractClientMockTest FaceExtractProperties props = new FaceExtractProperties(); props.setEndpoint("http://127.0.0.1:" + mockPort + "/api/face/extract"); props.setTimeout(3000); + props.setMock(false); props.setEnabled(true); props.setRealHttpEnabled(true); + props.setEnvironmentId("isolated-test-real"); + props.setAllowedEndpoints(java.util.Arrays.asList(props.getEndpoint())); + com.ruoyi.iot.config.IotMqttProperties mqtt = new com.ruoyi.iot.config.IotMqttProperties(); + mqtt.setEnabled(true); mqtt.setRealDeviceWriteEnabled(true); mqtt.setEnvironmentId(props.getEnvironmentId()); + mqtt.setDeviceWhitelist(java.util.Arrays.asList("TEST0001")); + Field gateField = FaceExtractClient.class.getDeclaredField("securityGate"); gateField.setAccessible(true); + gateField.set(client, new com.ruoyi.iot.lock.security.RealIotSecurityGate(mqtt)); Field propField = FaceExtractClient.class.getDeclaredField("properties"); propField.setAccessible(true); @@ -77,7 +86,7 @@ public class FaceExtractClientMockTest configField.set(client, mockConfigService); byte[] fakeImage = new byte[]{1, 2, 3, 4, 5, 6, 7, 8}; - String feature = client.extract(fakeImage, "test_face.jpg", "2.14.5_2.7.1_M3"); + String feature = client.extract(fakeImage, "test_face.jpg", "2.14.5_2.7.1_M3", "TEST0001"); assertNotNull("特征提取必须成功返回特征向量", feature); assertEquals("MOCK_FACE_FEATURE_VECTOR_BASE64_9999", feature); diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java index 1834471e6..b15284b3a 100644 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java +++ b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java @@ -1,107 +1,40 @@ package com.ruoyi.iot.lock.security; - -import java.util.Arrays; -import java.util.Date; -import org.junit.Assert; -import org.junit.Before; +import static org.junit.Assert.*; +import java.util.*; import org.junit.Test; import com.ruoyi.iot.config.IotMqttProperties; import com.ruoyi.iot.core.MqttClientManager; +import com.ruoyi.iot.lock.domain.LockAuth; -/** - * 真实 IoT 接入安全门禁与环境隔离单元测试 - */ -public class RealIotSecurityGateTest -{ - private IotMqttProperties properties; - private RealIotSecurityGate securityGate; - - @Before - public void setUp() - { - properties = new IotMqttProperties(); - properties.setEnabled(true); - properties.setMock(false); - properties.setRealDeviceWriteEnabled(false); - properties.setDeviceWhitelist(Arrays.asList("807104B5")); - properties.setReplayProtectionEnabled(true); - properties.setRetryTaskEnabled(true); - - securityGate = new RealIotSecurityGate(properties); +public class RealIotSecurityGateTest { + @Test public void defaultsAreClosed() { + IotMqttProperties p=new IotMqttProperties(); + RealIotSecurityGate g=new RealIotSecurityGate(p); + assertFalse(p.isEnabled());assertFalse(p.isRealDeviceWriteEnabled());assertFalse(p.isRetryTaskEnabled()); + assertTrue(p.getDeviceWhitelist().isEmpty());assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011")); + assertFalse(g.sameOrigin(new LockAuth())); } - - @Test - public void testRealDeviceWriteDisabledByDefault() - { - // 1. 验证全局写开关关闭时,即便是白名单内的设备也坚决拦截 - boolean allowed = securityGate.checkDeviceWriteAllowed("807104B5", "0011"); - Assert.assertFalse("当 realDeviceWriteEnabled=false 时必须拦截写入", allowed); - - // 2. 验证 Mock 模式下安全放行至本地模拟器 - properties.setMock(true); - boolean mockAllowed = securityGate.checkDeviceWriteAllowed("807104B5", "0011"); - Assert.assertTrue("Mock 模式下应放行至模拟替身", mockAllowed); + @Test public void explicitIdentityOnly() { + IotMqttProperties p=new IotMqttProperties();p.setEnabled(true);p.setRealDeviceWriteEnabled(true); + RealIotSecurityGate g=new RealIotSecurityGate(p); + assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011")); + p.setDeviceWhitelist(Arrays.asList("TEST0001")); + assertTrue(g.checkDeviceWriteAllowed("TEST0001","0011")); + assertFalse(g.checkDeviceWriteAllowed(null,"0011"));assertFalse(g.checkDeviceWriteAllowed("","0011")); + assertFalse(g.checkDeviceWriteAllowed("UNKNOWN","0011")); } - - @Test - public void testDeviceWhitelistValidation() - { - // 开启写开关后测试白名单 - properties.setRealDeviceWriteEnabled(true); - - // 白名单内设备允许 - Assert.assertTrue("白名单内设备应允许下发", - securityGate.checkDeviceWriteAllowed("807104B5", "0011")); - - // 未授权的设备SN必须拦截 - Assert.assertFalse("未授权设备必须拦截", - securityGate.checkDeviceWriteAllowed("UNAUTHORIZED_LOCK_999", "0011")); - Assert.assertFalse("空SN必须拦截", - securityGate.checkDeviceWriteAllowed("", "0011")); - Assert.assertFalse("null SN必须拦截", - securityGate.checkDeviceWriteAllowed(null, "0011")); + @Test public void provenanceIsStableAcrossGateRestartButNotAcrossEnvironments() { + IotMqttProperties p=new IotMqttProperties();p.setMock(true);p.setEnvironmentId("test-mock"); + RealIotSecurityGate first=new RealIotSecurityGate(p);LockAuth a=new LockAuth();first.initializeIntent(a,ExecutionStates.REVOKE); + a.setIntentAt(new Date(0));assertTrue(new RealIotSecurityGate(p).sameOrigin(a)); + p.setMock(false);p.setEnvironmentId("test-real");assertFalse(new RealIotSecurityGate(p).sameOrigin(a)); } - - @Test - public void testReplayProtectionForHistoricalCommands() - { - Date startupTime = securityGate.getSystemStartupTime(); - - // 启动前的历史指令(时间早于系统启动时间 5 秒) - Date historicalTime = new Date(startupTime.getTime() - 5000); - Assert.assertFalse("系统启动前的历史指令必须被防重放机制拦截", - securityGate.checkReplayAllowed(historicalTime)); - - // 启动后新产生的指令 - Date freshTime = new Date(startupTime.getTime() + 1000); - Assert.assertTrue("系统启动后的新业务指令允许重试", - securityGate.checkReplayAllowed(freshTime)); + @Test public void personNumberIsStrict() { + RealIotSecurityGate g=new RealIotSecurityGate(new IotMqttProperties()); + assertTrue(g.isValidPersonNo("ZZ000001"));assertFalse(g.isValidPersonNo("ZZ1"));assertFalse(g.isValidPersonNo("ZZ000001 ")); } - - @Test - public void testPersonNoValidationRule() - { - // 门锁固件要求严格 8 位字母数字 - Assert.assertTrue(securityGate.isValidPersonNo("AA000001")); - Assert.assertTrue(securityGate.isValidPersonNo("BA000001")); - Assert.assertTrue(securityGate.isValidPersonNo("12345678")); - - // 非法格式 - Assert.assertFalse("过短应拒绝", securityGate.isValidPersonNo("AA001")); - Assert.assertFalse("过长应拒绝", securityGate.isValidPersonNo("AA00000001")); - Assert.assertFalse("特殊符号应拒绝", securityGate.isValidPersonNo("AA00-001")); - Assert.assertFalse("空字符串应拒绝", securityGate.isValidPersonNo("")); - Assert.assertFalse("null 应拒绝", securityGate.isValidPersonNo(null)); - } - - @Test(expected = SecurityException.class) - public void testMqttClientManagerThrowsWhenRealWriteDisabled() - { - properties.setMock(false); - properties.setRealDeviceWriteEnabled(false); - - MqttClientManager manager = new MqttClientManager(properties, null); - // 尝试向外部主题发布消息,预期直接触发 SecurityException - manager.publish("ydlink/TEST/thing/data/batch", "{}", 1, false); + @Test(expected=SecurityException.class) public void rawRealPublicationAlwaysDenied() { + IotMqttProperties p=new IotMqttProperties();p.setRealDeviceWriteEnabled(true); + new MqttClientManager(p,null).publish("ydlink/TEST/thing/data/batch","{}",1,false); } } diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java new file mode 100644 index 000000000..557c5ed60 --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java @@ -0,0 +1,125 @@ +package com.ruoyi.iot.lock.security; + +import static org.junit.Assert.*; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.env.*; +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.iot.config.*; +import com.ruoyi.iot.lock.remote.FaceExtractClient; +import com.ruoyi.iot.lock.service.ILockServiceConfigService; +import com.ruoyi.iot.lock.domain.LockCommand; +import com.ruoyi.iot.openapi.config.OpenApiProperties; +import com.ruoyi.iot.openapi.filter.OpenApiAuthFilter; + +public class Round5sEgressIsolationTest { + private void inject(Object obj,String key,Object value)throws Exception {Field f=obj.getClass().getDeclaredField(key);f.setAccessible(true);f.set(obj,value);} + private void denied(Runnable work){try{work.run();fail("Expected denial");}catch(SecurityException expected){}} + private FaceExtractClient face(FaceExtractProperties props,AtomicInteger configReads)throws Exception { + FaceExtractClient client=new FaceExtractClient();inject(client,"properties",props); + IotMqttProperties mqtt=new IotMqttProperties();mqtt.setEnabled(true);mqtt.setRealDeviceWriteEnabled(true); + mqtt.setEnvironmentId("isolated-test-real");mqtt.setDeviceWhitelist(Arrays.asList("TEST0001")); + inject(client,"securityGate",new RealIotSecurityGate(mqtt)); + inject(client,"configService",Proxy.newProxyInstance(ILockServiceConfigService.class.getClassLoader(),new Class[]{ILockServiceConfigService.class},(p,m,a)->{ + configReads.incrementAndGet();JSONObject json=new JSONObject();json.put("endpoint","http://192.0.2.10/unapproved");return json; + }));return client; + } + @Test public void mockFlagIsActuallyBoundAndWinsOverDatabaseEndpoint()throws Exception { + StandardEnvironment env=new StandardEnvironment();env.getPropertySources().addFirst(new MapPropertySource("test",Map.of("iot.face.mock","true","iot.face.endpoint","http://192.0.2.10/unapproved"))); + FaceExtractProperties props=Binder.get(env).bind("iot.face",FaceExtractProperties.class).get();assertTrue(props.isMock()); + AtomicInteger reads=new AtomicInteger();String result=face(props,reads).extract(new byte[]{0},"synthetic.jpg","test"); + assertTrue(result.startsWith("mock_"));assertEquals(0,reads.get()); + } + @Test public void realFaceHttpNeedsExplicitNetworkAuthorization()throws Exception { + FaceExtractProperties props=new FaceExtractProperties();props.setMock(false);props.setEnvironmentId("isolated-test-real"); + AtomicInteger reads=new AtomicInteger();FaceExtractClient client=face(props,reads); + denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","TEST0001"));assertEquals(0,reads.get()); + } + @Test public void databaseEndpointOverrideCannotEscapeEndpointWhitelist()throws Exception { + FaceExtractProperties props=new FaceExtractProperties();props.setMock(false);props.setEnabled(true);props.setRealHttpEnabled(true); + props.setEnvironmentId("isolated-test-real");props.setAllowedEndpoints(Arrays.asList("http://127.0.0.1:1/approved")); + FaceExtractClient client=face(props,new AtomicInteger()); + denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","TEST0001")); + denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","UNKNOWN")); + denied(()->client.extract(new byte[]{0},"synthetic.jpg","test")); + } + @Test public void faceEnvironmentMismatchIsDeniedBeforeConfigOrNetwork()throws Exception { + FaceExtractProperties props=new FaceExtractProperties();props.setMock(false);props.setEnabled(true);props.setRealHttpEnabled(true); + props.setEnvironmentId("other-real");AtomicInteger reads=new AtomicInteger();FaceExtractClient client=face(props,reads); + denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","TEST0001"));assertEquals(0,reads.get()); + } + private int filter(boolean enabled,String configuredKey,String presentedKey,String context,String configuredPrefix)throws Exception { + return filterPath(enabled,configuredKey,presentedKey,context,configuredPrefix,"/openapi/iot/auth"); + } + private int filterPath(boolean enabled,String configuredKey,String presentedKey,String context,String configuredPrefix,String path)throws Exception { + OpenApiProperties props=new OpenApiProperties();props.setEnabled(enabled);props.setApiKey(configuredKey);props.setPathPrefix(configuredPrefix); + OpenApiAuthFilter filter=new OpenApiAuthFilter();inject(filter,"properties",props);AtomicInteger status=new AtomicInteger();AtomicInteger passed=new AtomicInteger(); + HttpServletRequest req=(HttpServletRequest)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{HttpServletRequest.class},(p,m,a)->{ + if(m.getName().equals("getRequestURI"))return context+path; + if(m.getName().equals("getContextPath"))return context; + if(m.getName().equals("getHeader"))return presentedKey; + return null; + }); + HttpServletResponse resp=(HttpServletResponse)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{HttpServletResponse.class},(p,m,a)->{ + if(m.getName().equals("setStatus"))status.set((Integer)a[0]); + if(m.getName().equals("getWriter"))return new PrintWriter(new StringWriter());return null; + }); + filter.doFilter(req,resp,(r,s)->passed.incrementAndGet());return passed.get()>0?200:status.get(); + } + @Test public void openApiDefaultAndMissingOrShortKeysFailClosed()throws Exception { + assertEquals(404,filter(false,null,null,"","/openapi/iot")); + assertEquals(401,filter(true,null,null,"","/openapi/iot")); + assertEquals(401,filter(true,"short-test-key","short-test-key","","/openapi/iot")); + } + @Test public void openApiPrefixOverrideAndContextPathCannotBypassAuthentication()throws Exception { + String key=UUID.randomUUID().toString()+UUID.randomUUID(); + assertEquals(401,filter(true,key,null,"/application","/unrelated")); + assertEquals(200,filter(true,key,key,"/application","/unrelated")); + } + @Test public void encodedOrMatrixPathsCannotBypassOpenApiAuthentication()throws Exception { + String key=UUID.randomUUID().toString()+UUID.randomUUID(); + assertEquals(401,filterPath(true,key,null,"/application","/unrelated","/openapi%2Fiot/auth")); + assertEquals(401,filterPath(true,key,null,"","/unrelated","/openapi/iot;ignored=1/auth")); + assertEquals(404,filterPath(false,key,null,"","/unrelated","/openapi%2Fiot/auth")); + } + @Test public void provenanceCannotBeInjectedThroughJson()throws Exception { + tools.jackson.databind.json.JsonMapper mapper=tools.jackson.databind.json.JsonMapper.builder().build(); + LockCommand command=mapper.readValue("{\"executionMode\":\"REAL\",\"environmentId\":\"fake-real\",\"intentVersion\":99,\"intentKey\":\"fake\",\"executionState\":\"DISPATCHING\"}",LockCommand.class); + assertNull(command.getExecutionMode());assertNull(command.getIntentKey());assertNull(command.getExecutionState());assertNull(command.getIntentVersion()); + } + @Test public void rawMqttControllerIsAbsentInRealModeOrWithoutExplicitQaOptIn() { + for (Map values:Arrays.>asList( + Map.of("iot.mqtt.mock","false","iot.qa-api-enabled","true"), + Map.of("iot.mqtt.mock","true","iot.qa-api-enabled","false"))) { + try(org.springframework.context.annotation.AnnotationConfigApplicationContext context = + new org.springframework.context.annotation.AnnotationConfigApplicationContext()) { + context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test",values)); + context.register(com.ruoyi.iot.controller.IotMqttController.class);context.refresh(); + assertTrue(context.getBeansOfType(com.ruoyi.iot.controller.IotMqttController.class).isEmpty()); + } + } + } + @Test public void checkedInYamlDefaultsBindToClosedCapabilities() throws Exception { + StandardEnvironment env=new StandardEnvironment(); + env.getPropertySources().remove(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME); + env.getPropertySources().remove(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME); + for(PropertySource source:new org.springframework.boot.env.YamlPropertySourceLoader().load("defaults", + new org.springframework.core.io.FileSystemResource("../ruoyi-admin/src/main/resources/application.yml"))) { + env.getPropertySources().addLast(source); + } + IotMqttProperties mqtt=Binder.get(env).bind("iot.mqtt",IotMqttProperties.class).get(); + assertFalse(mqtt.isEnabled());assertTrue(mqtt.isMock());assertFalse(mqtt.isRealDeviceWriteEnabled()); + assertTrue(mqtt.getDeviceWhitelist().isEmpty());assertFalse(mqtt.isRetryTaskEnabled());assertFalse(mqtt.isProtocolVerified()); + FaceExtractProperties face=Binder.get(env).bind("iot.face",FaceExtractProperties.class).get(); + assertTrue(face.isMock());assertFalse(face.isEnabled());assertFalse(face.isRealHttpEnabled());assertTrue(face.getAllowedEndpoints().isEmpty()); + OpenApiProperties api=Binder.get(env).bind("iot.openapi",OpenApiProperties.class).get(); + assertFalse(api.isEnabled());assertTrue(api.getApiKey()==null||api.getApiKey().isEmpty()); + assertEquals("",env.getProperty("token.secret")); + } +} diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java new file mode 100644 index 000000000..49a1847b5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java @@ -0,0 +1,328 @@ +package com.ruoyi.iot.lock.security; + +import static org.junit.Assert.*; +import java.io.*; +import java.lang.reflect.*; +import java.nio.file.*; +import java.sql.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import javax.sql.DataSource; +import org.h2.jdbcx.JdbcDataSource; +import org.h2.tools.RunScript; +import org.junit.*; +import org.mybatis.spring.*; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.*; +import com.ruoyi.iot.config.IotMqttProperties; +import com.ruoyi.iot.core.MqttClientManager; +import com.ruoyi.iot.lock.LockConstants; +import com.ruoyi.iot.lock.domain.*; +import com.ruoyi.iot.lock.mapper.*; +import com.ruoyi.iot.lock.protocol.*; +import com.ruoyi.iot.lock.service.*; +import com.ruoyi.iot.lock.service.impl.*; +import com.ruoyi.iot.service.IMqttService; + +/** Real Services + production MyBatis XML + real local SQL transactions. No remote service is used. */ +public class Round5sExecutionIntegrationTest { + private DataSource ds; + private LockAuthMapper authMapper; + private LockCommandMapper commandMapper; + private LockExecutionTransactions tx; + private IotMqttProperties props; + private RealIotSecurityGate gate; + private LockAuthServiceImpl auths; + private LockCommandServiceImpl commands; + private LockDevice device; + private LockGateway gateway; + private LockGroup group; + private Transport transport; + private SqlSessionTemplate sessions; + + @Before public void setup() throws Exception { + JdbcDataSource raw = new JdbcDataSource(); + Path cases=Files.createDirectories(Paths.get("target/round5s-db")); + Path database=Files.createTempDirectory(cases,"case-").resolve("execution").toAbsolutePath(); + raw.setURL("jdbc:h2:file:" + database + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"); + try (Connection c = raw.getConnection()) { + RunScript.execute(c, new InputStreamReader(new ClassPathResource("round5s-before-migration.sql").getInputStream())); + RunScript.execute(c, Files.newBufferedReader(migration())); + } + ds = new AbstractDataSource() { + @Override public Connection getConnection() throws SQLException { Connection c = raw.getConnection(); c.setSchema("iotlocks"); return c; } + @Override public Connection getConnection(String u, String p) throws SQLException { return getConnection(); } + }; + SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(ds); + factory.setTypeAliasesPackage("com.ruoyi.iot.lock.domain"); + factory.setMapperLocations(new ClassPathResource("mapper/lock/LockAuthMapper.xml"), new ClassPathResource("mapper/lock/LockCommandMapper.xml")); + sessions = new SqlSessionTemplate(factory.getObject()); + authMapper = sessions.getMapper(LockAuthMapper.class); commandMapper = sessions.getMapper(LockCommandMapper.class); + tx = new LockExecutionTransactions(new DataSourceTransactionManager(ds)); + props = new IotMqttProperties(); props.setMock(false); props.setEnabled(true); + props.setRealDeviceWriteEnabled(true); props.setProtocolVerified(true); + props.setEnvironmentId("isolated-test-real"); props.setDeviceWhitelist(Arrays.asList("TEST0001")); + device = new LockDevice(); device.setDeviceId(1L); device.setDeviceSn("TEST0001"); device.setGatewayId(2L); + device.setDevicePassword(UUID.randomUUID().toString()); device.setDevVer("11"); device.setDevType("19"); + gateway = new LockGateway(); gateway.setGatewayId(2L); gateway.setGatewaySn("TESTGW01"); gateway.setGroupId(3L); + group = new LockGroup(); group.setGroupId(3L); group.setGroupCode("TESTGROUP"); + transport = new Transport(); restartServices(); + } + private Path migration() { return Paths.get("../sql/iot/005_round5s_execution_safety.sql"); } + private void restartServices() throws Exception { + gate = new RealIotSecurityGate(props); + inject(gate, "deviceMapper", fake(LockDeviceMapper.class, device)); + inject(gate, "gatewayMapper", fake(LockGatewayMapper.class, gateway)); + inject(gate, "groupMapper", fake(LockGroupMapper.class, group)); + inject(gate, "authMapper", authMapper); inject(gate, "commandMapper", commandMapper); + commands = new LockCommandServiceImpl(); auths = new LockAuthServiceImpl(); + for (Object service : Arrays.asList(commands, auths)) { + inject(service, "commandMapper", commandMapper); inject(service, "authMapper", authMapper); + inject(service, "securityGate", gate); inject(service, "transactions", tx); + } + inject(commands, "mqttService", transport); + inject(auths, "deviceMapper", fake(LockDeviceMapper.class, device)); + inject(auths, "gatewayMapper", fake(LockGatewayMapper.class, gateway)); + inject(auths, "groupMapper", fake(LockGroupMapper.class, group)); + inject(auths, "commandService", commands); + } + static void inject(Object obj, String name, Object value) throws Exception { + Field f = obj.getClass().getDeclaredField(name); f.setAccessible(true); f.set(obj, value); + } + @SuppressWarnings("unchecked") private T fake(Class type, Object result) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, (p,m,a) -> result); + } + private LockAuth fresh() { + LockAuth a = new LockAuth(); a.setPersonNo("ZZ000001"); a.setDeviceId(1L); + a.setDeviceSn(device.getDeviceSn()); a.setGatewayId(2L); a.setGroupId(3L); + a.setAuthType(LockConstants.AUTH_FACE); a.setAuthValue("synthetic-feature-only-in-isolated-test"); + a.setBeginTime(new java.util.Date(System.currentTimeMillis() - 1000)); + a.setEndTime(new java.util.Date(System.currentTimeMillis() + 600000)); + return a; + } + private LockCommand latest(LockAuth a) { + LockAuth current = authMapper.selectAuthById(a.getAuthId()); + return commandMapper.selectCommandByIntentKey(current.getIntentKey()); + } + private void ack(LockCommand c, String code) { + YdlinkFrameDTO request = YdlinkCodec.fromJson(c.getPayload()); + YdlinkFrameDTO response = new YdlinkFrameDTO(); response.setSeq(c.getCommandNo()); response.setOp(request.getOp()); + response.setFlatParams(Arrays.asList(String.valueOf(request.getParams().get(0)), c.getDeviceSn(), code, "", "test")); + String payload = YdlinkCodec.toJson(response); + if ("10".equals(code)) commands.handleGatewayAck(c.getTopic() + "_report", c.getCommandNo(), payload, "test"); + else commands.handleResponse(c.getTopic() + "_report", c.getCommandNo(), payload, "0".equals(code), "test"); + } + private LockAuth confirmedGrant() { + LockAuth a = fresh(); auths.insertAuth(a); LockCommand c = latest(a); ack(c,"10"); ack(c,"0"); + assertEquals(ExecutionStates.CONFIRMED, authMapper.selectAuthById(a.getAuthId()).getExecutionState()); + return a; + } + private void persistRevokeIntentOnly(LockAuth a) { + tx.required(() -> { + LockAuth current = authMapper.selectAuthByIdForUpdate(a.getAuthId()); + gate.advanceIntent(current, ExecutionStates.REVOKE); current.setStatus("1"); + current.setSyncStatus(LockConstants.SYNC_PENDING); assertEquals(1, authMapper.updateAuth(current)); return null; + }); + } + private void expectDenied(Runnable work) { try { work.run(); fail("Expected policy denial"); } catch (SecurityException expected) { } } + + @Test public void emptyWhitelistAndUnknownIdentityNeverReachTransport() { + props.setDeviceWhitelist(Collections.emptyList()); + assertFalse(gate.checkDeviceWriteAllowed(null,"0011")); assertFalse(gate.checkDeviceWriteAllowed("","0011")); + assertFalse(gate.checkDeviceWriteAllowed("TEST0001","0011")); + LockAuth a = fresh(); auths.insertAuth(a); assertEquals(0, transport.attempts.get()); + props.setDeviceWhitelist(Arrays.asList("UNKNOWN")); commands.retryPending(); assertEquals(0, transport.attempts.get()); + props.setDeviceWhitelist(Arrays.asList("TEST0001")); commands.retryPending(); assertEquals(1, transport.attempts.get()); + } + @Test public void groupIdentifierCannotServeAsDeviceWhitelist() { + props.setDeviceWhitelist(Arrays.asList(group.getGroupCode())); + LockAuth a = fresh(); auths.insertAuth(a); assertEquals(0, transport.attempts.get()); + } + @Test public void payloadGrantRevokeAndDualAckAreRealServiceResults() { + LockAuth a = fresh(); auths.insertAuth(a); + LockCommand grant = latest(a); YdlinkFrameDTO g = YdlinkCodec.fromJson(grant.getPayload()); + assertEquals("0011",grant.getCommandType()); assertEquals("facefeature",g.getOp()); + assertEquals(8,g.getParams().size()); assertEquals("add",g.getParams().get(0)); + assertEquals("ZZ000001",g.getParams().get(1)); assertEquals("TEST0001",g.getParams().get(6)); + assertEquals("ydlink/TESTGROUP/thing/data/batch",grant.getTopic()); assertEquals(grant.getCommandNo(),g.getSeq()); + ack(grant,"10"); assertEquals(LockConstants.SYNC_SENT, authMapper.selectAuthById(a.getAuthId()).getSyncStatus()); + ack(grant,"0"); auths.clear(a.getAuthId()); + LockCommand revoke = latest(a); YdlinkFrameDTO r = YdlinkCodec.fromJson(revoke.getPayload()); + assertEquals("0012",revoke.getCommandType()); assertEquals("facefeature",r.getOp()); + assertEquals(Arrays.asList("delete","ZZ000001","TEST0001",device.getDevicePassword()),r.getParams()); + ack(revoke,"0"); assertNotEquals(LockConstants.SYNC_CLEARED,authMapper.selectAuthById(a.getAuthId()).getSyncStatus()); + ack(revoke,"10"); assertEquals(LockConstants.SYNC_CLEARED,authMapper.selectAuthById(a.getAuthId()).getSyncStatus()); + } + @Test public void passwordLayoutAndMissingCredentialsAreValidated() { + LockAuth a=fresh();a.setAuthType(LockConstants.AUTH_PASSWORD);a.setAuthValue(String.valueOf(new java.security.SecureRandom().nextInt(900000)+100000)); + auths.insertAuth(a); assertEquals(7,YdlinkCodec.fromJson(latest(a).getPayload()).getParams().size()); + LockAuth invalid=fresh();invalid.setAuthValue("");auths.insertAuth(invalid); + assertNull(latest(invalid)); assertEquals(ExecutionStates.MANUAL_REVIEW,authMapper.selectAuthById(invalid.getAuthId()).getExecutionState()); + } + @Test public void mockHistoryCannotBePromotedOrRepushedAfterModeSwitch() throws Exception { + props.setMock(true); props.setEnvironmentId("isolated-mock"); transport.connected=false; + LockAuth a=fresh();auths.insertAuth(a);LockCommand mock=latest(a); + props.setMock(false);props.setEnvironmentId("isolated-test-real");transport.connected=true;restartServices(); + commands.retryPending();auths.retryPending();auths.clear(a.getAuthId()); + assertEquals(0,transport.attempts.get()); + assertEquals(ExecutionStates.QUARANTINED,commandMapper.selectCommandById(mock.getCommandId()).getExecutionState()); + assertEquals("MOCK",authMapper.selectAuthById(a.getAuthId()).getExecutionMode()); + expectDenied(() -> commands.retry(mock.getCommandId())); + } + @Test public void revokeCommandNeverAttemptedRecoversAcrossRestartDespiteOldRequestTime() throws Exception { + LockAuth a=confirmedGrant();transport.connected=false;auths.clear(a.getAuthId());LockCommand revoke=latest(a); + sql("update lock_command set req_time = timestamp '2000-01-01 00:00:00' where command_id="+revoke.getCommandId()); + assertNull(revoke.getAttemptStartedAt());int before=transport.attempts.get(); + restartServices();transport.connected=true;assertEquals(1,commands.retryPending()); + assertEquals(before+1,transport.attempts.get());assertEquals(revoke.getCommandNo(),latest(a).getCommandNo()); + } + @Test public void committedRevokeIntentWithoutCommandRecoversAfterRestart() throws Exception { + LockAuth a=confirmedGrant();persistRevokeIntentOnly(a);assertNull(latest(a)); + restartServices();assertEquals(1,auths.retryPending()); + assertEquals(LockConstants.CMD_AUTH_CLEAR,latest(a).getCommandType()); + assertEquals(0,auths.retryPending());assertEquals(2,count("lock_command")); + } + @Test public void sentWithUnknownAckCannotBeBlindlyRetried() { + LockAuth a=confirmedGrant();auths.clear(a.getAuthId());LockCommand revoke=latest(a);int before=transport.attempts.get(); + sql("update lock_command set attempt_started_at=timestamp '2000-01-01 00:00:00' where command_id="+revoke.getCommandId()); + commands.markTimeout(30);commands.retryPending();auths.retryPending(); + expectDenied(() -> commands.retry(revoke.getCommandId())); + assertEquals(before,transport.attempts.get());assertEquals(ExecutionStates.MANUAL_REVIEW,latest(a).getExecutionState()); + } + @Test public void crashAfterCommittedClaimRequiresReviewAndNeverDuplicates() throws Exception { + LockAuth a=confirmedGrant();transport.crash=true; + try {auths.clear(a.getAuthId());fail();}catch(SimulatedProcessCrash expected){} + LockCommand uncertain=latest(a); + assertEquals(ExecutionStates.DISPATCHING,uncertain.getExecutionState()); + int before=transport.attempts.get();transport.crash=false;restartServices(); + commands.retryPending();auths.retryPending();assertEquals(before,transport.attempts.get()); + sql("update lock_command set attempt_started_at=timestamp '2000-01-01 00:00:00' where command_id="+uncertain.getCommandId()); + commands.markTimeout(30);assertEquals(ExecutionStates.MANUAL_REVIEW,latest(a).getExecutionState()); + } + @Test public void ambiguousTransportExceptionDoesNotBecomeRetryableFail() { + LockAuth a=confirmedGrant();transport.ambiguous=true;auths.clear(a.getAuthId());int before=transport.attempts.get(); + commands.retryPending();auths.retryPending();auths.clear(a.getAuthId()); + assertEquals(before,transport.attempts.get());assertEquals(ExecutionStates.MANUAL_REVIEW,latest(a).getExecutionState()); + } + @Test public void explicitDeviceFailureRequiresReviewAndCannotSuppressAnotherRevokeVersion() { + LockAuth a=confirmedGrant();auths.clear(a.getAuthId());LockCommand revoke=latest(a);ack(revoke,"20"); + assertEquals(ExecutionStates.REJECTED,latest(a).getExecutionState()); + assertEquals(ExecutionStates.MANUAL_REVIEW,authMapper.selectAuthById(a.getAuthId()).getExecutionState()); + int before=transport.attempts.get();commands.retryPending();auths.retryPending();assertEquals(before,transport.attempts.get()); + } + @Test public void lateRevokeAckCannotOverwriteNewGrantVersion() { + LockAuth a=confirmedGrant();auths.clear(a.getAuthId());LockCommand old=latest(a);ack(old,"10");ack(old,"0"); + LockAuth change=new LockAuth();change.setAuthId(a.getAuthId());auths.updateAuth(change); + LockAuth before=authMapper.selectAuthById(a.getAuthId());ack(old,"0");ack(old,"10"); + LockAuth after=authMapper.selectAuthById(a.getAuthId()); + assertEquals(before.getIntentKey(),after.getIntentKey());assertEquals(ExecutionStates.GRANT,after.getIntentAction()); + assertEquals(before.getSyncStatus(),after.getSyncStatus());assertNotEquals(LockConstants.SYNC_CLEARED,after.getSyncStatus()); + } + @Test public void wrongTopicOpSequenceOrTargetCannotConfirm() { + LockAuth a=fresh();auths.insertAuth(a);LockCommand c=latest(a); + YdlinkFrameDTO r=new YdlinkFrameDTO();r.setSeq(c.getCommandNo());r.setOp("facefeature"); + r.setFlatParams(Arrays.asList("add","OTHERDEV","0")); + commands.handleResponse(c.getTopic()+"_report",c.getCommandNo(),YdlinkCodec.toJson(r),true,"ignored"); + assertEquals(ExecutionStates.AWAITING_ACK,latest(a).getExecutionState()); + r.setFlatParams(Arrays.asList("add",device.getDeviceSn(),"0")); + commands.handleResponse("ydlink/OTHER/thing/data/batch_report",c.getCommandNo(),YdlinkCodec.toJson(r),true,"ignored"); + assertEquals(ExecutionStates.AWAITING_ACK,latest(a).getExecutionState()); + } + @Test public void disabledWriteAndNetworkNeverInvokeTransport() { + props.setRealDeviceWriteEnabled(false);LockAuth a=fresh();auths.insertAuth(a);commands.retryPending();assertEquals(0,transport.attempts.get()); + props.setRealDeviceWriteEnabled(true);props.setEnabled(false);commands.retryPending();assertEquals(0,transport.attempts.get()); + } + @Test public void unapprovedProtocolCannotReachTransport() { + props.setProtocolVerified(false);LockAuth a=fresh();auths.insertAuth(a);commands.retryPending(); + assertEquals(0,transport.attempts.get());assertNull(latest(a).getAttemptStartedAt()); + } + @Test public void mappingChangesOrEmptyPayloadAreRejectedBeforeDispatch() { + transport.connected=false;LockAuth a=fresh();auths.insertAuth(a);LockCommand c=latest(a); + sql("update lock_command set payload='' where command_id="+c.getCommandId());transport.connected=true; + commands.retryPending();assertEquals(0,transport.attempts.get());assertEquals(ExecutionStates.QUARANTINED,latest(a).getExecutionState()); + LockAuth b=fresh();transport.connected=false;auths.insertAuth(b);gateway.setGroupId(999L);transport.connected=true; + commands.retryPending();assertEquals(0,transport.attempts.get()); + } + @Test public void transactionRollbackLeavesNoIntentCommandOrNetworkAttempt() { + try { tx.required(() -> { auths.insertAuth(fresh()); throw new IllegalStateException("simulate rollback"); }); fail(); } + catch(IllegalStateException expected) { } + assertEquals(0,count("lock_auth"));assertEquals(0,count("lock_command"));assertEquals(0,transport.attempts.get()); + } + @Test public void concurrentDispatchClaimsOnlyOneAttempt() throws Exception { + transport.connected=false;LockAuth a=fresh();auths.insertAuth(a);LockCommand c=latest(a);transport.connected=true; + ExecutorService pool=Executors.newFixedThreadPool(2); + try { Future one=pool.submit(() -> commands.dispatch(c.getCommandId()));Future two=pool.submit(() -> commands.dispatch(c.getCommandId()));one.get(10,TimeUnit.SECONDS);two.get(10,TimeUnit.SECONDS); } + finally { pool.shutdownNow(); } + assertEquals(1,transport.attempts.get()); + } + @Test public void additiveMigrationQuarantinesLegacyAndIsRerunnable() throws Exception { + sql("insert into lock_auth(auth_no,status,del_flag) values('legacy','1','0')"); + try(Connection c=ds.getConnection()) { RunScript.execute(c,Files.newBufferedReader(migration())); } + LockAuth legacy=authMapper.selectAuthList(new LockAuth()).get(0); + assertEquals("LEGACY",legacy.getExecutionMode());assertEquals(Long.valueOf(0),legacy.getIntentVersion()); + assertEquals(ExecutionStates.QUARANTINED,legacy.getExecutionState());auths.retryPending();assertEquals(0,transport.attempts.get()); + } + @Test public void rawPublishReconnectAndWillCannotBypassPolicy() throws Exception { + MqttClientManager manager=new MqttClientManager(props,null,gate); + expectDenied(() -> manager.publish("ydlink/TESTGROUP/thing/data/batch","{}",1,false)); + props.setEnabled(false);expectDenied(manager::connect);expectDenied(manager::reconnect); + IotMqttProperties.MqttWill will=new IotMqttProperties.MqttWill();will.setTopic("ydlink/TESTGROUP/thing/data/batch");props.setWill(will); + Method method=MqttClientManager.class.getDeclaredMethod("buildConnectOptions");method.setAccessible(true); + try {method.invoke(manager);fail();}catch(InvocationTargetException expected){assertTrue(expected.getCause() instanceof SecurityException);} + props.setMock(false);expectDenied(() -> manager.mockReceive("x","{}")); + assertEquals(0,transport.attempts.get()); + } + @Test public void mqttInternalSessionOrPersistenceCannotReplayControlPackets() throws Exception { + MqttClientManager manager=new MqttClientManager(props,null,gate); + props.setAutomaticReconnect(true);props.setCleanSession(false); + Method options=MqttClientManager.class.getDeclaredMethod("buildConnectOptions");options.setAccessible(true); + org.eclipse.paho.client.mqttv3.MqttConnectOptions value=(org.eclipse.paho.client.mqttv3.MqttConnectOptions)options.invoke(manager); + assertFalse(value.isAutomaticReconnect());assertTrue(value.isCleanSession()); + props.setPersistenceDirectory("untrusted-history"); + Method persistence=MqttClientManager.class.getDeclaredMethod("createPersistence");persistence.setAccessible(true); + try{persistence.invoke(manager);fail();}catch(InvocationTargetException expected){assertTrue(expected.getCause() instanceof SecurityException);} + assertEquals(0,transport.attempts.get()); + } + @Test public void actualMqttManagerPublishesValidatedPayloadAtMostOnceWithoutRetaining() throws Exception { + transport.connected=false;LockAuth a=fresh();auths.insertAuth(a);LockCommand command=latest(a); + AtomicReference captured=new AtomicReference<>(); + org.eclipse.paho.client.mqttv3.MqttClient client=new org.eclipse.paho.client.mqttv3.MqttClient( + "tcp://127.0.0.1:1","round5s-"+UUID.randomUUID(), + new org.eclipse.paho.client.mqttv3.persist.MemoryPersistence()) { + @Override public boolean isConnected(){return true;} + @Override public void publish(String topic,org.eclipse.paho.client.mqttv3.MqttMessage message){ + assertEquals(command.getTopic(),topic);captured.set(message); // No connect() or socket publication occurs. + } + }; + try { + MqttClientManager manager=new MqttClientManager(props,null,gate);inject(manager,"client",client); + inject(commands,"mqttService",new com.ruoyi.iot.service.impl.MqttServiceImpl(manager)); + commands.retryPending();assertNotNull(captured.get());assertEquals(0,captured.get().getQos()); + assertFalse(captured.get().isRetained()); + assertEquals(command.getPayload(),new String(captured.get().getPayload(),java.nio.charset.StandardCharsets.UTF_8)); + } finally {client.close();} + } + private void sql(String text) { try(Connection c=ds.getConnection();Statement s=c.createStatement()){s.execute(text);}catch(SQLException e){throw new AssertionError(e);} } + private int count(String table) {try(Connection c=ds.getConnection();Statement s=c.createStatement();ResultSet r=s.executeQuery("select count(*) from "+table)){r.next();return r.getInt(1);}catch(SQLException e){throw new AssertionError(e);} } + private class Transport implements IMqttService { + boolean connected=true;boolean ambiguous;boolean crash; + AtomicInteger attempts=new AtomicInteger(); + @Override public boolean isConnected(){return connected;} + @Override public void publishCommand(LockCommand command){ + gate.requirePersistedDispatch(command); + // Independent connection proves the claim is committed before the transport attempt. + try(Connection c=ds.getConnection();PreparedStatement s=c.prepareStatement("select execution_state from lock_command where command_id=?")){ + s.setLong(1,command.getCommandId());try(ResultSet r=s.executeQuery()){assertTrue(r.next());assertEquals(ExecutionStates.DISPATCHING,r.getString(1));} + }catch(SQLException e){throw new AssertionError(e);} + if(crash)throw new SimulatedProcessCrash(); + attempts.incrementAndGet();if(ambiguous)throw new IllegalStateException("synthetic uncertain transport"); + } + @Override public void publish(String t,String p){throw new AssertionError("raw transport forbidden");} + @Override public void publish(String t,Object p,Integer q,Boolean r){throw new AssertionError("raw transport forbidden");} + @Override public void subscribe(String t){} @Override public void subscribe(String t,Integer q){} + @Override public void unsubscribe(String t){} @Override public void reconnect(){} + @Override public Map getSubscriptions(){return Collections.emptyMap();} + } + private static class SimulatedProcessCrash extends Error {} +} diff --git a/RuoYi-Vue/ruoyi-iot/src/test/resources/round5s-before-migration.sql b/RuoYi-Vue/ruoyi-iot/src/test/resources/round5s-before-migration.sql new file mode 100644 index 000000000..86d6c2670 --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/test/resources/round5s-before-migration.sql @@ -0,0 +1,21 @@ +CREATE SCHEMA IF NOT EXISTS iotlocks; +SET SCHEMA iotlocks; +CREATE TABLE lock_auth ( + auth_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + auth_no varchar(64), person_no varchar(64), person_name varchar(128), + device_id bigint, device_sn varchar(64), gateway_id bigint, group_id bigint, + auth_type varchar(8), auth_value text, auth_value_md5 varchar(64), + begin_time timestamp, end_time timestamp, auth_time timestamp, + sync_status varchar(8), sync_time timestamp, sync_msg varchar(512), retry_count integer, + status varchar(8), del_flag varchar(8), create_by varchar(64), create_time timestamp, + update_by varchar(64), update_time timestamp, remark varchar(512) +); +CREATE TABLE lock_command ( + command_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + command_no varchar(64) UNIQUE, command_type varchar(16), target_type varchar(8), target_id bigint, + target_code varchar(64), biz_id bigint, topic varchar(256), payload text, req_time timestamp, + resp_topic varchar(256), resp_payload text, resp_time timestamp, command_status varchar(8), + retry_count integer, max_retry integer, next_retry_time timestamp, + gateway_ack varchar(8), device_ack varchar(8), device_result varchar(8), error_msg varchar(512), + create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, remark varchar(512) +); 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 index cc7819f7b..8fbbded5a 100644 --- 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 @@ -132,6 +132,7 @@ public class DmsTokenManager { } private TokenEntry performLogin() { + dmsProperties.requireIsolatedMockNetwork(); String appId = dmsProperties.getAppId(); String appSecret = dmsProperties.getAppSecret(); @@ -154,6 +155,7 @@ public class DmsTokenManager { try { URL url = new URL(targetUrl); conn = (HttpURLConnection) url.openConnection(); + conn.setInstanceFollowRedirects(false); conn.setRequestMethod("POST"); conn.setConnectTimeout(dmsProperties.getConnectTimeoutMs()); conn.setReadTimeout(dmsProperties.getReadTimeoutMs()); 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 index 84c031de3..e97a3f17b 100644 --- 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 @@ -81,6 +81,7 @@ public class DmsHttpClient { } private T executePost(String path, Object requestBody, Class responseClass, boolean isMutation, boolean isRetry) { + dmsProperties.requireIsolatedMockNetwork(); String token = tokenManager.getToken(); String targetUrl = buildUrl(path); @@ -88,6 +89,7 @@ public class DmsHttpClient { try { URL url = new URL(targetUrl); conn = (HttpURLConnection) url.openConnection(); + conn.setInstanceFollowRedirects(false); conn.setRequestMethod("POST"); conn.setConnectTimeout(dmsProperties.getConnectTimeoutMs()); conn.setReadTimeout(dmsProperties.getReadTimeoutMs()); 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 index 0ef182aac..3a1fa8b84 100644 --- 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 @@ -15,6 +15,18 @@ public class DmsProperties { */ private boolean enabled = false; + private boolean mock = false; + public boolean isMock() { return mock; } + public void setMock(boolean value) { mock = value; } + /** Legacy DMS has no common device provenance contract in this fusion branch. */ + public void requireIsolatedMockNetwork() { + java.net.URI uri = java.net.URI.create(baseUrl == null ? "" : baseUrl); + if (!enabled || !mock || uri.getUserInfo() != null + || !java.util.Arrays.asList("127.0.0.1", "localhost", "::1", "[::1]").contains(uri.getHost())) { + throw new SecurityException("Legacy DMS real networking is disabled; only explicit local Mock endpoints are permitted"); + } + } + /** * DMS 服务端基础地址 (默认 https://dms.dmsiot.com) */ diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java index bc69ad1f0..ab8aa80aa 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java @@ -89,7 +89,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe @Autowired(required = false) private LockCommandMapper lockCommandMapper; - @Autowired(required = false) + @Autowired private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; @org.springframework.beans.factory.annotation.Value("${train.lock.password-fallback-enabled:false}") @@ -106,6 +106,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe } @Override + @org.springframework.transaction.annotation.Transactional public LockAuthBridgeResult reconcileCheckin(Long accommodationId, String operator) { if (accommodationId == null) @@ -226,12 +227,13 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe log.info("学员 {} 在门锁 {} 存在历史撤权记录 (authId={}),重新入住复用记录并激活,避免唯一约束冲突", personNo, lockDeviceSn, candidateRevokedAuth.getAuthId()); // 核心防竞态治理:作废取消该授权在历史周期中尚未终态的旧指令(特别是未执行/未回执的旧 AUTH_CLEAR) - cancelActiveCommandsForAuth(candidateRevokedAuth.getAuthId()); + securityGate.advanceIntent(candidateRevokedAuth, com.ruoyi.iot.lock.security.ExecutionStates.GRANT); candidateRevokedAuth.setAuthNo("AUTH" + new SimpleDateFormat("yyyyMMddHHmmss").format(now) + StringUtils.substring(IdUtils.fastSimpleUUID(), 0, 6).toUpperCase()); candidateRevokedAuth.setPersonName(student.getsName()); candidateRevokedAuth.setGatewayId(device.getGatewayId()); candidateRevokedAuth.setGroupId(device.getGroupId()); + securityGate.bindAuthorizationIdentity(candidateRevokedAuth); resolveCredential(candidateRevokedAuth, student, device); resolveValidityWindow(candidateRevokedAuth, enrollment); candidateRevokedAuth.setAuthTime(now); @@ -241,7 +243,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe candidateRevokedAuth.setUpdateBy(operator); candidateRevokedAuth.setUpdateTime(now); candidateRevokedAuth.setRemark("由住宿安排 " + accommodationId + " 重新入住激活"); - lockAuthMapper.updateAuth(candidateRevokedAuth); + if (lockAuthMapper.updateAuth(candidateRevokedAuth) != 1) throw new ServiceException("Concurrent authorization intent update"); final Long authIdToPush = candidateRevokedAuth.getAuthId(); dispatchPushAfterCommit(authIdToPush); @@ -274,7 +276,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe auth.setCreateTime(now); auth.setRemark("由住宿安排 " + accommodationId + " 触发创建"); - // 插入数据库 + // Source and version belong to the committed business intent, before any afterCommit callback. + securityGate.bindAuthorizationIdentity(auth); + securityGate.initializeIntent(auth, com.ruoyi.iot.lock.security.ExecutionStates.GRANT); lockAuthMapper.insertAuth(auth); log.info("门锁授权记录已落库(待下发): authId={}, authNo={}, personNo={}, deviceSn={}", auth.getAuthId(), auth.getAuthNo(), personNo, lockDeviceSn); @@ -287,6 +291,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe } @Override + @org.springframework.transaction.annotation.Transactional public LockAuthBridgeResult reconcileCheckout(Long accommodationId, String operator) { if (accommodationId == null) @@ -353,9 +358,17 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe // 核心安全门禁:退宿主事务内持久化撤权意图,将状态变更为停用/撤销中 (status='1') // 确保即使进程崩溃重启,该记录也绝不会被重试机制误判为待建权记录! + activeAuth.setPreviousIntentVersion(activeAuth.getIntentVersion()); + activeAuth.setPreviousExecutionState(activeAuth.getExecutionState()); + if (!securityGate.sameOrigin(activeAuth)) { + activeAuth.setExecutionState(com.ruoyi.iot.lock.security.ExecutionStates.QUARANTINED); + activeAuth.setIntentAction(com.ruoyi.iot.lock.security.ExecutionStates.REVOKE); + } else if (!com.ruoyi.iot.lock.security.ExecutionStates.REVOKE.equals(activeAuth.getIntentAction())) { + securityGate.advanceIntent(activeAuth, com.ruoyi.iot.lock.security.ExecutionStates.REVOKE); + } activeAuth.setStatus("1"); activeAuth.setRemark("由住宿安排 " + accommodationId + " 退宿触发撤权"); - lockAuthMapper.updateAuth(activeAuth); + if (lockAuthMapper.updateAuth(activeAuth) != 1) throw new ServiceException("Concurrent revocation intent update"); // 事务提交后异步下发清除 final Long authIdToClear = activeAuth.getAuthId(); @@ -365,6 +378,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe } @Override + @org.springframework.transaction.annotation.Transactional public TransferBridgeResult reconcileTransfer(Long oldAccommodationId, Long newAccommodationId, String operator) { if (oldAccommodationId == null || newAccommodationId == null) @@ -404,9 +418,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe */ private boolean isAuthActiveStatus(String syncStatus) { - return LockConstants.SYNC_PENDING.equals(syncStatus) - || LockConstants.SYNC_SENT.equals(syncStatus) - || LockConstants.SYNC_SUCCESS.equals(syncStatus); + return !LockConstants.SYNC_CLEARED.equals(syncStatus); } /** @@ -439,10 +451,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe throw new ServiceException("POLICY_UNCONFIRMED_BLOCKED: 学员未录入人脸特征数据,且自动临时密码降级策略未获客户确认已默认禁用 (学员: " + student.getsName() + ")"); } - // 仅在明确启用时降级为 6 位临时密码 - log.warn("学员 {} 未采集人脸特征,依配置启用临时密码降级策略(PROVISIONAL)", student.getPersonNo()); - auth.setAuthType(LockConstants.AUTH_PASSWORD); - auth.setAuthValue(generateSafeTempPassword(student.getPersonNo())); + throw new ServiceException("PASSWORD_FALLBACK_DISABLED: Supply an explicitly approved credential"); } /** @@ -489,16 +498,6 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe auth.setEndTime(endTime); } - /** - * 生成安全临时密码(6位数字,结合稳定种子) - */ - private String generateSafeTempPassword(String personNo) - { - int hash = Math.abs((personNo + "_RAILWAY_SALT").hashCode()); - int code = (hash % 900000) + 100000; - return String.valueOf(code); - } - /** * 事务提交后调度下发(AFTER_COMMIT) */ @@ -575,43 +574,4 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe } } - private void cancelActiveCommandsForAuth(Long authId) - { - if (authId == null || lockCommandMapper == null) - { - return; - } - try - { - LockCommand query = new LockCommand(); - query.setBizId(authId); - List list = lockCommandMapper.selectCommandList(query); - if (list != null) - { - for (LockCommand cmd : list) - { - if (LockConstants.STATUS_PENDING.equals(cmd.getCommandStatus()) - || LockConstants.STATUS_SENT.equals(cmd.getCommandStatus())) - { - log.info("学员重新入住,作废历史周期未终态命令: commandId={}, seq={}, type={}", - cmd.getCommandId(), cmd.getCommandNo(), cmd.getCommandType()); - if (lockCommandService != null) - { - lockCommandService.cancel(cmd.getCommandId()); - } - else - { - cmd.setCommandStatus(LockConstants.STATUS_CANCELED); - cmd.setErrorMsg("被学员重新入住新授权作废"); - lockCommandMapper.updateCommand(cmd); - } - } - } - } - } - catch (Exception ex) - { - log.warn("作废历史未终态命令时发生非致命异常: authId={}, msg={}", authId, ex.getMessage()); - } - } } diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java index 10631af44..0f2ca625c 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java @@ -352,6 +352,7 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService * @return 结果 */ @Override + @Transactional public int checkOut(Long accommodationId, String operator) { TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId); @@ -361,7 +362,10 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService } if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus())) { - return 1; // 已经是退宿状态 + if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null) { + iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); + } + return 1; // Idempotent checkout still reconciles durable revocation intent. } int rows = trainAccommodationMapper.updateAccommodationStatus(accommodationId, TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); @@ -370,14 +374,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService boolean useIot = "iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null; if (useIot) { - try - { - iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); - } - catch (Exception ex) - { - log.error("IoT lock auth revoke on checkout failed for accommodation {}: {}", accommodationId, ex.getMessage()); - } + // Failure to persist the revoke intent must roll back the checkout transaction. + iotLockAuthBridgeService.reconcileCheckout(accommodationId, operator); } else if (accessAuthorizationOrchestrator != null) { 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 index 7674aa054..7fe228f0e 100644 --- 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 @@ -43,6 +43,7 @@ public class DmsAsyncLifecycleAndSecurityTest { properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/Round5sDmsBoundaryTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/Round5sDmsBoundaryTest.java new file mode 100644 index 000000000..85b6a7d4f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/Round5sDmsBoundaryTest.java @@ -0,0 +1,23 @@ +package com.ruoyi.train.integration.dms; + +import static org.junit.Assert.*; +import org.junit.Test; +import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.client.DmsHttpClient; +import com.ruoyi.train.integration.dms.auth.DmsTokenManager; + +public class Round5sDmsBoundaryTest { + @Test public void externalDmsCannotBypassUnifiedRealDevicePolicy() { + DmsProperties p=new DmsProperties();p.setEnabled(true);p.setMock(true);p.setBaseUrl("https://192.0.2.10"); + try {new DmsHttpClient(p,null).postMutation("/synthetic",null,Object.class);fail();} + catch(SecurityException expected){} // Rejected before token acquisition or networking. + } + @Test public void localDmsStillNeedsExplicitMockMode() { + DmsProperties p=new DmsProperties();p.setEnabled(true);p.setBaseUrl("http://127.0.0.1:1"); + try {new DmsTokenManager(p).getToken();fail();}catch(SecurityException expected){} + } + @Test public void disabledDmsCannotQueryOrMutate() { + DmsProperties p=new DmsProperties();p.setMock(true);p.setBaseUrl("http://127.0.0.1:1"); + try {new DmsHttpClient(p,null).postQuery("/synthetic",null,Object.class);fail();}catch(SecurityException expected){} + } +} 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 index b2b2b0b24..be3b51531 100644 --- 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 @@ -37,6 +37,7 @@ public class DmsTokenManagerTest { properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); properties.setTokenTtlSeconds(7200); 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 index 53d0252f8..d6df201f7 100644 --- 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 @@ -30,6 +30,7 @@ public class DmsDoorLockAdapterTest { DmsProperties properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); 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 index 5cb7b9725..950421015 100644 --- 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 @@ -30,6 +30,7 @@ public class DmsHttpClientTest { properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); properties.setConnectTimeoutMs(1000); @@ -164,6 +165,7 @@ public class DmsHttpClientTest { DmsProperties disabledProps = new DmsProperties(); disabledProps.setEnabled(false); disabledProps.setBaseUrl(mockServer.getBaseUrl()); + disabledProps.setMock(true); disabledProps.setAppId("disabled-app-id"); disabledProps.setAppSecret("disabled-app-secret"); 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 index 76829db0f..4821dd79c 100644 --- 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 @@ -32,6 +32,7 @@ public class DmsLocationAdapterTest { DmsProperties properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); 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 index 6922e12ed..7933ccb8c 100644 --- 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 @@ -29,6 +29,7 @@ public class DmsOperationStatusAdapterTest { DmsProperties properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); 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 index 1477f3709..7c17f846e 100644 --- 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 @@ -30,6 +30,7 @@ public class DmsPasswordAdapterTest { DmsProperties properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java index b7f2d34ad..f5356a43e 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java @@ -68,6 +68,7 @@ public class AccessAuthorizationOrchestrationTest { properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); properties.setTokenTtlSeconds(7200); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java index 7e6def885..b5608e56b 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java @@ -74,6 +74,7 @@ public class DmsMockE2EScenariosSuiteTest { properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-suite-app-id"); properties.setAppSecret("test-suite-app-secret"); properties.setTokenTtlSeconds(7200); 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 index c7b33e4bb..40838890c 100644 --- 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 @@ -37,6 +37,7 @@ public class DmsOperationPollerTest { properties = new DmsProperties(); properties.setEnabled(true); properties.setBaseUrl(mockServer.getBaseUrl()); + properties.setMock(true); properties.setAppId("test-app-id"); properties.setAppSecret("test-app-secret"); properties.setPollIntervalSeconds(10); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java index c8bda7b66..6b3662f4e 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java @@ -114,6 +114,17 @@ public class TrainIotLockAuthBridgeMockTest // 组装桥接服务 bridgeService = new TrainIotLockAuthBridgeServiceImpl(); + com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties(); + executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock"); + com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps); + setField(executionGate, "deviceMapper", createMockDeviceMapper()); + setField(executionGate, "gatewayMapper", Proxy.newProxyInstance( + com.ruoyi.iot.lock.mapper.LockGatewayMapper.class.getClassLoader(), + new Class[]{com.ruoyi.iot.lock.mapper.LockGatewayMapper.class}, (proxy, method, args) -> { + com.ruoyi.iot.lock.domain.LockGateway gateway = new com.ruoyi.iot.lock.domain.LockGateway(); + gateway.setGatewayId(1L); gateway.setGroupId(1L); return gateway; + })); + setField(bridgeService, "securityGate", executionGate); setField(bridgeService, "trainAccommodationMapper", createMockAccommodationMapper()); setField(bridgeService, "trainEnrollmentMapper", createMockEnrollmentMapper()); setField(bridgeService, "trainStudentMapper", createMockStudentMapper()); @@ -659,13 +670,8 @@ public class TrainIotLockAuthBridgeMockTest bridgeService.setPasswordFallbackEnabled(true); try { - LockAuthBridgeResult result = bridgeService.reconcileCheckin(3008L, "admin"); - assertEquals("SUCCESS", result.getStatus()); - LockAuth auth = authStore.get(result.getAuthId()); - assertNotNull(auth); - assertEquals("应降级为密码授权", LockConstants.AUTH_PASSWORD, auth.getAuthType()); - assertNotNull("应生成密码内容", auth.getAuthValue()); - assertEquals("临时密码应为6位数字", 6, auth.getAuthValue().length()); + try { bridgeService.reconcileCheckin(3008L, "admin"); fail("Deterministic password fallback must remain disabled"); } + catch (ServiceException expected) { assertTrue(expected.getMessage().contains("PASSWORD_FALLBACK_DISABLED")); } } finally { @@ -966,6 +972,7 @@ public class TrainIotLockAuthBridgeMockTest LockAuth initialAuth = authStore.get(authId); initialAuth.setStatus("1"); // 停用 initialAuth.setSyncStatus(LockConstants.SYNC_CLEARED); // 已清除 + initialAuth.setExecutionState(com.ruoyi.iot.lock.security.ExecutionStates.CONFIRMED); authStore.put(authId, initialAuth); // 2. 学员再次办理重新入住 (同一宿舍门锁 LOCK_SN_101) @@ -1025,30 +1032,11 @@ public class TrainIotLockAuthBridgeMockTest String oldClearSeq = oldClearCmd.getCommandNo(); assertEquals(LockConstants.STATUS_SENT, oldClearCmd.getCommandStatus()); - // 3. 学员再次入住同一门锁 A (此时旧撤权命令尚未收到回执) - LockAuthBridgeResult recheckinResult = bridgeService.reconcileCheckin(3001L, "admin"); - assertEquals("SUCCESS", recheckinResult.getStatus()); - assertEquals(authId, recheckinResult.getAuthId()); - - // 4. 验证旧撤权命令在重新入住时已被安全作废取消,杜绝重试任务继续推进 - LockCommand canceledOldCmd = commandStore.get(oldClearSeq); - assertEquals("旧撤权命令必须已被作废", LockConstants.STATUS_CANCELED, canceledOldCmd.getCommandStatus()); - assertTrue("错误说明必须记录被重新入住作废", canceledOldCmd.getErrorMsg().contains("作废")); - - // 5. 模拟旧 AUTH_CLEAR 延迟执行、晚到回执到达 - ILockAuthService authService = (ILockAuthService) getField(bridgeService, "lockAuthService"); - authService.handleResult(oldClearSeq, true, "门锁设备延迟上报清除成功"); - - // 6. 核心门禁断言:旧回执坚决不得清空新授权,更不得将 syncStatus 覆盖为 SYNC_CLEARED - LockAuth currentAuth = authStore.get(authId); - assertEquals("授权状态必须保持在用 (0)", "0", currentAuth.getStatus()); - assertNotEquals("同步状态绝不得被旧撤权回执覆盖为已清除 (2)", LockConstants.SYNC_CLEARED, currentAuth.getSyncStatus()); - assertEquals("同步状态应保持为重新入住的下发中状态 (1)", LockConstants.SYNC_SENT, currentAuth.getSyncStatus()); - - // 7. 进一步断言:重试任务 retryPending 执行时,绝对不会撤销新权限 - int retried = authService.retryPending(); - assertEquals("当前无待重试撤权", 0, retried); - assertEquals("0", currentAuth.getStatus()); + authBeforeClear.setExecutionState(com.ruoyi.iot.lock.security.ExecutionStates.AWAITING_ACK); + try { bridgeService.reconcileCheckin(3001L, "admin"); fail("Uncertain revoke cannot be canceled into a new grant"); } + catch (SecurityException expected) { } + assertEquals(LockConstants.STATUS_SENT, commandStore.get(oldClearSeq).getCommandStatus()); + assertEquals("1", authStore.get(authId).getStatus()); } // 辅助模拟回执方法 diff --git a/RuoYi-Vue/sql/iot/005_round5s_execution_safety.sql b/RuoYi-Vue/sql/iot/005_round5s_execution_safety.sql new file mode 100644 index 000000000..df9c4ad74 --- /dev/null +++ b/RuoYi-Vue/sql/iot/005_round5s_execution_safety.sql @@ -0,0 +1,26 @@ +-- Round 5S additive migration. Apply ONLY to an approved isolated database first. +-- No history backfill to REAL, no deletion, no account/privilege changes. +-- Stop the application before migration; do not run old and new writers together. +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS execution_mode varchar(16) NOT NULL DEFAULT 'LEGACY'; +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS environment_id varchar(128); +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS intent_key varchar(64); +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS intent_version bigint NOT NULL DEFAULT 0; +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS execution_state varchar(32) NOT NULL DEFAULT 'QUARANTINED'; +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS intent_action varchar(16); +ALTER TABLE iotlocks.lock_auth ADD COLUMN IF NOT EXISTS intent_at timestamp; +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS execution_mode varchar(16) NOT NULL DEFAULT 'LEGACY'; +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS environment_id varchar(128); +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS intent_key varchar(64); +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS intent_version bigint NOT NULL DEFAULT 0; +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS execution_state varchar(32) NOT NULL DEFAULT 'QUARANTINED'; +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS intent_action varchar(16); +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS intent_at timestamp; +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS device_sn varchar(64); +ALTER TABLE iotlocks.lock_command ADD COLUMN IF NOT EXISTS attempt_started_at timestamp; +CREATE UNIQUE INDEX IF NOT EXISTS uq_round5s_command_intent ON iotlocks.lock_command(intent_key); +CREATE UNIQUE INDEX IF NOT EXISTS uq_round5s_auth_intent ON iotlocks.lock_auth(intent_key); +CREATE INDEX IF NOT EXISTS ix_round5s_command_recovery ON iotlocks.lock_command(execution_state, attempt_started_at); +CREATE INDEX IF NOT EXISTS ix_round5s_auth_recovery ON iotlocks.lock_auth(execution_state, intent_action); +-- Existing rows are LEGACY / version 0 / QUARANTINED. Never bulk relabel them. +-- READY: proven never attempted; DISPATCHING/AWAITING_ACK: outcome may be unknown. +-- MANUAL_REVIEW/QUARANTINED: no automatic resend; CONFIRMED requires correlated device evidence. diff --git a/build_ui/src/utils/iotExecutionState.js b/build_ui/src/utils/iotExecutionState.js new file mode 100644 index 000000000..b888092b7 --- /dev/null +++ b/build_ui/src/utils/iotExecutionState.js @@ -0,0 +1,13 @@ +export function executionStateLabel(state) { + const labels = { + READY: '尚未发送', + DISPATCHING: '发送结果待确认', + AWAITING_ACK: '等待设备确认', + CONFIRMED: '设备已确认', + REJECTED: '设备拒绝,待核对', + MANUAL_REVIEW: '待人工核对', + QUARANTINED: '已隔离,禁止下发', + CANCELED: '已取消未发指令' + } + return labels[state] || '来源待核实' +} diff --git a/build_ui/src/views/iot/auth/index.vue b/build_ui/src/views/iot/auth/index.vue index 60e47d6f4..0bd2e18a3 100644 --- a/build_ui/src/views/iot/auth/index.vue +++ b/build_ui/src/views/iot/auth/index.vue @@ -183,6 +183,9 @@ - + + + + + + @@ -113,6 +118,9 @@ {{ detail.commandNo }} {{ detail.commandStatus }} + {{ executionStateLabel(detail.executionState) }} + {{ detail.deviceSn }} + {{ detail.intentVersion }} {{ detail.topic }} {{ detail.errorMsg }} @@ -133,6 +141,7 @@