From dbeda01697aa449182910aa0eec45435134574f3 Mon Sep 17 00:00:00 2001 From: Tyke Chen <190473011+chentyke@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:36:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E8=B7=A8=E6=97=B6=E5=8C=BA=E6=97=B6=E9=97=B4=E4=B8=8E=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DeviceAddressBookServiceImpl.java | 37 ++-- .../service/impl/DeviceServiceImpl.java | 83 +++----- .../impl/MqttGatewayAuthorization.java | 111 +++++++++++ .../device/vo/UserShowDeviceListVO.java | 9 +- .../impl/DeviceTimeSerializationTest.java | 87 +++++++++ .../impl/MqttGatewayAuthorizationTest.java | 180 ++++++++++++++++++ main/manager-web/package.json | 1 + main/manager-web/src/utils/deviceTime.mjs | 41 ++++ .../src/views/AddressBookManagement.vue | 13 +- .../src/views/DeviceManagement.vue | 33 ++-- main/manager-web/tests/deviceTime.test.mjs | 52 +++++ 11 files changed, 543 insertions(+), 104 deletions(-) create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java create mode 100644 main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/DeviceTimeSerializationTest.java create mode 100644 main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorizationTest.java create mode 100644 main/manager-web/src/utils/deviceTime.mjs create mode 100644 main/manager-web/tests/deviceTime.test.mjs diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java index 5114bf62..8b6ce468 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceAddressBookServiceImpl.java @@ -1,11 +1,10 @@ package xiaozhi.modules.device.service.impl; -import java.util.Date; +import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import java.nio.charset.StandardCharsets; import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Lazy; @@ -13,8 +12,8 @@ import org.springframework.stereotype.Service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import cn.hutool.http.HttpRequest; import cn.hutool.json.JSONUtil; +import xiaozhi.common.constant.Constant; import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisUtils; import xiaozhi.modules.device.dao.DeviceAddressBookDao; @@ -201,36 +200,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService { result.put("status", "error"); String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true); - String mqttSignatureKey = sysParamsService.getValue("server.mqtt_signature_key", true); + String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true); if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl) - || StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) { + || MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) { result.put("message", action + "失败,网关配置缺失"); return result; } - String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()); try { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] hash = md.digest((dateStr + mqttSignatureKey).getBytes(StandardCharsets.UTF_8)); - StringBuilder hexString = new StringBuilder(); - for (byte b : hash) { - String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); - } - String token = hexString.toString(); - String url = "http://" + mqttGatewayUrl + path; - String response = HttpRequest.post(url) - .header("Authorization", "Bearer " + token) - .header("Content-Type", "application/json") - .body(JSONUtil.toJsonStr(body)) - .timeout(5000) - .execute() - .body(); + String response = MqttGatewayAuthorization.postJson( + url, + JSONUtil.toJsonStr(body), + mqttSignatureKey, + Instant.now(), + 5000); if (StringUtils.isNotBlank(response)) { Map gwResult = JSONUtil.parseObj(response); @@ -270,4 +255,4 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService { } return newAlias; } -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index 45cf81d4..138c4164 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -31,15 +31,9 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.date.DateUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; -import cn.hutool.http.ContentType; -import cn.hutool.http.Header; -import cn.hutool.http.HttpRequest; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; @@ -187,14 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl .put("clientIds", deviceIds).build(); if (ToolUtil.isNotEmpty(deviceIds)) { - // 发送请求 - String resultMessage = HttpRequest.post(url) - .header(Header.CONTENT_TYPE, ContentType.JSON.getValue()) - .header(Header.AUTHORIZATION, "Bearer " + generateBearerToken()) - .body(JSONUtil.toJsonStr(params)) - .timeout(10000) // 超时,毫秒 - .execute().body(); - return resultMessage; + return postToMqttGateway(url, params); } // 返回响应 return ""; @@ -304,15 +291,20 @@ public class DeviceServiceImpl extends BaseServiceImpl @Override public List getUserDeviceList(Long userId, String agentId) { List devices = getUserDevices(userId, agentId); - return devices.stream().map(device -> { - UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class); - vo.setDeviceType(device.getBoard()); - // 设置UTC时间戳供前端使用时区转换 - if (device.getLastConnectedAt() != null) { - vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime()); - } - return vo; - }).toList(); + return devices.stream().map(this::toUserShowDeviceListVO).toList(); + } + + private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) { + UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class); + vo.setDeviceType(device.getBoard()); + vo.setBoard(device.getBoard()); + vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate())); + vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt())); + return vo; + } + + private Long toTimestamp(Date date) { + return date == null ? null : date.getTime(); } @Override @@ -385,17 +377,11 @@ public class DeviceServiceImpl extends BaseServiceImpl .like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords())); // 循环处理page获取回来的数据,返回需要的字段 List list = page.getRecords().stream().map(device -> { - UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class); + UserShowDeviceListVO vo = toUserShowDeviceListVO(device); // 把最后修改的时间,改为简短描述的时间 vo.setRecentChatTime(DateUtils.getShortTime(device.getUpdateDate())); sysUserUtilService.assignUsername(device.getUserId(), vo::setBindUserName); - vo.setDeviceType(device.getBoard()); - vo.setBoard(device.getBoard()); - // 设置UTC时间戳供前端使用时区转换 - if (device.getLastConnectedAt() != null) { - vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime()); - } return vo; }).toList(); // 计算页数 @@ -699,20 +685,13 @@ public class DeviceServiceImpl extends BaseServiceImpl return mqtt; } - /** - * 生成BearerToken - */ - private String generateBearerToken() { - try { - String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN); - String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false); - if (ToolUtil.isEmpty(signatureKey)) { - return null; - } - return DigestUtil.sha256Hex(dateStr + signatureKey); - } catch (Exception e) { - return null; - } + private String postToMqttGateway(String url, Object requestBody) { + String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false); + return MqttGatewayAuthorization.postJson( + url, + JSONUtil.toJsonStr(requestBody), + signatureKey, + Instant.now()); } @Override @@ -773,13 +752,7 @@ public class DeviceServiceImpl extends BaseServiceImpl .put("payload", payload) .build(); - // 发送请求 - String resultMessage = HttpRequest.post(url) - .header(Header.CONTENT_TYPE, ContentType.JSON.getValue()) - .header(Header.AUTHORIZATION, "Bearer " + generateBearerToken()) - .body(JSONUtil.toJsonStr(requestBody)) - .timeout(10000) // 超时,毫秒 - .execute().body(); + String resultMessage = postToMqttGateway(url, requestBody); // 解析响应 if (StringUtils.isBlank(resultMessage)) { @@ -870,13 +843,7 @@ public class DeviceServiceImpl extends BaseServiceImpl .put("payload", payload) .build(); - // 发送请求 - String resultMessage = HttpRequest.post(url) - .header(Header.CONTENT_TYPE, ContentType.JSON.getValue()) - .header(Header.AUTHORIZATION, "Bearer " + generateBearerToken()) - .body(JSONUtil.toJsonStr(requestBody)) - .timeout(10000) // 超时,毫秒 - .execute().body(); + String resultMessage = postToMqttGateway(url, requestBody); // 解析响应 if (StringUtils.isNotBlank(resultMessage)) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java new file mode 100644 index 00000000..f165419a --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorization.java @@ -0,0 +1,111 @@ +package xiaozhi.modules.device.service.impl; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.function.Function; + +import org.apache.commons.lang3.StringUtils; + +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.ContentType; +import cn.hutool.http.Header; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; + +final class MqttGatewayAuthorization { + + private static final int HTTP_UNAUTHORIZED = 401; + private static final int DEFAULT_TIMEOUT_MILLIS = 10000; + + private MqttGatewayAuthorization() { + } + + static String postJson(String url, String jsonBody, String signatureKey, Instant now) { + return postJson(url, jsonBody, signatureKey, now, DEFAULT_TIMEOUT_MILLIS); + } + + static String postJson(String url, String jsonBody, String signatureKey, Instant now, int timeoutMillis) { + GatewayResponse response = executeWithDateFallback( + signatureKey, + now, + token -> executeRequest(url, jsonBody, token, timeoutMillis)); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new GatewayRequestException( + "MQTT Gateway request failed with HTTP status " + response.statusCode(), + response.statusCode()); + } + return response.body(); + } + + static List generateDailyTokens(String signatureKey, Instant now) { + if (isMissingSignatureKey(signatureKey)) { + throw new GatewayRequestException("MQTT Gateway signature key is empty", null); + } + + LocalDate utcDate = now.atZone(ZoneOffset.UTC).toLocalDate(); + return List.of( + tokenFor(utcDate, signatureKey), + tokenFor(utcDate.minusDays(1), signatureKey), + tokenFor(utcDate.plusDays(1), signatureKey)); + } + + static GatewayResponse executeWithDateFallback(String signatureKey, Instant now, + Function requestExecutor) { + GatewayResponse lastAuthenticationFailure = null; + for (String token : generateDailyTokens(signatureKey, now)) { + GatewayResponse response = requestExecutor.apply(token); + if (!isAuthenticationFailure(response.statusCode())) { + return response; + } + lastAuthenticationFailure = response; + } + + Integer statusCode = lastAuthenticationFailure == null ? null : lastAuthenticationFailure.statusCode(); + throw new GatewayRequestException( + "MQTT Gateway rejected all daily authorization tokens" + + (statusCode == null ? "" : " (HTTP " + statusCode + ")"), + statusCode); + } + + private static String tokenFor(LocalDate date, String signatureKey) { + return DigestUtil.sha256Hex(date + signatureKey); + } + + private static boolean isAuthenticationFailure(int statusCode) { + return statusCode == HTTP_UNAUTHORIZED; + } + + static boolean isMissingSignatureKey(String signatureKey) { + return StringUtils.isBlank(signatureKey) || "null".equalsIgnoreCase(signatureKey.trim()); + } + + private static GatewayResponse executeRequest(String url, String jsonBody, String token, int timeoutMillis) { + try (HttpResponse response = HttpRequest.post(url) + .header(Header.CONTENT_TYPE, ContentType.JSON.getValue()) + .header(Header.AUTHORIZATION, "Bearer " + token) + .body(jsonBody) + .timeout(timeoutMillis) + .execute()) { + return new GatewayResponse(response.getStatus(), response.body()); + } + } + + record GatewayResponse(int statusCode, String body) { + } + + static final class GatewayRequestException extends RuntimeException { + private final Integer statusCode; + + GatewayRequestException(String message, Integer statusCode) { + super(message); + this.statusCode = statusCode; + } + + Integer statusCode() { + return statusCode; + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java b/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java index a2582c28..857d08cd 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/vo/UserShowDeviceListVO.java @@ -38,11 +38,14 @@ public class UserShowDeviceListVO { @Schema(description = "最近对话时间") private String recentChatTime; - @Schema(description = "最后连接时间(UTC毫秒)") + @Schema(description = "最后连接时间戳(毫秒)", type = "string", example = "1783689702000") private Long lastConnectedAtTimestamp; - @Schema(description = "绑定时间") + @Schema(description = "绑定时间戳(毫秒)", type = "string", example = "1783689702000") + private Long createDateTimestamp; + + @Schema(description = "绑定时间(兼容字段,请使用 createDateTimestamp)", deprecated = true) @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC") private Date createDate; -} \ No newline at end of file +} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/DeviceTimeSerializationTest.java b/main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/DeviceTimeSerializationTest.java new file mode 100644 index 00000000..f58013f6 --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/DeviceTimeSerializationTest.java @@ -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 getUserDevices(Long userId, String agentId) { + return List.of(entity); + } + }; + } +} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorizationTest.java b/main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorizationTest.java new file mode 100644 index 00000000..c3644b5f --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/device/service/impl/MqttGatewayAuthorizationTest.java @@ -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 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 tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, now); + + assertEquals(tokenFor(gatewayDate), tokens.get(expectedTokenIndex)); + } + + @Test + @DisplayName("仅在 401 时按日期候选重试并保留请求体") + void retriesOnlyAuthenticationFailures() throws IOException { + AtomicInteger requestCount = new AtomicInteger(); + List authorizationHeaders = new ArrayList<>(); + List requestBodies = new ArrayList<>(); + List 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 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); + } +} diff --git a/main/manager-web/package.json b/main/manager-web/package.json index 0dce6f99..0d0804db 100644 --- a/main/manager-web/package.json +++ b/main/manager-web/package.json @@ -7,6 +7,7 @@ "check:i18n": "node scripts/check-i18n.js", "test:snapshot": "node --test src/components/agentSnapshotDisplayUtils.test.mjs src/apis/module/agentSnapshotApi.test.mjs", "build": "vue-cli-service build", + "test:unit": "node --test tests/*.test.mjs", "analyze": "cross-env ANALYZE=true vue-cli-service build" }, "dependencies": { diff --git a/main/manager-web/src/utils/deviceTime.mjs b/main/manager-web/src/utils/deviceTime.mjs new file mode 100644 index 00000000..f1c1a64b --- /dev/null +++ b/main/manager-web/src/utils/deviceTime.mjs @@ -0,0 +1,41 @@ +export function hasTimestampValue(timestamp) { + if (timestamp === null || timestamp === undefined) return false; + return typeof timestamp !== 'string' || timestamp.trim() !== ''; +} + +export function parseTimestamp(timestamp) { + if (!hasTimestampValue(timestamp)) return null; + if (typeof timestamp !== 'number' && typeof timestamp !== 'string') return null; + + const normalizedTimestamp = typeof timestamp === 'string' ? timestamp.trim() : timestamp; + const parsedTimestamp = Number(normalizedTimestamp); + if (!Number.isFinite(parsedTimestamp)) return null; + + return Number.isNaN(new Date(parsedTimestamp).getTime()) ? null : parsedTimestamp; +} + +export function parseLegacyDate(dateValue) { + if (!dateValue) return null; + const timestamp = new Date(dateValue).getTime(); + return Number.isNaN(timestamp) ? null : timestamp; +} + +export function formatTimestamp(timestamp, formatter = value => new Date(value).toLocaleString()) { + const parsedTimestamp = parseTimestamp(timestamp); + return parsedTimestamp === null ? '-' : formatter(parsedTimestamp); +} + +export function formatCreateDate(timestamp, legacyDate, formatter) { + if (!hasTimestampValue(timestamp)) return legacyDate || '-'; + return formatTimestamp(timestamp, formatter); +} + +export function compareTimestamps(firstTimestamp, secondTimestamp) { + const firstIsValid = Number.isFinite(firstTimestamp); + const secondIsValid = Number.isFinite(secondTimestamp); + + if (firstIsValid && secondIsValid) return firstTimestamp - secondTimestamp; + if (firstIsValid) return -1; + if (secondIsValid) return 1; + return 0; +} diff --git a/main/manager-web/src/views/AddressBookManagement.vue b/main/manager-web/src/views/AddressBookManagement.vue index 6762b9c2..39513021 100644 --- a/main/manager-web/src/views/AddressBookManagement.vue +++ b/main/manager-web/src/views/AddressBookManagement.vue @@ -120,7 +120,7 @@
{{ $t('addressBookManagement.addTime') }}
-
{{ selectedDevice.createDate || '-' }}
+
{{ formatDeviceCreateDate(selectedDevice) }}
@@ -207,6 +207,7 @@ import Api from "@/apis/api.js"; import AddressBookApi from "@/apis/module/addressBook.js"; import MacAddressMask from "@/components/MacAddressMask.vue"; import CustomButton from "@/components/CustomButton.vue"; +import { formatCreateDate, parseTimestamp } from '@/utils/deviceTime.mjs'; export default { name: "AddressBookManagement", @@ -281,6 +282,7 @@ export default { remarks: device.alias || '', online: false, createDate: device.createDate, + createDateTimestamp: device.createDateTimestamp, lastConnectedAt: device.lastConnectedAtTimestamp, deviceStatus: 'offline' })); @@ -526,9 +528,10 @@ export default { this.isEditingAgentName = false; }, getTimeAgo(timestamp) { - if (!timestamp) return '-'; + const ts = parseTimestamp(timestamp); + if (ts === null) return '-'; const now = new Date(); - const date = new Date(Number(timestamp)); + const date = new Date(ts); const diff = now - date; const seconds = Math.floor(diff / 1000); @@ -545,6 +548,10 @@ export default { if (months < 12) return this.$t('addressBookManagement.monthsAgo', { months }); return this.$t('addressBookManagement.yearsAgo', { years }); }, + formatDeviceCreateDate(device) { + if (!device) return '-'; + return formatCreateDate(device.createDateTimestamp, device.createDate); + }, getDeviceAvatar(deviceId) { // 根据 deviceId 计算 MD5,选择对应的头像 const avatars = [ diff --git a/main/manager-web/src/views/DeviceManagement.vue b/main/manager-web/src/views/DeviceManagement.vue index 3ecbd9cc..7d9a75c3 100644 --- a/main/manager-web/src/views/DeviceManagement.vue +++ b/main/manager-web/src/views/DeviceManagement.vue @@ -103,6 +103,14 @@ import VersionFooter from "@/components/VersionFooter.vue"; import MacAddressMask from "@/components/MacAddressMask.vue"; import CustomButton from "@/components/CustomButton.vue"; import CustomTable from "@/components/CustomTable.vue"; +import { + compareTimestamps, + formatCreateDate, + formatTimestamp, + hasTimestampValue, + parseLegacyDate, + parseTimestamp, +} from '@/utils/deviceTime.mjs'; export default { components: { @@ -327,26 +335,31 @@ export default { this.loading = false; if (data.code === 0) { this.deviceList = data.data.map(device => { + const hasCreateDateTimestamp = hasTimestampValue(device.createDateTimestamp); + const rawBindTime = hasCreateDateTimestamp + ? parseTimestamp(device.createDateTimestamp) + : parseLegacyDate(device.createDate); return { device_id: device.id, model: device.board, firmwareVersion: device.appVersion, macAddress: device.macAddress, - bindTime: device.createDate, - lastConversation: device.lastConnectedAtTimestamp - ? this.formatRelativeTime(device.lastConnectedAtTimestamp) - : '-', + bindTime: formatCreateDate(device.createDateTimestamp, device.createDate), + lastConversation: formatTimestamp(device.lastConnectedAtTimestamp), remark: device.alias, _originalRemark: device.alias, isEdit: false, _submitting: false, otaSwitch: device.autoUpdate === 1, - rawBindTime: new Date(device.createDate).getTime(), + rawBindTime, selected: false, deviceStatus: 'offline' }; }) - .sort((a, b) => a.rawBindTime - b.rawBindTime); + .sort((firstDevice, secondDevice) => compareTimestamps( + firstDevice.rawBindTime, + secondDevice.rawBindTime, + )); this.activeSearchKeyword = ""; this.searchKeyword = ""; @@ -429,14 +442,6 @@ export default { const version = row.firmwareVersion.replace(/\./g, ''); return Number(version) >= 200; }, - formatRelativeTime(timestamp) { - if (!timestamp) return '-'; - const ts = Number(timestamp); - if (isNaN(ts)) return '-'; - const date = new Date(ts); - if (isNaN(date.getTime())) return '-'; - return date.toLocaleString(); - }, } }; diff --git a/main/manager-web/tests/deviceTime.test.mjs b/main/manager-web/tests/deviceTime.test.mjs new file mode 100644 index 00000000..6f5a4981 --- /dev/null +++ b/main/manager-web/tests/deviceTime.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + compareTimestamps, + formatCreateDate, + formatTimestamp, + parseTimestamp, +} from '../src/utils/deviceTime.mjs'; + +const TIMESTAMP = 1783689702000; + +test('accepts epoch milliseconds as a number or numeric string', () => { + assert.equal(parseTimestamp(TIMESTAMP), TIMESTAMP); + assert.equal(parseTimestamp(String(TIMESTAMP)), TIMESTAMP); + assert.equal(parseTimestamp(` ${TIMESTAMP} `), TIMESTAMP); +}); + +test('rejects missing, invalid and non-scalar timestamp values', () => { + const invalidValues = [null, undefined, '', ' ', 'invalid', true, {}, [], Infinity, Number.MAX_VALUE]; + + invalidValues.forEach(value => assert.equal(parseTimestamp(value), null)); + assert.equal(formatTimestamp('invalid'), '-'); +}); + +test('falls back to the legacy createDate only when the timestamp is missing', () => { + const legacyDate = '2026-07-10 21:21:42'; + const formatter = timestamp => `local:${timestamp}`; + + assert.equal(formatCreateDate(undefined, legacyDate, formatter), legacyDate); + assert.equal(formatCreateDate('', legacyDate, formatter), legacyDate); + assert.equal(formatCreateDate('invalid', legacyDate, formatter), '-'); + assert.equal(formatCreateDate(String(TIMESTAMP), legacyDate, formatter), `local:${TIMESTAMP}`); +}); + +test('formats the same instant consistently in each browser time zone', () => { + const formatter = timeZone => timestamp => new Date(timestamp).toLocaleString('en-US', { timeZone }); + + for (const timeZone of ['Asia/Shanghai', 'America/Sao_Paulo']) { + assert.equal( + formatTimestamp(TIMESTAMP, formatter(timeZone)), + formatTimestamp(String(TIMESTAMP), formatter(timeZone)), + ); + } +}); + +test('sorts valid epoch values first and leaves invalid values at the end', () => { + const timestamps = [null, TIMESTAMP + 1, undefined, TIMESTAMP]; + + assert.deepEqual(timestamps.sort(compareTimestamps), [TIMESTAMP, TIMESTAMP + 1, null, undefined]); + assert.equal(compareTimestamps(null, undefined), 0); +});