fix: 修复设备跨时区时间与在线状态

This commit is contained in:
Tyke Chen
2026-07-14 10:08:23 +08:00
parent ad3fb4d8c8
commit dbeda01697
11 changed files with 543 additions and 104 deletions
@@ -0,0 +1,87 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.security.config.WebMvcConfig;
@DisplayName("设备时间序列化回归测试")
class DeviceTimeSerializationTest {
@ParameterizedTest(name = "浏览器时区 {0}")
@ValueSource(strings = { "Asia/Shanghai", "America/Sao_Paulo" })
@DisplayName("#3280 绑定时间和最后连接时间在任意浏览器时区都表示同一时刻")
void serializedDeviceTimesDescribeTheSameInstantAcrossBrowserTimeZones(String browserTimeZone) {
Instant connectedAt = Instant.parse("2026-07-10T13:21:42Z");
DeviceEntity entity = new DeviceEntity();
entity.setCreateDate(Date.from(connectedAt));
entity.setLastConnectedAt(Date.from(connectedAt));
DeviceServiceImpl deviceService = serviceReturning(entity);
UserShowDeviceListVO device = deviceService.getUserDeviceList(1L, "agent-id").getFirst();
ObjectMapper objectMapper = new WebMvcConfig().jackson2HttpMessageConverter().getObjectMapper();
JsonNode payload = objectMapper.valueToTree(device);
Instant createDate = Instant.ofEpochMilli(
Long.parseLong(payload.path("createDateTimestamp").asText()));
Instant lastConnectedAt = Instant.ofEpochMilli(
Long.parseLong(payload.path("lastConnectedAtTimestamp").asText()));
ZoneId browserZone = ZoneId.of(browserTimeZone);
assertAll(
() -> assertTrue(payload.path("createDateTimestamp").isTextual(),
"Long 时间戳必须遵循现有 JSON 契约序列化为字符串"),
() -> assertTrue(payload.path("lastConnectedAtTimestamp").isTextual(),
"Long 时间戳必须遵循现有 JSON 契约序列化为字符串"),
() -> assertEquals(connectedAt, createDate,
"createDateTimestamp 必须保留源时间点"),
() -> assertEquals(connectedAt, lastConnectedAt,
"lastConnectedAtTimestamp 必须保留源时间点"),
() -> assertEquals(lastConnectedAt.atZone(browserZone).toLocalDateTime(),
createDate.atZone(browserZone).toLocalDateTime(),
"绑定时间和最后连接时间在同一浏览器中必须显示为相同的本地时间"),
() -> assertTrue(payload.path("createDate").isTextual(),
"兼容字段 createDate 必须继续保留"));
}
@Test
@DisplayName("时间为空时新旧字段均保持 null")
void nullDeviceTimesRemainNull() {
DeviceEntity entity = new DeviceEntity();
DeviceServiceImpl deviceService = serviceReturning(entity);
UserShowDeviceListVO device = deviceService.getUserDeviceList(1L, "agent-id").getFirst();
ObjectMapper objectMapper = new WebMvcConfig().jackson2HttpMessageConverter().getObjectMapper();
JsonNode payload = objectMapper.valueToTree(device);
assertAll(
() -> assertTrue(payload.path("createDateTimestamp").isNull()),
() -> assertTrue(payload.path("lastConnectedAtTimestamp").isNull()),
() -> assertTrue(payload.path("createDate").isNull()));
}
private DeviceServiceImpl serviceReturning(DeviceEntity entity) {
return new DeviceServiceImpl(null, null, null, null, null, null) {
@Override
public List<DeviceEntity> getUserDevices(Long userId, String agentId) {
return List.of(entity);
}
};
}
}
@@ -0,0 +1,180 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import cn.hutool.crypto.digest.DigestUtil;
@DisplayName("MQTT Gateway 日期鉴权回归测试")
class MqttGatewayAuthorizationTest {
private static final String SIGNATURE_KEY = "test-signature-key";
private static final Instant FIXED_INSTANT = Instant.parse("2026-07-14T00:30:00Z");
private HttpServer server;
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
@Test
@DisplayName("以 UTC 日期生成当天、前一天和后一天三个候选 token")
void generatesUtcDateCandidates() {
List<String> tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, FIXED_INSTANT);
assertEquals(List.of(
tokenFor("2026-07-14"),
tokenFor("2026-07-13"),
tokenFor("2026-07-15")), tokens);
}
@ParameterizedTest(name = "Gateway 时区 {0}")
@MethodSource("gatewayTimeZones")
@DisplayName("候选 token 覆盖上海和圣保罗 Gateway 的本地日期")
void coversGatewayLocalDate(String gatewayTimeZone, Instant now, int expectedTokenIndex) {
String gatewayDate = now.atZone(ZoneId.of(gatewayTimeZone)).toLocalDate().toString();
List<String> tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, now);
assertEquals(tokenFor(gatewayDate), tokens.get(expectedTokenIndex));
}
@Test
@DisplayName("仅在 401 时按日期候选重试并保留请求体")
void retriesOnlyAuthenticationFailures() throws IOException {
AtomicInteger requestCount = new AtomicInteger();
List<String> authorizationHeaders = new ArrayList<>();
List<String> requestBodies = new ArrayList<>();
List<String> expectedTokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, FIXED_INSTANT);
startServer(exchange -> {
int attempt = requestCount.getAndIncrement();
authorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization"));
requestBodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
if (attempt == 0) {
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
} else if (attempt == 1) {
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
} else {
respond(exchange, 200, "{\"online\":true}");
}
});
String requestBody = "{\"clientIds\":[\"device-id\"]}";
String response = MqttGatewayAuthorization.postJson(
serverUrl(), requestBody, SIGNATURE_KEY, FIXED_INSTANT);
assertEquals("{\"online\":true}", response);
assertEquals(3, requestCount.get());
assertEquals(expectedTokens.stream().map(token -> "Bearer " + token).toList(), authorizationHeaders);
assertEquals(List.of(requestBody, requestBody, requestBody), requestBodies);
}
@ParameterizedTest(name = "HTTP {0}")
@ValueSource(ints = { 403, 500 })
@DisplayName("非 401 错误不重试且向上抛出")
void doesNotRetryNonAuthenticationFailure(int statusCode) throws IOException {
AtomicInteger requestCount = new AtomicInteger();
startServer(exchange -> {
requestCount.incrementAndGet();
exchange.getRequestBody().readAllBytes();
respond(exchange, statusCode, "{\"error\":\"request rejected\"}");
});
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
serverUrl(), "{}", SIGNATURE_KEY, FIXED_INSTANT));
assertEquals(statusCode, exception.statusCode());
assertEquals(1, requestCount.get());
}
@Test
@DisplayName("所有日期候选被拒绝时不把错误响应当作设备离线数据")
void propagatesAuthenticationFailureAfterAllCandidatesAreRejected() throws IOException {
AtomicInteger requestCount = new AtomicInteger();
startServer(exchange -> {
requestCount.incrementAndGet();
exchange.getRequestBody().readAllBytes();
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
});
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
serverUrl(), "{}", SIGNATURE_KEY, FIXED_INSTANT));
assertEquals(401, exception.statusCode());
assertEquals(3, requestCount.get());
}
@ParameterizedTest(name = "密钥值 [{0}]")
@NullAndEmptySource
@ValueSource(strings = { " ", "null", " NULL " })
@DisplayName("缺少或占位签名密钥时在发起 HTTP 请求前失败")
void rejectsMissingSignatureKeyBeforeSendingRequest(String signatureKey) {
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
"http://127.0.0.1:1", "{}", signatureKey, FIXED_INSTANT));
assertNull(exception.statusCode());
}
private static Stream<Arguments> gatewayTimeZones() {
return Stream.of(
Arguments.of("Asia/Shanghai", Instant.parse("2026-07-13T17:30:00Z"), 2),
Arguments.of("America/Sao_Paulo", Instant.parse("2026-07-14T00:30:00Z"), 1));
}
private void startServer(HttpHandler handler) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/mqtt", handler);
server.start();
}
private String serverUrl() {
assertTrue(server != null, "test server must be started");
return "http://127.0.0.1:" + server.getAddress().getPort() + "/mqtt";
}
private static void respond(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (var output = exchange.getResponseBody()) {
output.write(bytes);
}
}
private static String tokenFor(String date) {
return DigestUtil.sha256Hex(date + SIGNATURE_KEY);
}
}