mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-21 22:53:56 +08:00
fix: 修复设备跨时区时间与在线状态
This commit is contained in:
+10
-25
@@ -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<String, Object> gwResult = JSONUtil.parseObj(response);
|
||||
|
||||
+21
-54
@@ -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<DeviceDao, DeviceEntity>
|
||||
.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<DeviceDao, DeviceEntity>
|
||||
@Override
|
||||
public List<UserShowDeviceListVO> getUserDeviceList(Long userId, String agentId) {
|
||||
List<DeviceEntity> devices = getUserDevices(userId, agentId);
|
||||
return devices.stream().map(device -> {
|
||||
return devices.stream().map(this::toUserShowDeviceListVO).toList();
|
||||
}
|
||||
|
||||
private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
// 设置UTC时间戳供前端使用时区转换
|
||||
if (device.getLastConnectedAt() != null) {
|
||||
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
|
||||
}
|
||||
vo.setBoard(device.getBoard());
|
||||
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
|
||||
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
|
||||
return vo;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private Long toTimestamp(Date date) {
|
||||
return date == null ? null : date.getTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -385,17 +377,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords()));
|
||||
// 循环处理page获取回来的数据,返回需要的字段
|
||||
List<UserShowDeviceListVO> 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<DeviceDao, DeviceEntity>
|
||||
return mqtt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成BearerToken
|
||||
*/
|
||||
private String generateBearerToken() {
|
||||
try {
|
||||
String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN);
|
||||
private String postToMqttGateway(String url, Object requestBody) {
|
||||
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;
|
||||
}
|
||||
return MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(requestBody),
|
||||
signatureKey,
|
||||
Instant.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -773,13 +752,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.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<DeviceDao, DeviceEntity>
|
||||
.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)) {
|
||||
|
||||
+111
@@ -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<String> 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<String, GatewayResponse> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,13 @@ 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;
|
||||
|
||||
|
||||
+87
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -120,7 +120,7 @@
|
||||
<i class="el-icon-time"></i>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">{{ $t('addressBookManagement.addTime') }}</div>
|
||||
<div class="stat-value">{{ selectedDevice.createDate || '-' }}</div>
|
||||
<div class="stat-value">{{ formatDeviceCreateDate(selectedDevice) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user