Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ecb4cf07 | ||
|
|
425210d89e | ||
|
|
df461c0f46 | ||
|
|
6562476c78 | ||
|
|
71d9084af9 | ||
|
|
bdaa1d0c24 | ||
|
|
98e8fe240d | ||
|
|
83149d3a00 | ||
|
|
ed4bbff6cb | ||
|
|
340c3698d2 | ||
|
|
b712ac68a9 | ||
|
|
d0935dceff | ||
|
|
030edb6a9a | ||
|
|
22857abee9 | ||
|
|
7e70484540 | ||
|
|
6e5e528245 | ||
|
|
d1bd477fe9 | ||
|
|
0b5a0097cd | ||
|
|
f87b1b0460 | ||
|
|
ff5aa91417 | ||
|
|
9d03417fdc |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -30,6 +30,35 @@ python start.py
|
||||
- 事件桥地址:ws://127.0.0.1:8006/wakeword-ws
|
||||
- 健康检查:http://127.0.0.1:8006/health
|
||||
|
||||
# 开启认证后的用法
|
||||
|
||||
服务端 `config.yaml` 里 `server.auth.enabled` 设为 `true` 时,本页面需要额外的凭证才能连上:
|
||||
|
||||
1. **激活码**:向服务端运维要一个码,填进页面的「激活码」输入框。页面请求 OTA 时带上它,服务端换发一次 websocket token,用掉即失效。
|
||||
2. **设备MAC**:填无分隔符或带冒号的 12 位 MAC 都行,页面会统一去掉分隔符转小写后再发给服务端。OTA 请求头和 websocket 子协议用的是同一个值,签名才对得上。
|
||||
|
||||
激活码只能用一次,页面拿到 token 后会自己存着,之后每次 OTA 都用 token 续签,不用再填码。
|
||||
|
||||
websocket 的凭证走子协议头而不是 URL:`Sec-WebSocket-Protocol: xiaozhi, t-<token>, d-<deviceId>, c-<clientId>`。浏览器不能自定义 websocket 请求头,服务端握手时只回显 `xiaozhi`。放进 URL 查询串的凭证不再被采信,理由是查询串会原样落进反代访问日志。
|
||||
|
||||
## 跨域
|
||||
|
||||
服务端默认不下发跨域响应头,页面从别的来源打开时会被浏览器拦掉。跨域失败在浏览器里读不到状态码,只会在控制台报一句不透明的错,所以要让服务端把页面的来源加进白名单:
|
||||
|
||||
`http://127.0.0.1:8006` 和 `http://localhost:8006` 是两个不同来源,按上面哪个地址打开就得填哪个,两个都填最省事:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
cors:
|
||||
allowed_origins:
|
||||
- http://127.0.0.1:8006
|
||||
- http://localhost:8006
|
||||
```
|
||||
|
||||
`main/digital-human` 里的页面是用 `python start.py` 起 HTTP 服务打开的,不是双击文件;用 `file://` 打开时页面会自己弹窗挡住整页,来源是 `null` 也不必往名单里填。
|
||||
|
||||
不开启认证时以上都不用填,行为与之前一致。
|
||||
|
||||
# 目录说明
|
||||
|
||||
- `start.py`:模块启动入口
|
||||
|
||||
@@ -155,8 +155,14 @@
|
||||
<div class="config-row">
|
||||
<div class="config-item">
|
||||
<label for="clientId">客户端ID:</label>
|
||||
<input type="text" id="clientId" value="web_test_client" placeholder="client-id">
|
||||
<input type="text" id="clientId" placeholder="client-id(接口要求填写,服务端不校验)">
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="activationCode">激活码:</label>
|
||||
<input type="text" id="activationCode" placeholder="仅 auth.enabled: true 时需要">
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-item">
|
||||
<label for="deviceName">设备名称:</label>
|
||||
<input type="text" id="deviceName" value="Web测试设备" maxlength="50"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAudioPlayer } from './core/audio/player.js?v=0205';
|
||||
import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js?v=0205';
|
||||
import { initMcpTools } from './core/mcp/tools.js?v=0205';
|
||||
import { startWakewordBridgeListener } from './core/network/wakeword-bridge.js?v=0205';
|
||||
import { normalizeDeviceId } from './core/network/ota-connector.js?v=0205';
|
||||
import { uiController } from './ui/controller.js?v=0205';
|
||||
import { log } from './utils/logger.js?v=0205';
|
||||
|
||||
@@ -303,8 +304,11 @@ class App {
|
||||
|
||||
log(`正在发送图片到视觉分析接口: ${url}`, 'info');
|
||||
|
||||
const deviceId = document.getElementById('deviceMac')?.value || '';
|
||||
const clientId = document.getElementById('clientId')?.value || 'web_test_client';
|
||||
const deviceId = normalizeDeviceId(document.getElementById('deviceMac')?.value || '');
|
||||
const clientId = document.getElementById('clientId')?.value || '';
|
||||
if (!deviceId || !clientId) {
|
||||
throw new Error('视觉分析失败:请填写设备MAC与客户端ID');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('question', question);
|
||||
|
||||
@@ -72,6 +72,18 @@ export function loadConfig() {
|
||||
if (savedEmojiEnabled !== null && emojiEnabledInput) {
|
||||
emojiEnabledInput.value = savedEmojiEnabled;
|
||||
}
|
||||
|
||||
// 激活码和 token 要读回来,否则刷新页面就断了:
|
||||
// 服务端激活码用掉即作废,读不回来就只能重新填一个
|
||||
const activationCodeInput = document.getElementById('activationCode');
|
||||
if (activationCodeInput) {
|
||||
activationCodeInput.value = localStorage.getItem('xz_tester_activationCode') || '';
|
||||
}
|
||||
// token 是 OTA 下发回来的续签凭证,比激活码长效,放着下次直接用
|
||||
const token = localStorage.getItem('xz_tester_token');
|
||||
if (token) {
|
||||
sessionStorage.setItem('xz_tester_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
@@ -86,6 +98,10 @@ export function saveConfig() {
|
||||
localStorage.setItem('xz_tester_deviceMac', deviceMacInput.value);
|
||||
localStorage.setItem('xz_tester_deviceName', deviceNameInput.value);
|
||||
localStorage.setItem('xz_tester_clientId', clientIdInput.value);
|
||||
const activationCodeInput = document.getElementById('activationCode');
|
||||
if (activationCodeInput) {
|
||||
localStorage.setItem('xz_tester_activationCode', activationCodeInput.value.trim());
|
||||
}
|
||||
const emojiEnabledInput = document.getElementById('emojiEnabled');
|
||||
if (emojiEnabledInput) {
|
||||
localStorage.setItem('xz_tester_emojiEnabled', emojiEnabledInput.value);
|
||||
@@ -107,6 +123,8 @@ export function getConfig() {
|
||||
const deviceMac = document.getElementById('deviceMac')?.value.trim() || '';
|
||||
const deviceName = document.getElementById('deviceName')?.value.trim() || '';
|
||||
const clientId = document.getElementById('clientId')?.value.trim() || '';
|
||||
const activationCode = document.getElementById('activationCode')?.value.trim() || '';
|
||||
const token = sessionStorage.getItem('xz_tester_token') || '';
|
||||
const emojiEnabled = document.getElementById('emojiEnabled')?.value !== 'false';
|
||||
|
||||
return {
|
||||
@@ -114,6 +132,10 @@ export function getConfig() {
|
||||
deviceName,
|
||||
deviceMac,
|
||||
clientId,
|
||||
activationCode,
|
||||
// 有 token 就用它续签,激活码是一次性的,用掉即失效,
|
||||
// 第二次再拿激活码去 OTA 会被拒
|
||||
token,
|
||||
emojiEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { log } from '../../utils/logger.js?v=0205';
|
||||
|
||||
// 子协议值必须是合法的 token 字符,冒号不在其中,所以 Device-Id 去掉分隔符。
|
||||
// OTA 请求头与 websocket 子协议必须用同一个值,否则 token 签名对不上。
|
||||
// 方括号里横线必须写在最前面,写成 [:-_] 会当成 : 到 _ 的区间,
|
||||
// 连大写 A-Z 一起吃掉,大写 MAC 会被算成空串
|
||||
export function normalizeDeviceId(deviceId) {
|
||||
return (deviceId || '').replace(/[-:_]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
// WebSocket 连接
|
||||
export async function webSocketConnect(otaUrl, config) {
|
||||
|
||||
@@ -21,21 +29,32 @@ export async function webSocketConnect(otaUrl, config) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用OTA返回的websocket URL
|
||||
let connUrl = new URL(websocket.url);
|
||||
|
||||
// 添加token参数(从OTA响应中获取)
|
||||
if (websocket.token) {
|
||||
if (websocket.token.startsWith("Bearer ")) {
|
||||
connUrl.searchParams.append('authorization', websocket.token);
|
||||
} else {
|
||||
connUrl.searchParams.append('authorization', 'Bearer ' + websocket.token);
|
||||
}
|
||||
// token 存下来下次续签用:激活码用掉即作废,
|
||||
// 不存的话第二次点连接必然被服务端拒。
|
||||
// 响应里 websocket 和 mqtt 是二选一,带 mqtt 的响应走不到这里
|
||||
const issuedToken = (websocket.token || '').replace(/^Bearer\s+/i, '');
|
||||
if (issuedToken) {
|
||||
sessionStorage.setItem('xz_tester_token', issuedToken);
|
||||
localStorage.setItem('xz_tester_token', issuedToken);
|
||||
}
|
||||
|
||||
// 添加认证参数(保持原有逻辑)
|
||||
connUrl.searchParams.append('device-id', config.deviceId);
|
||||
connUrl.searchParams.append('client-id', config.clientId);
|
||||
// 使用OTA返回的websocket URL
|
||||
const connUrl = new URL(websocket.url);
|
||||
|
||||
// 浏览器不能自定义 websocket 请求头,凭证和设备标识改走子协议头:
|
||||
// xiaozhi, t-<token>, d-<deviceId>, c-<clientId>,服务端只回显 xiaozhi。
|
||||
// 不再拼进 URL 查询串,那会原样落进反代访问日志
|
||||
const subprotocols = [
|
||||
'xiaozhi',
|
||||
`d-${normalizeDeviceId(config.deviceId)}`,
|
||||
`c-${config.clientId}`
|
||||
];
|
||||
if (websocket.token) {
|
||||
const token = websocket.token.startsWith('Bearer ')
|
||||
? websocket.token.slice(7)
|
||||
: websocket.token;
|
||||
subprotocols.splice(1, 0, `t-${token}`);
|
||||
}
|
||||
|
||||
const wsurl = connUrl.toString()
|
||||
|
||||
@@ -45,7 +64,7 @@ export async function webSocketConnect(otaUrl, config) {
|
||||
document.getElementById('serverUrl').value = wsurl;
|
||||
}
|
||||
|
||||
return new WebSocket(connUrl.toString());
|
||||
return new WebSocket(connUrl.toString(), subprotocols);
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
@@ -64,12 +83,15 @@ function validateConfig(config) {
|
||||
// OTA发送请求,验证状态,并返回响应数据
|
||||
async function sendOTA(otaUrl, config) {
|
||||
try {
|
||||
// 有 token 用 token 续签,没有才用激活码换首次 token
|
||||
const credential = config.token || config.activationCode;
|
||||
const res = await fetch(otaUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Device-Id': config.deviceId,
|
||||
'Client-Id': config.clientId
|
||||
'Device-Id': normalizeDeviceId(config.deviceId),
|
||||
'Client-Id': config.clientId,
|
||||
...(credential ? { 'Authorization': `Bearer ${credential}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
version: 0,
|
||||
@@ -104,6 +126,14 @@ async function sendOTA(otaUrl, config) {
|
||||
const result = await res.json();
|
||||
return result; // 返回完整的响应数据
|
||||
} catch (err) {
|
||||
// 跨域失败在浏览器里是不透明的:fetch 直接抛 TypeError,
|
||||
// 拿不到状态码,也看不出是服务端没回跨域头还是服务没起来。
|
||||
// 页面知道自己的来源,直接点名让人去加白名单
|
||||
if (err instanceof TypeError) {
|
||||
log(`请求 ${otaUrl} 被浏览器拦下(跨域)。把 ${window.location.origin} 加到服务端的 server.cors.allowed_origins 里`, 'error');
|
||||
} else {
|
||||
log(`OTA请求失败: ${err.message}`, 'error');
|
||||
}
|
||||
return null; // 失败返回null
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ manager-api 该项目基于SpringBoot框架开发。
|
||||
# 开发环境
|
||||
JDK 21
|
||||
Maven 3.8+
|
||||
MySQL 8.0+
|
||||
MySQL 8.0.19+(迁移脚本 202610231200.sql 用了 WITH ... UPDATE 改写,8.0.19 才有;低版本会整段回滚导致服务起不来)
|
||||
Redis 5.0+
|
||||
Vue 3.x
|
||||
|
||||
|
||||
+15
-7
@@ -3,6 +3,7 @@ package xiaozhi.modules.device.controller;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -28,6 +29,7 @@ import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
||||
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.device.service.impl.DeviceServiceImpl;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
|
||||
@Tag(name = "设备管理", description = "OTA 相关接口")
|
||||
@@ -46,15 +48,16 @@ public class OTAController {
|
||||
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
|
||||
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
|
||||
if (StringUtils.isBlank(deviceId)) {
|
||||
return createResponse(DeviceReportRespDTO.createError("Device ID is required"));
|
||||
return createResponse(DeviceReportRespDTO.createError("Device ID is required"), HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
deviceId = DeviceServiceImpl.normalizeDeviceId(deviceId);
|
||||
if (StringUtils.isBlank(clientId)) {
|
||||
clientId = deviceId;
|
||||
}
|
||||
boolean macAddressValid = isMacAddressValid(deviceId);
|
||||
// 设备Id和Mac地址应是一致的, 并且必须需要application字段
|
||||
if (!macAddressValid) {
|
||||
return createResponse(DeviceReportRespDTO.createError("Invalid device ID"));
|
||||
return createResponse(DeviceReportRespDTO.createError("Invalid device ID"), HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return createResponse(deviceService.checkDeviceActive(deviceId, clientId, deviceReportReqDTO));
|
||||
}
|
||||
@@ -94,20 +97,25 @@ public class OTAController {
|
||||
|
||||
@SneakyThrows
|
||||
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO) {
|
||||
return createResponse(deviceReportRespDTO, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO, HttpStatus status) {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
String json = objectMapper.writeValueAsString(deviceReportRespDTO);
|
||||
byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
|
||||
return ResponseEntity
|
||||
.ok()
|
||||
.status(status)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.contentLength(jsonBytes.length)
|
||||
.body(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单判断mac地址是否有效(非严格)
|
||||
*
|
||||
* 判断 mac 地址是否有效,收紧为严格的十六进制 MAC 形态
|
||||
*
|
||||
* @param macAddress
|
||||
* @return
|
||||
*/
|
||||
@@ -115,8 +123,8 @@ public class OTAController {
|
||||
if (StringUtils.isBlank(macAddress)) {
|
||||
return false;
|
||||
}
|
||||
// MAC地址通常为12位十六进制数字,可以包含冒号或连字符分隔符
|
||||
String macPattern = "^([0-9A-Za-z]{2}[:-]){5}([0-9A-Za-z]{2})$";
|
||||
// MAC地址为12位十六进制数字,可用冒号、连字符或下划线分隔,也接受无分隔符形态
|
||||
String macPattern = "^([0-9A-Fa-f]{2}[:\\-_]){5}[0-9A-Fa-f]{2}$|^[0-9A-Fa-f]{12}$";
|
||||
return macAddress.matches(macPattern);
|
||||
}
|
||||
}
|
||||
+81
-21
@@ -137,7 +137,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
deviceEntity.setBoard(board);
|
||||
deviceEntity.setAgentId(agentId);
|
||||
deviceEntity.setAppVersion(appVersion);
|
||||
deviceEntity.setMacAddress(macAddress);
|
||||
deviceEntity.setMacAddress(normalizeDeviceId(macAddress));
|
||||
deviceEntity.setUserId(user.getId());
|
||||
deviceEntity.setCreator(user.getId());
|
||||
deviceEntity.setAutoUpdate(1);
|
||||
@@ -198,7 +198,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
// 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
|
||||
if (deviceById == null) {
|
||||
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
|
||||
firmware.setVersion(deviceReport.getApplication().getVersion());
|
||||
String currentVersion = deviceReport != null && deviceReport.getApplication() != null
|
||||
? deviceReport.getApplication().getVersion()
|
||||
: null;
|
||||
firmware.setVersion(currentVersion);
|
||||
firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
|
||||
response.setFirmware(firmware);
|
||||
} else {
|
||||
@@ -219,13 +222,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
// 检查是否启用认证并生成token
|
||||
String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true);
|
||||
if ("true".equalsIgnoreCase(authEnabled)) {
|
||||
try {
|
||||
// 生成token
|
||||
String token = generateWebSocketToken(clientId, macAddress);
|
||||
websocket.setToken(token);
|
||||
} catch (Exception e) {
|
||||
log.error("生成WebSocket token失败: {}", e.getMessage());
|
||||
// 仅已登记绑定的设备签发 token;未登记的设备不返回可用凭证,
|
||||
// 登记操作需登录智控台,因此不会成为任意 MAC 换取 token 的通道
|
||||
if (deviceById == null) {
|
||||
log.warn("设备未在智控台登记,跳过 WebSocket token 签发: {}", macAddress);
|
||||
websocket.setToken("");
|
||||
} else {
|
||||
try {
|
||||
// 生成token
|
||||
String token = generateWebSocketToken(clientId, macAddress);
|
||||
websocket.setToken(token);
|
||||
} catch (Exception e) {
|
||||
log.error("生成WebSocket token失败: {}", e.getMessage());
|
||||
websocket.setToken("");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
websocket.setToken("");
|
||||
@@ -252,16 +262,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
|
||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
|
||||
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
||||
try {
|
||||
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
|
||||
: "GID_default";
|
||||
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
||||
if (mqtt != null) {
|
||||
mqtt.setEndpoint(mqttUdpConfig);
|
||||
response.setMqtt(mqtt);
|
||||
// MQTT 配置含连接签名,与 websocket token 一样仅对已登记设备下发
|
||||
if (deviceById == null) {
|
||||
log.warn("设备未在智控台登记,跳过 MQTT 配置下发: {}", macAddress);
|
||||
} else {
|
||||
try {
|
||||
String groupId = deviceById.getBoard() != null ? deviceById.getBoard() : "GID_default";
|
||||
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
||||
if (mqtt != null) {
|
||||
mqtt.setEndpoint(mqttUdpConfig);
|
||||
response.setMqtt(mqtt);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成MQTT配置失败: {}", e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成MQTT配置失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,9 +409,54 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
if (StringUtils.isBlank(macAddress)) {
|
||||
return null;
|
||||
}
|
||||
DeviceEntity device = selectByMacAddress(normalizeDeviceId(macAddress));
|
||||
if (device != null) {
|
||||
return device;
|
||||
}
|
||||
// 归一之前写进去的存量数据,原文再试一次,别让写法不同就查不到
|
||||
return selectByMacAddress(macAddress);
|
||||
}
|
||||
|
||||
private DeviceEntity selectByMacAddress(String macAddress) {
|
||||
QueryWrapper<DeviceEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("mac_address", macAddress);
|
||||
return baseDao.selectOne(wrapper);
|
||||
// 按 id 定序后取第一条。id 是 VARCHAR,比的是字符序而不是入库先后,
|
||||
// 但主键保证同一组里 id 不重复,所以每次查询拿到的都是同一台;
|
||||
// 不定序的话拿到哪台随数据库返回顺序变,称呼和缓存键跟着变
|
||||
wrapper.orderByAsc("id");
|
||||
// 有重复行时 selectOne 直接抛 TooManyResultsException,报活和激活两条路一起崩,
|
||||
// 库里还看不出发生过什么。迁移对撞车的行整组不动,那种库会同时存在两行,
|
||||
// 取第一条让设备读得出来;两行归谁所有是人的判断,脚本不替人定
|
||||
List<DeviceEntity> devices = baseDao.selectList(wrapper);
|
||||
return devices.isEmpty() ? null : devices.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 Device-Id 归一成小写冒号分隔的 MAC 形态
|
||||
* <p>
|
||||
* 入库、查询、签名、订阅地址、缓存键统一走这个值,否则同一台设备换个
|
||||
* 分隔符或大小写就会被当成没登记,表现为静默不发凭证也不下发固件。
|
||||
* 不是合法 MAC 时原样返回,交给后续校验拒绝。
|
||||
*
|
||||
* @param deviceId 设备上报或用户填写的 Device-Id
|
||||
* @return 归一后的 Device-Id
|
||||
*/
|
||||
public static String normalizeDeviceId(String deviceId) {
|
||||
if (StringUtils.isBlank(deviceId)) {
|
||||
return deviceId;
|
||||
}
|
||||
String hex = deviceId.toLowerCase().replace(":", "").replace("-", "").replace("_", "");
|
||||
if (hex.length() != 12) {
|
||||
return deviceId;
|
||||
}
|
||||
StringBuilder mac = new StringBuilder(17);
|
||||
for (int i = 0; i < 12; i += 2) {
|
||||
if (i > 0) {
|
||||
mac.append(':');
|
||||
}
|
||||
mac.append(hex, i, i + 2);
|
||||
}
|
||||
return mac.toString();
|
||||
}
|
||||
|
||||
private DeviceReportRespDTO.ServerTime buildServerTime() {
|
||||
@@ -549,21 +608,22 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
|
||||
@Override
|
||||
public void manualAddDevice(Long userId, DeviceManualAddDTO dto) {
|
||||
String macAddress = normalizeDeviceId(dto.getMacAddress());
|
||||
// 检查mac是否已存在
|
||||
QueryWrapper<DeviceEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("mac_address", dto.getMacAddress());
|
||||
wrapper.eq("mac_address", macAddress);
|
||||
DeviceEntity exist = baseDao.selectOne(wrapper);
|
||||
if (exist != null) {
|
||||
throw new RenException(ErrorCode.MAC_ADDRESS_ALREADY_EXISTS);
|
||||
}
|
||||
Date now = new Date();
|
||||
DeviceEntity entity = new DeviceEntity();
|
||||
entity.setId(dto.getMacAddress());
|
||||
entity.setId(macAddress);
|
||||
entity.setUserId(userId);
|
||||
entity.setAgentId(dto.getAgentId());
|
||||
entity.setBoard(dto.getBoard());
|
||||
entity.setAppVersion(dto.getAppVersion());
|
||||
entity.setMacAddress(dto.getMacAddress());
|
||||
entity.setMacAddress(macAddress);
|
||||
entity.setCreateDate(now);
|
||||
entity.setUpdateDate(now);
|
||||
entity.setLastConnectedAt(now);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- 设备 MAC 归一:改成小写冒号分隔,与 OTA 上报归一后的写法对齐
|
||||
--
|
||||
-- 之前用户在智控台手填的 MAC 原样入库(可能是 AA-BB-… 、aabbcc… 或全大写),
|
||||
-- OTA 侧归一后按 aa:bb:… 精确查库,查不到就静默不发凭证、不下发固件。
|
||||
-- 这里把存量刷成归一后的形态。
|
||||
--
|
||||
-- 只改 mac_address,不动 id:手填设备的 id 就是当时的原始 MAC,而 ai_chat_history
|
||||
-- 等表按 device_id 存着对话记录,改 id 会牵动这些外键和缓存键,风险大于收益。
|
||||
-- 查不到归一值时查询侧会拿原文再试一次。
|
||||
--
|
||||
-- 需要 MySQL 8.0.19 及以上:下面三段都是 WITH ... UPDATE,这个写法 8.0.19 才有。
|
||||
-- 低版本上第一段就是语法错,三段又都在一个事务里,语法错会整体回滚,
|
||||
-- manager-api 起不来。README 的环境要求写的是同一个下限。
|
||||
--
|
||||
-- ⚠️ 三段都只刷「归一后全表只剩它一行」的那种,这不是保守,是必须。
|
||||
--
|
||||
-- 同一台设备被填过两种写法(AA-BB-CC-DD-EE-FF 和 aabbccddeeff)时,两行会归一成
|
||||
-- 同一个值,全刷就撞了。而两段撞了的后果完全不一样,方向是反的:
|
||||
--
|
||||
-- ai_device:mac_address 上只有普通索引不是唯一键,撞了数据库一声不吭。
|
||||
-- 两行一模一样存下来,之后 getDeviceByMacAddress 的 selectOne 命中多行直接抛
|
||||
-- TooManyResultsException。这台设备之后彻底读不出来,报活和激活两条路全崩,
|
||||
-- 库里没有任何东西能看出发生过什么,用户只看到一个错误响应。
|
||||
--
|
||||
-- ai_device_address_book:主键是 (mac_address, target_mac),撞上会报 Duplicate
|
||||
-- entry,整个 changeset 回滚,服务起不来。吵,但看得见。
|
||||
--
|
||||
-- 也就是没有 DB 保护的那段才会静默坏数据。下面每段的 cnt.n = 1 就是这个保护:
|
||||
-- 刷完不会出现两行同值。撞上的行一个都不动,保持迁移前的状态——写法不同就 eq 不到,
|
||||
-- 用户看得见、也还能重新添加,manualAddDevice 现在入库前会归一,重新添加会补上一行
|
||||
-- 归一后的。
|
||||
--
|
||||
-- ── ① ai_device.mac_address ────────────────────────────────────────────
|
||||
|
||||
WITH norm AS (
|
||||
SELECT `id`, LOWER(CONCAT(SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 1, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 3, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 5, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 7, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 9, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 11, 2))) AS `nmac`
|
||||
FROM `ai_device`
|
||||
WHERE `mac_address` REGEXP '^([0-9A-Fa-f]{2}[-:_]?){5}[0-9A-Fa-f]{2}$'
|
||||
),
|
||||
cnt AS (SELECT `nmac`, COUNT(*) AS `n` FROM norm GROUP BY `nmac`)
|
||||
UPDATE `ai_device` d
|
||||
JOIN norm ON norm.`id` = d.`id`
|
||||
JOIN cnt ON cnt.`nmac` = norm.`nmac`
|
||||
SET d.`mac_address` = norm.`nmac`
|
||||
WHERE cnt.`n` = 1
|
||||
-- BINARY 是逐字节比:ai_device 走的是库的 utf8mb4_0900_ai_ci,
|
||||
-- 普通 <> 认为 AA:BB:… 和 aa:bb:… 相等,大写行会永远刷不掉
|
||||
AND BINARY d.`mac_address` <> BINARY norm.`nmac`;
|
||||
|
||||
--
|
||||
-- ── ② ai_device_address_book 的两列 ────────────────────────────────────
|
||||
-- 主键是 (mac_address, target_mac),撞的是这一对,所以两列分开刷、各自判重。
|
||||
-- 一次只改一列时另一列不动,会不会撞只由这一列决定:
|
||||
-- mac 相同的行 target 必然不同(主键),target 归一成同一个值的行有 cnt 挡着。
|
||||
--
|
||||
-- 12 位以外的地址(ESP32 伪 MAC 是 32 位)正则不匹配,原样不动,和 Java 那边
|
||||
-- normalizeDeviceId 的行为一致——它同样只归一 12 位那种。mac 和 target 各自判断,
|
||||
-- 一列不合法不妨碍另一列刷(伪 MAC 那台本机的 mac 照样刷得掉)。
|
||||
|
||||
WITH norm AS (
|
||||
SELECT `mac_address`, `target_mac`, LOWER(CONCAT(SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 1, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 3, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 5, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 7, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 9, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`, ':', ''), '-', ''), '_', ''), 11, 2))) AS `nmac`
|
||||
FROM `ai_device_address_book`
|
||||
WHERE `mac_address` REGEXP '^([0-9A-Fa-f]{2}[-:_]?){5}[0-9A-Fa-f]{2}$'
|
||||
),
|
||||
cnt AS (SELECT `nmac`, COUNT(*) AS `n` FROM norm GROUP BY `nmac`)
|
||||
UPDATE `ai_device_address_book` b
|
||||
JOIN norm ON norm.`mac_address` = b.`mac_address` AND norm.`target_mac` = b.`target_mac`
|
||||
JOIN cnt ON cnt.`nmac` = norm.`nmac`
|
||||
SET b.`mac_address` = norm.`nmac`
|
||||
WHERE cnt.`n` = 1
|
||||
AND BINARY b.`mac_address` <> BINARY norm.`nmac`;
|
||||
|
||||
WITH norm AS (
|
||||
SELECT `mac_address`, `target_mac`, LOWER(CONCAT(SUBSTRING(REPLACE(REPLACE(REPLACE(`target_mac`, ':', ''), '-', ''), '_', ''), 1, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`target_mac`, ':', ''), '-', ''), '_', ''), 3, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`target_mac`, ':', ''), '-', ''), '_', ''), 5, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`target_mac`, ':', ''), '-', ''), '_', ''), 7, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`target_mac`, ':', ''), '-', ''), '_', ''), 9, 2), ':', SUBSTRING(REPLACE(REPLACE(REPLACE(`target_mac`, ':', ''), '-', ''), '_', ''), 11, 2))) AS `ntmac`
|
||||
FROM `ai_device_address_book`
|
||||
WHERE `target_mac` REGEXP '^([0-9A-Fa-f]{2}[-:_]?){5}[0-9A-Fa-f]{2}$'
|
||||
),
|
||||
cnt AS (SELECT `ntmac`, COUNT(*) AS `n` FROM norm GROUP BY `ntmac`)
|
||||
UPDATE `ai_device_address_book` b
|
||||
JOIN norm ON norm.`mac_address` = b.`mac_address` AND norm.`target_mac` = b.`target_mac`
|
||||
JOIN cnt ON cnt.`ntmac` = norm.`ntmac`
|
||||
SET b.`target_mac` = norm.`ntmac`
|
||||
WHERE cnt.`n` = 1
|
||||
AND BINARY b.`target_mac` <> BINARY norm.`ntmac`;
|
||||
|
||||
--
|
||||
-- ── ③ 刷完之后跑这个,看有没有被跳过的重复 ─────────────────────────────
|
||||
-- 有结果说明同一台设备被填过多种写法,两行现在还是分开存的,行为和迁移前完全一样。
|
||||
-- 真要合得人来看:两行的 user_id 可能不是同一个人,谁该留下不是机器能定的,
|
||||
-- 所以这里只报不动。
|
||||
--
|
||||
-- SELECT LOWER(CONCAT(SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 1, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 3, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 5, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 7, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 9, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 11, 2))) AS `nmac`,
|
||||
-- COUNT(*) AS `c`
|
||||
-- FROM `ai_device`
|
||||
-- WHERE `mac_address` REGEXP '^([0-9A-Fa-f]{2}[-:_]?){5}[0-9A-Fa-f]{2}$'
|
||||
-- GROUP BY `nmac` HAVING `c` > 1;
|
||||
--
|
||||
-- ── 漏刷检查:刷完之后还有哪些行是原写法 ──────────────────────────────────
|
||||
-- 上一条只找重复组,而重复组本来就该留着原样,所以它查不出漏刷:
|
||||
-- 判重之外的行要是压根没被刷到,它一条也报不出来。
|
||||
-- 下面这条报的是「还是原写法」的所有行。跑出结果不一定是漏刷——
|
||||
-- 重复组在上面那条名单里的那些是故意留的,剩下的是真漏刷。
|
||||
--
|
||||
-- 第 ① 段在有重复组的表上整段刷不动过一次:MySQL 26.7.0 实测,
|
||||
-- 该刷的行一行没动、ROW_COUNT() 报 0、退出码 0、不打日志、不报错。
|
||||
-- 没有报错可等,只能靠这条查。
|
||||
--
|
||||
-- SELECT `id`, `mac_address` AS `still_original`,
|
||||
-- LOWER(CONCAT(SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 1, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 3, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 5, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 7, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 9, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 11, 2))) AS `should_be`
|
||||
-- FROM `ai_device`
|
||||
-- WHERE `mac_address` REGEXP '^([0-9A-Fa-f]{2}[-:_]?){5}[0-9A-Fa-f]{2}$'
|
||||
-- AND BINARY `mac_address` <> BINARY LOWER(CONCAT(SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 1, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 3, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 5, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 7, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 9, 2), ':',
|
||||
-- SUBSTRING(REPLACE(REPLACE(REPLACE(`mac_address`,':',''),'-',''),'_',''), 11, 2)))
|
||||
-- ORDER BY `id`;
|
||||
--
|
||||
-- 三段合在一个 changeset 里,也就是一个事务(Liquibase 默认 runInTransaction 为真),
|
||||
-- 任何一段报错都会整体回滚、服务起不来。所以它们不该去撞,上面的 cnt 就是为这个。
|
||||
-- 值已经是对的三段都是空操作,重跑安全。
|
||||
@@ -753,3 +753,10 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202609172100.sql
|
||||
- changeSet:
|
||||
id: 202610231200
|
||||
author: wk01
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610231200.sql
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
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.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.ibatis.exceptions.TooManyResultsException;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.modules.device.dao.DeviceDao;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
|
||||
/**
|
||||
* #3448 设备标识归一化回归测试
|
||||
*
|
||||
* <p>
|
||||
* 同一台设备换个分隔符或大小写上报,入库、查重、查询三处必须算成同一个值,
|
||||
* 否则表现为静默不发凭证、不下发固件,没有任何报错可查。
|
||||
*/
|
||||
@DisplayName("设备标识归一化回归测试")
|
||||
class DeviceMacAddressNormalizeTest {
|
||||
|
||||
private static final String CANONICAL = "aa:bb:cc:dd:ee:ff";
|
||||
|
||||
@Test
|
||||
@DisplayName("冒号、连字符、下划线、无分隔符、大小写写法归一到同一个值")
|
||||
void everySpellingCollapsesToCanonical() {
|
||||
for (String spelling : new String[] {
|
||||
"aa:bb:cc:dd:ee:ff", "AA:BB:CC:DD:EE:FF", "aa-bb-cc-dd-ee-ff",
|
||||
"aa_bb_cc_dd_ee_ff", "aabbccddeeff", "Aa:Bb:Cc:Dd:Ee:Ff" }) {
|
||||
assertEquals(CANONICAL, DeviceServiceImpl.normalizeDeviceId(spelling), spelling);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不是 MAC 的标识原样返回,交给后续校验拒绝")
|
||||
void nonMacValuePassesThrough() {
|
||||
for (String value : new String[] { null, "", " ", "abc", "zz:zz:zz:zz:zz:zz", "00112233445566" }) {
|
||||
assertEquals(value, DeviceServiceImpl.normalizeDeviceId(value), String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("按任意写法上报都能查到归一后入库的那台设备")
|
||||
void lookupFindsRowStoredInCanonicalForm() {
|
||||
DeviceEntity stored = storedDevice(CANONICAL);
|
||||
DeviceServiceImpl service = service(daoMatchingOnly(CANONICAL, stored));
|
||||
|
||||
for (String spelling : new String[] {
|
||||
"aa:bb:cc:dd:ee:ff", "aabbccddeeff", "AA-BB-CC-DD-EE-FF", "aa_bb_cc_dd_ee_ff" }) {
|
||||
assertSame(stored, service.getDeviceByMacAddress(spelling), spelling);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("迁移前按原文入库的存量设备仍能查到,不被归一化挡掉")
|
||||
void lookupFallsBackToLegacyRow() {
|
||||
DeviceEntity legacy = storedDevice("AA-BB-CC-DD-EE-FF");
|
||||
DeviceServiceImpl service = service(daoMatchingOnly("AA-BB-CC-DD-EE-FF", legacy));
|
||||
|
||||
assertSame(legacy, service.getDeviceByMacAddress("AA-BB-CC-DD-EE-FF"),
|
||||
"归一后没查到,该退回原文再查一次,否则迁移前的设备直接失联");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("两种写法都查不到时返回 null")
|
||||
void lookupMissReturnsNull() {
|
||||
DeviceServiceImpl service = service(daoMatchingOnly(CANONICAL, storedDevice(CANONICAL)));
|
||||
|
||||
assertNull(service.getDeviceByMacAddress("11:22:33:44:55:66"));
|
||||
assertNull(service.getDeviceByMacAddress(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("库里存了两行同一个 MAC 时按 id 定序取第一条,两次读到同一台")
|
||||
void duplicateRowsResolveToTheSameRowEveryTime() {
|
||||
// 迁移脚本对重复组整组跳过,库里出现同 mac 的两行是可能的:
|
||||
// 用户把同一台设备用两种写法各添加过一次,迁移前后都该读得出来
|
||||
DeviceEntity first = storedDevice("AA-BB-CC-DD-EE-FF");
|
||||
first.setId("dev-0001");
|
||||
DeviceEntity second = storedDevice("aa:bb:cc:dd:ee:ff");
|
||||
second.setId("dev-0002");
|
||||
|
||||
DeviceServiceImpl service = service(daoReturningRowsOrderedById(first, second));
|
||||
|
||||
assertSame(first, service.getDeviceByMacAddress(CANONICAL));
|
||||
assertSame(first, service.getDeviceByMacAddress(CANONICAL),
|
||||
"同一份重复数据两次读到不同的行,称呼和缓存键会跟着变");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重复行取哪一台只由 id 定序决定,与库返回顺序无关")
|
||||
void duplicateRowChoiceIgnoresTheOrderTheDatabaseReturnedThem() {
|
||||
DeviceEntity first = storedDevice("AA-BB-CC-DD-EE-FF");
|
||||
first.setId("dev-0001");
|
||||
DeviceEntity second = storedDevice("aa:bb:cc:dd:ee:ff");
|
||||
second.setId("dev-0002");
|
||||
|
||||
DeviceEntity forwards = service(daoReturningRowsOrderedById(first, second))
|
||||
.getDeviceByMacAddress(CANONICAL);
|
||||
DeviceEntity backwards = service(daoReturningRowsOrderedById(second, first))
|
||||
.getDeviceByMacAddress(CANONICAL);
|
||||
|
||||
assertSame(first, forwards);
|
||||
assertSame(backwards, forwards, "换个返回顺序就换了一台,缓存键跟着变");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("手工添加按归一值查重、按归一值入库")
|
||||
void manualAddStoresNormalizedMac() {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
when(dao.selectOne(any())).thenReturn(null);
|
||||
DeviceServiceImpl service = service(dao);
|
||||
|
||||
DeviceManualAddDTO dto = new DeviceManualAddDTO();
|
||||
dto.setMacAddress("AABBCCDDEEFF");
|
||||
dto.setAgentId("1");
|
||||
service.manualAddDevice(9L, dto);
|
||||
|
||||
ArgumentCaptor<Wrapper<DeviceEntity>> queried = wrapperCaptor();
|
||||
verify(dao).selectOne(queried.capture());
|
||||
assertEquals(CANONICAL, queriedMac(queried.getValue()),
|
||||
"查重用的不是归一值,同一台设备换个写法就能被重复添加");
|
||||
|
||||
ArgumentCaptor<DeviceEntity> inserted = ArgumentCaptor.forClass(DeviceEntity.class);
|
||||
verify(dao).insert(inserted.capture());
|
||||
assertEquals(CANONICAL, inserted.getValue().getMacAddress());
|
||||
assertEquals(CANONICAL, inserted.getValue().getId(), "主键跟着一起归一,否则同一台设备能插进两条");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备激活入库的是归一后的 MAC")
|
||||
void activationStoresNormalizedMac() {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
when(dao.selectById(any())).thenReturn(null);
|
||||
RedisUtils redisUtils = mock(RedisUtils.class);
|
||||
Map<String, Object> activation = new HashMap<>();
|
||||
activation.put("mac_address", "AA-BB-CC-DD-EE-FF");
|
||||
activation.put("activation_code", "CODE1");
|
||||
when(redisUtils.get(any())).thenAnswer(invocation -> {
|
||||
String key = invocation.getArgument(0);
|
||||
return key.endsWith("CODE1") ? CANONICAL : activation;
|
||||
});
|
||||
DeviceServiceImpl service = service(dao, redisUtils);
|
||||
|
||||
UserDetail user = new UserDetail();
|
||||
user.setId(9L);
|
||||
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
|
||||
securityUser.when(SecurityUser::getUser).thenReturn(user);
|
||||
assertTrue(service.deviceActivation("1", "CODE1"));
|
||||
}
|
||||
|
||||
ArgumentCaptor<DeviceEntity> inserted = ArgumentCaptor.forClass(DeviceEntity.class);
|
||||
verify(dao).insert(inserted.capture());
|
||||
assertEquals(CANONICAL, inserted.getValue().getMacAddress());
|
||||
}
|
||||
|
||||
private DeviceServiceImpl service(DeviceDao dao) {
|
||||
return service(dao, mock(RedisUtils.class));
|
||||
}
|
||||
|
||||
/** baseDao 由父类持有,子类内部赋给 this,测试类自己碰不到它 */
|
||||
private DeviceServiceImpl service(DeviceDao dao, RedisUtils redisUtils) {
|
||||
return new DeviceServiceImpl(dao, null, null, redisUtils, null, null) {
|
||||
{
|
||||
this.baseDao = dao;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 假扮真库:先按 id 的字符序排好再返回
|
||||
*
|
||||
* <p>
|
||||
* 真库里 ORDER BY 排在返回之前,不定序的话拿到哪一行由存储引擎决定。
|
||||
* 这里不照做,用例量的就只是 mock 自己塞进去的顺序,排序有没有写、怎么排都测不出来
|
||||
*/
|
||||
private DeviceDao daoReturningRowsOrderedById(DeviceEntity... rows) {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
List<DeviceEntity> stored = List.of(rows);
|
||||
when(dao.selectList(any())).thenAnswer(invocation -> {
|
||||
Wrapper<DeviceEntity> wrapper = invocation.getArgument(0);
|
||||
assertTrue(wrapper.getSqlSegment().contains("ORDER BY id"),
|
||||
"重复行没有定序,拿到哪一台随数据库返回顺序变:" + wrapper.getSqlSegment());
|
||||
return stored.stream().sorted(Comparator.comparing(DeviceEntity::getId)).toList();
|
||||
});
|
||||
// selectOne 命中多行就是 MyBatis-Plus 抛的 TooManyResultsException,
|
||||
// 这里照抄真库的行为:用 selectOne 的写法改回去,用例会带着这个异常炸掉
|
||||
when(dao.selectOne(any())).thenThrow(
|
||||
new TooManyResultsException("Expected one result (or null) to be returned by selectOne(), but found: 2"));
|
||||
return dao;
|
||||
}
|
||||
|
||||
/** 只在查询值等于 storedMac 时才命中,用来验证查的是哪个值 */
|
||||
private DeviceDao daoMatchingOnly(String storedMac, DeviceEntity row) {
|
||||
DeviceDao dao = mock(DeviceDao.class);
|
||||
when(dao.selectList(any())).thenAnswer(invocation -> {
|
||||
Wrapper<DeviceEntity> wrapper = invocation.getArgument(0);
|
||||
return storedMac.equals(queriedMac(wrapper)) ? List.of(row) : List.of();
|
||||
});
|
||||
return dao;
|
||||
}
|
||||
|
||||
private DeviceEntity storedDevice(String mac) {
|
||||
DeviceEntity device = new DeviceEntity();
|
||||
device.setId(mac);
|
||||
device.setMacAddress(mac);
|
||||
return device;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ArgumentCaptor<Wrapper<DeviceEntity>> wrapperCaptor() {
|
||||
return ArgumentCaptor.forClass(Wrapper.class);
|
||||
}
|
||||
|
||||
/** eq() 绑定的值只在 AbstractWrapper 上,Wrapper 接口看不到 */
|
||||
private Map<String, Object> boundValues(Wrapper<DeviceEntity> wrapper) {
|
||||
return ((AbstractWrapper<?, ?, ?>) wrapper).getParamNameValuePairs();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出这次查询实际拿去比对的 MAC
|
||||
*
|
||||
* <p>
|
||||
* 绑定值是拼 SQL 时才写进去的,getSqlSegment() 先调一次才读得到
|
||||
*/
|
||||
private Object queriedMac(Wrapper<DeviceEntity> wrapper) {
|
||||
wrapper.getSqlSegment();
|
||||
return boundValues(wrapper).values().iterator().next();
|
||||
}
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 迁移脚本 202610231200.sql 的行为测试
|
||||
*
|
||||
* <p>
|
||||
* 这段迁移真正要保证的是刷完之后不会出现两行归一成同一个值。判重条件被去掉、
|
||||
* 连接条件写错、大写判断漏了 BINARY,后果都是同一台设备之后彻底读不出来,
|
||||
* 而且没有任何报错可查。子串匹配一个都测不出来,只能在真库上跑一遍看结果。
|
||||
*
|
||||
* <p>
|
||||
* 需要一个连得上的 MySQL。连不上就跳过而不是报错,变量是
|
||||
* XIAOZHI_TEST_MYSQL_URL、XIAOZHI_TEST_MYSQL_USER、XIAOZHI_TEST_MYSQL_PASSWORD
|
||||
*/
|
||||
@DisplayName("MAC 归一迁移脚本")
|
||||
class MacAddressMigrationSqlTest {
|
||||
|
||||
private static final String SCHEMA = "mac_migration_sql_test";
|
||||
|
||||
private static Connection connection;
|
||||
|
||||
@BeforeAll
|
||||
static void openConnection() {
|
||||
String url = System.getenv("XIAOZHI_TEST_MYSQL_URL");
|
||||
Assumptions.assumeTrue(url != null && !url.isBlank(),
|
||||
"没给 XIAOZHI_TEST_MYSQL_URL,跳过真库迁移测试");
|
||||
try {
|
||||
connection = DriverManager.getConnection(url,
|
||||
System.getenv().getOrDefault("XIAOZHI_TEST_MYSQL_USER", "root"),
|
||||
System.getenv().getOrDefault("XIAOZHI_TEST_MYSQL_PASSWORD", ""));
|
||||
} catch (SQLException e) {
|
||||
Assumptions.abort("连不上 " + url + ":" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void closeConnection() throws SQLException {
|
||||
if (connection == null) {
|
||||
return;
|
||||
}
|
||||
for (String statement : splitStatements("DROP SCHEMA IF EXISTS `" + SCHEMA + "`")) {
|
||||
connection.createStatement().execute(statement);
|
||||
}
|
||||
connection.close();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void recreateTables() throws SQLException {
|
||||
execute("DROP SCHEMA IF EXISTS `" + SCHEMA + "`");
|
||||
execute("CREATE SCHEMA `" + SCHEMA + "`");
|
||||
// 迁移脚本里的表名不带库名,默认库得先切过去
|
||||
connection.setCatalog(SCHEMA);
|
||||
// 建表语句照抄真实脚本,见 202503141346.sql:110 和 202605251426.sql:27。
|
||||
// schema 变了这里要跟着改,否则测的是一个库线上不存在的形状
|
||||
execute("""
|
||||
CREATE TABLE `%s`.`ai_device` (
|
||||
`id` VARCHAR(32) NOT NULL COMMENT '设备唯一标识',
|
||||
`user_id` BIGINT COMMENT '关联用户 ID',
|
||||
`mac_address` VARCHAR(50) COMMENT 'MAC 地址',
|
||||
`alias` VARCHAR(64) DEFAULT NULL COMMENT '设备别名',
|
||||
`create_date` DATETIME COMMENT '创建时间',
|
||||
`update_date` DATETIME COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_ai_device_created_at` (`mac_address`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备信息表'
|
||||
""".formatted(SCHEMA));
|
||||
execute("""
|
||||
CREATE TABLE `%s`.`ai_device_address_book` (
|
||||
`mac_address` VARCHAR(64) NOT NULL COMMENT '本设备MAC地址',
|
||||
`target_mac` VARCHAR(64) NOT NULL COMMENT '对方设备MAC地址',
|
||||
`alias` VARCHAR(64) DEFAULT NULL COMMENT '别名',
|
||||
`has_permission` TINYINT(1) DEFAULT TRUE COMMENT '是否有权限呼叫',
|
||||
PRIMARY KEY (`mac_address`, `target_mac`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='设备通讯录表'
|
||||
""".formatted(SCHEMA));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有重复组时,只刷归一后不撞车的那些行")
|
||||
void collidingGroupIsSkippedButUniqueRowsStillNormalize() throws SQLException {
|
||||
// 同一台设备被填过两种写法,这两行归一后会撞上,整组一个都不动
|
||||
insertDevice("d1", "AA-BB-CC-DD-EE-01");
|
||||
insertDevice("d2", "aabbccddee01");
|
||||
// 独立的设备,不该因为别处有重复组被连累
|
||||
insertDevice("x1", "11-22-33-44-55-66");
|
||||
// 已经是冒号分隔的大写写法,和小写归一值逐字节不同,得刷
|
||||
insertDevice("u1", "AA:BB:CC:DD:EE:99");
|
||||
// 32 位伪 MAC 正则不匹配,原样不动,和 Java 那边 normalizeDeviceId 一致
|
||||
insertDevice("p1", "aabbccddeeff00112233445566778899");
|
||||
|
||||
runMigration();
|
||||
|
||||
assertEquals("AA-BB-CC-DD-EE-01", deviceMac("d1"), "重复组该整组不动");
|
||||
assertEquals("aabbccddee01", deviceMac("d2"), "重复组该整组不动");
|
||||
assertEquals("11:22:33:44:55:66", deviceMac("x1"));
|
||||
assertEquals("aa:bb:cc:dd:ee:99", deviceMac("u1"));
|
||||
assertEquals("aabbccddeeff00112233445566778899", deviceMac("p1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有重复时全部刷成归一值")
|
||||
void everyUniqueRowNormalizes() throws SQLException {
|
||||
insertDevice("u1", "AA:BB:CC:DD:EE:99");
|
||||
insertDevice("u2", "aa-bb-cc-dd-ee-98");
|
||||
insertDevice("u3", "AABBCCDDEE97");
|
||||
insertDevice("u4", "aa_bb_cc_dd_ee_96");
|
||||
insertDevice("u5", "AA-BB-CC-DD-EE-95");
|
||||
|
||||
runMigration();
|
||||
|
||||
assertEquals("aa:bb:cc:dd:ee:99", deviceMac("u1"));
|
||||
assertEquals("aa:bb:cc:dd:ee:98", deviceMac("u2"));
|
||||
assertEquals("aa:bb:cc:dd:ee:97", deviceMac("u3"));
|
||||
assertEquals("aa:bb:cc:dd:ee:96", deviceMac("u4"));
|
||||
assertEquals("aa:bb:cc:dd:ee:95", deviceMac("u5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整库都是重复组时一行都不动")
|
||||
void allCollidingRowsStayUntouched() throws SQLException {
|
||||
insertDevice("d1", "AA-BB-CC-DD-EE-01");
|
||||
insertDevice("d2", "aabbccddee01");
|
||||
insertDevice("d3", "AA:BB:CC:DD:EE:01");
|
||||
|
||||
runMigration();
|
||||
|
||||
assertEquals("AA-BB-CC-DD-EE-01", deviceMac("d1"));
|
||||
assertEquals("aabbccddee01", deviceMac("d2"));
|
||||
assertEquals("AA:BB:CC:DD:EE:01", deviceMac("d3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("值已经是对的时空跑,重跑安全")
|
||||
void rerunningTheMigrationChangesNothing() throws SQLException {
|
||||
insertDevice("u1", "AA:BB:CC:DD:EE:99");
|
||||
insertDevice("d1", "AA-BB-CC-DD-EE-01");
|
||||
insertDevice("d2", "aabbccddee01");
|
||||
runMigration();
|
||||
|
||||
runMigration();
|
||||
|
||||
assertEquals("aa:bb:cc:dd:ee:99", deviceMac("u1"));
|
||||
assertEquals("AA-BB-CC-DD-EE-01", deviceMac("d1"));
|
||||
assertEquals("aabbccddee01", deviceMac("d2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通讯录两列各自判重:owner 撞车不动 owner,target 照刷")
|
||||
void addressBookColumnsAreGuardedIndependently() throws SQLException {
|
||||
// owner 有两种写法会撞,这两行的 owner 不动
|
||||
insertBook("row1", "AA-BB-CC-DD-EE-01", "11-22-33-44-55-66");
|
||||
insertBook("row2", "aabbccddee01", "11:22:33:44:55:77");
|
||||
// 这两行的 owner 各不相同,但 target 是同一台的两种写法:
|
||||
// owner 照刷,target 整组不动
|
||||
insertBook("row3", "22-22-33-44-55-88", "AA-BB-CC-DD-EE-02");
|
||||
insertBook("row4", "33-33-44-55-88-99", "aabbccddee02");
|
||||
|
||||
runMigration();
|
||||
|
||||
assertEquals("AA-BB-CC-DD-EE-01", bookMac("row1"), "owner 撞车的整组不动");
|
||||
assertEquals("aabbccddee01", bookMac("row2"), "owner 撞车的整组不动");
|
||||
assertEquals("11:22:33:44:55:66", bookTarget("row1"), "target 唯一就该刷");
|
||||
assertEquals("11:22:33:44:55:77", bookTarget("row2"), "target 唯一就该刷");
|
||||
|
||||
assertEquals("22:22:33:44:55:88", bookMac("row3"), "owner 唯一就该刷");
|
||||
assertEquals("AA-BB-CC-DD-EE-02", bookTarget("row3"), "target 撞车的整组不动");
|
||||
assertEquals("33:33:44:55:88:99", bookMac("row4"));
|
||||
assertEquals("aabbccddee02", bookTarget("row4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("判重条件没了,通讯录会撞主键把整段迁移卡住")
|
||||
void withoutTheDuplicateGuardTheAddressBookWouldCollideOnItsPrimaryKey() throws SQLException {
|
||||
// 主键是 (owner, target),两行得撞在这一对上:owner 和 target 都归一成同一个值
|
||||
insertBook("row1", "AA-BB-CC-DD-EE-01", "11-22-33-44-55-66");
|
||||
insertBook("row2", "aabbccddee01", "112233445566");
|
||||
|
||||
// 从真脚本上把判重摘掉再跑。脚本里已经没判重的话替换就是空操作,
|
||||
// 跑的还是没有保护的版本,这个用例照样成立
|
||||
String unguarded = readChangelog("202610231200.sql")
|
||||
.replace("\nJOIN cnt ON cnt.`nmac` = norm.`nmac`\n", "\n")
|
||||
.replace("\nJOIN cnt ON cnt.`ntmac` = norm.`ntmac`\n", "\n")
|
||||
.replace("WHERE cnt.`n` = 1", "WHERE 1 = 1");
|
||||
|
||||
SQLException failure = assertThrows(SQLException.class, () -> run(unguarded));
|
||||
|
||||
assertEquals("23000", failure.getSQLState(),
|
||||
"撞的是主键唯一性,没撞上说明这组数据造得不对:" + failure.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("排查重复的那条 SQL 报出被跳过的组")
|
||||
void duplicateDiagnosticListsTheSkippedGroup() throws SQLException {
|
||||
insertDevice("d1", "AA-BB-CC-DD-EE-01");
|
||||
insertDevice("d2", "aabbccddee01");
|
||||
insertDevice("x1", "11-22-33-44-55-66");
|
||||
runMigration();
|
||||
|
||||
assertEquals(List.of("aa:bb:cc:dd:ee:01"),
|
||||
queryColumn(diagnostic("③ 刷完之后跑这个"), 0),
|
||||
"只该报出被判重跳过的那个组,刷完了的行不该出现在名单里");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("漏刷检查报出还留在原写法的行")
|
||||
void leftoverCheckFindsRowsStillInOriginalSpelling() throws SQLException {
|
||||
insertDevice("d1", "AA-BB-CC-DD-EE-01");
|
||||
insertDevice("d2", "aabbccddee01");
|
||||
insertDevice("x1", "11-22-33-44-55-66");
|
||||
insertDevice("u1", "AA:BB:CC:DD:EE:99");
|
||||
runMigration();
|
||||
|
||||
assertEquals(List.of("AA-BB-CC-DD-EE-01", "aabbccddee01"),
|
||||
queryColumn(diagnostic("漏刷检查"), 1),
|
||||
"刷完只剩判重跳过的那两行是原写法,x1、u1 都该刷掉");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("漏刷检查能报出该刷没刷出来的行")
|
||||
void leftoverCheckFindsRowsTheMigrationNeverTouched() throws SQLException {
|
||||
insertDevice("d1", "AA-BB-CC-DD-EE-01");
|
||||
insertDevice("d2", "aabbccddee01");
|
||||
insertDevice("x1", "11-22-33-44-55-66");
|
||||
|
||||
assertEquals(List.of("d1", "d2", "x1"),
|
||||
queryColumn(diagnostic("漏刷检查"), 0),
|
||||
"整段迁移一行没动时漏刷检查要全报出来,不然迁移只做了一半没人知道");
|
||||
}
|
||||
|
||||
/** 按 Liquibase 的 sqlFile 那样读脚本:去注释,按分号切,在一个事务里逐句执行 */
|
||||
private void runMigration() throws SQLException {
|
||||
run(readChangelog("202610231200.sql"));
|
||||
}
|
||||
|
||||
private void run(String script) throws SQLException {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
for (String statement : splitStatements(script)) {
|
||||
execute(statement);
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
connection.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertDevice(String id, String mac) throws SQLException {
|
||||
execute("INSERT INTO `%s`.`ai_device` (`id`, `mac_address`) VALUES ('%s', '%s')"
|
||||
.formatted(SCHEMA, id, mac));
|
||||
}
|
||||
|
||||
/** alias 放行标识,mac_address 刷完就变了,靠它才找得到原来那一行 */
|
||||
private void insertBook(String rowTag, String mac, String target) throws SQLException {
|
||||
execute(("INSERT INTO `%s`.`ai_device_address_book` (`mac_address`, `target_mac`, `alias`)"
|
||||
+ " VALUES ('%s', '%s', '%s')").formatted(SCHEMA, mac, target, rowTag));
|
||||
}
|
||||
|
||||
private String deviceMac(String id) throws SQLException {
|
||||
return queryOne("SELECT `mac_address` FROM `%s`.`ai_device` WHERE `id` = '%s'".formatted(SCHEMA, id));
|
||||
}
|
||||
|
||||
private String bookMac(String rowTag) throws SQLException {
|
||||
return queryOne("SELECT `mac_address` FROM `%s`.`ai_device_address_book` WHERE `alias` = '%s'"
|
||||
.formatted(SCHEMA, rowTag));
|
||||
}
|
||||
|
||||
private String bookTarget(String rowTag) throws SQLException {
|
||||
return queryOne("SELECT `target_mac` FROM `%s`.`ai_device_address_book` WHERE `alias` = '%s'"
|
||||
.formatted(SCHEMA, rowTag));
|
||||
}
|
||||
|
||||
private String queryOne(String sql) throws SQLException {
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet rows = statement.executeQuery(sql)) {
|
||||
assertTrue(rows.next(), "查不到行:" + sql);
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void execute(String sql) throws SQLException {
|
||||
for (String statement : splitStatements(sql)) {
|
||||
try (Statement s = connection.createStatement()) {
|
||||
s.execute(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 去注释后按分号切。脚本里除了分号没有别的分句符 */
|
||||
private static List<String> splitStatements(String sql) {
|
||||
StringBuilder body = new StringBuilder();
|
||||
for (String line : sql.split("\n")) {
|
||||
if (!line.stripLeading().startsWith("--")) {
|
||||
body.append(line).append('\n');
|
||||
}
|
||||
}
|
||||
List<String> statements = new ArrayList<>();
|
||||
for (String piece : body.toString().split(";")) {
|
||||
if (!piece.isBlank()) {
|
||||
statements.add(piece.strip());
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static String readChangelog(String fileName) {
|
||||
try {
|
||||
return Files.readString(
|
||||
Path.of("src", "main", "resources", "db", "changelog", fileName),
|
||||
StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("读不到 " + fileName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把脚本里注释掉的那段排查 SQL 整条取出来
|
||||
*
|
||||
* <p>
|
||||
* 脚本里那一段是给人手工跑的,跑不走就等于没写。取出来在真库上执行一遍,
|
||||
* 段里的 SQL 坏了、引用了不存在的列,这里会直接报错
|
||||
*/
|
||||
private static String diagnostic(String marker) {
|
||||
List<String> collected = new ArrayList<>();
|
||||
boolean inBlock = false;
|
||||
for (String line : readChangelog("202610231200.sql").split("\n")) {
|
||||
if (!line.stripLeading().startsWith("--")) {
|
||||
if (!collected.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!inBlock) {
|
||||
inBlock = line.contains(marker);
|
||||
if (!inBlock) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
String body = line.stripLeading().substring(2).strip();
|
||||
if (collected.isEmpty() && !body.startsWith("SELECT")) {
|
||||
// 说明文字,去掉行首的 -- 之后就不是注释了,不能拼进语句里
|
||||
continue;
|
||||
}
|
||||
collected.add(body);
|
||||
if (body.endsWith(";")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertFalse(collected.isEmpty(), "脚本里找不到 " + marker + " 那段排查 SQL");
|
||||
return String.join("\n", collected);
|
||||
}
|
||||
|
||||
private List<String> queryColumn(String sql, int column) throws SQLException {
|
||||
List<String> values = new ArrayList<>();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet rows = statement.executeQuery(sql)) {
|
||||
while (rows.next()) {
|
||||
values.add(rows.getString(column + 1));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,40 @@ server:
|
||||
# 是否启用认证
|
||||
enabled: false
|
||||
# 白名单设备ID列表
|
||||
# 如果属于白名单内的设备,不校验token,直接放行
|
||||
allowed_devices:
|
||||
- "11:22:33:44:55:66"
|
||||
# 命中白名单只延长该设备 token 的有效期,不再跳过 token 校验
|
||||
# 格式为设备上报的 Device-Id(MAC),例如 "aa:bb:cc:dd:ee:ff"
|
||||
# 不要把 Client-Id 填进来:它是 UUID v4,擦 FLASH 或重装后会变化,
|
||||
# 当长期标识会导致设备直接被锁在门外
|
||||
allowed_devices: []
|
||||
# 白名单设备的 token 有效期(秒)。留空表示与 expire_seconds 一致,
|
||||
# 不因进白名单就放宽;确有需要时可设置,硬上限 90 天,超出会被截断
|
||||
allowed_devices_expire_seconds: null
|
||||
# 白名单设备首次接入免凭证换 token,每台设备只放行一次。
|
||||
# 升级前靠白名单免 token 的存量设备,靠它换到凭证,否则升级即掉线。
|
||||
# 白名单是运维显式列出的可信 MAC,这里不新增任何信任来源。
|
||||
# 若配了 activation_codes 或不想要这条自举路径,设成 false
|
||||
allowed_devices_bootstrap: true
|
||||
# 一次性激活码,供没有 token 的设备首次接入时换取 token,用掉即失效
|
||||
# 新设备接入流程:在此列表中预先加入一个码,设备带着该码请求 OTA 接口,
|
||||
# 服务端签发 token 后自动从列表移除该码
|
||||
# 写成 "码: Device-Id" 表示该码只能给这台设备用,例如 ["a1b2c3: aa:bb:cc:dd:ee:ff"];
|
||||
# 裸字符串表示不限设备。码本身不能带冒号
|
||||
activation_codes: []
|
||||
# token 吊销线(Unix 秒):签发时刻早于它的 token 一律判为无效。
|
||||
# 这是作废旧凭证的开关,不是"让设备换发新凭证"的开关——改大之后,
|
||||
# 存量设备带旧 token 请求 OTA 会被拒,没有激活码就换不到新 token,
|
||||
# 全网设备会一起下线。作废前请先确认 activation_codes 里有可用的码
|
||||
issued_after: 0
|
||||
# CORS 允许的来源列表,默认空表示不做跨域放行
|
||||
# 需要跨域时填具体来源,例如 ["https://your-console.example.com"]
|
||||
# 注意:填 "*" 不会放行任意来源。来源不在名单里就不下发跨域头,
|
||||
# 浏览器会直接拦下,带凭证的请求仍受同源保护
|
||||
# 仓库自带的测试页(main/digital-human)用 HTTP 服务打开时来源是
|
||||
# http://127.0.0.1:8006 或 http://localhost:8006——这是两个不同来源,
|
||||
# 按哪种方式打开就得把哪个填进来,只填一个照样连不上。
|
||||
# 用 file:// 直接双击打开的,页面会自己弹窗挡住,配置这一项没意义
|
||||
cors:
|
||||
allowed_origins: []
|
||||
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
||||
mqtt_gateway: null
|
||||
# MQTT签名密钥,用于生成MQTT连接密码,根据mqtt_gateway的.env文件配置
|
||||
|
||||
@@ -40,7 +40,7 @@ async def load_config():
|
||||
custom_config = read_config(custom_config_path)
|
||||
|
||||
if custom_config.get("manager-api", {}).get("url"):
|
||||
config = await get_config_from_api_async(custom_config)
|
||||
config = await get_config_from_api_async()
|
||||
else:
|
||||
# 合并配置
|
||||
config = merge_configs(default_config, custom_config)
|
||||
@@ -52,10 +52,16 @@ async def load_config():
|
||||
return config
|
||||
|
||||
|
||||
async def get_config_from_api_async(config):
|
||||
"""从Java API获取配置(异步版本)"""
|
||||
async def get_config_from_api_async():
|
||||
"""从Java API获取配置(异步版本),本地配置逐键覆盖上去
|
||||
|
||||
本地配置在这里自己读,不接受调用方传进来:调用方手里只有上一次合并
|
||||
完的结果,拿它当"本地"会把旧值盖回这次新拉到的响应,改配置不生效。
|
||||
"""
|
||||
local_config = read_config(get_project_dir() + "data/.config.yaml") or {}
|
||||
|
||||
# 初始化API客户端
|
||||
init_service(config)
|
||||
init_service(local_config)
|
||||
|
||||
# 获取服务器配置
|
||||
config_data = await get_server_config()
|
||||
@@ -64,23 +70,36 @@ async def get_config_from_api_async(config):
|
||||
|
||||
config_data["read_config_from_api"] = True
|
||||
config_data["manager-api"] = {
|
||||
"url": config["manager-api"].get("url", ""),
|
||||
"secret": config["manager-api"].get("secret", ""),
|
||||
"url": local_config.get("manager-api", {}).get("url", ""),
|
||||
"secret": local_config.get("manager-api", {}).get("secret", ""),
|
||||
}
|
||||
auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False)
|
||||
# server的配置以本地为准
|
||||
if config.get("server"):
|
||||
config_data["server"] = {
|
||||
"ip": config["server"].get("ip", ""),
|
||||
"port": config["server"].get("port", ""),
|
||||
"http_port": config["server"].get("http_port", ""),
|
||||
"vision_explain": config["server"].get("vision_explain", ""),
|
||||
"auth_key": config["server"].get("auth_key", ""),
|
||||
}
|
||||
config_data["server"]["auth"] = {"enabled": auth_enabled}
|
||||
server_config = config_data.setdefault("server", {})
|
||||
# 认证开关以智控台的参数管理为准,这里只补默认值。
|
||||
# 不能重建整个 auth,否则接口返回的 allowed_devices、activation_codes
|
||||
# 这些字段会被抹掉,智控台部署下等于配置写了不生效
|
||||
server_auth = server_config.setdefault("auth", {})
|
||||
server_auth.setdefault("enabled", False)
|
||||
# 智控台只下发 enabled 这一个键,本地配的白名单、激活码、吊销线、有效期
|
||||
# 得逐键并进去。整段跳过的话它们在官方全模块部署里一个都用不了,
|
||||
# 而本地这边没有任何提示,配了等于没配。
|
||||
# 智控台哪天开始下发这些键,以智控台为准
|
||||
for key, value in ((local_config.get("server") or {}).get("auth") or {}).items():
|
||||
if key != "enabled":
|
||||
server_auth.setdefault(key, value)
|
||||
|
||||
# server 的其余配置以本地为准,但只覆盖本地显式写过的字段。
|
||||
# 整块替换会把接口返回的 cors、websocket 等一并吞掉。
|
||||
# auth 例外,它归智控台管,在上面单独合并
|
||||
# 注意 auth_key 在 server 下、不在 server.auth 里,它归这一段管;
|
||||
# 而 config.yaml 里根本没有 auth_key 这一行,默认部署下压根不参与合并,
|
||||
# 靠 app.py 回落到 manager-api.secret。要改它得显式写进 data/.config.yaml
|
||||
for key, value in (local_config.get("server") or {}).items():
|
||||
if key != "auth":
|
||||
server_config[key] = value
|
||||
|
||||
# 如果服务器没有prompt_template,则从本地配置读取
|
||||
if not config_data.get("prompt_template"):
|
||||
config_data["prompt_template"] = config.get("prompt_template")
|
||||
config_data["prompt_template"] = local_config.get("prompt_template")
|
||||
return config_data
|
||||
|
||||
|
||||
|
||||
@@ -2,23 +2,67 @@ from aiohttp import web
|
||||
from config.logger import setup_logging
|
||||
|
||||
|
||||
def allowed_origins(config: dict) -> list:
|
||||
cors = config.get("server", {}).get("cors", {}) or {}
|
||||
return cors.get("allowed_origins", []) or []
|
||||
|
||||
|
||||
class BaseHandler:
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.logger = setup_logging()
|
||||
self._cors_wildcard_warned = False
|
||||
self._cors_empty_warned = False
|
||||
|
||||
def _add_cors_headers(self, response):
|
||||
def _resolve_cors_origin(self, origin: str | None) -> str | None:
|
||||
"""回显名单内的 Origin;其余情况不下发 CORS 头,由浏览器拦截
|
||||
|
||||
这里刻意不支持通配:回显任意来源并带上 Allow-Credentials 会让任意网站
|
||||
都能带凭证读响应,而固定写 "*" 配 Allow-Credentials 属于浏览器明确
|
||||
拒绝的组合,反而挡得更严。宁可什么都不放,也不放宽。
|
||||
"""
|
||||
if not origin:
|
||||
return None
|
||||
cors = self.config.get("server", {}).get("cors", {}) or {}
|
||||
allowed = cors.get("allowed_origins", []) or []
|
||||
if "*" in allowed and not self._cors_wildcard_warned:
|
||||
self._cors_wildcard_warned = True
|
||||
self.logger.bind(tag="BaseHandler").warning(
|
||||
"cors.allowed_origins 里的 * 不会放行任意来源,请填写具体来源"
|
||||
)
|
||||
return origin if origin in allowed else None
|
||||
|
||||
def _add_cors_headers(self, response, request=None):
|
||||
"""添加CORS头信息"""
|
||||
response.headers["Access-Control-Allow-Headers"] = (
|
||||
"client-id, content-type, device-id, authorization"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
requested_origin = (
|
||||
request.headers.get("Origin") if request is not None else None
|
||||
)
|
||||
origin = self._resolve_cors_origin(requested_origin)
|
||||
if requested_origin:
|
||||
# 只要请求带了 Origin 就标 Vary,哪怕这次没放行,
|
||||
# 否则共享缓存会把带放行头的响应喂给别的来源
|
||||
response.headers["Vary"] = "Origin"
|
||||
if origin:
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
elif requested_origin and not allowed_origins(self.config):
|
||||
# 名单空着的时候浏览器只会报一个不透明的跨域错误,
|
||||
# 服务端这边一点信号都没有,读日志的人会一头雾水
|
||||
if not self._cors_empty_warned:
|
||||
self._cors_empty_warned = True
|
||||
self.logger.bind(tag="BaseHandler").warning(
|
||||
f"cors.allowed_origins 是空的,本次来自 {requested_origin} 的"
|
||||
"跨域请求不会放行。浏览器侧只会报读不到状态码的错,"
|
||||
"排查这类问题时先把这个来源加进名单"
|
||||
)
|
||||
|
||||
async def handle_options(self, request):
|
||||
"""处理OPTIONS请求,添加CORS头信息"""
|
||||
response = web.Response(body=b"", content_type="text/plain")
|
||||
self._add_cors_headers(response)
|
||||
self._add_cors_headers(response, request)
|
||||
# 添加允许的方法
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
return response
|
||||
@@ -9,12 +9,47 @@ import glob
|
||||
from typing import Dict, List, Tuple
|
||||
from aiohttp import web
|
||||
|
||||
from core.auth import AuthManager
|
||||
from core.auth import AuthManager, auth_config_list, normalize_device_id
|
||||
from core.utils.util import get_local_ip, get_vision_url
|
||||
from core.api.base_handler import BaseHandler
|
||||
|
||||
TAG = __name__
|
||||
|
||||
# Device-Id 校验规则,与智控台 OTAController 使用同一套:
|
||||
# 冒号 / 连字符 / 下划线分隔的 6 段 hex,或无分隔符的 12 位 hex
|
||||
# 用 fullmatch 而不是自带锚点的正则:$ 会放过结尾的换行,两端接受范围就不一致了
|
||||
DEVICE_ID_PATTERN = re.compile(
|
||||
r"([0-9A-Fa-f]{2}[:\-_]){5}[0-9A-Fa-f]{2}|[0-9A-Fa-f]{12}"
|
||||
)
|
||||
|
||||
# Device-Model 会拼进 mqtt_client_id 进而参与密码签名,限制成不含
|
||||
# 分隔符与通配符的字符,避免拼出畸形凭证
|
||||
DEVICE_MODEL_PATTERN = re.compile(r"[A-Za-z0-9_\-]+")
|
||||
|
||||
|
||||
def _error_response(status: int, message: str) -> web.Response:
|
||||
"""OTA 拒绝响应,状态码与智控台 /ota/ 保持一致,固件按状态码判断才不会走偏"""
|
||||
return web.Response(
|
||||
text=json.dumps({"success": False, "message": message}, separators=(",", ":")),
|
||||
content_type="application/json",
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def _parse_activation_codes(raw_codes) -> Dict[str, str | None]:
|
||||
"""解析 activation_codes 成 码 -> 绑定的 Device-Id,不绑定时值为 None
|
||||
|
||||
支持两种写法:裸字符串表示不限设备,"码: Device-Id" 表示只能用这台设备换。
|
||||
码本身不能带冒号,冒号只作分隔用。
|
||||
"""
|
||||
codes: Dict[str, str | None] = {}
|
||||
for raw in raw_codes:
|
||||
code, _, bound_device = str(raw).partition(":")
|
||||
code = code.strip()
|
||||
if code:
|
||||
codes[code] = bound_device.strip() or None
|
||||
return codes
|
||||
|
||||
|
||||
def _safe_basename(filename: str) -> str:
|
||||
# Prevent directory traversal
|
||||
@@ -46,13 +81,7 @@ def _is_higher_version(a: str, b: str) -> bool:
|
||||
class OTAHandler(BaseHandler):
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
auth_config = config["server"].get("auth", {})
|
||||
self.auth_enable = auth_config.get("enabled", False)
|
||||
# 设备白名单
|
||||
self.allowed_devices = set(auth_config.get("allowed_devices", []))
|
||||
secret_key = config["server"]["auth_key"]
|
||||
expire_seconds = auth_config.get("expire_seconds")
|
||||
self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds)
|
||||
self._load_auth_config(config)
|
||||
|
||||
# firmware storage
|
||||
self.bin_dir = os.path.join(os.getcwd(), "data", "bin")
|
||||
@@ -63,6 +92,121 @@ class OTAHandler(BaseHandler):
|
||||
"files_by_model": {},
|
||||
}
|
||||
|
||||
def _load_auth_config(self, config: dict):
|
||||
"""从配置加载认证相关属性,改配置后可重复调用"""
|
||||
auth_config = config["server"].get("auth", {})
|
||||
self.auth_enable = auth_config.get("enabled", False)
|
||||
# 设备白名单:命中白名单只影响 token 有效期,不再跳过 token 校验
|
||||
# 加载时就归一成去掉分隔符的小写,和激活码绑定、OTA 请求头用同一套写法,
|
||||
# 否则同一台设备换个分隔符或大小写就既不认白名单也不认激活码,直接锁死
|
||||
self.allowed_devices = {
|
||||
normalize_device_id(item)
|
||||
for item in auth_config_list(auth_config, "allowed_devices")
|
||||
}
|
||||
# 白名单设备的 token 有效期,默认与普通设备一致
|
||||
self.allowed_expire_seconds = auth_config.get(
|
||||
"allowed_devices_expire_seconds", None
|
||||
)
|
||||
# 白名单设备首次接入免凭证自举一次,否则升级后没有任何换凭证的入口
|
||||
self.allowed_bootstrap = auth_config.get("allowed_devices_bootstrap", True)
|
||||
self.allowed_bootstrapped = set()
|
||||
# 一次性激活码,供无 token 的设备首次绑定;写 "码: Device-Id" 可绑到具体设备
|
||||
self.activation_codes = _parse_activation_codes(
|
||||
auth_config_list(auth_config, "activation_codes")
|
||||
)
|
||||
secret_key = config["server"]["auth_key"]
|
||||
expire_seconds = auth_config.get("expire_seconds")
|
||||
issued_after = auth_config.get("issued_after", 0)
|
||||
self.auth = AuthManager(
|
||||
secret_key=secret_key,
|
||||
expire_seconds=expire_seconds,
|
||||
issued_after=issued_after,
|
||||
)
|
||||
self._warn_if_locked_out()
|
||||
|
||||
def _warn_if_locked_out(self):
|
||||
"""存量白名单设备升级后若没有换凭证的入口会直接掉线,启动时说清楚"""
|
||||
if not self.auth_enable:
|
||||
return
|
||||
if not self.allowed_devices:
|
||||
return
|
||||
if self.allowed_bootstrap or self.activation_codes:
|
||||
return
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
"配置了 allowed_devices 但既没开 allowed_devices_bootstrap 也没有 "
|
||||
"activation_codes:这些白名单设备升级后没有凭证来源,会连不上。"
|
||||
"请在 config.yaml 的 server.auth 里加一个 activation_codes,"
|
||||
"或把 allowed_devices_bootstrap 设回 true"
|
||||
)
|
||||
|
||||
def _expire_for(self, device_id: str):
|
||||
"""白名单设备的 token 有效期,其余设备沿用默认"""
|
||||
if self.allowed_devices and normalize_device_id(device_id) in self.allowed_devices:
|
||||
return self.allowed_expire_seconds
|
||||
return None
|
||||
|
||||
def _issue_ws_token(self, request, client_id: str, device_id: str) -> Tuple:
|
||||
"""校验 OTA 凭证并签发 websocket token。
|
||||
|
||||
返回 (token, 待作废的激活码),无有效凭证时返回 ("", "")。
|
||||
激活码不在这里作废,交给调用方在响应组装成功后回收,中途失败不烧凭证。
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
presented = auth_header[7:] if auth_header.startswith("Bearer ") else ""
|
||||
if not presented:
|
||||
return self._bootstrap_whitelisted(device_id, client_id), ""
|
||||
|
||||
if self.auth.verify_token(
|
||||
presented,
|
||||
client_id=client_id,
|
||||
username=device_id,
|
||||
expire_seconds=self._expire_for(device_id),
|
||||
):
|
||||
return self.auth.generate_token(client_id, device_id), ""
|
||||
|
||||
if self._matches_activation_code(presented, device_id):
|
||||
return self.auth.generate_token(client_id, device_id), presented
|
||||
|
||||
return "", ""
|
||||
|
||||
def _bootstrap_whitelisted(self, device_id: str, client_id: str) -> str:
|
||||
"""白名单设备首次接入免凭证换 token,每台设备只放行一次
|
||||
|
||||
白名单是运维在配置里显式列出的可信 MAC,不接受任何上报值,
|
||||
也不新增信任来源;这里只是让升级前免 token 的存量设备换到凭证,
|
||||
否则白名单去掉旁路后它们升级即掉线。之后必须带 token。
|
||||
"""
|
||||
if not (
|
||||
self.allowed_bootstrap
|
||||
and self.allowed_devices
|
||||
and normalize_device_id(device_id) in self.allowed_devices
|
||||
and normalize_device_id(device_id) not in self.allowed_bootstrapped
|
||||
):
|
||||
return ""
|
||||
self.allowed_bootstrapped.add(normalize_device_id(device_id))
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
f"白名单设备免凭证自举签发 token: {device_id},此后该设备必须携带凭证"
|
||||
)
|
||||
return self.auth.generate_token(client_id, device_id)
|
||||
|
||||
def _matches_activation_code(self, presented: str, device_id: str) -> bool:
|
||||
"""激活码若绑定了设备,必须与上报的 Device-Id 一致"""
|
||||
if presented not in self.activation_codes:
|
||||
return False
|
||||
bound_device = self.activation_codes[presented]
|
||||
if not bound_device:
|
||||
return True
|
||||
return normalize_device_id(bound_device) == normalize_device_id(device_id)
|
||||
|
||||
def _consume_activation_code(self, presented: str) -> None:
|
||||
"""作废一次性激活码,只在响应组装成功后调用"""
|
||||
self.activation_codes.pop(presented, None)
|
||||
self.logger.bind(tag=TAG).info("激活码已使用并作废,设备完成首次绑定")
|
||||
|
||||
def _is_valid_device_id(self, device_id: str) -> bool:
|
||||
"""Device-Id 会被拼进 MQTT topic,格式校验与智控台用同一套规则"""
|
||||
return bool(DEVICE_ID_PATTERN.fullmatch(device_id))
|
||||
|
||||
def _refresh_bin_cache_if_needed(self):
|
||||
now = int(time.time())
|
||||
ttl = int(self._bin_cache.get("ttl", 30))
|
||||
@@ -152,7 +296,12 @@ class OTAHandler(BaseHandler):
|
||||
try:
|
||||
data = await request.text()
|
||||
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
|
||||
self.logger.bind(tag=TAG).debug(f"OTA请求头: {request.headers}")
|
||||
# OTA 请求开始携带 token 和一次性激活码,凭证不进日志
|
||||
safe_headers = {
|
||||
k: ("***" if k.lower() == "authorization" else v)
|
||||
for k, v in request.headers.items()
|
||||
}
|
||||
self.logger.bind(tag=TAG).debug(f"OTA请求头: {safe_headers}")
|
||||
self.logger.bind(tag=TAG).debug(f"OTA请求数据: {data}")
|
||||
|
||||
device_id = request.headers.get("device-id", "")
|
||||
@@ -167,6 +316,20 @@ class OTAHandler(BaseHandler):
|
||||
else:
|
||||
raise Exception("OTA请求ClientID为空")
|
||||
|
||||
# 开启认证时,MQTT 与 WebSocket 两条下发路径都必须先通过凭证校验
|
||||
ws_token = ""
|
||||
activation_code = ""
|
||||
if self.auth_enable:
|
||||
ws_token, activation_code = self._issue_ws_token(
|
||||
request, client_id, device_id
|
||||
)
|
||||
if not ws_token:
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
f"OTA请求凭证无效: device_id={device_id}"
|
||||
)
|
||||
response = _error_response(401, "invalid credential")
|
||||
return response
|
||||
|
||||
data_json = {}
|
||||
try:
|
||||
data_json = json.loads(data) if data else {}
|
||||
@@ -235,12 +398,22 @@ class OTAHandler(BaseHandler):
|
||||
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
|
||||
|
||||
if mqtt_gateway_endpoint: # 如果配置了非空字符串
|
||||
# 尝试从请求数据中获取设备型号(已解析 above)
|
||||
try:
|
||||
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"获取设备型号失败: {e}")
|
||||
group_id = "GID_default"
|
||||
# 下面两个值都会拼进 MQTT 凭证,只在真下发 MQTT 时才校验,
|
||||
# 免得不开 MQTT 的部署被无端拦下
|
||||
if not self._is_valid_device_id(device_id):
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
f"OTA请求设备ID格式非法: {device_id}"
|
||||
)
|
||||
response = _error_response(400, "invalid device-id")
|
||||
return response
|
||||
if not DEVICE_MODEL_PATTERN.fullmatch(device_model):
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
f"OTA请求设备型号格式非法: {device_model}"
|
||||
)
|
||||
response = _error_response(400, "invalid device-model")
|
||||
return response
|
||||
|
||||
group_id = f"GID_{device_model}"
|
||||
|
||||
mac_address_safe = device_id.replace(":", "_")
|
||||
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
|
||||
@@ -269,6 +442,8 @@ class OTAHandler(BaseHandler):
|
||||
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
|
||||
|
||||
# 构建MQTT配置(直接使用 mqtt_gateway 字符串)
|
||||
# token 一并下发:开认证时固件下载要带凭证,MQTT 设备自己不会再
|
||||
# 走一遍 OTA,不在这里给就永远换不到凭证
|
||||
return_json["mqtt"] = {
|
||||
"endpoint": mqtt_gateway_endpoint,
|
||||
"client_id": mqtt_client_id,
|
||||
@@ -276,18 +451,13 @@ class OTAHandler(BaseHandler):
|
||||
"password": password,
|
||||
"publish_topic": "device-server",
|
||||
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||
"token": ws_token,
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置")
|
||||
|
||||
else: # 未配置 mqtt_gateway,下发 WebSocket
|
||||
# 如果开启了认证,则进行认证校验
|
||||
token = ""
|
||||
if self.auth_enable:
|
||||
if self.allowed_devices:
|
||||
if device_id not in self.allowed_devices:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
else:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
# token 已在入口处完成凭证校验,认证关闭时保持不下发
|
||||
token = ws_token
|
||||
# NOTE: use websocket_port here
|
||||
return_json["websocket"] = {
|
||||
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||
@@ -341,6 +511,9 @@ class OTAHandler(BaseHandler):
|
||||
text=json.dumps(return_json, separators=(",", ":")),
|
||||
content_type="application/json",
|
||||
)
|
||||
# 响应已经组装成功,这时才作废激活码,中途出错不烧凭证
|
||||
if activation_code:
|
||||
self._consume_activation_code(activation_code)
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"OTA POST处理异常: {e}")
|
||||
return_json = {"success": False, "message": "request error."}
|
||||
@@ -349,7 +522,7 @@ class OTAHandler(BaseHandler):
|
||||
content_type="application/json",
|
||||
)
|
||||
finally:
|
||||
self._add_cors_headers(response)
|
||||
self._add_cors_headers(response, request)
|
||||
return response
|
||||
|
||||
async def handle_get(self, request):
|
||||
@@ -366,7 +539,7 @@ class OTAHandler(BaseHandler):
|
||||
self.logger.bind(tag=TAG).error(f"OTA GET请求异常: {e}")
|
||||
response = web.Response(text="OTA接口异常", content_type="text/plain")
|
||||
finally:
|
||||
self._add_cors_headers(response)
|
||||
self._add_cors_headers(response, request)
|
||||
return response
|
||||
|
||||
async def handle_download(self, request):
|
||||
@@ -375,8 +548,33 @@ class OTAHandler(BaseHandler):
|
||||
URL: /xiaozhi/ota/download/{filename}
|
||||
- 只允许下载 data/bin 目录下的 .bin 文件
|
||||
- filename 必须是 basename 且匹配安全的模式
|
||||
- 开启认证时需携带与 OTA 请求一致的凭证
|
||||
"""
|
||||
try:
|
||||
if self.auth_enable:
|
||||
device_id = request.headers.get("Device-Id", "")
|
||||
client_id = request.headers.get("Client-Id", "")
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
token = (
|
||||
auth_header[7:] if auth_header.startswith("Bearer ") else ""
|
||||
)
|
||||
authorized = (
|
||||
device_id
|
||||
and client_id
|
||||
and token
|
||||
and self.auth.verify_token(
|
||||
token,
|
||||
client_id=client_id,
|
||||
username=device_id,
|
||||
expire_seconds=self._expire_for(device_id),
|
||||
)
|
||||
)
|
||||
if not authorized:
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
f"固件下载凭证无效: device_id={device_id}"
|
||||
)
|
||||
raise web.HTTPUnauthorized(text="unauthorized")
|
||||
|
||||
fname = request.match_info.get("filename", "")
|
||||
if not fname:
|
||||
raise web.HTTPBadRequest(text="filename required")
|
||||
@@ -409,7 +607,7 @@ class OTAHandler(BaseHandler):
|
||||
resp = web.Response(text="download error", status=500)
|
||||
finally:
|
||||
try:
|
||||
self._add_cors_headers(resp)
|
||||
self._add_cors_headers(resp, request)
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
@@ -29,14 +29,7 @@ class VisionHandler(BaseHandler):
|
||||
|
||||
def _verify_auth_token(self, request) -> Tuple[bool, Optional[str]]:
|
||||
"""验证认证token"""
|
||||
# 测试模式:允许特定测试令牌或跳过验证
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
client_id = request.headers.get("Client-Id", "")
|
||||
|
||||
# 允许测试客户端跳过认证
|
||||
if client_id == "web_test_client":
|
||||
device_id = request.headers.get("Device-Id", "test_device")
|
||||
return True, device_id
|
||||
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return False, None
|
||||
@@ -155,7 +148,7 @@ class VisionHandler(BaseHandler):
|
||||
)
|
||||
finally:
|
||||
if response:
|
||||
self._add_cors_headers(response)
|
||||
self._add_cors_headers(response, request)
|
||||
return response
|
||||
|
||||
async def handle_get(self, request):
|
||||
@@ -178,5 +171,5 @@ class VisionHandler(BaseHandler):
|
||||
content_type="application/json",
|
||||
)
|
||||
finally:
|
||||
self._add_cors_headers(response)
|
||||
self._add_cors_headers(response, request)
|
||||
return response
|
||||
@@ -1,8 +1,53 @@
|
||||
import hmac
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
DEFAULT_EXPIRE_SECONDS = 60 * 60 * 24 * 30
|
||||
# 有效期上限,防止把过期时间配成准永久凭证
|
||||
MAX_EXPIRE_SECONDS = 60 * 60 * 24 * 90
|
||||
|
||||
|
||||
def normalize_expire_seconds(expire_seconds) -> int:
|
||||
"""有效期归一化:非整数或非正数回落到默认值,超出上限则截断
|
||||
|
||||
白名单设备的有效期是单独传的,走这里和构造时保持同一套规则,
|
||||
否则配成 0 会让 token 一秒就失效,配成字符串则会被当成校验失败
|
||||
"""
|
||||
try:
|
||||
value = int(expire_seconds)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_EXPIRE_SECONDS
|
||||
if value <= 0:
|
||||
return DEFAULT_EXPIRE_SECONDS
|
||||
return min(value, MAX_EXPIRE_SECONDS)
|
||||
|
||||
|
||||
def normalize_issued_after(issued_after) -> int:
|
||||
"""吊销时刻归一化
|
||||
|
||||
非整数和负数直接报错——这两类是配错了,不会有人这么填。
|
||||
超前的时间戳只降级成警告并截到当前时间:这一格最容易踩的是跨主机时钟偏差,
|
||||
而吊销线超前本来就等于没设,不值得为它把整个进程拖到起不来。
|
||||
"""
|
||||
try:
|
||||
value = int(issued_after or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"server.auth.issued_after 必须是整数,当前值:{issued_after!r}")
|
||||
if value < 0:
|
||||
raise ValueError(f"server.auth.issued_after 不能为负数,当前值:{value}")
|
||||
now = int(time.time())
|
||||
if value > now:
|
||||
logger.warning(
|
||||
f"server.auth.issued_after 填的是未来的时间戳(当前值 {value},本机 {now}),"
|
||||
"已截到当前时间。吊销线超前等于没设,请检查两台机器的时钟是否对得上"
|
||||
)
|
||||
return now
|
||||
return value
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""认证异常"""
|
||||
@@ -10,6 +55,29 @@ class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_device_id(device_id: str) -> str:
|
||||
"""把 Device-Id 归一成去掉分隔符的小写
|
||||
|
||||
白名单、激活码绑定、OTA 请求头三处都要用同一个值比较,
|
||||
同一台设备换个分隔符或大小写就会被一边认一边不认。
|
||||
"""
|
||||
return re.sub(r"[:\-_]", "", device_id).lower()
|
||||
|
||||
|
||||
def auth_config_list(auth_config: dict, key: str) -> list:
|
||||
"""取 auth 段里的列表型配置,配成标量字符串时直接报错
|
||||
|
||||
不校验的话 set("aa:bb:cc:dd:ee:ff") 会逐字符拆成 7 个元素,
|
||||
激活码字符串会被拆成十个单字符码,配了等于没配还看不出问题。
|
||||
"""
|
||||
value = auth_config.get(key)
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"server.auth.{key} 必须是列表,当前值:{value!r}")
|
||||
return value
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""
|
||||
统一授权认证管理器
|
||||
@@ -19,12 +87,15 @@ class AuthManager:
|
||||
在 Websocket 中,header:{Device-ID: device_id, Client-ID: client_id, Authorization: Bearer token, ......}
|
||||
"""
|
||||
|
||||
def __init__(self, secret_key: str, expire_seconds: int = 60 * 60 * 24 * 30):
|
||||
if not expire_seconds or expire_seconds < 0:
|
||||
self.expire_seconds = 60 * 60 * 24 * 30
|
||||
else:
|
||||
self.expire_seconds = expire_seconds
|
||||
def __init__(
|
||||
self,
|
||||
secret_key: str,
|
||||
expire_seconds: int = DEFAULT_EXPIRE_SECONDS,
|
||||
issued_after: int = 0,
|
||||
):
|
||||
self.expire_seconds = normalize_expire_seconds(expire_seconds)
|
||||
self.secret_key = secret_key
|
||||
self.issued_after = normalize_issued_after(issued_after)
|
||||
|
||||
def _sign(self, content: str) -> str:
|
||||
"""HMAC-SHA256签名并Base64编码"""
|
||||
@@ -49,18 +120,32 @@ class AuthManager:
|
||||
token = f"{signature}.{ts}"
|
||||
return token
|
||||
|
||||
def verify_token(self, token: str, client_id: str, username: str) -> bool:
|
||||
def verify_token(
|
||||
self,
|
||||
token: str,
|
||||
client_id: str,
|
||||
username: str,
|
||||
expire_seconds: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
验证token有效性
|
||||
Args:
|
||||
token: 客户端传入的token
|
||||
client_id: 连接使用的client_id
|
||||
username: 连接使用的username
|
||||
expire_seconds: 本次校验采用的过期时长,不传时用实例默认值
|
||||
"""
|
||||
expire = (
|
||||
self.expire_seconds
|
||||
if expire_seconds is None
|
||||
else normalize_expire_seconds(expire_seconds)
|
||||
)
|
||||
try:
|
||||
sig_part, ts_str = token.split(".")
|
||||
ts = int(ts_str)
|
||||
if int(time.time()) - ts > self.expire_seconds:
|
||||
if ts < self.issued_after:
|
||||
return False # 签发时刻早于吊销线
|
||||
if int(time.time()) - ts > expire:
|
||||
return False # 过期
|
||||
|
||||
expected_sig = self._sign(f"{client_id}|{username}|{ts}")
|
||||
|
||||
@@ -214,8 +214,12 @@ class ConnectionHandler:
|
||||
self.client_ip = real_ip.split(",")[0].strip()
|
||||
else:
|
||||
self.client_ip = ws.remote_address[0]
|
||||
safe_headers = {
|
||||
k: ("***" if k.lower() == "authorization" else v)
|
||||
for k, v in self.headers.items()
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"{self.client_ip} conn - Headers: {self.headers}"
|
||||
f"{self.client_ip} conn - Headers: {safe_headers}"
|
||||
)
|
||||
|
||||
self.device_id = self.headers.get("device-id", None)
|
||||
|
||||
@@ -32,12 +32,40 @@ _setup_websockets_logger()
|
||||
|
||||
from core.connection import ConnectionHandler
|
||||
from config.config_loader import get_config_from_api_async
|
||||
from core.auth import AuthManager, AuthenticationError
|
||||
from core.auth import (
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
auth_config_list,
|
||||
normalize_device_id,
|
||||
)
|
||||
from core.utils.modules_initialize import initialize_modules
|
||||
from core.utils.util import check_vad_update, check_asr_update
|
||||
|
||||
TAG = __name__
|
||||
|
||||
# 浏览器不能自定义 websocket 请求头,约定把凭证和设备标识放进子协议头:
|
||||
# Sec-WebSocket-Protocol: xiaozhi, t-<token>, d-<deviceId>, c-<clientId>
|
||||
# 握手时只回显 xiaozhi,其余值由服务端自己解析。加前缀是为了让每个值
|
||||
# 都落在合法的子协议 token 字符集内(deviceId 的冒号就不合法)
|
||||
WS_SUBPROTOCOL = "xiaozhi"
|
||||
WS_SUBPROTOCOL_FIELDS = (("t-", "authorization"), ("d-", "device-id"), ("c-", "client-id"))
|
||||
|
||||
|
||||
def _parse_subprotocol_headers(headers: dict) -> dict:
|
||||
"""从子协议头里取出 token、device-id、client-id
|
||||
|
||||
取出来的值与请求头形态保持一致(authorization 带 Bearer 前缀),
|
||||
这样下游不用关心凭证到底是从哪来的。
|
||||
"""
|
||||
values = {}
|
||||
for item in headers.get("sec-websocket-protocol", "").split(","):
|
||||
item = item.strip()
|
||||
for prefix, key in WS_SUBPROTOCOL_FIELDS:
|
||||
if item.startswith(prefix) and len(item) > len(prefix):
|
||||
value = item[len(prefix) :]
|
||||
values[key] = f"Bearer {value}" if key == "authorization" else value
|
||||
return values
|
||||
|
||||
|
||||
class WebSocketServer:
|
||||
def __init__(self, config: dict):
|
||||
@@ -60,13 +88,31 @@ class WebSocketServer:
|
||||
self._intent = modules["intent"] if "intent" in modules else None
|
||||
self._memory = modules["memory"] if "memory" in modules else None
|
||||
|
||||
auth_config = self.config["server"].get("auth", {})
|
||||
self._load_auth_config(self.config)
|
||||
|
||||
def _load_auth_config(self, config: dict):
|
||||
"""从配置加载认证相关属性,改配置后可重复调用"""
|
||||
auth_config = config["server"].get("auth", {})
|
||||
self.auth_enable = auth_config.get("enabled", False)
|
||||
# 设备白名单
|
||||
self.allowed_devices = set(auth_config.get("allowed_devices", []))
|
||||
secret_key = self.config["server"]["auth_key"]
|
||||
# 设备白名单:命中白名单只影响 token 的校验时长,不再跳过校验本身
|
||||
# 和 OTA 侧同一套写法:加载时归一成去分隔符的小写,
|
||||
# 否则设备换个分隔符或大小写上报,同一台设备一边认白名单一边不认
|
||||
self.allowed_devices = {
|
||||
normalize_device_id(device_id)
|
||||
for device_id in auth_config_list(auth_config, "allowed_devices")
|
||||
}
|
||||
# 白名单设备的 token 有效期,默认与普通设备一致,不因白名单放宽
|
||||
self.allowed_expire_seconds = auth_config.get(
|
||||
"allowed_devices_expire_seconds", None
|
||||
)
|
||||
secret_key = config["server"]["auth_key"]
|
||||
expire_seconds = auth_config.get("expire_seconds", None)
|
||||
self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds)
|
||||
issued_after = auth_config.get("issued_after", 0)
|
||||
self.auth = AuthManager(
|
||||
secret_key=secret_key,
|
||||
expire_seconds=expire_seconds,
|
||||
issued_after=issued_after,
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
server_config = self.config["server"]
|
||||
@@ -78,36 +124,36 @@ class WebSocketServer:
|
||||
host,
|
||||
port,
|
||||
process_request=self._http_response,
|
||||
select_subprotocol=self._select_subprotocol,
|
||||
ping_interval=None,
|
||||
):
|
||||
await asyncio.Future()
|
||||
|
||||
async def _handle_connection(self, websocket: websockets.ServerConnection):
|
||||
headers = dict(websocket.request.headers)
|
||||
if headers.get("device-id", None) is None:
|
||||
# 尝试从 URL 的查询参数中获取 device-id
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
def _select_subprotocol(self, connection, subprotocols):
|
||||
"""客户端按约定发了子协议就回显 xiaozhi,否则不选,回显会被浏览器拒掉"""
|
||||
return WS_SUBPROTOCOL if WS_SUBPROTOCOL in subprotocols else None
|
||||
|
||||
# 从 WebSocket 请求中获取路径
|
||||
request_path = websocket.request.path
|
||||
if not request_path:
|
||||
self.logger.bind(tag=TAG).error("无法获取请求路径")
|
||||
await websocket.close()
|
||||
return
|
||||
parsed_url = urlparse(request_path)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
if "device-id" not in query_params:
|
||||
await websocket.send("端口正常,如需测试连接,请启动digital-human测试")
|
||||
await websocket.close()
|
||||
return
|
||||
else:
|
||||
websocket.request.headers["device-id"] = query_params["device-id"][0]
|
||||
if "client-id" in query_params:
|
||||
websocket.request.headers["client-id"] = query_params["client-id"][0]
|
||||
if "authorization" in query_params:
|
||||
websocket.request.headers["authorization"] = query_params[
|
||||
"authorization"
|
||||
][0]
|
||||
def _connection_headers(self, websocket: websockets.ServerConnection) -> dict:
|
||||
"""合并请求头与子协议头
|
||||
|
||||
请求头优先,两者都不从 URL 查询串取:查询串会原样落进反代访问日志,
|
||||
设备标识和 token 都不该出现在那里。
|
||||
合并结果写回 request.headers:ConnectionHandler 会再读一次那里,
|
||||
不写回去浏览器连接就是认证过了但会话里没有 device-id。
|
||||
"""
|
||||
headers = dict(websocket.request.headers)
|
||||
for key, value in _parse_subprotocol_headers(headers).items():
|
||||
if not headers.get(key):
|
||||
headers[key] = value
|
||||
websocket.request.headers[key] = value
|
||||
return headers
|
||||
|
||||
async def _handle_connection(self, websocket: websockets.ServerConnection):
|
||||
headers = self._connection_headers(websocket)
|
||||
if headers.get("device-id", None) is None:
|
||||
await websocket.send("端口正常,如需测试连接,请启动digital-human测试")
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
"""处理新连接,每次创建独立的ConnectionHandler"""
|
||||
# 先认证,后建立连接
|
||||
@@ -165,7 +211,7 @@ class WebSocketServer:
|
||||
try:
|
||||
async with self.config_lock:
|
||||
# 重新获取配置(使用异步版本)
|
||||
new_config = await get_config_from_api_async(self.config)
|
||||
new_config = await get_config_from_api_async()
|
||||
if new_config is None:
|
||||
self.logger.bind(tag=TAG).error("获取新配置失败")
|
||||
return False
|
||||
@@ -176,6 +222,15 @@ class WebSocketServer:
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"检查VAD和ASR类型是否需要更新: {update_vad} {update_asr}"
|
||||
)
|
||||
# auth_key 是 app.py 启动时算出来写进 config 的,
|
||||
# config.yaml 里没有这一行,重新拉一次接口就没了。
|
||||
# 换掉了等于换了一把签名密钥,刷新前签发的 token 全部作废
|
||||
new_config["server"].setdefault(
|
||||
"auth_key", self.config["server"]["auth_key"]
|
||||
)
|
||||
# 认证属性先跟着配置刷新:_load_auth_config 配错会抛,
|
||||
# 抛了却已经把 self.config 换掉的话,模块不重建、配置还换了一半
|
||||
self._load_auth_config(new_config)
|
||||
# 更新配置
|
||||
self.config = new_config
|
||||
# 重新初始化组件
|
||||
@@ -210,22 +265,26 @@ class WebSocketServer:
|
||||
async def _handle_auth(self, websocket: websockets.ServerConnection):
|
||||
# 先认证,后建立连接
|
||||
if self.auth_enable:
|
||||
headers = dict(websocket.request.headers)
|
||||
headers = self._connection_headers(websocket)
|
||||
device_id = headers.get("device-id", None)
|
||||
client_id = headers.get("client-id", None)
|
||||
if self.allowed_devices and device_id in self.allowed_devices:
|
||||
# 如果属于白名单内的设备,不校验token,直接放行
|
||||
return
|
||||
token = headers.get("authorization", "")
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:] # 移除'Bearer '前缀
|
||||
else:
|
||||
# 否则校验token
|
||||
token = headers.get("authorization", "")
|
||||
if token.startswith("Bearer "):
|
||||
token = token[7:] # 移除'Bearer '前缀
|
||||
else:
|
||||
raise AuthenticationError("Missing or invalid Authorization header")
|
||||
# 进行认证
|
||||
auth_success = self.auth.verify_token(
|
||||
token, client_id=client_id, username=device_id
|
||||
)
|
||||
if not auth_success:
|
||||
raise AuthenticationError("Invalid token")
|
||||
raise AuthenticationError("Missing or invalid Authorization header")
|
||||
# 白名单设备只延长 token 有效期,仍然校验 token
|
||||
expire_seconds = None
|
||||
if self.allowed_devices and normalize_device_id(
|
||||
device_id or ""
|
||||
) in self.allowed_devices:
|
||||
expire_seconds = self.allowed_expire_seconds
|
||||
# 进行认证
|
||||
auth_success = self.auth.verify_token(
|
||||
token,
|
||||
client_id=client_id,
|
||||
username=device_id,
|
||||
expire_seconds=expire_seconds,
|
||||
)
|
||||
if not auth_success:
|
||||
raise AuthenticationError("Invalid token")
|
||||
Reference in new issue
Block a user