fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验 - 优化设备相关查询,避免重复访问数据源 - 补充并记录并发测试与审查修复实施计划
This commit is contained in:
@@ -39,11 +39,6 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
|
||||
|
||||
public void savePending(DeviceCommand command) {
|
||||
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
|
||||
pendingIds().add(command.getCommandId());
|
||||
}
|
||||
|
||||
static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
|
||||
return StringUtils.isNotBlank(topicDeviceNo)
|
||||
&& pendingCommand != null
|
||||
@@ -68,16 +63,6 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void removePending(String commandId) {
|
||||
if (StringUtils.isBlank(commandId)) {
|
||||
return;
|
||||
}
|
||||
withCommandLock(commandId, () -> {
|
||||
deletePending(commandId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAck(String deviceNo, String payload) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
@@ -111,7 +96,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
boolean ackSaved = withCommandLock(ack.getCommandId(), () -> {
|
||||
long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs();
|
||||
CommandLockResult ackResult = withCommandLock(ack.getCommandId(), lockWaitMs, () -> {
|
||||
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(ack.getCommandId()));
|
||||
if (pending == null) {
|
||||
log.warn("[MQTT] ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
|
||||
@@ -126,14 +112,36 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
|
||||
return true;
|
||||
});
|
||||
if (!ackSaved) {
|
||||
log.warn("[MQTT] ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
|
||||
if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) {
|
||||
log.warn("[MQTT] ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}",
|
||||
deviceNo, ack.getCommandId(), lockWaitMs);
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.INTERRUPTED) {
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.ACTION_FAILED) {
|
||||
return;
|
||||
}
|
||||
refreshDeviceOnline(deviceNo);
|
||||
log.info("[MQTT] 收到命令确认 设备编号={} 命令编号={} message={}", deviceNo, ack.getCommandId(), ack.getMessage());
|
||||
}
|
||||
|
||||
public void savePending(DeviceCommand command) {
|
||||
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
|
||||
pendingIds().add(command.getCommandId());
|
||||
}
|
||||
|
||||
public void removePending(String commandId) {
|
||||
if (StringUtils.isBlank(commandId)) {
|
||||
return;
|
||||
}
|
||||
withCommandLock(commandId, () -> {
|
||||
deletePending(commandId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void handlePlainAck(String deviceNo, String payload) {
|
||||
List<DeviceCommand> pendingCommands = findPendingCommandsByDeviceNo(deviceNo);
|
||||
refreshDeviceOnline(deviceNo);
|
||||
@@ -150,7 +158,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
ack.setStatus("1");
|
||||
ack.setMessage(payload);
|
||||
|
||||
boolean ackSaved = withCommandLock(commandId, () -> {
|
||||
long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs();
|
||||
CommandLockResult ackResult = withCommandLock(commandId, lockWaitMs, () -> {
|
||||
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(commandId));
|
||||
if (pending == null) {
|
||||
log.warn("[MQTT] 非 JSON ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, commandId);
|
||||
@@ -165,13 +174,24 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
|
||||
return true;
|
||||
});
|
||||
if (!ackSaved) {
|
||||
log.warn("[MQTT] 非 JSON ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, commandId);
|
||||
if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) {
|
||||
log.warn("[MQTT] 非 JSON ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}",
|
||||
deviceNo, commandId, lockWaitMs);
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.INTERRUPTED) {
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.ACTION_FAILED) {
|
||||
return;
|
||||
}
|
||||
log.info("[MQTT] 收到非 JSON 命令确认 设备编号={} 命令编号={} 消息体={}", deviceNo, commandId, payload);
|
||||
}
|
||||
|
||||
private CommandLockResult withCommandLock(String commandId, BooleanSupplier action) {
|
||||
return withCommandLock(commandId, 0L, action);
|
||||
}
|
||||
|
||||
private List<DeviceCommand> findPendingCommandsByDeviceNo(String deviceNo) {
|
||||
List<DeviceCommand> commands = new ArrayList<>();
|
||||
for (String commandId : pendingIds().readAll()) {
|
||||
@@ -284,22 +304,22 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
pendingIds().remove(commandId);
|
||||
}
|
||||
|
||||
private boolean withCommandLock(String commandId, BooleanSupplier action) {
|
||||
private CommandLockResult withCommandLock(String commandId, long waitTimeMs, BooleanSupplier action) {
|
||||
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
|
||||
boolean locked = false;
|
||||
try {
|
||||
locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
|
||||
locked = lock.tryLock(waitTimeMs, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
|
||||
if (!locked) {
|
||||
return false;
|
||||
return CommandLockResult.LOCK_UNAVAILABLE;
|
||||
}
|
||||
return action.getAsBoolean();
|
||||
return action.getAsBoolean() ? CommandLockResult.SUCCESS : CommandLockResult.ACTION_FAILED;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId);
|
||||
return false;
|
||||
return CommandLockResult.INTERRUPTED;
|
||||
} catch (RuntimeException e) {
|
||||
log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e);
|
||||
return false;
|
||||
return CommandLockResult.ACTION_FAILED;
|
||||
} finally {
|
||||
if (locked && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
@@ -307,6 +327,13 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private enum CommandLockResult {
|
||||
SUCCESS,
|
||||
LOCK_UNAVAILABLE,
|
||||
INTERRUPTED,
|
||||
ACTION_FAILED
|
||||
}
|
||||
|
||||
private String pendingKey(String commandId) {
|
||||
return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ public class MqttProperties {
|
||||
private String pendingSetKey = "mqtt:command:pending:ids";
|
||||
private String retryLockKeyPrefix = "lock:mqtt:command:retry:";
|
||||
private long retryLockTtlMs = 30000;
|
||||
private long ackLockWaitMs = 3000;
|
||||
private String deviceStatusCachePrefix = "mqtt:device:status:";
|
||||
private long deviceStatusCacheTtlSeconds = 900;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.dromara.mqtt;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
@@ -16,6 +17,7 @@ import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RSet;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -24,6 +26,7 @@ import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -42,6 +45,7 @@ class MqttCommandAckServiceTest {
|
||||
}
|
||||
applicationContext = new GenericApplicationContext();
|
||||
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
|
||||
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
}
|
||||
@@ -93,6 +97,44 @@ class MqttCommandAckServiceTest {
|
||||
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleAckWaitsBrieflyForCommandLock() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RLock commandLock = mock(RLock.class);
|
||||
RLock statusLock = mock(RLock.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
|
||||
DeviceCommand pending = new DeviceCommand();
|
||||
pending.setCommandId("cmd-1");
|
||||
pending.setDeviceNo("D01");
|
||||
|
||||
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||
when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
|
||||
when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||
|
||||
service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");
|
||||
|
||||
verify(commandLock).tryLock(3000, 30000, TimeUnit.MILLISECONDS);
|
||||
redis.verify(() -> RedisUtils.setCacheObject(
|
||||
eq("mqtt:command:ack:cmd-1"),
|
||||
any(DeviceCommandAck.class),
|
||||
eq(Duration.ofSeconds(86400))
|
||||
));
|
||||
verify(pendingIds).remove("cmd-1");
|
||||
verify(commandLock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -12,7 +12,9 @@ import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.exception.SseException;
|
||||
import org.dromara.common.core.exception.base.BaseException;
|
||||
import org.dromara.common.core.utils.MessageUtils;
|
||||
import org.dromara.common.core.utils.StreamUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||
@@ -57,9 +59,11 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) {
|
||||
log.error(e.getMessage());
|
||||
String requestURI = request.getRequestURI();
|
||||
String message = resolveErrorMessage(e.getMessage());
|
||||
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
|
||||
Integer code = e.getCode();
|
||||
return ObjectUtil.isNotNull(code) ? R.fail(code, e.getMessage()) : R.fail(e.getMessage());
|
||||
return ObjectUtil.isNotNull(code) ? R.fail(code, message) : R.fail(message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +92,10 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(BaseException.class)
|
||||
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
|
||||
log.error(e.getMessage());
|
||||
return R.fail(e.getMessage());
|
||||
String requestURI = request.getRequestURI();
|
||||
String message = e.getMessage();
|
||||
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
|
||||
return R.fail(message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,7 +156,7 @@ public class GlobalExceptionHandler {
|
||||
public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +166,7 @@ public class GlobalExceptionHandler {
|
||||
public R<Void> handleException(Exception e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生系统异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,4 +238,12 @@ public class GlobalExceptionHandler {
|
||||
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "SpEL解析失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
private String resolveErrorMessage(String message) {
|
||||
if (StringUtils.isBlank(message)) {
|
||||
return MessageUtils.message("operation.fail");
|
||||
}
|
||||
String i18nMessage = MessageUtils.message(message);
|
||||
return message.equals(i18nMessage) ? message : i18nMessage;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.dromara.common.web.handler;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.base.BaseException;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.context.support.StaticMessageSource;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
private GlobalExceptionHandler handler;
|
||||
private GenericApplicationContext applicationContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
StaticMessageSource messageSource = new StaticMessageSource();
|
||||
messageSource.addMessage("operation.fail", Locale.getDefault(), "操作失败");
|
||||
applicationContext = new GenericApplicationContext();
|
||||
applicationContext.registerBean("messageSource", StaticMessageSource.class, () -> messageSource);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
handler = new GlobalExceptionHandler();
|
||||
when(request.getRequestURI()).thenReturn("/app/v1/device/D01");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
applicationContext.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleRuntimeException_hidesInternalMessage() {
|
||||
R<Void> result = handler.handleRuntimeException(
|
||||
new IllegalStateException("jdbc:mysql://internal-host/water"), request);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(R.FAIL);
|
||||
assertThat(result.getMsg()).doesNotContain("internal-host");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleException_hidesInternalMessage() {
|
||||
R<Void> result = handler.handleException(
|
||||
new Exception("oss accessKeySecret=internal-secret"), request);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(R.FAIL);
|
||||
assertThat(result.getMsg()).doesNotContain("internal-secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleBaseException_logsThrowableAndRequestUri() {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
try {
|
||||
handler.handleBaseException(new BaseException("user", "业务校验失败"), request);
|
||||
|
||||
ILoggingEvent event = appender.list.get(appender.list.size() - 1);
|
||||
assertThat(event.getLevel()).isEqualTo(Level.WARN);
|
||||
assertThat(event.getFormattedMessage()).contains("/app/v1/device/D01");
|
||||
assertThat(event.getThrowableProxy()).isNotNull();
|
||||
} finally {
|
||||
logger.detachAppender(appender);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user