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

@@ -1,15 +1,17 @@
package org.dromara.app.domain.vo;
import jakarta.validation.constraints.NotEmpty;
import org.dromara.app.domain.AppSchedule;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import org.dromara.app.domain.AppSchedule;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
@@ -47,7 +49,7 @@ public class AppScheduleVo implements Serializable {
/**
* 设备ids
*/
private List deviceNos;
private List<AppDeviceVo> deviceNos;
/**
* 状态 0-关闭 1 开启
*/
@@ -55,7 +57,7 @@ public class AppScheduleVo implements Serializable {
private String status;
@NotEmpty(message = "至少需要配置一天")
private List<AppScheduleDetailVo> details;
private List<Map<String, Object>> details;
}

View File

@@ -13,7 +13,7 @@ import java.util.Map;
import java.util.regex.Pattern;
/**
* 设备数据上报处理器 — 匹配 /{deviceNo}/publish/power
* 设备电量及在线心跳处理器,匹配 /{deviceNo}/publish/power
*/
@Slf4j
@Component
@@ -23,6 +23,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
private final IAppDeviceService appDeviceService;
private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
@Override
public Pattern topicPattern() {
@@ -44,13 +45,15 @@ public class DeviceDataHandler implements MqttTopicHandler {
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
return;
}
// 更新设备电量 + 同步在线状态到数据库
// 有效电量上报同时作为设备在线心跳。
AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setDeviceNo(deviceNo);
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
appDeviceBo.setPowerLevelUpdatatime(new Date());
appDeviceService.updateByBo(appDeviceBo);
log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
deviceStatusService.markOnline(deviceNo);
log.info("[MQTT] 设备电量更新并刷新在线心跳 时间={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceNo, payload);
} catch (Exception e) {
log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);

View File

@@ -33,6 +33,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
private final AppDeviceMapper appDeviceMapper;
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
@Override
public Pattern topicPattern() {
@@ -60,8 +61,9 @@ DeviceRegisterHandler implements MqttTopicHandler {
}
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
appDeviceService.registerByMqtt(device);
deviceStatusService.markOnline(deviceNo);
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac);
log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}",
log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}",
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
} catch (Exception e) {
log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e);

View File

@@ -13,10 +13,10 @@ import java.util.Map;
import java.util.regex.Pattern;
/**
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。
* 设备遗嘱离线状态处理器,匹配 /{deviceIdentity}/publish/status。
* <p>
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。
* 在线状态由电量上报刷新;本处理器仅通过 LWT status=offline 标记异常离线。
*/
@Slf4j
@Component
@@ -42,17 +42,18 @@ public class DeviceStatusHandler implements MqttTopicHandler {
@Override
public void handle(String deviceIdentity, String payload, boolean retained) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
Map<String, Object> body = parsePayload(payload);
String deviceNo = resolveDeviceNo(deviceIdentity, body);
if (deviceNo == null) {
log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}",
HandlerLogTime.now(), deviceIdentity, payload);
return;
}
String status = parseStatus(payload);
String status = parseStatus(payload, body);
if ("online".equals(status) || "1".equals(status)) {
deviceStatusService.markOnline(deviceNo);
log.info("[MQTT] 设备状态在线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
log.debug("[MQTT] 忽略设备主动在线状态,在线状态由电量心跳维护 时间={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceNo, payload);
return;
}
if ("offline".equals(status) || "0".equals(status)) {
@@ -69,22 +70,52 @@ public class DeviceStatusHandler implements MqttTopicHandler {
HandlerLogTime.now(), deviceNo, payload);
}
private String parseStatus(String payload) {
private String resolveDeviceNo(String deviceIdentity, Map<String, Object> body) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
if (deviceNo != null || body == null) {
return deviceNo;
}
Object deviceMac = body.get("deviceMac");
if (deviceMac == null || StringUtils.isBlank(String.valueOf(deviceMac))) {
return null;
}
return deviceIdentityResolver.resolveDeviceNo(String.valueOf(deviceMac));
}
private Map<String, Object> parsePayload(String payload) {
if (StringUtils.isBlank(payload)) {
return null;
}
String trimmed = payload.trim();
if (!trimmed.startsWith("{")) {
return trimmed.toLowerCase(Locale.ROOT);
return null;
}
try {
Map<String, Object> body = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
return objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
});
Object status = body.get("status");
return status == null ? null : String.valueOf(status).trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e);
return null;
}
}
private String parseStatus(String payload, Map<String, Object> body) {
if (body == null) {
if (StringUtils.isBlank(payload)) {
return null;
}
String trimmed = payload.trim();
return trimmed.startsWith("{") ? null : trimmed.toLowerCase(Locale.ROOT);
}
Object status = body.get("status");
if (status != null) {
return String.valueOf(status).trim().toLowerCase(Locale.ROOT);
}
Object offline = body.get("offline");
if (offline != null && "true".equalsIgnoreCase(String.valueOf(offline).trim())) {
return "offline";
}
return null;
}
}

View File

@@ -32,7 +32,7 @@ public class MqttDeviceStatusService {
@Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
private long deviceStatusCacheTtlSeconds;
public void markOnline(String deviceNo) {

View File

@@ -29,6 +29,14 @@ public interface IAppDeviceService {
*/
AppDeviceVo queryById(String deviceNo);
/**
* 按设备编号批量查询设备。
*
* @param deviceNos 设备编号集合
* @return 设备列表
*/
List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos);
/**
* 分页查询设备信息
列表

View File

@@ -8,6 +8,7 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
import java.util.Collection;
import java.util.List;
import java.util.Set;
public interface IAppSchedulingDeviceService {
@@ -25,6 +26,14 @@ public interface IAppSchedulingDeviceService {
List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo);
/**
* 批量查询已绑定排程的设备编号。
*
* @param deviceNos 待检查的设备编号
* @return 已绑定排程的设备编号
*/
Set<String> findBoundDeviceNos(Collection<String> deviceNos);
Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo);
AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo);

View File

@@ -73,6 +73,14 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return baseMapper.selectVoById(deviceNo);
}
@Override
public List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return List.of();
}
return baseMapper.selectVoByIds(deviceNos);
}
@Override
public TableDataInfo<AppDeviceVo> queryPageList(AppDeviceBo bo, PageQuery pageQuery) {
Page<AppDeviceVo> page = pageQuery.build();
@@ -307,7 +315,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return params;
}
params.put("bindDevice", exists);
if (exists.getUserId() == null && exists.getWorkStatus().equals("2") && exists.getStatus().equals("0") ) {
if (exists.getUserId() == null && "2".equals(exists.getWorkStatus()) && "0".equals(exists.getStatus())) {
params.put("bindDeviceStatus", 305);
params.put("bindDeviceStatusName", "设备未配网,请先去配置网络");
return params;

View File

@@ -165,10 +165,10 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
return conflicts;
}
private List saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) {
List list = new ArrayList<>();
private List<Map<String, Object>> saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) {
List<Map<String, Object>> detailList = new ArrayList<>();
if (details == null) {
return list;
return detailList;
}
for (AppScheduleBo.AppScheduleDetail item : details) {
AppScheduleDetail detail = new AppScheduleDetail();
@@ -181,13 +181,13 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
detail.setTriggerType(item.getTriggerType());
detail.setStatus(item.getStatus());
scheduleDetailMapper.insert(detail);
list.add(toDetailMap(detail, item.getTimeData()));
detailList.add(toDetailMap(detail, item.getTimeData()));
}
return list;
return detailList;
}
private List updateDetails(List<AppScheduleBo.AppScheduleDetail> details) {
List detailList = new ArrayList<>();
private List<Map<String, Object>> updateDetails(List<AppScheduleBo.AppScheduleDetail> details) {
List<Map<String, Object>> detailList = new ArrayList<>();
if (details == null) {
return detailList;
}

View File

@@ -21,6 +21,8 @@ import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@RequiredArgsConstructor
@@ -106,4 +108,19 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo));
}
@Override
public Set<String> findBoundDeviceNos(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return Set.of();
}
return baseMapper.selectList(
new QueryWrapper<AppSchedulingDevice>()
.select("device_no")
.in("device_no", deviceNos))
.stream()
.map(AppSchedulingDevice::getDeviceNo)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
}
}

View File

@@ -18,8 +18,8 @@ import java.util.List;
/**
* 设备离线检测定时任务
* <p>
* 逻辑:默认通过设备 LWT 离线消息更新状态
* 兼容旧设备时,可打开 ttl-compat-enabled继续使用 Redis 在线 Key 过期兜底离线。
* 逻辑:电量上报刷新 Redis 在线 KeyKey 超时后判定设备离线
* 设备 LWT 离线消息仍可立即更新离线状态,本任务用于处理未收到遗嘱的异常断线。
* 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在,
* 则将数据库 status 更新为 0离线
* </p>
@@ -37,19 +37,19 @@ public class DeviceOfflineCheckTask {
@Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
private long deviceStatusCacheTtlSeconds;
@Value("${mqtt.offline-check.enabled:true}")
private boolean enabled;
@Value("${mqtt.offline-check.ttl-compat-enabled:false}")
@Value("${mqtt.offline-check.ttl-compat-enabled:true}")
private boolean ttlCompatEnabled;
/**
* 每 90 秒检查一次。默认不开启 TTL 兼容离线检测,避免低功耗长连接设备被误判离线
* 每 30 秒检查一次电量心跳是否超时
* 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms
*/
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}")
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:30000}")
public void checkOfflineDevices() {
if (!enabled || !ttlCompatEnabled) {
return;
@@ -96,7 +96,7 @@ public class DeviceOfflineCheckTask {
List<String> updatedDeviceNos = new ArrayList<>();
for (String deviceNo : offlineDeviceNos) {
if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备掉线")) {
if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备电量心跳超时")) {
updatedDeviceNos.add(deviceNo);
}
}

View File

@@ -0,0 +1,306 @@
package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppScheduleDetailVo;
import org.dromara.app.domain.vo.AppScheduleVo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssUploadVo;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.dromara.system.service.ISysUserService;
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.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AppControllerConcurrencyTest {
private static final int WORKER_COUNT = 32;
private static final int GROUP_TIMEOUT_SECONDS = 30;
private static final Long USER_ID = 99L;
private static final DateTimeFormatter STRICT_SECOND_FORMATTER =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
@Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService;
@Mock private ISysOssService ossService;
@Mock private IAppVersionService appVersionService;
private GenericApplicationContext applicationContext;
@BeforeEach
void setUp() {
applicationContext = new GenericApplicationContext();
Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterEach
void tearDown() {
applicationContext.close();
}
@Test
void switchDevice_formatsAllStartTimesCorrectlyUnderConcurrency() throws Exception {
AppController controller = newController();
int iterationsPerWorker = 100;
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
ConcurrentLinkedQueue<String> startTimes = new ConcurrentLinkedQueue<>();
AtomicInteger successfulResponses = new AtomicInteger();
when(appDeviceService.switchDevice(eq("D01"), eq("1"), anyString(), eq(10)))
.thenAnswer(invocation -> {
startTimes.add(invocation.getArgument(2, String.class));
return true;
});
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
R<Void> result = controller.switchDevice(
"{\"deviceNo\":\"D01\",\"workStatus\":\"1\",\"durationMin\":\"10\"}");
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
successfulResponses.incrementAndGet();
});
assertThat(successfulResponses.get()).isEqualTo(expectedCalls);
assertThat(startTimes).hasSize(expectedCalls).allSatisfy(startTime ->
assertThatCode(() -> LocalDateTime.parse(startTime, STRICT_SECOND_FORMATTER))
.doesNotThrowAnyException());
verify(appDeviceService, times(expectedCalls))
.switchDevice(eq("D01"), eq("1"), anyString(), eq(10));
}
@Test
void uploadImage_validatesAllSupportedFormatsUnderConcurrency() throws Exception {
AppController controller = newController();
int iterationsPerWorker = 50;
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
List<ImageFixture> fixtures = supportedImages();
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setOriginalName("plant-image");
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
ImageFixture fixture = fixtures.get((worker + iteration) % fixtures.size());
MockMultipartFile file = new MockMultipartFile(
"file", fixture.fileName(), fixture.contentType(), fixture.content());
R<SysOssUploadVo> result = controller.uploadImage(file);
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
assertThat(result.getData().getOssId()).isEqualTo("123");
assertThat(result.getData().getUrl()).isEqualTo(oss.getUrl());
});
verify(ossService, times(expectedCalls)).upload(any(MultipartFile.class));
}
@Test
void editScheduleStatus_keepsMqttPayloadsIsolatedUnderConcurrency() throws Exception {
AppController controller = newController();
int iterationsPerWorker = 20;
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
ConcurrentHashMap<String, Map<String, Object>> payloadsByDevice = new ConcurrentHashMap<>();
when(appScheduleService.queryById(anyLong())).thenAnswer(invocation -> {
Long scheduleId = invocation.getArgument(0, Long.class);
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(scheduleId);
schedule.setUserId(USER_ID);
schedule.setStatus("1");
return schedule;
});
when(appScheduleService.updateStatusByBo(any(AppScheduleBo.class))).thenReturn(true);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenAnswer(invocation -> {
AppSchedulingDeviceBo bo = invocation.getArgument(0, AppSchedulingDeviceBo.class);
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
binding.setScheduleId(bo.getScheduleId());
binding.setDeviceNo(deviceNo(bo.getScheduleId()));
return List.of(binding);
});
when(appScheduleDetailService.queryByScheduleIdByStatus(anyLong())).thenAnswer(invocation -> {
Long scheduleId = invocation.getArgument(0, Long.class);
AppScheduleDetailVo detail = new AppScheduleDetailVo();
detail.setScheduleId(scheduleId);
detail.setWeekday((int) ((scheduleId - 1) % 7) + 1);
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
detail.setTriggerType(detailMarker(scheduleId));
detail.setStatus("1");
return List.of(detail);
});
when(deviceCommandService.sendScheduleBindCommand(anyString(), any())).thenAnswer(invocation -> {
String deviceNo = invocation.getArgument(0, String.class);
Map<String, Object> payload = invocation.getArgument(1);
assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull();
return "cmd-" + deviceNo;
});
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(USER_ID);
Long scheduleId = (long) worker * iterationsPerWorker + iteration + 1;
AppScheduleBo bo = new AppScheduleBo();
bo.setId(scheduleId);
bo.setStatus("1");
R<Void> result = controller.editScheduleStatus(bo);
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
}
});
assertThat(payloadsByDevice).hasSize(expectedCalls);
for (long scheduleId = 1; scheduleId <= expectedCalls; scheduleId++) {
String deviceNo = deviceNo(scheduleId);
Map<String, Object> payload = payloadsByDevice.get(deviceNo);
assertThat(payload).as(deviceNo).isNotNull();
assertThat(payload.get("deviceNo")).isEqualTo(deviceNo);
List<?> details = asList(payload.get("details"));
assertThat(details).hasSize(1);
Map<?, ?> detail = asMap(details.get(0));
assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId));
assertThat(detail.get("weekday")).isEqualTo((int) ((scheduleId - 1) % 7) + 1);
}
verify(deviceCommandService, times(expectedCalls)).sendScheduleBindCommand(anyString(), any());
verify(appScheduleDetailService, times(expectedCalls)).queryByScheduleIdByStatus(anyLong());
}
private void runConcurrently(int iterationsPerWorker, ConcurrentOperation operation) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(WORKER_COUNT);
CountDownLatch ready = new CountDownLatch(WORKER_COUNT);
CountDownLatch start = new CountDownLatch(1);
List<Future<Void>> futures = new ArrayList<>(WORKER_COUNT);
try {
for (int worker = 0; worker < WORKER_COUNT; worker++) {
int workerIndex = worker;
futures.add(executor.submit(() -> {
ready.countDown();
if (!start.await(10, TimeUnit.SECONDS)) {
throw new TimeoutException("并发工作线程等待启动超时");
}
for (int iteration = 0; iteration < iterationsPerWorker; iteration++) {
operation.execute(workerIndex, iteration);
}
return null;
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).as("32个工作线程应全部就绪").isTrue();
start.countDown();
awaitAll(futures);
} finally {
start.countDown();
executor.shutdownNow();
assertThat(executor.awaitTermination(10, TimeUnit.SECONDS))
.as("并发测试线程池应按时结束")
.isTrue();
}
}
private void awaitAll(List<Future<Void>> futures) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(GROUP_TIMEOUT_SECONDS);
for (Future<Void> future : futures) {
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("并发测试组执行超过" + GROUP_TIMEOUT_SECONDS + "");
}
future.get(remainingNanos, TimeUnit.NANOSECONDS);
}
}
private AppController newController() {
return new AppController(
appDeviceService,
appScheduleService,
appScheduleDetailService,
appSchedulingDeviceService,
userService,
appWateringLogService,
deviceCommandService,
ossService,
appVersionService
);
}
private List<ImageFixture> supportedImages() {
return List.of(
new ImageFixture("plant.jpg", "image/jpeg", new byte[]{
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
}),
new ImageFixture("plant.png", "image/png", new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
}),
new ImageFixture("plant.gif", "image/gif",
new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}),
new ImageFixture("plant.webp", "image/webp", new byte[]{
0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
}),
new ImageFixture("plant.bmp", "image/bmp",
new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00})
);
}
private String deviceNo(Long scheduleId) {
return "D" + scheduleId;
}
private String detailMarker(Long scheduleId) {
return "schedule-" + scheduleId;
}
private List<?> asList(Object value) {
assertThat(value).isInstanceOf(List.class);
return (List<?>) value;
}
private Map<?, ?> asMap(Object value) {
assertThat(value).isInstanceOf(Map.class);
return (Map<?, ?>) value;
}
@FunctionalInterface
private interface ConcurrentOperation {
void execute(int worker, int iteration) throws Exception;
}
private record ImageFixture(String fileName, String contentType, byte[] content) {
}
}

View File

@@ -3,12 +3,14 @@ package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssUploadVo;
import org.dromara.system.domain.vo.SysOssVo;
@@ -17,22 +19,23 @@ import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -42,13 +45,13 @@ public class AppControllerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService;
@Mock private ISysOssService ossService;
@Mock private IAppVersionService appVersionService;
@Captor private ArgumentCaptor<Map<String, Object>> schedulePayloadCaptor;
@Test
public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() {
@@ -100,10 +103,9 @@ public class AppControllerTest {
public void checkVersion_rejectsMissingCurrentVersion() {
AppController controller = newController();
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", " "));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("当前版本不能为空");
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", " ")))
.isInstanceOf(ServiceException.class)
.hasMessage("当前版本不能为空");
verify(appVersionService, never()).queryLatestByPlatform(any());
}
@@ -111,10 +113,9 @@ public class AppControllerTest {
public void checkVersion_rejectsUnsupportedPlatform() {
AppController controller = newController();
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0"));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("平台类型仅支持 android 或 ios");
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0")))
.isInstanceOf(ServiceException.class)
.hasMessage("平台类型仅支持 android 或 ios");
verify(appVersionService, never()).queryLatestByPlatform(any());
}
@@ -123,10 +124,9 @@ public class AppControllerTest {
AppController controller = newController();
when(appVersionService.queryLatestByPlatform("android")).thenReturn(null);
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0"));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("版本配置不存在");
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0")))
.isInstanceOf(ServiceException.class)
.hasMessage("版本配置不存在");
}
@Test
@@ -136,7 +136,7 @@ public class AppControllerTest {
"file",
"plant.png",
"image/png",
new byte[]{1, 2, 3}
pngBytes()
);
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
@@ -153,6 +153,29 @@ public class AppControllerTest {
verify(ossService).upload(file);
}
@Test
public void uploadImage_acceptsAllSupportedBinaryImageFormats() {
AppController controller = newController();
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setOriginalName("plant-image");
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
List<MockMultipartFile> files = List.of(
new MockMultipartFile("file", "plant.jpg", "image/jpeg", jpegBytes()),
new MockMultipartFile("file", "plant.gif", "image/gif", gifBytes()),
new MockMultipartFile("file", "plant.webp", "image/webp", webpBytes()),
new MockMultipartFile("file", "plant.bmp", "image/bmp", bmpBytes())
);
for (MockMultipartFile file : files) {
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
assertThat(result.getCode()).as(file.getOriginalFilename()).isEqualTo(200);
}
verify(ossService, times(files.size())).upload(any(MultipartFile.class));
}
@Test
public void uploadImage_rejectsNonImageFile() {
AppController controller = newController();
@@ -163,13 +186,67 @@ public class AppControllerTest {
new byte[]{1, 2, 3}
);
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("只能上传图片文件");
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
.isInstanceOf(ServiceException.class)
.hasMessage("只能上传图片文件");
verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class));
}
@Test
public void uploadImage_rejectsForgedImageContent() {
AppController controller = newController();
MockMultipartFile file = new MockMultipartFile(
"file",
"plant.png",
"image/png",
"not-an-image".getBytes(StandardCharsets.UTF_8)
);
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
.isInstanceOf(ServiceException.class)
.hasMessage("图片文件内容不合法");
verify(ossService, never()).upload(any(MultipartFile.class));
}
@Test
public void uploadImage_rejectsFileLargerThanTwentyMegabytes() {
AppController controller = newController();
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(20L * 1024 * 1024 + 1);
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
.isInstanceOf(ServiceException.class)
.hasMessage("图片大小不能超过20MB");
verify(ossService, never()).upload(any(MultipartFile.class));
}
@Test
public void uploadImage_rejectsSvg() {
AppController controller = newController();
MockMultipartFile file = new MockMultipartFile(
"file",
"plant.svg",
"image/svg+xml",
"<svg/>".getBytes(StandardCharsets.UTF_8)
);
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
.isInstanceOf(ServiceException.class)
.hasMessage("仅支持jpg、jpeg、png、gif、webp、bmp图片");
verify(ossService, never()).upload(any(MultipartFile.class));
}
@Test
public void getDeviceInfo_propagatesUnexpectedException() {
AppController controller = newController();
IllegalStateException failure = new IllegalStateException("database unavailable");
when(appDeviceService.queryById("D01")).thenThrow(failure);
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01")))
.isSameAs(failure);
}
@Test
public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception {
AppController controller = newController();
@@ -186,6 +263,47 @@ public class AppControllerTest {
assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00");
}
@Test
public void scheduleDeviceList_usesSingleBatchBindingLookup() {
AppController controller = newController();
AppDeviceVo first = ownedDevice("D01", 99L);
AppDeviceVo second = ownedDevice("D02", 99L);
when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second));
when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02")))
.thenReturn(Set.of("D01"));
R<Map<String, Object>> result = callWithLogin(99L, () -> controller.scheduleDeviceList(10L));
assertThat(result.getData().get("deviceList")).isEqualTo(List.of(second));
verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString());
}
@Test
public void getScheduleInfo_usesSingleBatchDeviceLookup() {
AppController controller = newController();
AppScheduleVo schedule = ownedSchedule(10L, 99L);
AppSchedulingDeviceVo firstBinding = new AppSchedulingDeviceVo();
firstBinding.setDeviceNo("D01");
AppSchedulingDeviceVo secondBinding = new AppSchedulingDeviceVo();
secondBinding.setDeviceNo("D02");
AppDeviceVo firstDevice = ownedDevice("D01", 99L);
AppDeviceVo secondDevice = ownedDevice("D02", 99L);
List<AppDeviceVo> queriedDevices = List.of(secondDevice, firstDevice);
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
.thenReturn(List.of(firstBinding, secondBinding));
when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(queriedDevices);
R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L));
assertThat(result.getData().getDeviceNos())
.extracting(AppDeviceVo::getDeviceNo)
.containsExactly("D01", "D02");
verify(appDeviceService, never()).queryById("D01");
verify(appDeviceService, never()).queryById("D02");
}
@Test
public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
AppController controller = newController();
@@ -217,23 +335,21 @@ public class AppControllerTest {
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture());
Map<String, Object> payload = payloadCaptor.getValue();
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture());
Map<String, Object> payload = schedulePayloadCaptor.getValue();
assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
List<Map<String, Object>> details = (List<Map<String, Object>>) payload.get("details");
List<?> details = asList(payload.get("details"));
assertThat(details).hasSize(1);
Map<String, Object> detailPayload = details.get(0);
assertThat(detailPayload)
.containsEntry("weekday", 1)
.containsEntry("triggerType", "0")
.containsEntry("status", "1")
.doesNotContainKey("id")
.doesNotContainKey("zones");
Map<?, ?> detailPayload = asMap(details.get(0));
assertThat(detailPayload.get("weekday")).isEqualTo(1);
assertThat(detailPayload.get("triggerType")).isEqualTo("0");
assertThat(detailPayload.get("status")).isEqualTo("1");
assertThat(detailPayload.containsKey("id")).isFalse();
assertThat(detailPayload.containsKey("zones")).isFalse();
List<Object> timeData = (List<Object>) detailPayload.get("timeData");
List<?> timeData = asList(detailPayload.get("timeData"));
assertThat(timeData).hasSize(1);
JSONObject timeSlot = (JSONObject) timeData.get(0);
assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00");
@@ -275,7 +391,7 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
var ordered = inOrder(appScheduleService, deviceCommandService);
InOrder ordered = inOrder(appScheduleService, deviceCommandService);
ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L);
}
@@ -348,6 +464,29 @@ public class AppControllerTest {
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L);
}
@Test
public void editScheduleStatus_queriesScheduleDetailsOnceForMultipleDevices() {
AppController controller = newController();
AppScheduleBo bo = new AppScheduleBo();
bo.setId(10L);
bo.setStatus("1");
AppSchedulingDeviceVo first = new AppSchedulingDeviceVo();
first.setDeviceNo("D01");
AppSchedulingDeviceVo second = new AppSchedulingDeviceVo();
second.setDeviceNo("D02");
when(appScheduleService.queryById(10L)).thenReturn(ownedSchedule(10L, 99L));
when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
.thenReturn(List.of(first, second));
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
callWithLogin(99L, () -> controller.editScheduleStatus(bo));
verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L);
verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any());
}
@Test
public void latestWaterLog_returnsLatestRunningLogById() throws Exception {
AppController controller = newController();
@@ -387,7 +526,6 @@ public class AppControllerTest {
appDeviceService,
appScheduleService,
appScheduleDetailService,
appScheduleServiceimpl,
appSchedulingDeviceService,
userService,
appWateringLogService,
@@ -400,7 +538,8 @@ public class AppControllerTest {
private <T> R<T> callWithLogin(Long userId, Supplier<R<T>> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext();
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
loginHelper.when(LoginHelper::getUserId).thenReturn(userId);
@@ -408,6 +547,16 @@ public class AppControllerTest {
}
}
private List<?> asList(Object value) {
assertThat(value).isInstanceOf(List.class);
return (List<?>) value;
}
private Map<?, ?> asMap(Object value) {
assertThat(value).isInstanceOf(Map.class);
return (Map<?, ?>) value;
}
private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) {
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(scheduleId);
@@ -422,6 +571,35 @@ public class AppControllerTest {
return device;
}
private byte[] pngBytes() {
return new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
};
}
private byte[] jpegBytes() {
return new byte[]{
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
};
}
private byte[] gifBytes() {
return new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61};
}
private byte[] webpBytes() {
return new byte[]{
0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
};
}
private byte[] bmpBytes() {
return new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00};
}
private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException {
while (true) {
int second = Calendar.getInstance().get(Calendar.SECOND);

View File

@@ -0,0 +1,64 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.service.IAppDeviceService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DeviceDataHandlerTest {
@Mock
private IAppDeviceService appDeviceService;
@Mock
private DeviceIdentityResolver deviceIdentityResolver;
@Mock
private MqttDeviceStatusService deviceStatusService;
@InjectMocks
private DeviceDataHandler handler;
@Test
void handleMarksDeviceOnlineAndUpdatesPowerLevel() {
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
withJsonContext(() -> {
handler.handle("D01", "{\"powerLevel\":\"86\"}");
return null;
});
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
AppDeviceBo updatedDevice = deviceCaptor.getValue();
assertThat(updatedDevice.getDeviceNo()).isEqualTo("D01");
assertThat(updatedDevice.getPowerLevel()).isEqualTo("86");
assertThat(updatedDevice.getPowerLevelUpdatatime()).isNotNull();
verify(deviceStatusService).markOnline("D01");
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
}

View File

@@ -1,5 +1,7 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.mqtt.DeviceCommand;
@@ -13,8 +15,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.support.GenericApplicationContext;
import java.lang.reflect.Method;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -30,6 +34,7 @@ class DeviceRegisterHandlerTest {
@Mock private ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
@Mock private IDeviceCommandPublisher commandPublisher;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private MqttDeviceStatusService deviceStatusService;
@Test
void firstRegistrationReplyStillUsesMacTopic() throws Exception {
@@ -37,7 +42,8 @@ class DeviceRegisterHandlerTest {
appDeviceService,
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver
deviceIdentityResolver,
deviceStatusService
);
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
@@ -61,7 +67,8 @@ class DeviceRegisterHandlerTest {
appDeviceService,
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver
deviceIdentityResolver,
deviceStatusService
);
AppDevice existing = new AppDevice();
existing.setDeviceNo("2075423638947475457");
@@ -69,11 +76,24 @@ class DeviceRegisterHandlerTest {
when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing);
when(commandPublisherProvider.getIfAvailable()).thenReturn(null);
handler.handle("2075423638947475457", "{}");
withJsonContext(() -> {
handler.handle("2075423638947475457", "{}");
return null;
});
org.mockito.ArgumentCaptor<AppDeviceBo> captor = org.mockito.ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).registerByMqtt(captor.capture());
assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457");
assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc");
verify(deviceStatusService).markOnline("2075423638947475457");
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
}

View File

@@ -35,13 +35,13 @@ class DeviceStatusHandlerTest {
}
@Test
void handleMarksDeviceOnlineFromPlainPayload() {
void handleIgnoresOnlineStatusBecausePowerReportMarksDeviceOnline() {
DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
handler.handle("D01", "online");
verify(deviceStatusService).markOnline("D01");
verify(deviceStatusService, never()).markOnline(anyString());
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
}
@@ -56,6 +56,19 @@ class DeviceStatusHandlerTest {
verify(deviceStatusService, never()).markOnline(anyString());
}
@Test
void handleMarksDeviceOfflineFromOfflineFlagAndPayloadMac() {
DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("unknown-topic-identity")).thenReturn(null);
when(deviceIdentityResolver.resolveDeviceNo("DC:DA:0C:FA:29:5E")).thenReturn("D01");
handler.handle("unknown-topic-identity", "{\"deviceMac\":\"DC:DA:0C:FA:29:5E\",\"offline\":\"true\"}");
verify(deviceIdentityResolver).resolveDeviceNo("DC:DA:0C:FA:29:5E");
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
verify(deviceStatusService, never()).markOnline(anyString());
}
@Test
void handleResolvesLastWillTopicMacToDeviceNo() {
String macAddress = "DC:DA:0C:FA:29:5E";

View File

@@ -76,7 +76,7 @@ class MqttDeviceStatusServiceTest {
redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:device:status:D01"),
any(Map.class),
eq(Duration.ofSeconds(900))
eq(Duration.ofSeconds(600))
));
verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class));
verify(lock).unlock();
@@ -128,7 +128,7 @@ class MqttDeviceStatusServiceTest {
private MqttDeviceStatusService newService() {
MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService);
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 900L);
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L);
return service;
}
}

View File

@@ -3,6 +3,7 @@ package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
@@ -174,6 +175,22 @@ class AppDeviceServiceImplTest {
}
}
@Test
void queryByDeviceNos_delegatesToSingleBatchQuery() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
List<String> deviceNos = List.of("D01", "D02");
List<AppDeviceVo> expected = List.of(new AppDeviceVo(), new AppDeviceVo());
when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected);
assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected);
verify(appDeviceMapper).selectVoByIds(deviceNos);
}
private int countNonWhitePixels(BufferedImage image, int startY, int endY) {
int count = 0;
for (int y = startY; y < endY; y++) {

View File

@@ -0,0 +1,40 @@
package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
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 java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AppSchedulingDeviceServiceImplTest {
@Mock
private AppSchedulingDeviceMapper mapper;
@Test
void findBoundDeviceNos_usesOneConstrainedQuery() {
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper);
AppSchedulingDevice first = new AppSchedulingDevice();
first.setDeviceNo("D01");
AppSchedulingDevice duplicate = new AppSchedulingDevice();
duplicate.setDeviceNo("D01");
when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(first, duplicate));
Set<String> result = service.findBoundDeviceNos(List.of("D01", "D02"));
assertThat(result).containsExactly("D01");
verify(mapper, times(1)).selectList(any(Wrapper.class));
}
}

View File

@@ -67,15 +67,31 @@ class DeviceOfflineCheckTaskTest {
task.checkOfflineDevices();
redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(900)));
redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(600)));
verify(deviceStatusService, never()).markOfflineIfCacheMissing(any(), any());
}
}
@Test
void checkOfflineDevicesMarksDeviceOfflineWhenPowerHeartbeatExpired() {
DeviceOfflineCheckTask task = newTask();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.hasKey("mqtt:device:status:D01")).thenReturn(false);
task.checkOfflineDevices();
verify(deviceStatusService).markOfflineIfCacheMissing("D01", "设备电量心跳超时");
}
}
private DeviceOfflineCheckTask newTask() {
DeviceOfflineCheckTask task = new DeviceOfflineCheckTask(appDeviceMapper, deviceStatusService);
ReflectionTestUtils.setField(task, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 900L);
ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 600L);
ReflectionTestUtils.setField(task, "enabled", true);
ReflectionTestUtils.setField(task, "ttlCompatEnabled", true);
return task;