修复物联安全:持久化撤权恢复并统一真实外发门禁
This commit is contained in:
1 parent
373921447f
commit
0c1d97a6f0
70 files changed
+1817
-1459
No files matched your search
+12
-2
@@ -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<Object> records = new ArrayList<>();
|
||||
|
||||
+3
-2
@@ -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<Integer, UserEntity> users = new LinkedHashMap<Integer, UserEntity>();
|
||||
{
|
||||
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 = "获取用户列表")
|
||||
|
||||
@@ -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
|
||||
multi-statement-allow: true
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version><scope>test</scope></dependency>
|
||||
|
||||
<!-- SpringBoot Web容器 -->
|
||||
<dependency>
|
||||
@@ -61,4 +62,4 @@
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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<String, Object> 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)
|
||||
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
|
||||
|
||||
<!-- MQTT客户端(Eclipse Paho,协议 MQTT 3.1.1) -->
|
||||
<dependency>
|
||||
|
||||
@@ -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<String> 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<String> getAllowedEndpoints() { return allowedEndpoints; }
|
||||
public void setAllowedEndpoints(java.util.List<String> value) { allowedEndpoints = value; }
|
||||
|
||||
/** 调用超时(毫秒) */
|
||||
private int timeout = 10000;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<String> deviceWhitelist = new ArrayList<String>();
|
||||
|
||||
/** 是否启用历史命令防重放保护(默认为 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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<java.util.Map<String, Object>> 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<String, Object> 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<String, Object> 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());
|
||||
|
||||
+2
-1
@@ -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')")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,9 @@ import com.ruoyi.iot.lock.domain.LockAuth;
|
||||
*/
|
||||
public interface LockAuthMapper
|
||||
{
|
||||
LockAuth selectAuthByIdForUpdate(Long authId);
|
||||
List<LockAuth> selectReadyIntents();
|
||||
int updateExecution(LockAuth auth);
|
||||
List<LockAuth> selectAuthList(LockAuth auth);
|
||||
|
||||
LockAuth selectAuthById(Long authId);
|
||||
|
||||
@@ -11,6 +11,9 @@ import com.ruoyi.iot.lock.domain.LockCommand;
|
||||
*/
|
||||
public interface LockCommandMapper
|
||||
{
|
||||
LockCommand selectCommandByIdForUpdate(Long commandId);
|
||||
LockCommand selectCommandByIntentKey(String intentKey);
|
||||
List<LockCommand> selectReadyCommands();
|
||||
List<LockCommand> selectCommandList(LockCommand command);
|
||||
|
||||
LockCommand selectCommandById(Long commandId);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+46
@@ -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); }
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+31
-15
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+145
-113
@@ -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 硬件接入安全门禁
|
||||
* <p>
|
||||
* 安全设计职责:
|
||||
* 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<String> 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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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> T required(Supplier<T> work) { return required.execute(status -> work.get()); }
|
||||
public <T> T committed(Supplier<T> 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(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
+129
-612
@@ -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<LockAuth> selectAuthList(LockAuth query) { return authMapper.selectAuthList(query); }
|
||||
@Override public LockAuth selectAuthById(Long id) { return authMapper.selectAuthById(id); }
|
||||
@Override public List<LockAuth> 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<LockAuth> 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<LockAuth> 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<LockAuth> 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<LockAuth> 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<LockCommand> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定授权所属分组
|
||||
* <p>
|
||||
* 分组关系为「门锁 → 网关 → 分组」,统一由门锁所属网关推导,不取门锁的冗余分组字段,
|
||||
* 避免两者不一致。下发主题为 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装授权下发/清除报文
|
||||
* <p>
|
||||
* 协议格式(一维 params,门锁报文由网关订阅后转发):
|
||||
* <pre>
|
||||
* {"devId":"网关SN","devPsw":"门锁密码","devVer":"11","devType":"19","ac":"req",
|
||||
* "at":"1790909167","seq":"1790909167589","op":"facefeature","mode":"s2d","chksum":"xx",
|
||||
* "params":["add","人员编号","授权内容","授权锁标识","开始时间","结束时间","门锁SN","门锁密码"]}
|
||||
* </pre>
|
||||
*
|
||||
* @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.
|
||||
}
|
||||
}
|
||||
+216
-385
@@ -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<String> NO_RETRY_COMMAND_TYPES = new HashSet<String>(
|
||||
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<LockCommand> 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<LockCommand> 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<LockCommand> 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<List<String>> records = YdlinkCodec.toRecordList(response.getParams());
|
||||
if (records.size() != 1) return false;
|
||||
List<String> 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<LockCommand> selectCommandList(LockCommand query) { return commandMapper.selectCommandList(query); }
|
||||
@Override public LockCommand selectCommandById(Long id) { return commandMapper.selectCommandById(id); }
|
||||
}
|
||||
+24
@@ -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<LockDevice> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+15
-20
@@ -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<byte[]> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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<LockGateway> 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+6
-5
@@ -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 ? "已删除已确认撤权的记录" : "撤权意图已记录,设备确认前保留记录");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+23
-3
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
/**
|
||||
* 客户端是否已连接
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -30,10 +30,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="executionMode" column="execution_mode" />
|
||||
<result property="environmentId" column="environment_id" />
|
||||
<result property="intentKey" column="intent_key" />
|
||||
<result property="intentVersion" column="intent_version" />
|
||||
<result property="executionState" column="execution_state" />
|
||||
<result property="intentAction" column="intent_action" />
|
||||
<result property="intentAt" column="intent_at" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectAuthVo">
|
||||
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 id="insertAuth" parameterType="LockAuth" useGeneratedKeys="true" keyProperty="authId">
|
||||
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"
|
||||
<if test="authTime != null">auth_time = #{authTime},</if>
|
||||
<if test="status != null and status != ''">status = #{status},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
<if test="executionMode != null">execution_mode = #{executionMode},</if>
|
||||
<if test="environmentId != null">environment_id = #{environmentId},</if>
|
||||
<if test="intentKey != null">intent_key = #{intentKey},</if>
|
||||
<if test="intentVersion != null">intent_version = #{intentVersion},</if>
|
||||
<if test="executionState != null">execution_state = #{executionState},</if>
|
||||
<if test="intentAction != null">intent_action = #{intentAction},</if>
|
||||
<if test="intentAt != null">intent_at = #{intentAt},</if>
|
||||
<if test="syncStatus != null">sync_status = #{syncStatus},</if>
|
||||
update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
where auth_id = #{authId}
|
||||
<if test="previousIntentVersion != null">and intent_version = #{previousIntentVersion} and execution_state = #{previousExecutionState}</if>
|
||||
</update>
|
||||
|
||||
<update id="updateSyncStatus">
|
||||
@@ -172,4 +190,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
delete from lock_auth where device_id = #{deviceId}
|
||||
</delete>
|
||||
|
||||
|
||||
<select id="selectReadyIntents" resultMap="LockAuthResult"><include refid="selectAuthVo"/> where del_flag = '0' and execution_state = 'READY' order by case when intent_action = 'REVOKE' then 0 else 1 end, auth_id</select>
|
||||
<update id="updateExecution">
|
||||
update lock_auth set execution_state = #{executionState}, sync_msg = #{syncMsg}, sync_time = CURRENT_TIMESTAMP
|
||||
<if test="syncStatus != null">, sync_status = #{syncStatus}</if>
|
||||
where auth_id = #{authId} and intent_key = #{intentKey} and intent_version = #{intentVersion}
|
||||
</update>
|
||||
<select id="selectAuthByIdForUpdate" resultMap="LockAuthResult"><include refid="selectAuthVo"/> where auth_id = #{authId} for update</select>
|
||||
</mapper>
|
||||
@@ -31,10 +31,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="executionMode" column="execution_mode" />
|
||||
<result property="environmentId" column="environment_id" />
|
||||
<result property="intentKey" column="intent_key" />
|
||||
<result property="intentVersion" column="intent_version" />
|
||||
<result property="executionState" column="execution_state" />
|
||||
<result property="intentAction" column="intent_action" />
|
||||
<result property="intentAt" column="intent_at" />
|
||||
<result property="deviceSn" column="device_sn" />
|
||||
<result property="attemptStartedAt" column="attempt_started_at" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectCommandVo">
|
||||
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"
|
||||
|
||||
<select id="selectTimeoutCommands" parameterType="java.util.Date" resultMap="LockCommandResult">
|
||||
<include refid="selectCommandVo"/>
|
||||
where command_status = '1' and req_time < #{timeoutTime}
|
||||
where execution_state in ('DISPATCHING', 'AWAITING_ACK') and attempt_started_at < #{timeoutTime}
|
||||
order by command_id
|
||||
</select>
|
||||
|
||||
<insert id="insertCommand" parameterType="LockCommand" useGeneratedKeys="true" keyProperty="commandId">
|
||||
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 id="updateCommand" parameterType="LockCommand">
|
||||
update lock_command
|
||||
<set>
|
||||
<if test="commandType != null">command_type = #{commandType},</if>
|
||||
<if test="topic != null">topic = #{topic},</if>
|
||||
<if test="payload != null">payload = #{payload},</if>
|
||||
<if test="reqTime != null">req_time = #{reqTime},</if>
|
||||
<if test="respTopic != null">resp_topic = #{respTopic},</if>
|
||||
<if test="respPayload != null">resp_payload = #{respPayload},</if>
|
||||
@@ -128,9 +136,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<if test="deviceAck != null">device_ack = #{deviceAck},</if>
|
||||
<if test="deviceResult != null">device_result = #{deviceResult},</if>
|
||||
<if test="errorMsg != null">error_msg = #{errorMsg},</if>
|
||||
<if test="executionState != null">execution_state = #{executionState},</if>
|
||||
<if test="attemptStartedAt != null">attempt_started_at = #{attemptStartedAt},</if>
|
||||
update_by = #{updateBy}, update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
where command_id = #{commandId}
|
||||
<if test="previousExecutionState != null">and execution_state = #{previousExecutionState}</if>
|
||||
<if test="intentKey != null">and intent_key = #{intentKey} and intent_version = #{intentVersion}</if>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommandByIds" parameterType="Long">
|
||||
@@ -140,4 +152,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
|
||||
<select id="selectCommandByIntentKey" resultMap="LockCommandResult"><include refid="selectCommandVo"/> where intent_key = #{intentKey}</select>
|
||||
<select id="selectReadyCommands" resultMap="LockCommandResult"><include refid="selectCommandVo"/> where execution_state = 'READY' order by case when intent_action = 'REVOKE' then 0 else 1 end, command_id</select>
|
||||
<select id="selectCommandByIdForUpdate" resultMap="LockCommandResult"><include refid="selectCommandVo"/> where command_id = #{commandId} for update</select>
|
||||
</mapper>
|
||||
+12
-3
@@ -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);
|
||||
|
||||
+29
-96
@@ -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);
|
||||
}
|
||||
}
|
||||
+125
@@ -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<String,Object> values:Arrays.<Map<String,Object>>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"));
|
||||
}
|
||||
}
|
||||
+328
@@ -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> T fake(Class<T> 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<Boolean> one=pool.submit(() -> commands.dispatch(c.getCommandId()));Future<Boolean> 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<org.eclipse.paho.client.mqttv3.MqttMessage> 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<String,Integer> getSubscriptions(){return Collections.emptyMap();}
|
||||
}
|
||||
private static class SimulatedProcessCrash extends Error {}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
+2
@@ -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());
|
||||
|
||||
+2
@@ -81,6 +81,7 @@ public class DmsHttpClient {
|
||||
}
|
||||
|
||||
private <T> T executePost(String path, Object requestBody, Class<T> 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());
|
||||
|
||||
+12
@@ -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)
|
||||
*/
|
||||
|
||||
+21
-61
@@ -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<LockCommand> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -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)
|
||||
{
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
|
||||
+23
@@ -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){}
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
|
||||
+2
@@ -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");
|
||||
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+19
-31
@@ -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());
|
||||
}
|
||||
|
||||
// 辅助模拟回执方法
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
export function executionStateLabel(state) {
|
||||
const labels = {
|
||||
READY: '尚未发送',
|
||||
DISPATCHING: '发送结果待确认',
|
||||
AWAITING_ACK: '等待设备确认',
|
||||
CONFIRMED: '设备已确认',
|
||||
REJECTED: '设备拒绝,待核对',
|
||||
MANUAL_REVIEW: '待人工核对',
|
||||
QUARANTINED: '已隔离,禁止下发',
|
||||
CANCELED: '已取消未发指令'
|
||||
}
|
||||
return labels[state] || '来源待核实'
|
||||
}
|
||||
@@ -183,6 +183,9 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理状态" align="center" min-width="150">
|
||||
<template slot-scope="scope">{{ executionStateLabel(scope.row.executionState) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="300" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
@@ -348,6 +351,7 @@ import { listAuth, getAuth, delAuth, addAuth, updateAuth, pushAuth, batchPush, c
|
||||
import { listFace, extract } from "@/api/iot/face";
|
||||
import { listDevice } from "@/api/iot/device";
|
||||
import { optionselect } from "@/api/iot/group";
|
||||
import { executionStateLabel } from "@/utils/iotExecutionState";
|
||||
|
||||
export default {
|
||||
name: "Auth",
|
||||
@@ -425,6 +429,7 @@ export default {
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
executionStateLabel,
|
||||
/** 加载授权依赖项:人员(人脸库)、门锁、分组 */
|
||||
loadOptions() {
|
||||
listFace({ pageNum: 1, pageSize: 500 }).then(response => {
|
||||
@@ -594,13 +599,13 @@ export default {
|
||||
if (valid) {
|
||||
if (this.form.authId != undefined) {
|
||||
updateAuth(this.form).then(response => {
|
||||
this.$modal.msgSuccess("修改成功,已重新下发");
|
||||
this.$modal.msgSuccess("授权意图已更新,请查询设备确认状态");
|
||||
this.open = false;
|
||||
this.getList();
|
||||
});
|
||||
} else {
|
||||
addAuth(this.form).then(response => {
|
||||
this.$modal.msgSuccess("新增成功,已下发");
|
||||
this.$modal.msgSuccess("授权意图已记录,请查询设备确认状态");
|
||||
this.open = false;
|
||||
this.getList();
|
||||
});
|
||||
@@ -622,7 +627,7 @@ export default {
|
||||
this.$modal.confirm('确认删除该人员在门锁上的人脸数据?平台侧授权记录会保留。').then(function() {
|
||||
return clearAuth(authId);
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess("删除指令已下发");
|
||||
this.$modal.msgSuccess("撤权请求已记录,设备清除结果待确认");
|
||||
this.getList();
|
||||
}).catch(() => {});
|
||||
},
|
||||
@@ -647,7 +652,7 @@ export default {
|
||||
return delAuth(authIds);
|
||||
}).then(() => {
|
||||
this.getList();
|
||||
this.$modal.msgSuccess("删除成功");
|
||||
this.$modal.msgSuccess("已处理删除请求,未确认撤权的记录将保留");
|
||||
}).catch(() => {});
|
||||
},
|
||||
deviceLabel(deviceId, deviceSn) {
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重试" align="center" prop="retryCount" width="60" />
|
||||
<el-table-column label="处理状态" align="center" min-width="150">
|
||||
<template slot-scope="scope">{{ executionStateLabel(scope.row.executionState) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下发时间" align="center" prop="reqTime" width="160">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.reqTime) }}</span>
|
||||
@@ -85,6 +88,7 @@
|
||||
type="text"
|
||||
icon="el-icon-refresh-right"
|
||||
@click="handleRetry(scope.row)"
|
||||
:disabled="scope.row.executionState !== 'READY' || !!scope.row.attemptStartedAt"
|
||||
v-hasPermi="['lock:command:retry']"
|
||||
>重发</el-button>
|
||||
<el-button
|
||||
@@ -92,6 +96,7 @@
|
||||
type="text"
|
||||
icon="el-icon-circle-close"
|
||||
@click="handleCancel(scope.row)"
|
||||
:disabled="scope.row.executionState !== 'READY' || !!scope.row.attemptStartedAt"
|
||||
v-hasPermi="['lock:command:retry']"
|
||||
>取消</el-button>
|
||||
</template>
|
||||
@@ -113,6 +118,9 @@
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="命令编号">{{ detail.commandNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="命令状态">{{ detail.commandStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">{{ executionStateLabel(detail.executionState) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标设备">{{ detail.deviceSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务版本">{{ detail.intentVersion }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下发主题" :span="2">{{ detail.topic }}</el-descriptions-item>
|
||||
<el-descriptions-item label="错误说明" :span="2">{{ detail.errorMsg }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@@ -133,6 +141,7 @@
|
||||
|
||||
<script>
|
||||
import { listCommand, getCommand, retryCommand, cancelCommand } from "@/api/iot/command";
|
||||
import { executionStateLabel } from "@/utils/iotExecutionState";
|
||||
|
||||
export default {
|
||||
name: "Command",
|
||||
@@ -160,6 +169,7 @@ export default {
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
executionStateLabel,
|
||||
getList() {
|
||||
this.loading = true;
|
||||
listCommand(this.queryParams).then(response => {
|
||||
@@ -185,7 +195,7 @@ export default {
|
||||
},
|
||||
handleRetry(row) {
|
||||
retryCommand(row.commandId).then(response => {
|
||||
this.$modal.msgSuccess("重发完成");
|
||||
this.$modal.msgSuccess("重试请求已受理,请查询处理状态");
|
||||
this.getList();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import { unlockScreen } from '@/api/login'
|
||||
import defAva from '@/assets/images/profile.jpg'
|
||||
import defAva from '@/assets/images/avatars/default.jpg'
|
||||
|
||||
export default {
|
||||
name: 'LockScreen',
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Round 5S — 真实 IoT 安全收口与验证边界
|
||||
|
||||
基线:`373921447fa4727c20aed7b28c5d1519fe26fa78`。仅本地修复;未部署、推送、修改共享数据库、连接真实 MQTT 或操作真实人脸服务。
|
||||
|
||||
## 1. 持久化契约
|
||||
|
||||
复用 `iotlocks.lock_auth` 与 `iotlocks.lock_command`。新增来源模式、稳定环境 ID、意图 UUID、递增业务版本、意图方向/时间、执行状态。命令另存明确设备 SN 与发送尝试开始时间。元数据由服务端生成,JSON 请求不能注入。
|
||||
|
||||
`status/sync_status` 保留原界面兼容语义,但不能单独证明设备已撤权。期望状态在授权意图中,发送状态在命令中,设备确认在双回执字段中。
|
||||
|
||||
| 执行状态 | 意义 | 恢复规则 |
|
||||
| --- | --- | --- |
|
||||
| READY | 当前来源、当前环境、当前版本,尚未尝试发送 | 经身份、协议及写入门禁后可以恢复;PENDING 或连接失败导致的 FAIL 均不丢失意图 |
|
||||
| DISPATCHING | 数据库已提交发送占用,传输可能尚未执行或结果未知 | 禁止盲重发;超时进入人工核对 |
|
||||
| AWAITING_ACK | 传输调用返回,但设备结果未确认 | 禁止自动或人工命令重发;等待严格关联回执,超时人工核对 |
|
||||
| CONFIRMED | 当前版本的门锁成功回执与网关回执齐备 | 授权确认成功或撤权确认清除;重复回执不改写 |
|
||||
| REJECTED | 设备明确返回失败 | 意图进入人工核对,不能把 FAIL 当成重发许可 |
|
||||
| MANUAL_REVIEW | 无法证明物理结果、旧操作仍可能影响设备 | 不宣称成功、不取消物理结果、不生成后继授权绕过问题 |
|
||||
| QUARANTINED | LEGACY、Mock/真实来源不符、环境不符、非法身份或载荷 | 不重放、不自动升级为 REAL |
|
||||
| CANCELED | 操作者取消了尚未尝试发送的命令 | 记录保留;未完成意图进入核对,不能伪称设备撤权 |
|
||||
|
||||
服务器启动时间不再作为可信来源判断依据。兼容属性 `replayProtectionEnabled` 即使关闭,也不能关闭来源、环境、版本和发送占用校验。
|
||||
|
||||
相同环境 ID 必须跨重启保持稳定;不能把进程启动时间或随机进程 ID 当作环境 ID。切换为另一环境时,旧意图保持隔离。
|
||||
|
||||
## 2. 事务与恢复
|
||||
|
||||
1. 入住/退宿业务事务内持久化授权或撤权意图;退宿失败不能吞掉意图写入异常后仍声称成功。重复退宿仍执行撤权对账。
|
||||
2. 同一意图 UUID 最多创建一条命令,数据库唯一索引作为最终保护。
|
||||
3. 命令只落入 outbox;外发必须在事务提交之后。
|
||||
4. 独立事务按“授权行 → 命令行”顺序锁定并校验当前版本,把发送占用及尝试时间提交到数据库。
|
||||
5. 传输边界重新核验持久化占用、设备映射、载荷及权限;只执行一次发送尝试。
|
||||
6. 不确定异常、占用后崩溃、回执超时均进入核对。无协议幂等或查询证明时,不自动重发。
|
||||
7. 回执校验 Topic、seq、op、action、设备 SN、结果码及当前意图版本。命令确认和授权投影在同一数据库事务中完成。
|
||||
|
||||
READY 意图没有命令时可补建;已有 READY 命令由命令恢复器处理。失败/未知命令不再无限阻止恢复,也不会被补偿器绕过后重复生成。恢复扫描优先撤权,不因固定前 100 条策略阻塞后续合法意图。
|
||||
|
||||
旧版本回执无法更新新版本授权。未知旧撤权不能被本地“取消”后立即重新授予权限。
|
||||
|
||||
人工核对入口为现有授权/命令查询 API 返回的 `executionState`、`intentAction`、版本和静态原因。此次不增加能够把未知结果强行改成成功的 API。操作人员应保存设备侧证据,经批准后的下一阶段处理;不得直接批量修改数据库状态或来源标记。
|
||||
|
||||
## 3. 身份与协议
|
||||
|
||||
真实白名单为空、SN 为空或未知时一律拒绝。身份取明确设备 SN,再验证“设备 → 网关 → 分组”的数据库映射及授权快照。分组只负责路由,不是白名单身份。
|
||||
|
||||
本轮支持已有协议证据中的人脸/密码授权结构:
|
||||
|
||||
| 业务命令 | 帧 op | params |
|
||||
| --- | --- | --- |
|
||||
| 0011 人脸授权 | facefeature | add、8 位人员编号、特征、人员编号、起始时间、结束时间、设备 SN、设备密码 |
|
||||
| 0011 密码授权 | password | add、8 位人员编号、密码、起始时间、结束时间、设备 SN、设备密码 |
|
||||
| 0012 撤权 | 对应授权类型的 op | delete、8 位人员编号、设备 SN、设备密码 |
|
||||
|
||||
Topic 为已有 `ydlink/{groupCode}/thing/data/batch`。seq 与命令流水一致,保持既有信封顺序;空载荷、错人员/目标/路由/类型、缺凭据或非法时间窗口在发布前拒绝。
|
||||
|
||||
不新增设备协议字段,不声称本地 UUID 被硬件理解,也不假定硬件支持幂等。既有 `chksum=xx`、固件和时区等仍需老师原始协议/报文独立确认;`iot.mqtt.protocol-verified` 默认 false,在获得证据之前真实传输被拒绝。未确认的指纹、IC 卡、网关配置和其他真实控制操作在此分支拒绝,不能扩大设备白名单来绕过。
|
||||
|
||||
## 4. 外发约束
|
||||
|
||||
- 原始 MQTT publish 在真实模式永久拒绝。运维 Controller 仅在 `iot.mqtt.mock=true` 且 QA API 显式开启时加载,并要求专门权限 `iot:mqtt:manage`。该权限未自动授予任何外部账号。
|
||||
- 真实命令只能使用 command-aware 传输,并提供已提交的发送占用。所有授权、撤权、补偿、人工重试均走同一门禁。
|
||||
- MQTT 默认不连接,手动 connect/reconnect 也检查模式与连接开关。所有遗嘱消息被拒绝,避免绕过 outbox。控制报文禁止 retained。
|
||||
- 控制发送使用 QoS 0(at-most-once),禁用 Paho 自动重连、强制 clean session、拒绝文件报文持久化,防止客户端/会话队列绕过 outbox 自行重发。连接健康检查只创建无历史队列的新会话。丢失或送达不确定进入人工核对;该传输策略还需隔离 Broker 与老师端兼容验证,不能以提高 QoS 的方式绕过未知结果保护。
|
||||
- OpenAPI 默认关闭;无密钥、短密钥和错误密钥拒绝。过滤器锚定真实 Controller 路径并处理 context path,配置路径不能绕过鉴权。
|
||||
- 人脸 Mock 默认 true,优先于数据库 endpoint,直接返回合成结果。真实 HTTP 另需显式开关、环境一致、目标设备授权和精确端点白名单;禁止重定向。数据库 endpoint 无法越过白名单。
|
||||
- 人脸图片只接受上传目录内的真实路径,拒绝外部 URL、目录穿越、符号链接越界及超大文件。Mock 提取不下载任何图片。
|
||||
- 旧 DMS 不具备此分支的设备来源契约,真实网络被禁止,仅保留显式启用且端点为 loopback 的 Mock 回归通道,禁止重定向。
|
||||
- 定时补偿默认关闭。开启后也不能发送旧 Mock、未知来源、发送结果未知或旧版本命令。
|
||||
|
||||
## 5. 安全迁移方案(未在共享环境执行)
|
||||
|
||||
脚本:`RuoYi-Vue/sql/iot/005_round5s_execution_safety.sql`。
|
||||
|
||||
这是增量字段与索引迁移,不删除/重建表,不创建账号,不修改权限。历史行默认 `LEGACY / version 0 / QUARANTINED`;禁止按日期或设备编号自动回填 REAL。空旧意图键保留为空,现有记录不被重新发送。
|
||||
|
||||
管理员后续须先备份并批准目标库、停止旧写入进程,在隔离 Kingbase 数据库验证脚本、Mapper、生成键、锁顺序及事务语义,再考虑批准应用到共享库。不能使用全量 qa-bootstrap 重新初始化已有库。回退时保留新增字段和证据,不自动删除列;回退旧应用也不得开启真实连接。
|
||||
|
||||
已在隔离 H2 PostgreSQL 模式测试脚本重复执行、默认隔离和唯一键。H2 不能替代 Kingbase 的方言及运行环境验收。
|
||||
|
||||
## 6. 认证值与历史资料
|
||||
|
||||
JWT 仅接受运行环境注入的 Base64 HS512 密钥(解码后至少 64 字节)。缺失/非法时登录签发安全失败,没有固定弱值回退。OpenAPI 使用注入密钥。Druid 控制台默认关闭,显式开启而无合格注入凭据时启动校验失败。
|
||||
|
||||
新 QA 种子不保留可直接登录的共享密码哈希,测试身份默认禁用。旧确定性临时密码降级被拒绝,即使旧开关被误开启也不会生成可预测密码。开发 Swagger 用户测试 API 默认禁用,示例中不保存密码或手机号。
|
||||
|
||||
管理员待轮换/复核清单(本轮未操作外部账号):
|
||||
|
||||
| 范围 | 所需处置 |
|
||||
| --- | --- |
|
||||
| 历史 application.yml JWT 值 | 更换密钥,确认旧 Token/会话失效策略 |
|
||||
| 历史 OpenAPI 测试密钥 | 废止已部署固定值,仅在批准环境启用新密钥 |
|
||||
| 历史 Druid 控制台凭据 | 禁用或轮换并核验实际访问控制 |
|
||||
| 实验工程 DB/MQTT/启动脚本凭据 | 管理员核实仍有效的账号后轮换;本轮未复制或推送 |
|
||||
| 既有 QA/共享库预置账号 | 审核、禁用或轮换;修改种子不会改变已经存在的账号 |
|
||||
| 实验历史 iotlocks.dmp、人脸上传文件 | 限制传播并核对留存权限;不改写旧 Git 历史 |
|
||||
| 继承依赖测试私钥、pay.png/profile.jpg 等素材 | 区分公开测试夹具与实际秘密,审核素材来源;旧提交和静态资源未自动清理 |
|
||||
|
||||
锁屏默认头像改用已有通用角色素材。源仓库历史、原工程和实验工程均保持不变,不 Force Push。
|
||||
|
||||
## 7. 自动化证据与未验证范围
|
||||
|
||||
`Round5sExecutionIntegrationTest` 使用真实 `LockAuthServiceImpl`、`LockCommandServiceImpl`、生产 MyBatis XML、DataSourceTransactionManager 和新建本地临时文件 H2 库。每次连接关闭后由文件重新打开,测试数据库不连接服务器。只有设备映射查询、MQTT 传输及回执是可控替身。
|
||||
|
||||
覆盖空/合法/非法白名单、分组冒充、人脸/密码及撤权载荷、Mock 切换隔离、旧请求时间恢复、意图提交后命令创建前中断、提交发送占用后中断、未知回执、明确设备拒绝、旧回执保护、错 Topic/设备、写入/连接关闭、协议未批准、回滚、并发发送占用、增量迁移及原始 MQTT/遗嘱旁路。
|
||||
|
||||
另有 YAML 人脸绑定/端点隔离、OpenAPI context path/密钥、JSON 元数据注入、JWT/Druid 认证、DMS 边界以及原业务桥接定向回归测试。桥接替身测试不作为真实恢复机制证明,旧“取消 SENT 后重新授权”和可预测密码测试已改为拒绝的安全语义。
|
||||
|
||||
重现命令(只运行选定测试,不运行数百项全量测试):
|
||||
|
||||
```sh
|
||||
mvn -o -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am \
|
||||
'-Dtest=Round5s*,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false test
|
||||
mvn -o -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am -DskipTests package
|
||||
```
|
||||
|
||||
本轮自动化结果:81 项定向测试通过,失败/错误/跳过均为 0;Java 21 下九模块后端打包成功。授权、命令和锁屏三个 Vue 模板经隔离安装的 Vue 2.6.12 编译器检查无错误,脚本及状态标签工具通过语法检查;未完成前端全量 bundle 构建或浏览器验收。处理状态在授权/命令页面显示,未知结果不能通过“重发/取消”按钮操作,提示不再把请求受理写成设备已执行。
|
||||
|
||||
本轮未证明:Kingbase 实际升级与整套住宿事务、新配置下完整 Spring Boot 启动、跨进程/多实例故障注入、老师原始协议 checksum/固件/设备幂等、真实 Broker ACL/TLS、现场设备在线状态、实际门锁授权撤权以及管理员凭据轮换。
|
||||
|
||||
结论:软件安全逻辑可继续隔离验证,不能把本轮测试写成真实门锁联调成功;真实写入、网络连接、人脸 HTTP、OpenAPI 和自动补偿的默认开关保持关闭。
|
||||
@@ -11,11 +11,11 @@
|
||||
|
||||
| 执行顺序 | 文件名称 | 模式 (Schema) | 来源追溯 (Source) | 前置依赖 | SHA-256 哈希值 |
|
||||
| :---: | :--- | :---: | :--- | :--- | :--- |
|
||||
| **01** | `sql/01_system_foundation.sql` | `public` | `sql/ry_20250522.sql` + `sql/quartz.sql` (彻底限定 public. 命名空间,消除系统视图歧义与转义语法) | 空库 | `befce0720daebd75247401b528b4617f00774b99240bf7cece130288b899a782` |
|
||||
| **01** | `sql/01_system_foundation.sql` | `public` | `sql/ry_20250522.sql` + `sql/quartz.sql` (彻底限定 public. 命名空间,消除系统视图歧义与转义语法) | 空库 | `03fd416f7ef069326ec9fc27bfa2778e57f94580d7f107417e658cd82b33d408` |
|
||||
| **02** | `sql/02_training_v2_schema.sql` | `public` | 融合工程真实实体、Mapper XML、权威 V2 迁移基线彻底对齐版 (对齐学习时长幂等索引) | 01 (sys_*) | `e2d72300ee4a0bd411c569d3418426e4391aa4145751ba8e16a813799fa5820b` |
|
||||
| **03** | `sql/03_system_permissions_and_menus.sql` | `public` | `sql/system/kingbase/` (004~015) 纯净单事务版 (彻底限定 public. ALTER/INDEX) | 01, 02 | `194f7c53b981222e4e47e0d6582d01386e8e219fb39942671a61fafb16ca47d3` |
|
||||
| **04** | `sql/04_iotlocks_schema.sql` | `iotlocks` + `public` | `sql/kingbase/lock.sql` (修复事务、字典与动态授权,规避菜单2060冲突) | 01, 03 | `3140e7c1b0be68c9364483d3a521f671f2559f2f7dda400f8a983bdafed6e94e` |
|
||||
| **05** | `sql/05_synthetic_demo_data.sql` | `public` | `sql/demo/` (001~004) 纯合成演示主数据 (修复 score_id 幂等冲突目标) | 01, 02, 03 | `4177dc7fd99c6f82479c3ffb10f347bc22bec484bd76c6ff8ba34dcf2f6f1543` |
|
||||
| **05** | `sql/05_synthetic_demo_data.sql` | `public` | `sql/demo/` (001~004) 纯合成演示主数据 (修复 score_id 幂等冲突目标) | 01, 02, 03 | `4ec64a905a32c940160b3fdbc01b3be85291e639f4a4612bd0c04c0e9b162e02` |
|
||||
| **05b** | `sql/05b_fusion_iot_test_data.sql` | `iotlocks` + `public` | 专属跨模式 IoT 门锁业务融合测试数据集 (初始未授权状态,对齐04列名) | 01 ~ 05 | `97a98ae7ac46d6a22e966b0d9046d94faa166fbfd9173778d8803abeaaab01f2` |
|
||||
| **06** | `sql/06_verify_schema_integrity.sql` | `public` + `iotlocks` | 自动化只读断言脚本 (含 Mapper 列、部分唯一索引与初始未授权断言) | 01 ~ 05b | `413ac8778e7acfecbae760cadaef565af2cdcc1f38e54f2c19ba0f29c11c32b2` |
|
||||
| **07** | `sql/07_restore_training_menus_and_notice.sql` | `public` | 恢复原系统完整业务路由菜单体系与补齐 sys_notice_read 运行时表 | 01 ~ 06 | `9cdf10533034f8824ddbcf6fcb0abc7e7b873ad02ed66dc6581691a005a8dc17` |
|
||||
|
||||
@@ -76,8 +76,8 @@ create table public.sys_user (
|
||||
-- ----------------------------
|
||||
-- 初始化-用户信息表数据
|
||||
-- ----------------------------
|
||||
insert into public.sys_user values(1, 103, 'admin', '若依', '00', 'ry@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', sysdate(), sysdate(), 'admin', sysdate(), '', null, '管理员');
|
||||
insert into public.sys_user values(2, 105, 'ry', '若依', '00', 'ry@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', sysdate(), sysdate(), 'admin', sysdate(), '', null, '测试员');
|
||||
insert into public.sys_user values(1, 103, 'admin', '若依', '00', 'ry@163.com', '15888888888', '1', '', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', sysdate(), sysdate(), 'admin', sysdate(), '', null, '管理员');
|
||||
insert into public.sys_user values(2, 105, 'ry', '若依', '00', 'ry@qq.com', '15666666666', '1', '', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', sysdate(), sysdate(), 'admin', sysdate(), '', null, '测试员');
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
@@ -556,7 +556,7 @@ create table public.sys_config (
|
||||
) engine=innodb auto_increment=100 comment = '参数配置表';
|
||||
|
||||
insert into public.sys_config values(1, '主框架页-默认皮肤样式名称', 'sys.index.skinName', 'skin-blue', 'Y', 'admin', sysdate(), '', null, '蓝色 skin-blue、绿色 skin-green、紫色 skin-purple、红色 skin-red、黄色 skin-yellow' );
|
||||
insert into public.sys_config values(2, '用户管理-账号初始密码', 'sys.user.initPassword', '123456', 'Y', 'admin', sysdate(), '', null, '初始化密码 123456' );
|
||||
insert into public.sys_config values(2, '用户管理-账号初始密码', 'sys.user.initPassword', '', 'Y', 'admin', sysdate(), '', null, '初始化密码 123456' );
|
||||
insert into public.sys_config values(3, '主框架页-侧边栏主题', 'sys.index.sideTheme', 'theme-dark', 'Y', 'admin', sysdate(), '', null, '深色主题theme-dark,浅色主题theme-light' );
|
||||
insert into public.sys_config values(4, '账号自助-验证码开关', 'sys.account.captchaEnabled', 'true', 'Y', 'admin', sysdate(), '', null, '是否开启验证码功能(true开启,false关闭)');
|
||||
insert into public.sys_config values(5, '账号自助-是否开启用户注册功能', 'sys.account.registerUser', 'false', 'Y', 'admin', sysdate(), '', null, '是否开启注册用户功能(true开启,false关闭)');
|
||||
@@ -890,4 +890,7 @@ create table public.QRTZ_SIMPROP_TRIGGERS (
|
||||
foreign key (sched_name, trigger_name, trigger_group) references public.QRTZ_TRIGGERS(sched_name, trigger_name, trigger_group)
|
||||
) engine=innodb comment = '同步机制的行锁表';
|
||||
|
||||
commit;
|
||||
commit;
|
||||
|
||||
-- Round 5S: seeded demo identities remain disabled until an authorized administrator provisions credentials.
|
||||
UPDATE public.sys_user SET status = '1' WHERE password = '!ROUND5S_DISABLED_NO_LOGIN';
|
||||
@@ -32,11 +32,11 @@ BEGIN;
|
||||
INSERT INTO public.sys_user
|
||||
(user_id, dept_id, user_name, nick_name, user_type, email, phonenumber, sex, password, status, del_flag, login_ip, login_date, create_by, create_time, remark)
|
||||
VALUES
|
||||
(10, 112, 'demo_admin', '演示培训管理员', '00', 'demo_admin@sict.railway.cn', '18800000001', '0', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用培训管理员账号'),
|
||||
(11, 112, 'demo_teacher', '崔宏伟(教师)', '00', 'cuihw@sict.railway.cn', '18800000011', '0', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用主讲教师账号(绑定T101)'),
|
||||
(12, 116, 'demo_teacher2', '张建国(教师)', '00', 'zhangjg@sict.railway.cn', '18800000012', '0', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用第二教师账号(绑定T102)'),
|
||||
(21, 119, 'demo_student', '李强(学员)', '00', 'liqiang@sict.railway.cn', '18800000101', '0', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用参训学员账号(绑定S101)'),
|
||||
(22, 120, 'demo_student2', '王芳(学员)', '00', 'wangfang@sict.railway.cn', '18800000102', '1', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用参训学员账号(绑定S102)')
|
||||
(10, 112, 'demo_admin', '演示培训管理员', '00', 'demo_admin@sict.railway.cn', '18800000001', '0', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用培训管理员账号'),
|
||||
(11, 112, 'demo_teacher', '崔宏伟(教师)', '00', 'cuihw@sict.railway.cn', '18800000011', '0', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用主讲教师账号(绑定T101)'),
|
||||
(12, 116, 'demo_teacher2', '张建国(教师)', '00', 'zhangjg@sict.railway.cn', '18800000012', '0', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用第二教师账号(绑定T102)'),
|
||||
(21, 119, 'demo_student', '李强(学员)', '00', 'liqiang@sict.railway.cn', '18800000101', '0', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用参训学员账号(绑定S101)'),
|
||||
(22, 120, 'demo_student2', '王芳(学员)', '00', 'wangfang@sict.railway.cn', '18800000102', '1', '!ROUND5S_DISABLED_NO_LOGIN', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '演示专用参训学员账号(绑定S102)')
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
user_name = EXCLUDED.user_name,
|
||||
nick_name = EXCLUDED.nick_name,
|
||||
@@ -62,6 +62,9 @@ UPDATE public.sys_config SET config_value = 'false' WHERE config_key = 'sys.acco
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- Round 5S: seeded demo identities remain disabled until an authorized administrator provisions credentials.
|
||||
UPDATE public.sys_user SET status = '1' WHERE password = '!ROUND5S_DISABLED_NO_LOGIN';
|
||||
|
||||
-- >>> 来源: sql/demo/002_demo_master_data.sql >>>
|
||||
-- ==============================================================================
|
||||
-- RailwaySystem Clean Demo 资产: 002_demo_master_data.sql
|
||||
|
||||
Reference in new issue
Block a user