fix(app): 修复 AppController 安全与查询问题

- 加强异常处理、类型安全和图片上传校验
- 优化设备相关查询,避免重复访问数据源
- 补充并记录并发测试与审查修复实施计划
This commit is contained in:
yuhaiming
2026-07-17 08:20:44 +08:00
parent 4a4aabf19e
commit 70e2356f22
82 changed files with 6070 additions and 583 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}