Compare commits

...
Author SHA1 Message Date
CGDandGitHub f2b4c7b932 Merge pull request #3308 from CaixyPromise/feature/native-mqtt-clean-pr
feature(mqtt): 新增原生MQTT与UDP传输支持
2026-07-27 16:28:41 +08:00
CGDandGitHub 5b288bb0d2 Merge pull request #3307 from CaixyPromise/feature/connection-runtime-pr
refactor: 传输协议无关的连接运行时
2026-07-27 16:26:18 +08:00
Sakura-RanChenandGitHub 5e0d853256 Merge pull request #3309 from xinnan-tech/py-fix-reportHandle
refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
2026-07-27 15:24:15 +08:00
wengzh 2a9f809700 refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
1.  重命名参数与变量,明确上报类型与音频格式
2.  拆分PCM和Opus转WAV逻辑,分别处理不同输入格式
3.  移动校验逻辑到上报队列函数开头,提前拦截无效上报
4.  完善Opus转WAV的解码与资源释放流程
2026-07-27 09:59:32 +08:00
caixypromise eac573706d feat: add optional native mqtt and udp transport 2026-07-27 02:10:55 +08:00
caixypromise 0c582ed3b6 refactor: introduce transport-neutral connection runtime 2026-07-27 02:05:26 +08:00
CGDandGitHub f5ed1aaec8 Merge pull request #3305 from xinnan-tech/update_version
Bump to 0.9.6
2026-07-24 11:32:33 +08:00
hrz 9da58e254c Bump to 0.9.6 2026-07-24 09:53:03 +08:00
CGDandGitHub 27e57631a7 Merge pull request #3300 from xinnan-tech/test/manager-api-runtime-warnings
test(manager-api): 清理测试运行告警
2026-07-23 17:46:04 +08:00
CGDandGitHub b1e39da9b8 Merge pull request #3301 from chentyke/fix/issue-3299-auto-update-state
fix: 修复设备自动升级开关状态不生效
2026-07-22 17:38:13 +08:00
Tyke Chen e87fc766d2 fix: restore device auto-update switch behavior 2026-07-22 17:10:21 +08:00
Tyke Chen 942d55118b test(manager-api): clean test runtime warnings 2026-07-22 16:41:44 +08:00
CGDandGitHub f47f3b050b Merge pull request #3297 from xinnan-tech/fix/manager-api-compiler-warnings
fix(manager-api): 清理编译器告警
2026-07-22 16:22:46 +08:00
wengzhandGitHub e3453b1a87 Merge pull request #3298 from xinnan-tech/py-fix-intent
Py fix intent
2026-07-21 15:16:19 +08:00
Tyke Chen d760465408 fix(manager-api): make JSON collections type-safe 2026-07-21 14:43:25 +08:00
Tyke Chen bdb2538239 fix(manager-api): clean deprecated and generic warnings 2026-07-21 14:43:25 +08:00
CGDandGitHub ea6c144e43 Merge pull request #3294 from xinnan-tech/fix/manager-api-startup-warnings
fix(manager-api): 消除启动阶段告警
2026-07-21 14:37:22 +08:00
CGDandGitHub a79aa455d6 Merge pull request #3292 from xinnan-tech/fix/manager-api-test-i18n
test: 修复 manager-api 国际化测试配置
2026-07-21 14:29:30 +08:00
wengzh 6e92d169ec refactor(get_news_from_newsnow): 改用流式处理解析网页内容
将原有的直接转换响应内容改为流式读取字节流并传入参数,适配MarkItDown的convert_stream接口,优化大内容处理时的内存占用
2026-07-20 15:51:23 +08:00
Sakura-RanChen ed89c05245 fix: intent阻塞主线程 2026-07-20 14:48:26 +08:00
Tyke Chen b500d1c6bd fix(manager-api): avoid duplicate address book insert mapping 2026-07-20 14:46:14 +08:00
Tyke Chen 0598b09629 fix(manager-api): remove invalid logback startup directives 2026-07-20 14:46:14 +08:00
Tyke Chen e4907121f4 fix(manager-api): eliminate premature bean initialization warnings 2026-07-20 14:46:14 +08:00
CGDandGitHub 7c58fa37b2 Merge pull request #3293 from xinnan-tech/refactor/mybatis-plus-single-source
refactor(manager-api): consolidate MyBatis-Plus 3.5.17 integration
2026-07-20 14:42:31 +08:00
Tyke Chen 2a618d2f8f test: 修复 manager-api 国际化测试配置 2026-07-20 10:34:33 +08:00
Tyke Chen 10a67c772f refactor(manager-api): consolidate mybatis-plus 3.5.17 integration 2026-07-20 10:31:08 +08:00
CGDandGitHub 7e8e5d34b8 Merge pull request #3279 from xinnan-tech/fix/issue-3276-context-provider
fix: 修复 ContextProviderListTypeHandler 多版本兼容问题
2026-07-17 09:28:14 +08:00
CGDandGitHub c6bc20c05e Merge pull request #3283 from xinnan-tech/fix/device-timezone-consistency
fix: 修复跨时区场景下设备时间与在线状态异常
2026-07-16 16:58:49 +08:00
wengzhandGitHub 1998807b69 Merge pull request #3285 from xinnan-tech/py-aec
perf: 优化相关逻辑减少重复计算
2026-07-14 16:36:16 +08:00
Sakura-RanChen 55c0495661 perf: 优化相关逻辑减少重复计算 2026-07-14 15:52:30 +08:00
Tyke Chen dbeda01697 fix: 修复设备跨时区时间与在线状态 2026-07-14 10:08:23 +08:00
Tyke Chen 023dea2441 fix: 修复上下文源类型处理器兼容问题 2026-07-13 16:03:39 +08:00
CGDandGitHub ad3fb4d8c8 Merge pull request #3278 from xinnan-tech/agent-snapshot-history-fixes
fix: 完善智能体配置历史与恢复逻辑
2026-07-13 15:47:08 +08:00
3030332422 176785830a update:修改部分样式 2026-07-13 15:44:06 +08:00
Tyke Chen d78ecfce6f Merge remote-tracking branch 'origin/main' into agent-snapshot-history-fixes 2026-07-10 20:26:30 +08:00
Tyke Chen 179281e49c fix: harden agent snapshot restore flow 2026-07-10 20:21:44 +08:00
hrzandGitHub bc1aa1a089 Merge pull request #3267 from xinnan-tech/fix-agent-idor
fix: prevent agent IDOR access
2026-07-10 17:36:00 +08:00
Tyke Chen 687b6db96b fix: resolve mobile type and lint errors 2026-07-10 11:45:16 +08:00
Tyke Chen a5aee109fe feat: improve agent snapshot history 2026-07-10 11:44:35 +08:00
Tyke Chen d11cfc6923 Merge remote-tracking branch 'origin/main' into fix-agent-idor
# Conflicts:
#	main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java
#	main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java
#	main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
2026-07-09 20:21:37 +08:00
Tyke Chen aae2ef2152 fix: prevent agent IDOR access 2026-07-08 15:52:47 +08:00
226 changed files with 27927 additions and 2664 deletions
+5
View File
@@ -112,12 +112,17 @@ celerybeat.pid
# Environments
.env
.venv
/.venv-*/
env/
venv/
ENV/
env.bak/
venv.bak/
# Repository-local runtimes and package-manager caches
/.runtime/
/main/manager-web/.npm-cache/
# Spyder project settings
.spyderproject
.spyproject
+161
View File
@@ -0,0 +1,161 @@
# Native MQTT/UDP integration
The server can accept device MQTT control messages and encrypted UDP audio
without running `xiaozhi-mqtt-gateway`. Native MQTT is optional. The existing
Gateway path and direct WebSocket path remain supported.
## Compatibility modes
| Mode | Device control | Device audio | Server business runtime |
| --- | --- | --- | --- |
| Direct WebSocket | WebSocket JSON | WebSocket binary | Shared runtime |
| MQTT Gateway | MQTT via Gateway WebSocket | UDP via Gateway WebSocket | Shared runtime |
| Native MQTT | MQTT 3.1.1 QoS 0 | AES-128-CTR UDP | Shared runtime |
Native MQTT keeps the physical MQTT connection alive between conversations.
Each device `hello` creates a new logical conversation and UDP encryption
session. `goodbye` ends only that logical conversation; MQTT is closed only by
MQTT disconnect, duplicate client takeover, keepAlive expiry, server shutdown,
or a protocol/authentication error.
## Enable Native MQTT
Native MQTT is disabled by default. Both the protocol and server switches must
be enabled:
```yaml
protocols:
enabled_protocols: [websocket, mqtt]
websocket_enabled: true
mqtt_enabled: true
mqtt_server:
enabled: true
host: 0.0.0.0
port: 1883
udp_port: 1883
udp_bind_host: ""
public_endpoint: mqtt.example.com
signature_key: replace-with-a-strong-secret
max_connections: 1000
max_pending_connections: 128
heartbeat_interval: 30
max_payload_size: 8192
message_queue_size: 128
business_ready_timeout: 30
goodbye_timeout: 1
close_timeout: 2
# Includes the common model and any Agent-specific local ASR variants.
shared_asr_max_models: 3
```
`host` is the local MQTT listen address. `public_endpoint` must be reachable by
the device and may be either `host` or `host:port`; manager-api normalizes it
and does not append the port twice. When `host` is a wildcard and
`public_endpoint` is a local IPv4 address, the UDP socket binds that address so
reply datagrams use the same source IP advertised to the device. Set
`udp_bind_host` only when the local UDP bind address must differ from the
advertised endpoint (for example, behind NAT); leaving it empty enables the
automatic behavior. Open the configured MQTT TCP and UDP ports in the firewall.
When configuration is read from manager-api, configure the equivalent
`protocols.*` and `mqtt_server.*` system parameters. The OTA response prefers a
valid Native endpoint when Native is enabled. If Native is disabled or invalid,
manager-api falls back to `server.mqtt_gateway`. If neither is configured, OTA
returns only WebSocket information.
The current firmware persists the OTA `mqtt` object. The UDP server, key and
nonce used by Native mode are negotiated in the MQTT `hello` response, so
manager-api does not send a separate top-level OTA `udp` object.
Long-lived Native connections refresh Agent-private components at logical
`hello` boundaries. Local ASR variants are shared by effective configuration
instead of loaded once per device. `shared_asr_max_models` bounds resident
models; when capacity is exhausted, the replacement runtime is rejected and
the previous healthy runtime remains active.
The default capacity reserves one transition slot so a single Agent can replace
an active private local model without dropping its healthy runtime first. Idle
variants are cached and evicted before another model is loaded; resident models
never exceed this limit.
MQTT application packets are dispatched in order outside the socket read loop,
so PINGREQ remains responsive while a logical Hello waits for private runtime
refresh. `business_ready_timeout` bounds that wait and closes an unusable
connection instead of leaving the device in a half-negotiated session.
`max_connections` limits authenticated client identities, while
`max_pending_connections` separately bounds sockets that have not completed
CONNECT. A connection using an existing client id may therefore replace its
previous owner even when active capacity is full. The server sends the success
CONNACK only after the previous owner has been reclaimed. `goodbye_timeout` and
`close_timeout` bound best-effort device reset and physical resource cleanup so
a stalled peer cannot block heartbeat or shutdown indefinitely.
## Authentication and topics
manager-api generates the device credentials and topics:
- client id: `<group>@@@<mac_with_underscores>@@@<mac_with_underscores>`
- publish topic: `device-server`
- subscribe topic: `devices/p2p/<mac_with_underscores>`
- username: Base64-encoded JSON metadata
- password: Base64(HMAC-SHA256(`client_id + "|" + username`, signature key))
Use the same secret for manager-api `mqtt_server.signature_key` and
xiaozhi-server `mqtt_server.signature_key`. The legacy
`server.mqtt_signature_key` remains a fallback for Gateway compatibility.
Native MQTT accepts MQTT 3.1.1 CONNECT, SUBSCRIBE, PUBLISH QoS 0, PING and
DISCONNECT. Unsupported protocol versions, QoS levels, malformed packets, or
invalid credentials close the physical connection.
## Session and UDP flow
1. Device connects and subscribes over MQTT.
2. Server validates CONNECT credentials and replaces any older connection with
the same client id.
3. Device publishes protocol version 3 `hello`.
4. Server waits until private configuration and business components are ready.
5. Server creates a logical session and returns the UDP server, AES key, nonce,
output audio parameters and session id.
6. The first valid UDP packet from the MQTT peer binds the UDP source tuple for
that logical session. Source changes are rejected until the next `hello`.
7. MQTT JSON and decrypted Opus frames enter the same message processors used
by WebSocket and Gateway connections.
8. Server `goodbye` returns the device to Idle and finalizes conversation tasks
while preserving the MQTT connection.
The 16-byte UDP header is also the AES-CTR nonce:
| Bytes | Field |
| --- | --- |
| 0 | packet type (`1`) |
| 1 | reserved |
| 2..3 | encrypted payload length, big endian |
| 4..7 | connection id, big endian |
| 8..11 | timestamp, big endian |
| 12..15 | sequence, big endian |
Datagrams with an invalid type, header, payload length, connection id, source
address or stale sequence are discarded. Sequence gaps are tolerated because
UDP can lose packets; late and duplicate packets are discarded. AES-CTR
provides confidentiality but not integrity, so Native MQTT and UDP should be
exposed only with a strong signature key and appropriate network controls.
## Verification
From `main/xiaozhi-server`:
```bash
python -m compileall -q .
```
From `main/manager-api` with JDK 21:
```bash
mvn clean package
```
Before deployment, verify Native and Gateway separately with real hardware:
multiple conversations, TTS interruption, exit to Idle, duplicate reconnect,
server restart, short network loss and a 30-60 minute keepAlive soak.
+17 -5
View File
@@ -20,7 +20,7 @@
<java.version>21</java.version>
<junit.version>5.10.1</junit.version>
<druid.version>1.2.20</druid.version>
<mybatisplus.version>3.5.5</mybatisplus.version>
<mybatisplus.version>3.5.17</mybatisplus.version>
<hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.version>
<knife4j.version>4.6.0</knife4j.version>
@@ -33,6 +33,7 @@
<aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version>
<skipTests>true</skipTests>
<argLine></argLine>
</properties>
<dependencies>
@@ -176,13 +177,13 @@
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
@@ -272,11 +273,22 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<proc>full</proc>
<compilerArgs>
<arg>-Xlint:deprecation,unchecked</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>${skipTests}</skipTests>
<argLine>@{argLine} -Xshare:off -javaagent:"${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar"</argLine>
</configuration>
</plugin>
</plugins>
@@ -106,6 +106,32 @@ public interface Constant {
*/
String SERVER_MQTT_GATEWAY = "server.mqtt_gateway";
/**
* MQTT原生服务开关
*/
String SERVER_MQTT_ENABLED = "server.mqtt_enabled";
/**
* 新版协议配置
*/
String PROTOCOLS_ENABLED = "protocols.enabled_protocols";
String PROTOCOLS_WEBSOCKET_ENABLED = "protocols.websocket_enabled";
String PROTOCOLS_MQTT_ENABLED = "protocols.mqtt_enabled";
/**
* 新版原生MQTT配置
*/
String MQTT_SERVER_ENABLED = "mqtt_server.enabled";
String MQTT_SERVER_HOST = "mqtt_server.host";
String MQTT_SERVER_PORT = "mqtt_server.port";
String MQTT_SERVER_UDP_PORT = "mqtt_server.udp_port";
String MQTT_SERVER_UDP_BIND_HOST = "mqtt_server.udp_bind_host";
String MQTT_SERVER_PUBLIC_ENDPOINT = "mqtt_server.public_endpoint";
String MQTT_SERVER_SIGNATURE_KEY = "mqtt_server.signature_key";
String MQTT_SERVER_MANAGER_API = "mqtt_server.manager_api";
String MQTT_SERVER_MANAGER_API_SECRET = "mqtt_server.manager_api_secret";
String SERVER_MQTT_MANAGER_API = "server.mqtt_manager_api";
/**
* ota地址
*/
@@ -324,7 +350,7 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.9.5";
public static final String VERSION = "0.9.6";
/**
* 无效固件URL
@@ -350,4 +376,4 @@ public interface Constant {
return value;
}
}
}
}
@@ -67,7 +67,7 @@ public class DataFilterInterceptor implements InnerInterceptor {
private String getSelect(String buildSql, DataScope scope) {
try {
Select select = (Select) CCJSqlParserUtil.parse(buildSql);
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
PlainSelect plainSelect = select.getPlainSelect();
Expression expression = plainSelect.getWhere();
if (expression == null) {
@@ -82,4 +82,4 @@ public class DataFilterInterceptor implements InnerInterceptor {
return buildSql;
}
}
}
}
@@ -5,7 +5,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.logging.Log;
@@ -81,8 +81,8 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
// 处理排序字段
if (orderField instanceof String) {
orderFields.add((String) orderField);
} else if (orderField instanceof List) {
orderFields.addAll((List<String>) orderField);
} else if (orderField instanceof List<?> fields) {
fields.forEach(field -> orderFields.add(String.class.cast(field)));
}
// 有排序字段则排序
@@ -142,11 +142,12 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return SqlHelper.retBool(result);
}
protected Class<M> currentMapperClass() {
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
protected Class<?> currentMapperClass() {
return ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
}
@Override
@SuppressWarnings("unchecked")
public Class<T> currentModelClass() {
return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1);
}
@@ -181,8 +182,9 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
* 执行批量操作
*/
@SuppressWarnings("deprecation")
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
return SqlHelper.executeBatch(this.currentModelClass(), this.log, list, batchSize, consumer);
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiFunction<SqlSession, E, Integer> operation) {
return SqlHelper.executeBatch(SqlHelper.sqlSessionFactory(this.currentModelClass()), this.log, list, batchSize,
operation);
}
@Override
@@ -209,7 +211,7 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return executeBatch(entityList, batchSize, (sqlSession, entity) -> {
MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>();
param.put(Constants.ENTITY, entity);
sqlSession.update(sqlStatement, param);
return sqlSession.update(sqlStatement, param);
});
}
@@ -225,6 +227,6 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
@Override
public boolean deleteBatchIds(Collection<? extends Serializable> idList) {
return SqlHelper.retBool(baseDao.deleteBatchIds(idList));
return SqlHelper.retBool(baseDao.deleteByIds(idList));
}
}
}
@@ -24,6 +24,7 @@ import xiaozhi.common.utils.ConvertUtils;
public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T>
implements CrudService<T, D> {
@SuppressWarnings("unchecked")
protected Class<D> currentDtoClass() {
return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
}
@@ -70,6 +71,6 @@ public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends Bas
@Override
public void delete(Serializable[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
}
}
@@ -1,7 +1,9 @@
package xiaozhi.common.utils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,6 +18,10 @@ import cn.hutool.core.util.StrUtil;
*/
public class JsonUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final TypeReference<Map<String, Object>> STRING_OBJECT_MAP = new TypeReference<>() {
};
private static final TypeReference<List<Map<String, Object>>> STRING_OBJECT_MAP_LIST = new TypeReference<>() {
};
public static String toJsonString(Object object) {
try {
@@ -67,4 +73,59 @@ public class JsonUtils {
}
}
public static Map<String, Object> parseMap(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
return parseObject(text, STRING_OBJECT_MAP);
}
public static List<Map<String, Object>> parseMapList(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
return parseObject(text, STRING_OBJECT_MAP_LIST);
}
public static Map<String, Object> toStringObjectMap(Object value) {
if (value == null) {
return null;
}
if (!(value instanceof Map<?, ?> map)) {
throw new ClassCastException("Expected Map but got " + value.getClass().getName());
}
Map<String, Object> result = new LinkedHashMap<>(map.size());
for (Map.Entry<?, ?> entry : map.entrySet()) {
result.put(String.class.cast(entry.getKey()), entry.getValue());
}
return result;
}
public static List<Map<String, Object>> toStringObjectMapList(Object value) {
if (value == null) {
return null;
}
List<?> list = List.class.cast(value);
List<Map<String, Object>> result = new ArrayList<>(list.size());
for (Object item : list) {
result.add(toStringObjectMap(item));
}
return result;
}
public static <T> List<T> toList(Object value, Class<T> elementType) {
if (value == null) {
return null;
}
List<?> list = List.class.cast(value);
List<T> result = new ArrayList<>(list.size());
for (Object item : list) {
result.add(elementType.cast(item));
}
return result;
}
}
@@ -75,11 +75,11 @@ public class SensitiveDataUtils {
Object value = jsonObject.get(key);
if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) {
result.put(key, maskMiddle((String) value));
result.set(key, maskMiddle((String) value));
} else if (value instanceof JSONObject) {
result.put(key, maskSensitiveFields((JSONObject) value));
result.set(key, maskSensitiveFields((JSONObject) value));
} else {
result.put(key, value);
result.set(key, value);
}
}
@@ -162,4 +162,4 @@ public class SensitiveDataUtils {
}
}
}
}
}
@@ -22,10 +22,10 @@ public class SqlFilter {
return null;
}
// 去掉'|"|;|\字符
str = StringUtils.replace(str, "'", "");
str = StringUtils.replace(str, "\"", "");
str = StringUtils.replace(str, ";", "");
str = StringUtils.replace(str, "\\", "");
str = str.replace("'", "");
str = str.replace("\"", "");
str = str.replace(";", "");
str = str.replace("\\", "");
// 转换成小写
str = str.toLowerCase();
@@ -28,10 +28,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.ResultUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
@@ -52,8 +55,6 @@ import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser;
@Tag(name = "智能体管理")
@@ -61,15 +62,40 @@ import xiaozhi.modules.security.user.SecurityUser;
@RestController
@RequestMapping("/agent")
public class AgentController {
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
private final AgentService agentService;
private final AgentTemplateService agentTemplateService;
private final DeviceService deviceService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils;
private final AgentTagService agentTagService;
private void requireAgentPermission(String agentId) {
if (!agentService.checkAgentPermission(agentId, SecurityUser.getUserId())) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
}
private String requireSessionAgent(String sessionId) {
String agentId = agentChatHistoryService.getAgentIdBySessionId(sessionId);
if (StringUtils.isBlank(agentId)) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
agentService.getAgentById(agentId);
return agentId;
}
private String requireAudioPermission(String audioId) {
String agentId = agentChatHistoryService.getAgentIdByAudioId(audioId);
if (StringUtils.isBlank(agentId)) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
requireAgentPermission(agentId);
return agentId;
}
@GetMapping("/list")
@Operation(summary = "获取用户智能体列表")
@RequiresPermissions("sys:role:normal")
@@ -100,7 +126,7 @@ public class AgentController {
@Operation(summary = "获取智能体详情")
@RequiresPermissions("sys:role:normal")
public Result<AgentInfoVO> getAgentById(@PathVariable("id") String id) {
AgentInfoVO agent = agentService.getAgentById(id);
AgentInfoVO agent = agentService.getAgentById(id, SecurityUser.getUserId());
return ResultUtils.success(agent);
}
@@ -114,20 +140,16 @@ public class AgentController {
@PutMapping("/saveMemory/{macAddress}")
@Operation(summary = "根据设备id更新智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
return new Result<>();
}
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
agentService.updateAgentById(device.getAgentId(), agentUpdateDTO, false);
return new Result<>();
agentService.updateAgentMemoryByDeviceMacAddress(macAddress, dto, SecurityUser.getUserId());
return new Result<Void>().ok(null);
}
@PostMapping("/chat-summary/{sessionId}/save")
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
requireSessionAgent(sessionId);
try {
// 异步执行总结生成任务,立即返回成功响应
new Thread(() -> {
@@ -149,6 +171,7 @@ public class AgentController {
@PostMapping("/chat-title/{sessionId}/generate")
@Operation(summary = "根据会话ID生成聊天标题")
public Result<Void> generateAndSaveChatTitle(@PathVariable String sessionId) {
requireSessionAgent(sessionId);
agentChatSummaryService.generateAndSaveChatTitle(sessionId);
return new Result<Void>().ok(null);
}
@@ -157,7 +180,7 @@ public class AgentController {
@Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
agentService.updateAgentById(id, dto);
agentService.updateAgentById(id, dto, SecurityUser.getUserId());
return new Result<>();
}
@@ -165,7 +188,7 @@ public class AgentController {
@Operation(summary = "删除智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> delete(@PathVariable String id) {
agentService.deleteAgent(id);
agentService.deleteAgentById(id, SecurityUser.getUserId());
return new Result<>();
}
@@ -188,6 +211,7 @@ public class AgentController {
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
@PathVariable("id") String id,
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
requireAgentPermission(id);
params.put("agentId", id);
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
@@ -235,6 +259,7 @@ public class AgentController {
@RequiresPermissions("sys:role:normal")
public Result<String> getContentByAudioId(
@PathVariable("id") String id) {
requireAudioPermission(id);
// 查询聊天记录
String data = agentChatHistoryService.getContentByAudioId(id);
return new Result<String>().ok(data);
@@ -244,12 +269,13 @@ public class AgentController {
@Operation(summary = "获取音频下载ID")
@RequiresPermissions("sys:role:normal")
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
requireAudioPermission(audioId);
byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) {
return new Result<String>().error("音频不存在");
}
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId, AUDIO_PLAY_TOKEN_EXPIRE_SECONDS);
return new Result<String>().ok(uuid);
}
@@ -305,6 +331,7 @@ public class AgentController {
@Operation(summary = "获取智能体的标签")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentTagDTO>> getAgentTags(@PathVariable String id) {
requireAgentPermission(id);
List<AgentTagDTO> tags = agentTagService.getTagsByAgentId(id);
return new Result<List<AgentTagDTO>>().ok(tags);
}
@@ -313,8 +340,9 @@ public class AgentController {
@Operation(summary = "保存智能体的标签")
@RequiresPermissions("sys:role:normal")
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
List<String> tagIds = (List<String>) params.get("tagIds");
List<String> tagNames = (List<String>) params.get("tagNames");
requireAgentPermission(id);
List<String> tagIds = JsonUtils.toList(params.get("tagIds"), String.class);
List<String> tagNames = JsonUtils.toList(params.get("tagNames"), String.class);
AgentUpdateDTO dto = new AgentUpdateDTO();
dto.setTagIds(tagIds);
dto.setTagNames(tagNames);
@@ -6,17 +6,20 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentSnapshotService;
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
@@ -51,9 +54,10 @@ public class AgentSnapshotController {
@PostMapping("/{snapshotId}/restore")
@Operation(summary = "恢复智能体快照")
@RequiresPermissions("sys:role:normal")
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId) {
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId,
@RequestBody @Valid AgentSnapshotRestoreDTO request) {
checkPermission(agentId);
agentSnapshotService.restoreSnapshot(agentId, snapshotId);
agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken());
return new Result<>();
}
@@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao<AgentEntity> {
* @param agentId 智能体ID
*/
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
/**
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
* 不更新所属用户、创建信息等不属于快照的字段。
*
* @param agent 已应用目标快照的智能体
* @return 受影响行数
*/
int updateSnapshotFields(@Param("agent") AgentEntity agent);
}
@@ -1,5 +1,7 @@
package xiaozhi.modules.agent.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -17,4 +19,11 @@ public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
List<AgentSnapshotEntity> selectLegacyRedactionBatch(@Param("afterId") String afterId,
@Param("limit") int limit,
@Param("targetRedactionVersion") int targetRedactionVersion);
int updateRedactedSnapshots(@Param("snapshots") List<AgentSnapshotEntity> snapshots,
@Param("redactionVersion") int redactionVersion);
}
@@ -39,7 +39,7 @@ public class AgentDTO {
private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory;
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
@@ -50,4 +50,4 @@ public class AgentDTO {
@Schema(description = "标签列表")
private List<AgentTagDTO> tags;
}
}
@@ -14,6 +14,6 @@ public class AgentMemoryDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory;
}
}
@@ -0,0 +1,13 @@
package xiaozhi.modules.agent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "智能体快照恢复请求")
public class AgentSnapshotRestoreDTO {
@NotBlank
@Schema(description = "预览时由服务端生成的当前配置状态指纹")
private String currentStateToken;
}
@@ -39,10 +39,10 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "小模型标识", example = "slm_model_02", nullable = true)
private String slmModelId;
@Schema(description = "VLLM模型标识", example = "vllm_model_02", required = false)
@Schema(description = "VLLM模型标识", example = "vllm_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String vllmModelId;
@Schema(description = "语音合成模型标识", example = "tts_model_02", required = false)
@Schema(description = "语音合成模型标识", example = "tts_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String ttsModelId;
@Schema(description = "音色标识", example = "voice_02", nullable = true)
@@ -74,7 +74,7 @@ public class AgentEntity {
private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory;
@Schema(description = "语言编码")
@@ -97,4 +97,4 @@ public class AgentEntity {
@Schema(description = "更新时间")
private Date updatedAt;
}
}
@@ -47,4 +47,7 @@ public class AgentSnapshotEntity {
@Schema(description = "创建时间")
private Date createdAt;
@Schema(description = "快照数据脱敏规则版本")
private Integer redactionVersion;
}
@@ -1,6 +1,6 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
@@ -11,7 +11,7 @@ import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
public interface AgentChatAudioService extends IRepository<AgentChatAudioEntity> {
/**
* 保存音频数据
*
@@ -3,9 +3,9 @@ package xiaozhi.modules.agent.service;
import java.util.List;
import java.util.Map;
import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.common.page.PageData;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
@@ -18,7 +18,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
public interface AgentChatHistoryService extends IRepository<AgentChatHistoryEntity> {
/**
* 根据智能体ID获取会话列表
@@ -37,6 +37,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
*/
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
/**
* 根据会话ID获取智能体ID
*
* @param sessionId 会话ID
* @return 智能体ID
*/
String getAgentIdBySessionId(String sessionId);
/**
* 根据智能体ID删除聊天记录
*
@@ -62,6 +70,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
*/
String getContentByAudioId(String audioId);
/**
* 根据音频ID获取智能体ID
*
* @param audioId 音频ID
* @return 智能体ID
*/
String getAgentIdByAudioId(String audioId);
/**
* 查询此音频id是否属于此智能体
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentPluginMapping;
@@ -10,7 +10,7 @@ import xiaozhi.modules.agent.entity.AgentPluginMapping;
* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service
* @createDate 2025-05-25 22:33:17
*/
public interface AgentPluginMappingService extends IService<AgentPluginMapping> {
public interface AgentPluginMappingService extends IRepository<AgentPluginMapping> {
/**
* 根据智能体id获取插件参数
@@ -7,6 +7,7 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.vo.AgentInfoVO;
@@ -35,6 +36,15 @@ public interface AgentService extends BaseService<AgentEntity> {
*/
AgentInfoVO getAgentById(String id);
/**
* 根据ID获取当前用户有权访问的智能体
*
* @param id 智能体ID
* @param userId 当前用户ID
* @return 智能体实体
*/
AgentInfoVO getAgentById(String id, Long userId);
/**
* 插入智能体
*
@@ -100,6 +110,32 @@ public interface AgentService extends BaseService<AgentEntity> {
*/
void updateAgentById(String agentId, AgentUpdateDTO dto);
/**
* 更新当前用户有权访问的智能体
*
* @param agentId 智能体ID
* @param dto 更新智能体所需的信息
* @param userId 当前用户ID
*/
void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId);
/**
* 根据设备MAC地址更新当前用户有权访问的智能体记忆
*
* @param macAddress 设备MAC地址
* @param dto 智能体记忆
* @param userId 当前用户ID
*/
void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId);
/**
* 删除当前用户有权访问的智能体
*
* @param agentId 智能体ID
* @param userId 当前用户ID
*/
void deleteAgentById(String agentId, Long userId);
/**
* 更新智能体
*
@@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
AgentSnapshotVO getSnapshot(String agentId, String snapshotId);
void restoreSnapshot(String agentId, String snapshotId);
void restoreSnapshot(String agentId, String snapshotId, String currentStateToken);
void deleteSnapshot(String agentId, String snapshotId);
Integer getCurrentVersionNo(String agentId);
void deleteByAgentId(String agentId);
long redactLegacySnapshots();
}
@@ -1,7 +1,7 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
/**
@@ -9,7 +9,7 @@ import xiaozhi.modules.agent.entity.AgentTemplateEntity;
* @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service
* @createDate 2025-03-22 11:48:18
*/
public interface AgentTemplateService extends IService<AgentTemplateEntity> {
public interface AgentTemplateService extends IRepository<AgentTemplateEntity> {
/**
* 获取默认模板
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import xiaozhi.modules.agent.dao.AiAgentChatAudioDao;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
@@ -16,7 +16,7 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
* @since 1.0.0
*/
@Service
public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao, AgentChatAudioEntity>
public class AgentChatAudioServiceImpl extends CrudRepository<AiAgentChatAudioDao, AgentChatAudioEntity>
implements AgentChatAudioService {
@Override
public String saveAudio(byte[] audioData) {
@@ -14,7 +14,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
@@ -39,7 +39,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
*/
@Service
@RequiredArgsConstructor
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHistoryDao, AgentChatHistoryEntity>
implements AgentChatHistoryService {
private final AgentChatTitleService agentChatTitleService;
@@ -88,6 +88,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
}
@Override
public String getAgentIdBySessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return null;
}
AgentChatHistoryEntity entity = baseMapper.selectOne(
new LambdaQueryWrapper<AgentChatHistoryEntity>()
.select(AgentChatHistoryEntity::getAgentId)
.eq(AgentChatHistoryEntity::getSessionId, sessionId)
.last("LIMIT 1"));
return entity == null ? null : entity.getAgentId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
@@ -154,7 +167,7 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
// 尝试解析为 JSON
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(content, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(content);
if (jsonMap != null && jsonMap.containsKey("content")) {
Object contentObj = jsonMap.get("content");
return contentObj != null ? contentObj.toString() : content;
@@ -176,6 +189,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
return agentChatHistoryEntity == null ? null : agentChatHistoryEntity.getContent();
}
@Override
public String getAgentIdByAudioId(String audioId) {
if (audioId == null || audioId.isBlank()) {
return null;
}
AgentChatHistoryEntity entity = baseMapper.selectOne(
new LambdaQueryWrapper<AgentChatHistoryEntity>()
.select(AgentChatHistoryEntity::getAgentId)
.eq(AgentChatHistoryEntity::getAudioId, audioId)
.last("LIMIT 1"));
return entity == null ? null : entity.getAgentId();
}
@Override
public boolean isAudioOwnedByAgent(String audioId, String agentId) {
// 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体
@@ -76,7 +76,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
List<String> initResponses = client.listenerWithoutClose(response -> {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
// 检查是否有result字段,表示初始化成功
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
@@ -92,7 +92,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
boolean initSucceeded = false;
for (String response : initResponses) {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
if (jsonMap.containsKey("result")) {
log.info("MCP初始化成功,智能体ID: {}", id);
@@ -123,7 +123,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 等待工具列表响应 (id=2)
List<String> toolsResponses = client.listener(response -> {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
} catch (Exception e) {
log.warn("解析工具列表响应失败: {}", response, e);
@@ -134,18 +134,18 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 处理工具列表响应
for (String response : toolsResponses) {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
// 检查是否有result字段
Object resultObj = jsonMap.get("result");
if (resultObj instanceof Map) {
Map<String, Object> resultMap = (Map<String, Object>) resultObj;
if (resultObj instanceof Map<?, ?>) {
Map<String, Object> resultMap = JsonUtils.toStringObjectMap(resultObj);
Object toolsObj = resultMap.get("tools");
if (toolsObj instanceof List) {
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
if (toolsObj instanceof List<?>) {
List<Map<String, Object>> toolsList = JsonUtils.toStringObjectMapList(toolsObj);
// 提取工具名称列表
List<String> result = toolsList.stream()
.map(tool -> (String) tool.get("name"))
.map(tool -> String.class.cast(tool.get("name")))
.filter(name -> name != null)
.sorted()
.collect(Collectors.toList());
@@ -232,4 +232,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 加密后成token值
return AESUtils.encrypt(key, json);
}
}
}
@@ -9,7 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -29,7 +29,7 @@ import xiaozhi.modules.model.service.ModelConfigService;
@Service
@RequiredArgsConstructor
@Slf4j
public class AgentPluginMappingServiceImpl extends ServiceImpl<AgentPluginMappingMapper, AgentPluginMapping>
public class AgentPluginMappingServiceImpl extends CrudRepository<AgentPluginMappingMapper, AgentPluginMapping>
implements AgentPluginMappingService {
private final AgentPluginMappingMapper agentPluginMappingMapper;
private final KnowledgeBaseService knowledgeBaseService;
@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
@@ -33,6 +34,7 @@ import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTagDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentTagDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
@@ -56,9 +58,9 @@ import xiaozhi.modules.model.dto.VoiceDTO;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.model.service.ModelProviderService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService;
@Service
@AllArgsConstructor
@@ -93,6 +95,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
if (agent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
requireCurrentUserPermissionIfPresent(agent);
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
@@ -116,6 +119,65 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
return agent;
}
@Override
public AgentInfoVO getAgentById(String id, Long userId) {
AgentInfoVO agent = getAgentById(id);
requireAgentPermission(agent, userId);
return agent;
}
private AgentEntity getAgentEntityOrThrow(String agentId) {
AgentEntity agent = agentDao.selectById(agentId);
if (agent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
return agent;
}
private boolean isCurrentUserSuperAdmin() {
UserDetail user = SecurityUser.getUser();
return user != null && Integer.valueOf(SuperAdminEnum.YES.value()).equals(user.getSuperAdmin());
}
private void requireCurrentUserPermissionIfPresent(AgentEntity agent) {
Long userId = SecurityUser.getUserId();
if (userId != null) {
requireAgentPermission(agent, userId);
}
}
private boolean hasAgentPermission(AgentEntity agent, Long userId) {
if (agent == null) {
return false;
}
if (isCurrentUserSuperAdmin()) {
return true;
}
return userId != null && userId.equals(agent.getUserId());
}
private void requireAgentPermission(AgentEntity agent, Long userId) {
if (!hasAgentPermission(agent, userId)) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
}
private boolean hasDevicePermission(DeviceEntity device, Long userId) {
if (device == null) {
return false;
}
if (isCurrentUserSuperAdmin()) {
return true;
}
return userId != null && userId.equals(device.getUserId());
}
private void requireDevicePermission(DeviceEntity device, Long userId) {
if (!hasDevicePermission(device, userId)) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
}
@Override
public boolean insert(AgentEntity entity) {
// 如果ID为空,自动生成一个UUID作为ID
@@ -275,25 +337,11 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
}
@Override
public boolean checkAgentPermission(String agentId, Long userId) {
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) {
return false;
}
// 获取智能体信息
AgentEntity agent = getAgentById(agentId);
if (agent == null) {
return false;
}
// 如果是超级管理员,直接返回true
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
return true;
}
// 检查是否是智能体的所有者
return userId.equals(agent.getUserId());
}
public boolean checkAgentPermission(String agentId, Long userId) {
AgentEntity agent = agentDao.selectById(agentId);
return hasAgentPermission(agent, userId);
}
// 根据id更新智能体信息
@Override
@Transactional(rollbackFor = Exception.class)
@@ -301,27 +349,44 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
updateAgentById(agentId, dto, true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId) {
updateAgentById(agentId, dto, userId, true);
}
// 根据id更新智能体信息
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) {
if (agentDao.selectByIdForUpdate(agentId) == null) {
updateAgentById(agentId, dto, null, createSnapshot);
}
private void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId, boolean createSnapshot) {
AgentEntity lockedAgent = agentDao.selectByIdForUpdate(agentId);
if (lockedAgent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
if (userId == null) {
requireCurrentUserPermissionIfPresent(lockedAgent);
} else {
requireAgentPermission(lockedAgent, userId);
}
// 锁定后查询现有实体和关联配置
AgentEntity existingEntity = this.getAgentById(agentId);
if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) {
agentSnapshotService.createSnapshot(agentId, "initial");
if (createSnapshot) {
int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId);
agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current");
}
// 只更新提供的非空字段
if (dto.getAgentName() != null) {
existingEntity.setAgentName(dto.getAgentName());
}
if (dto.getAgentCode() != null) {
existingEntity.setAgentCode(dto.getAgentCode());
}
if (dto.getAgentCode() != null) {
existingEntity.setAgentCode(dto.getAgentCode());
}
if (dto.getAsrModelId() != null) {
existingEntity.setAsrModelId(dto.getAsrModelId());
}
@@ -418,10 +483,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.toList();
if (!toUpdate.isEmpty()) {
agentPluginMappingService.updateBatchById(toUpdate);
agentPluginMappingService.updateBatchById(toUpdate, IRepository.DEFAULT_BATCH_SIZE);
}
if (!toInsert.isEmpty()) {
agentPluginMappingService.saveBatch(toInsert);
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
}
// 5. 删除本次不在提交列表里的插件映射
@@ -430,7 +495,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.map(AgentPluginMapping::getId)
.toList();
if (!toDelete.isEmpty()) {
agentPluginMappingService.removeBatchByIds(toDelete);
agentPluginMappingService.removeByIds(toDelete);
}
}
@@ -478,6 +543,29 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null || StringUtils.isBlank(device.getAgentId()) || dto == null) {
return;
}
requireDevicePermission(device, userId);
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
updateAgentById(device.getAgentId(), agentUpdateDTO, userId, false);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteAgentById(String agentId, Long userId) {
AgentEntity agent = getAgentEntityOrThrow(agentId);
requireAgentPermission(agent, userId);
deleteAgent(agentId);
}
/**
* 验证大语言模型和意图识别的参数是否符合匹配
*
@@ -528,16 +616,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
template.setTtsVoiceId(timbre.getId());
}
}
}
entity.setTtsVoiceId(template.getTtsVoiceId());
entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
entity.setSummaryMemory(template.getSummaryMemory());
// 根据记忆模型类型设置默认的chatHistoryConf值
if (template.getMemModelId() != null) {
}
entity.setTtsVoiceId(template.getTtsVoiceId());
entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(),
timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId())));
entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
entity.setSummaryMemory(template.getSummaryMemory());
if (Constant.MEMORY_NO_MEM.equals(entity.getMemModelId())
|| Constant.MEMORY_MEM_REPORT_ONLY.equals(entity.getMemModelId())) {
entity.setSummaryMemory("");
}
// 根据记忆模型类型设置默认的chatHistoryConf值
if (template.getMemModelId() != null) {
if (template.getMemModelId().equals("Memory_nomem")) {
// 无记忆功能的模型,默认不记录聊天记录
entity.setChatHistoryConf(0);
@@ -583,7 +677,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
mapping.setPluginId(pluginId);
Map<String, Object> paramInfo = new HashMap<>();
List<Map<String, Object>> fields = JsonUtils.parseObject(provider.getFields(), List.class);
List<Map<String, Object>> fields = JsonUtils.parseMapList(provider.getFields());
if (fields != null) {
for (Map<String, Object> field : fields) {
paramInfo.put((String) field.get("key"), field.get("default"));
@@ -592,13 +686,18 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
mapping.setParamInfo(JsonUtils.toJsonString(paramInfo));
mapping.setAgentId(entity.getId());
toInsert.add(mapping);
}
// 保存默认插件
agentPluginMappingService.saveBatch(toInsert);
return entity.getId();
}
private String getDefaultLLMModelId() {
}
// 保存默认插件
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
agentSnapshotService.createSnapshot(entity.getId(), "initial");
return entity.getId();
}
private String defaultIfBlank(String value, String defaultValue) {
return StringUtils.isBlank(value) ? defaultValue : value;
}
private String getDefaultLLMModelId() {
try {
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
if (llmConfigs == null || llmConfigs.isEmpty()) {
@@ -0,0 +1,59 @@
package xiaozhi.modules.agent.service.impl;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.modules.agent.service.AgentSnapshotService;
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentSnapshotRedactionRunner implements SmartInitializingSingleton {
static final long ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS = 5_000;
static final long ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS = 15_000;
private final AgentSnapshotService agentSnapshotService;
@Override
public void afterSingletonsInstantiated() {
redactAndReport("startup");
}
@Scheduled(initialDelay = ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS,
fixedDelay = ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS)
public void redactLateRollingDeploymentWrites() {
redactAndReport("rolling-deployment");
}
private void redactAndReport(String trigger) {
long startedAt = System.nanoTime();
try {
long migrated = agentSnapshotService.redactLegacySnapshots();
long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
if (migrated > 0) {
log.warn("Agent snapshot legacy redaction trigger={} migrated={} durationMs={}. Rotate credentials "
+ "that may have appeared in historical snapshot URLs, cookies, sessions, or structured "
+ "headers.", trigger, migrated, durationMillis);
} else if ("startup".equals(trigger)) {
log.info("Agent snapshot legacy redaction startup pass completed: migrated=0 durationMs={}; "
+ "rolling-deployment compensation starts after {} ms and repeats every {} ms.",
durationMillis, ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS,
ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS);
}
} catch (RuntimeException exception) {
if ("startup".equals(trigger)) {
log.error("Agent snapshot legacy redaction failed during startup; blocking application startup "
+ "before it can accept traffic.", exception);
} else {
log.error("Agent snapshot legacy redaction failed during rolling-deployment compensation; the "
+ "scheduler will retry on its next run.", exception);
}
throw exception;
}
}
}
@@ -133,7 +133,7 @@ public class AgentTagServiceImpl extends BaseServiceImpl<AgentTagDao, AgentTagEn
}
if (tagIds != null && !tagIds.isEmpty()) {
List<AgentTagEntity> tagIdEntities = baseDao.selectBatchIds(tagIds);
List<AgentTagEntity> tagIdEntities = baseDao.selectByIds(tagIds);
for (AgentTagEntity tag : tagIdEntities) {
if (tag != null && (currentTagNames.contains(tag.getTagName()) ||
newTagNames.contains(tag.getTagName()))) {
@@ -4,8 +4,8 @@ import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import xiaozhi.modules.agent.dao.AgentTemplateDao;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -21,7 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
* @createDate 2025-03-22 11:48:18
*/
@Service
public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, AgentTemplateEntity>
public class AgentTemplateServiceImpl extends CrudRepository<AgentTemplateDao, AgentTemplateEntity>
implements AgentTemplateService {
/**
@@ -22,7 +22,7 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
@@ -46,7 +46,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
*/
@Service
@Slf4j
public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao, AgentVoicePrintEntity>
public class AgentVoicePrintServiceImpl extends CrudRepository<AgentVoicePrintDao, AgentVoicePrintEntity>
implements AgentVoicePrintService {
private final AgentChatAudioService agentChatAudioService;
private final RestTemplate restTemplate;
@@ -1,31 +1,59 @@
package xiaozhi.modules.agent.typehandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
import com.fasterxml.jackson.core.type.TypeReference;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
public class ContextProviderListTypeHandler extends AbstractJsonTypeHandler<List<ContextProviderDTO>> {
/**
* JSON type handler for context providers.
*
* <p>Do not extend MyBatis-Plus {@code AbstractJsonTypeHandler}: its constructor and JSON handler
* contract changed in MyBatis-Plus 3.5.6. {@link BaseTypeHandler} is part of MyBatis itself and
* keeps this handler compatible with both 3.5.5 and newer MyBatis-Plus releases.</p>
*/
public class ContextProviderListTypeHandler extends BaseTypeHandler<List<ContextProviderDTO>> {
private static final TypeReference<List<ContextProviderDTO>> CONTEXT_PROVIDER_LIST_TYPE = new TypeReference<>() {
};
@Override
protected List<ContextProviderDTO> parse(String json) {
public void setNonNullParameter(PreparedStatement ps, int i, List<ContextProviderDTO> parameter,
JdbcType jdbcType) throws SQLException {
ps.setString(i, JsonUtils.toJsonString(parameter));
}
@Override
public List<ContextProviderDTO> getNullableResult(ResultSet rs, String columnName) throws SQLException {
return parseNullable(rs.getString(columnName));
}
@Override
public List<ContextProviderDTO> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return parseNullable(rs.getString(columnIndex));
}
@Override
public List<ContextProviderDTO> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return parseNullable(cs.getString(columnIndex));
}
private List<ContextProviderDTO> parseNullable(String json) {
if (StringUtils.isBlank(json)) {
return Collections.emptyList();
return null;
}
List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE);
return providers == null ? Collections.emptyList() : providers;
}
@Override
protected String toJson(List<ContextProviderDTO> obj) {
return JsonUtils.toJsonString(obj == null ? Collections.emptyList() : obj);
}
}
@@ -18,13 +18,17 @@ public class AgentSnapshotVO {
private List<String> changedFields;
private List<String> fieldOrder;
private String source;
@Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值")
@Schema(description = "恢复来源快照ID,仅恢复结果版本有值")
private String restoreFromSnapshotId;
@Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值")
@Schema(description = "恢复来源版本号,仅恢复结果版本有值")
private Integer restoreFromVersionNo;
@Schema(description = "创建者,表示触发本次快照写入的操作人")
private Long creator;
private Date createdAt;
private AgentSnapshotDataDTO snapshotData;
private AgentSnapshotDataDTO afterSnapshotData;
@Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值")
private AgentSnapshotDataDTO currentSnapshotData;
@Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值")
private String currentStateToken;
}
@@ -10,7 +10,7 @@ public interface ConfigService {
* @param isCache 是否缓存
* @return 配置信息
*/
Object getConfig(Boolean isCache);
Map<String, Object> getConfig(Boolean isCache);
/**
* 获取智能体模型配置
@@ -28,4 +28,4 @@ public interface ConfigService {
* @return 替换词列表,格式如 ["模板1|模板01", "模板2|模板02"]
*/
List<String> getCorrectWords(String macAddress);
}
}
@@ -65,12 +65,12 @@ public class ConfigServiceImpl implements ConfigService {
private final CorrectWordFileService correctWordFileService;
@Override
public Object getConfig(Boolean isCache) {
public Map<String, Object> getConfig(Boolean isCache) {
if (isCache) {
// 先从Redis获取配置
Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
if (cachedConfig != null) {
return cachedConfig;
return JsonUtils.toStringObjectMap(cachedConfig);
}
}
@@ -123,7 +123,7 @@ public class ConfigServiceImpl implements ConfigService {
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
// 管理控制台请求,返回getConfig的结果
redisUtils.delete(redisKey); // 使用后清理
return (Map<String, Object>) getConfig(true);
return getConfig(true);
}
// 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
@@ -277,10 +277,10 @@ public class ConfigServiceImpl implements ConfigService {
// 遍历除最后一个key之外的所有key
for (int i = 0; i < keys.length - 1; i++) {
String key = keys[i];
if (!current.containsKey(key)) {
current.put(key, new HashMap<String, Object>());
}
current = (Map<String, Object>) current.get(key);
Object nestedConfig = current.computeIfAbsent(key, ignored -> new HashMap<String, Object>());
Map<String, Object> nestedMap = JsonUtils.toStringObjectMap(nestedConfig);
current.put(key, nestedMap);
current = nestedMap;
}
// 处理最后一个key
@@ -125,7 +125,9 @@ public class DeviceController {
return new Result<Void>().error("设备不存在");
}
BeanUtils.copyProperties(deviceUpdateDTO, entity);
deviceService.updateById(entity);
if (!deviceService.updateById(entity)) {
return new Result<Void>().error(ErrorCode.UPDATE_DATA_FAILED);
}
return new Result<Void>();
}
@@ -211,4 +213,4 @@ public class DeviceController {
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
return new Result<Void>();
}
}
}
@@ -1,6 +1,7 @@
package xiaozhi.modules.device.controller;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
@@ -78,8 +79,8 @@ public class OTAController {
@Hidden
public ResponseEntity<String> getOTA() {
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
if (StringUtils.isBlank(mqttUdpConfig)) {
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置");
if (!isNativeMqttReady() && !isConfiguredValue(mqttUdpConfig)) {
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址或未启用原生MQTT,请登录智控台,在参数管理找到【server.mqtt_gateway】或【mqtt_server.enabled/protocols.mqtt_enabled】配置");
}
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
@@ -92,6 +93,49 @@ public class OTAController {
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
}
private boolean isNativeMqttEnabled() {
String enabled = sysParamsService.getValue(Constant.MQTT_SERVER_ENABLED, false);
boolean serverEnabled = isConfiguredValue(enabled)
? isTrue(enabled)
: isTrue(sysParamsService.getValue(Constant.SERVER_MQTT_ENABLED, false));
boolean protocolEnabled = isTrue(
sysParamsService.getValue(Constant.PROTOCOLS_MQTT_ENABLED, false));
if (!protocolEnabled) {
String enabledProtocols = sysParamsService.getValue(Constant.PROTOCOLS_ENABLED, false);
if (StringUtils.isNotBlank(enabledProtocols)) {
String normalized = enabledProtocols
.replace("[", "")
.replace("]", "")
.replace("\"", "");
protocolEnabled = Arrays.stream(normalized.split("[;,\\s]+"))
.anyMatch("mqtt"::equalsIgnoreCase);
}
}
return serverEnabled && protocolEnabled;
}
private boolean isNativeMqttReady() {
if (!isNativeMqttEnabled()) {
return false;
}
String signatureKey = sysParamsService.getValue(
Constant.MQTT_SERVER_SIGNATURE_KEY, false);
if (!isConfiguredValue(signatureKey)) {
signatureKey = sysParamsService.getValue(
Constant.SERVER_MQTT_SECRET, false);
}
return isConfiguredValue(signatureKey);
}
private boolean isTrue(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
private boolean isConfiguredValue(String value) {
return StringUtils.isNotBlank(value) && !"null".equalsIgnoreCase(value.trim());
}
@SneakyThrows
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO) {
ObjectMapper objectMapper = new ObjectMapper();
@@ -12,6 +12,11 @@ import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
@Mapper
public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity> {
/**
* 新增设备通讯录记录
*/
int insertAddressBook(DeviceAddressBookEntity entity);
/**
* 获取设备通讯录列表
*/
@@ -31,4 +36,4 @@ public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity
* 批量删除设备相关的通讯录记录
*/
void deleteByMacAddresses(@Param("macAddresses") List<String> macAddresses);
}
}
@@ -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,12 +12,13 @@ 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;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -60,7 +60,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
Map<String, Map<String, String>> allBooks = getAllAddressBooks();
if (isAnswer) {
return postToMqtt("/api/call/accept", Map.of("mac", callerMac), "接听");
DeviceEntity callerDevice =
deviceService.getDeviceByMacAddress(callerMac);
if (callerDevice == null) {
return errorResult("接听失败,设备信息不存在");
}
return postCallAccept(
buildMqttClientId(callerDevice),
Map.of("mac", callerMac));
}
// 主动呼叫模式
@@ -80,6 +87,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
return errorResult("呼叫失败,您没有权限呼叫该设备");
}
DeviceEntity callerDevice =
deviceService.getDeviceByMacAddress(callerMac);
DeviceEntity targetDevice =
deviceService.getDeviceByMacAddress(targetMac);
if (callerDevice == null || targetDevice == null) {
return errorResult("呼叫失败,设备信息不存在");
}
// 获取目标设备如何称呼主叫方
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
String callerNickname = null;
@@ -87,15 +102,19 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
callerNickname = targetBook.get(callerMac.toLowerCase());
}
if (StringUtils.isBlank(callerNickname)) {
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
callerNickname = callerDevice.getAlias();
if (StringUtils.isBlank(callerNickname)) {
callerNickname = formatMacAsDeviceName(callerMac);
}
}
return postToMqtt("/api/call/request",
Map.of("caller_mac", callerMac, "target_mac", targetMac, "caller_nickname", callerNickname),
"呼叫");
return postCallRequest(
buildMqttClientId(callerDevice),
buildMqttClientId(targetDevice),
Map.of(
"caller_mac", callerMac,
"target_mac", targetMac,
"caller_nickname", callerNickname));
}
@Override
@@ -168,7 +187,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
alias = generateUniqueAlias(macAddress, targetMac, alias);
entity.setAlias(alias);
entity.setHasPermission(hasPermission);
deviceAddressBookDao.insert(entity);
deviceAddressBookDao.insertAddressBook(entity);
} else {
if (alias != null) {
updateAlias(macAddress, targetMac, alias);
@@ -196,46 +215,50 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
return result;
}
private Map<String, Object> postToMqtt(String path, Map<String, Object> body, String action) {
private Map<String, Object> postCallRequest(
String callerClientId,
String targetClientId,
Map<String, Object> body) {
try {
MqttManagementHttpClient.Response response =
createMqttManagementRouter().sendCallRequest(
callerClientId, targetClientId, body);
return parseCallResponse(response, "呼叫");
} catch (Exception e) {
return errorResult("呼叫失败,请稍后再试");
}
}
private Map<String, Object> postCallAccept(
String clientId,
Map<String, Object> body) {
try {
MqttManagementHttpClient.Response response =
createMqttManagementRouter().sendCallAccept(
clientId, body);
return parseCallResponse(response, "接听");
} catch (Exception e) {
return errorResult("接听失败,请稍后再试");
}
}
private Map<String, Object> parseCallResponse(
MqttManagementHttpClient.Response response,
String action) {
Map<String, Object> result = new HashMap<>();
result.put("status", "error");
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
String mqttSignatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|| StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) {
result.put("message", action + "失败,网关配置缺失");
if (response == null || StringUtils.isBlank(response.body())) {
result.put("message", action + "失败,MQTT管理配置缺失");
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();
if (StringUtils.isNotBlank(response)) {
Map<String, Object> gwResult = JSONUtil.parseObj(response);
result.put("status", gwResult.get("status"));
result.put("message", gwResult.get("message"));
Map<String, Object> backendResult =
JSONUtil.parseObj(response.body());
result.put("status", backendResult.get("status"));
result.put("message", backendResult.get("message"));
if (backendResult.containsKey("code")) {
result.put("code", backendResult.get("code"));
}
return result;
} catch (Exception e) {
@@ -244,6 +267,17 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
}
MqttManagementRouter createMqttManagementRouter() {
return new MqttManagementRouter(
new MqttManagementEndpointResolver(sysParamsService),
new MqttManagementHttpClient());
}
private String buildMqttClientId(DeviceEntity device) {
return MqttClientId.build(
device.getBoard(), device.getMacAddress());
}
private String formatMacAsDeviceName(String mac) {
if (StringUtils.isBlank(mac) || mac.length() < 2) {
return mac;
@@ -270,4 +304,4 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
return newAlias;
}
}
}
@@ -5,11 +5,13 @@ import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -31,15 +33,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;
@@ -56,6 +52,7 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
@@ -115,7 +112,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
if (ToolUtil.isEmpty(cacheMap)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
}
@@ -162,39 +159,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
*/
@Override
public String getDeviceOnlineData(String agentId) {
// 从系统参数中获取MQTT网关地址
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
return "";
}
// 构建完整的URL
String url = StrUtil.format("http://{}/api/devices/status", mqttGatewayUrl);
// 获取当前用户的设备列表
UserDetail user = SecurityUser.getUser();
List<DeviceEntity> devices = getUserDevices(user.getId(), agentId);
// 构建deviceIds数组
Set<String> deviceIds = devices.stream().map(o -> {
String macAddress = Optional.ofNullable(o.getMacAddress()).orElse("unknown").replace(":", "_");
String groupId = Optional.ofNullable(o.getBoard()).orElse("GID_default").replace(":", "_");
return StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
}).collect(Collectors.toSet());
Set<String> deviceIds = devices.stream()
.map(device -> MqttClientId.build(
device.getBoard(), device.getMacAddress()))
.collect(Collectors.toSet());
// 构建请求入参
Map<String, Set<String>> params = MapUtil
.builder(new HashMap<String, Set<String>>())
.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 createMqttManagementRouter()
.getMergedStatus(deviceIds);
}
// 返回响应
return "";
@@ -206,6 +184,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
response.setServer_time(buildServerTime());
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
String reportedBoard = deviceReport.getBoard() == null
? null
: deviceReport.getBoard().getType();
if (deviceById != null
&& StringUtils.isBlank(deviceById.getBoard())
&& StringUtils.isNotBlank(reportedBoard)) {
deviceById.setBoard(reportedBoard);
baseDao.updateById(deviceById);
}
// 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
if (deviceById == null) {
@@ -214,10 +201,9 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
response.setFirmware(firmware);
} else {
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
if (deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
// 只有在设备已绑定且明确开启自动升级时才返回固件升级信息
if (Integer.valueOf(1).equals(deviceById.getAutoUpdate())) {
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(reportedBoard,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
}
@@ -260,20 +246,43 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
response.setWebsocket(websocket);
// 添加MQTT UDP配置
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
// UDP key/nonce/endpoint is negotiated by the MQTT hello response.
// OTA only needs to provide the MQTT connection parameters.
String groupId = "GID_default";
if (deviceById != null && StringUtils.isNotBlank(deviceById.getBoard())) {
groupId = deviceById.getBoard();
} else if (deviceReport.getBoard() != null && StringUtils.isNotBlank(deviceReport.getBoard().getType())) {
groupId = deviceReport.getBoard().getType();
}
boolean mqttConfigured = false;
if (isNativeMqttEnabled()) {
try {
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
: "GID_default";
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
if (mqtt != null) {
mqtt.setEndpoint(mqttUdpConfig);
DeviceReportRespDTO.MQTT mqtt = buildNativeMqttConfig(macAddress, groupId, clientId);
String endpoint = buildNativeMqttEndpoint();
if (mqtt != null && StringUtils.isNotBlank(endpoint)) {
mqtt.setEndpoint(endpoint);
response.setMqtt(mqtt);
mqttConfigured = true;
}
} catch (Exception e) {
log.error("生成MQTT配置失败: {}", e.getMessage());
log.error("生成原生MQTT配置失败: {}", e.getMessage());
}
}
if (!mqttConfigured) {
// Native 未启用或配置无效时继续兼容 MQTT Gateway。
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
try {
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
if (mqtt != null) {
mqtt.setEndpoint(mqttUdpConfig);
response.setMqtt(mqtt);
}
} catch (Exception e) {
log.error("生成MQTT配置失败: {}", e.getMessage());
}
}
}
@@ -304,15 +313,21 @@ 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 -> {
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
vo.setDeviceType(device.getBoard());
// 设置UTC时间戳供前端使用时区转换
if (device.getLastConnectedAt() != null) {
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
}
return vo;
}).toList();
return devices.stream().map(this::toUserShowDeviceListVO).toList();
}
private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) {
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
vo.setDeviceType(device.getBoard());
vo.setBoard(device.getBoard());
vo.setAutoUpdate(device.getAutoUpdate());
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
return vo;
}
private Long toTimestamp(Date date) {
return date == null ? null : date.getTime();
}
@Override
@@ -385,17 +400,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();
// 计算页数
@@ -425,7 +434,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey);
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(dataKey));
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode;
@@ -645,6 +654,210 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return String.format("%s.%d", signatureBase64, timestamp);
}
private boolean isNativeMqttEnabled() {
String enabled = sysParamsService.getValue(Constant.MQTT_SERVER_ENABLED, true);
String legacyEnabled = sysParamsService.getValue(Constant.SERVER_MQTT_ENABLED, true);
boolean serverEnabled = isConfiguredValue(enabled)
? isTrue(enabled)
: isTrue(legacyEnabled);
String protoEnabled = sysParamsService.getValue(Constant.PROTOCOLS_MQTT_ENABLED, true);
String enabledProtocols = sysParamsService.getValue(Constant.PROTOCOLS_ENABLED, true);
boolean protocolEnabled = isTrue(protoEnabled);
if (!protocolEnabled && StringUtils.isNotBlank(enabledProtocols)) {
String normalizedProtocols = enabledProtocols
.replace("[", "")
.replace("]", "")
.replace("\"", "");
protocolEnabled = Arrays.stream(normalizedProtocols.split("[;,\\s]+"))
.anyMatch("mqtt"::equalsIgnoreCase);
}
return serverEnabled && protocolEnabled;
}
private boolean isTrue(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
private boolean isConfiguredValue(String value) {
return StringUtils.isNotBlank(value)
&& !"null".equalsIgnoreCase(value.trim())
&& !value.contains("");
}
private String buildNativeMqttEndpoint() {
String publicEndpoint = sysParamsService.getValue(Constant.MQTT_SERVER_PUBLIC_ENDPOINT, true);
String host = sysParamsService.getValue(Constant.MQTT_SERVER_HOST, true);
EndpointParts endpointParts = parseEndpoint(publicEndpoint);
EndpointParts hostParts = parseEndpoint(host);
if ((isConfiguredValue(publicEndpoint) && endpointParts == null)
|| (isConfiguredValue(host) && hostParts == null)) {
return null;
}
EndpointParts selectedEndpoint = null;
if (endpointParts != null && isClientReachableMqttHost(endpointParts.host)) {
selectedEndpoint = endpointParts;
} else if (hostParts != null && isClientReachableMqttHost(hostParts.host)) {
selectedEndpoint = hostParts;
}
if (selectedEndpoint == null) {
return null;
}
String mqttHost = selectedEndpoint.host;
Integer port = selectedEndpoint.port;
if (port == null) {
port = parseInt(sysParamsService.getValue(Constant.MQTT_SERVER_PORT, true), 1883);
}
if (StringUtils.isBlank(mqttHost) || port == null) {
return null;
}
return formatEndpoint(mqttHost, port);
}
private boolean isClientReachableMqttHost(String host) {
if (StringUtils.isBlank(host)) {
return false;
}
String normalized = host.trim().toLowerCase(Locale.ROOT);
return !"null".equals(normalized)
&& !"localhost".equals(normalized)
&& !"localhost.localdomain".equals(normalized)
&& !"0.0.0.0".equals(normalized)
&& !normalized.startsWith("127.");
}
private Integer parseInt(String value, Integer defaultValue) {
if (StringUtils.isBlank(value) || "null".equalsIgnoreCase(value)) {
return defaultValue;
}
try {
int port = Integer.parseInt(value);
if (port < 1 || port > 65535) {
return null;
}
return port;
} catch (NumberFormatException e) {
return null;
}
}
private String formatEndpoint(String host, int port) {
String normalizedHost = host.trim();
if (normalizedHost.indexOf(':') >= 0) {
return null;
}
return normalizedHost + ":" + port;
}
private static class EndpointParts {
private final String host;
private final Integer port;
private EndpointParts(String host, Integer port) {
this.host = host;
this.port = port;
}
}
private EndpointParts parseEndpoint(String raw) {
if (StringUtils.isBlank(raw) || "null".equalsIgnoreCase(raw)) {
return new EndpointParts(null, null);
}
String trimmed = raw.trim();
trimmed = trimmed.replaceFirst("(?i)^(mqtt|tcp|ssl|ws|wss|http|https)://", "");
int slashIdx = trimmed.indexOf('/');
if (slashIdx >= 0) {
trimmed = trimmed.substring(0, slashIdx);
}
if (StringUtils.isBlank(trimmed) || trimmed.startsWith("[")
|| trimmed.indexOf(':') != trimmed.lastIndexOf(':')) {
return null;
}
String host = trimmed;
Integer port = null;
int colon = trimmed.lastIndexOf(':');
if (colon >= 0) {
if (colon == 0 || colon == trimmed.length() - 1) {
return null;
}
port = parseInt(trimmed.substring(colon + 1), null);
if (port == null) {
return null;
}
host = trimmed.substring(0, colon);
}
if (!isValidEndpointHost(host)) {
return null;
}
return new EndpointParts(host, port);
}
private boolean isValidEndpointHost(String host) {
if (StringUtils.isBlank(host)
|| host.length() > 253
|| host.chars().anyMatch(Character::isWhitespace)
|| host.indexOf('@') >= 0
|| host.indexOf('?') >= 0
|| host.indexOf('#') >= 0
|| host.indexOf('\\') >= 0) {
return false;
}
return Arrays.stream(host.split("\\.", -1))
.allMatch(label -> !label.isEmpty()
&& label.length() <= 63
&& Character.isLetterOrDigit(label.charAt(0))
&& Character.isLetterOrDigit(label.charAt(label.length() - 1))
&& label.chars().allMatch(ch ->
Character.isLetterOrDigit(ch)
|| ch == '-'
|| ch == '_'));
}
private String buildMqttUsername() throws Exception {
Map<String, String> userData = new HashMap<>();
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String clientIp = request.getRemoteAddr();
userData.put("ip", clientIp);
}
} catch (Exception e) {
userData.put("ip", "unknown");
}
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
return Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
}
private DeviceReportRespDTO.MQTT buildNativeMqttConfig(String macAddress, String groupId, String clientId)
throws Exception {
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
String mqttClientId = MqttClientId.build(groupId, macAddress);
String username = buildMqttUsername();
String password = "";
String signatureKey = sysParamsService.getValue(Constant.MQTT_SERVER_SIGNATURE_KEY, true);
if (!isConfiguredValue(signatureKey)) {
signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
}
if (!isConfiguredValue(signatureKey)) {
log.error("原生MQTT已启用但未配置签名密钥,跳过原生MQTT配置下发");
return null;
}
password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
DeviceReportRespDTO.MQTT mqtt = new DeviceReportRespDTO.MQTT();
mqtt.setClient_id(mqttClientId);
mqtt.setUsername(username);
mqtt.setPassword(password);
mqtt.setPublish_topic("device-server");
mqtt.setSubscribe_topic("devices/p2p/" + deviceIdSafeStr);
return mqtt;
}
/**
* 构建MQTT配置信息
*
@@ -662,28 +875,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
// 构建客户端ID格式:groupId@@@macAddress@@@uuid
String groupIdSafeStr = groupId.replace(":", "_");
String deviceIdSafeStr = macAddress.replace(":", "_");
String mqttClientId = String.format("%s@@@%s@@@%s", groupIdSafeStr, deviceIdSafeStr, deviceIdSafeStr);
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
String mqttClientId = MqttClientId.build(groupId, macAddress);
// 构建用户数据(包含IP等信息)
Map<String, String> userData = new HashMap<>();
// 尝试获取客户端IP
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String clientIp = request.getRemoteAddr();
userData.put("ip", clientIp);
}
} catch (Exception e) {
userData.put("ip", "unknown");
}
// 将用户数据编码为Base64 JSON
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
String username = Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
String username = buildMqttUsername();
// 生成密码签名
String password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
@@ -699,30 +894,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return mqtt;
}
/**
* 生成BearerToken
*/
private String generateBearerToken() {
try {
String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN);
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
if (ToolUtil.isEmpty(signatureKey)) {
return null;
}
return DigestUtil.sha256Hex(dateStr + signatureKey);
} catch (Exception e) {
return null;
}
MqttManagementRouter createMqttManagementRouter() {
return new MqttManagementRouter(
new MqttManagementEndpointResolver(sysParamsService),
new MqttManagementHttpClient());
}
@Override
public Object getDeviceTools(String deviceId) {
// 从系统参数中获取MQTT网关地址
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
return null;
}
// 获取设备信息
DeviceEntity device = baseDao.selectById(deviceId);
if (device == null) {
@@ -736,19 +915,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
// 构建clientId
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
// 构建完整的URL
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
String clientId = MqttClientId.build(
device.getBoard(), device.getMacAddress());
// 存储所有工具列表
List<Object> allTools = new ArrayList<>();
String cursor = null;
Set<String> seenCursors = new java.util.HashSet<>();
int pageCount = 0;
MqttManagementRouter managementRouter =
createMqttManagementRouter();
MqttManagementEndpointResolver.Backend selectedBackend = null;
// 循环获取分页数据
while (true) {
while (pageCount++ < 32) {
// 构建params
Map<String, Object> paramsMap = MapUtil.builder(new HashMap<String, Object>())
.put("withUserTools", true)
@@ -773,32 +953,44 @@ 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();
MqttManagementHttpClient.Response response =
managementRouter.sendReadOnlyCommand(
clientId, requestBody, selectedBackend);
if (response == null || !response.isSuccessfulHttp()) {
return null;
}
String resultMessage =
response.body();
if (selectedBackend == null) {
selectedBackend = response.backend();
}
// 解析响应
if (StringUtils.isBlank(resultMessage)) {
break;
return null;
}
JSONObject jsonObject = JSONUtil.parseObj(resultMessage);
JSONObject jsonObject;
try {
jsonObject = JSONUtil.parseObj(resultMessage);
} catch (RuntimeException e) {
return null;
}
if (!jsonObject.getBool("success", false)) {
break;
return null;
}
JSONObject data = jsonObject.getJSONObject("data");
if (data == null) {
break;
return null;
}
// 获取当前页的工具列表
JSONArray tools = data.getJSONArray("tools");
if (tools != null && !tools.isEmpty()) {
if (tools == null) {
return null;
}
if (!tools.isEmpty()) {
allTools.addAll(tools);
}
@@ -806,29 +998,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
String nextCursor = data.getStr("nextCursor");
if (StringUtils.isBlank(nextCursor)) {
// 没有下一页了
break;
Map<String, Object> resultData = new HashMap<>();
resultData.put("tools", allTools);
return resultData;
}
if (!seenCursors.add(nextCursor)) {
log.warn("MQTT设备工具列表返回重复cursor,终止分页: {}",
nextCursor);
return null;
}
cursor = nextCursor;
}
// 构建返回结果
if (allTools.isEmpty()) {
return null;
}
Map<String, Object> resultData = new HashMap<>();
resultData.put("tools", allTools);
return resultData;
return null;
}
@Override
public Object callDeviceTool(String deviceId, String toolName, Map<String, Object> arguments) {
// 从系统参数中获取MQTT网关地址
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
return null;
}
// 获取设备信息
DeviceEntity device = baseDao.selectById(deviceId);
if (device == null) {
@@ -842,12 +1028,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
// 构建clientId
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
// 构建完整的URL
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
String clientId = MqttClientId.build(
device.getBoard(), device.getMacAddress());
// 构建请求体
Map<String, Object> params = MapUtil
@@ -870,13 +1052,11 @@ 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();
MqttManagementHttpClient.Response response =
createMqttManagementRouter()
.sendMutatingCommand(clientId, requestBody);
String resultMessage =
response == null ? null : response.body();
// 解析响应
if (StringUtils.isNotBlank(resultMessage)) {
@@ -0,0 +1,28 @@
package xiaozhi.modules.device.service.impl;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
final class MqttClientId {
private MqttClientId() {
}
static String build(String board, String macAddress) {
String groupId = StringUtils.defaultIfBlank(
board, "GID_default").trim().replace(":", "_");
String deviceId = StringUtils.defaultIfBlank(macAddress, "unknown")
.trim()
.replace(":", "_");
return groupId + "@@@" + deviceId + "@@@" + deviceId;
}
static String normalizeDeviceId(String macAddress) {
return StringUtils.defaultIfBlank(macAddress, "unknown")
.trim()
.toLowerCase(Locale.ROOT)
.replace(":", "_")
.replace("-", "_");
}
}
@@ -0,0 +1,122 @@
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 = postJsonResponse(
url, jsonBody, signatureKey, now, 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 GatewayResponse postJsonResponse(
String url,
String jsonBody,
String signatureKey,
Instant now,
int timeoutMillis) {
return executeWithDateFallback(
signatureKey,
now,
token -> executeRequest(
url, jsonBody, token, timeoutMillis));
}
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;
}
}
}
@@ -0,0 +1,183 @@
package xiaozhi.modules.device.service.impl;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.sys.service.SysParamsService;
class MqttManagementEndpointResolver {
enum Backend {
NATIVE,
GATEWAY
}
record Endpoint(Backend backend, String baseUrl, String signatureKey) {
}
private final SysParamsService sysParamsService;
MqttManagementEndpointResolver(SysParamsService sysParamsService) {
this.sysParamsService = sysParamsService;
}
List<Endpoint> resolve() {
List<Endpoint> endpoints = new ArrayList<>(2);
resolveNative().ifPresent(endpoints::add);
resolveGateway().ifPresent(endpoints::add);
return endpoints;
}
java.util.Optional<Endpoint> resolveNative() {
if (!isNativeMqttEnabled()) {
return java.util.Optional.empty();
}
String endpoint = normalizeHttpEndpoint(sysParamsService.getValue(
Constant.MQTT_SERVER_MANAGER_API, true));
String signatureKey = firstConfigured(
sysParamsService.getValue(
Constant.MQTT_SERVER_MANAGER_API_SECRET, true),
sysParamsService.getValue(
Constant.MQTT_SERVER_SIGNATURE_KEY, true),
sysParamsService.getValue(
Constant.SERVER_MQTT_SECRET, true));
if (endpoint == null || signatureKey == null) {
throw new ManagementConfigurationException(
"原生MQTT已启用但管理端点或密钥无效");
}
return java.util.Optional.of(
new Endpoint(Backend.NATIVE, endpoint, signatureKey));
}
java.util.Optional<Endpoint> resolveGateway() {
String rawGateway = sysParamsService.getValue(
Constant.SERVER_MQTT_GATEWAY, true);
String rawEndpoint = sysParamsService.getValue(
Constant.SERVER_MQTT_MANAGER_API, true);
if (configured(rawGateway) == null
&& configured(rawEndpoint) == null) {
return java.util.Optional.empty();
}
String endpoint = normalizeHttpEndpoint(rawEndpoint);
String signatureKey = configured(
sysParamsService.getValue(
Constant.SERVER_MQTT_SECRET, true));
if (endpoint == null || signatureKey == null) {
throw new ManagementConfigurationException(
"MQTT Gateway管理端点或密钥无效");
}
return java.util.Optional.of(
new Endpoint(Backend.GATEWAY, endpoint, signatureKey));
}
boolean isNativeMqttEnabled() {
String enabled = sysParamsService.getValue(
Constant.MQTT_SERVER_ENABLED, true);
String legacyEnabled = sysParamsService.getValue(
Constant.SERVER_MQTT_ENABLED, true);
boolean serverEnabled = configured(enabled) != null
? isTrue(enabled)
: isTrue(legacyEnabled);
String protocolEnabled = sysParamsService.getValue(
Constant.PROTOCOLS_MQTT_ENABLED, true);
String enabledProtocols = sysParamsService.getValue(
Constant.PROTOCOLS_ENABLED, true);
boolean enabledByProtocol = isTrue(protocolEnabled);
if (!enabledByProtocol && StringUtils.isNotBlank(enabledProtocols)) {
String normalized = enabledProtocols
.replace("[", "")
.replace("]", "")
.replace("\"", "");
enabledByProtocol = Arrays.stream(
normalized.split("[;,\\s]+"))
.anyMatch("mqtt"::equalsIgnoreCase);
}
return serverEnabled && enabledByProtocol;
}
static String normalizeHttpEndpoint(String raw) {
String configured = configured(raw);
if (configured == null) {
return null;
}
boolean hasScheme = configured.matches(
"^[A-Za-z][A-Za-z0-9+.-]*://.*");
if (hasScheme
&& !configured.matches("(?i)^https?://.*")) {
return null;
}
String candidate = hasScheme ? configured : "http://" + configured;
try {
URI uri = new URI(candidate);
String scheme = uri.getScheme();
if (scheme == null
|| (!"http".equalsIgnoreCase(scheme)
&& !"https".equalsIgnoreCase(scheme))
|| StringUtils.isBlank(uri.getHost())
|| uri.getUserInfo() != null
|| uri.getQuery() != null
|| uri.getFragment() != null
|| uri.getPort() == 0
|| uri.getPort() > 65535) {
return null;
}
String path = uri.getPath();
if (path == null || "/".equals(path)) {
path = "";
} else {
path = path.replaceAll("/+$", "");
}
return new URI(
scheme.toLowerCase(Locale.ROOT),
null,
uri.getHost(),
uri.getPort(),
path,
null,
null).toString();
} catch (URISyntaxException | IllegalArgumentException e) {
return null;
}
}
private static String firstConfigured(String... values) {
for (String value : values) {
String normalized = configured(value);
if (normalized != null) {
return normalized;
}
}
return null;
}
private static String configured(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
String normalized = value.trim();
if ("null".equalsIgnoreCase(normalized)
|| normalized.contains("")) {
return null;
}
return normalized;
}
private static boolean isTrue(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
static final class ManagementConfigurationException
extends RuntimeException {
ManagementConfigurationException(String message) {
super(message);
}
}
}
@@ -0,0 +1,56 @@
package xiaozhi.modules.device.service.impl;
import java.time.Instant;
import cn.hutool.json.JSONUtil;
class MqttManagementHttpClient {
private final int timeoutMillis;
MqttManagementHttpClient() {
this(5000);
}
MqttManagementHttpClient(int timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
Response post(
MqttManagementEndpointResolver.Endpoint endpoint,
String path,
Object requestBody) {
String url = appendPath(endpoint.baseUrl(), path);
MqttGatewayAuthorization.GatewayResponse response =
MqttGatewayAuthorization.postJsonResponse(
url,
JSONUtil.toJsonStr(requestBody),
endpoint.signatureKey(),
Instant.now(),
timeoutMillis);
return new Response(
endpoint.backend(),
response.statusCode(),
response.body());
}
static String appendPath(String baseUrl, String path) {
String normalizedBase = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
String normalizedPath = path.startsWith("/")
? path
: "/" + path;
return normalizedBase + normalizedPath;
}
record Response(
MqttManagementEndpointResolver.Backend backend,
int statusCode,
String body) {
boolean isSuccessfulHttp() {
return statusCode >= 200 && statusCode < 300;
}
}
}
@@ -0,0 +1,431 @@
package xiaozhi.modules.device.service.impl;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
final class MqttManagementRouter {
private final MqttManagementEndpointResolver endpointResolver;
private final MqttManagementHttpClient httpClient;
MqttManagementRouter(
MqttManagementEndpointResolver endpointResolver,
MqttManagementHttpClient httpClient) {
this.endpointResolver = endpointResolver;
this.httpClient = httpClient;
}
String getMergedStatus(Set<String> clientIds) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return "";
}
Map<String, MutableDeviceStatus> merged =
initializeStatuses(clientIds);
int successfulBackends = 0;
RuntimeException lastFailure = null;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response response =
httpClient.post(
endpoint,
"/api/devices/status",
Map.of("clientIds", clientIds));
if (!response.isSuccessfulHttp()) {
throw new IllegalStateException(
"MQTT管理状态查询失败: "
+ response.statusCode());
}
mergeStatusResponse(
merged, endpoint.backend(), response.body());
successfulBackends++;
} catch (RuntimeException e) {
lastFailure = e;
}
}
if (lastFailure != null
|| successfulBackends != endpoints.size()) {
throw new ManagementUnavailableException(
"无法确认全部MQTT后端状态", lastFailure);
}
Map<String, Object> result = new LinkedHashMap<>();
merged.forEach((clientId, status) ->
result.put(clientId, status.toMap()));
return JSONUtil.toJsonStr(result);
}
MqttManagementHttpClient.Response sendReadOnlyCommand(
String clientId, Object requestBody) {
return sendReadOnlyCommand(clientId, requestBody, null);
}
MqttManagementHttpClient.Response sendReadOnlyCommand(
String clientId,
Object requestBody,
MqttManagementEndpointResolver.Backend preferredBackend) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return null;
}
if (preferredBackend != null) {
MqttManagementEndpointResolver.Endpoint endpoint =
endpoints.stream()
.filter(candidate ->
candidate.backend()
== preferredBackend)
.findFirst()
.orElseThrow(() ->
new ManagementUnavailableException(
"分页MQTT后端配置已变化",
null));
return sendCommand(endpoint, clientId, requestBody);
}
MqttManagementHttpClient.Response lastResponse = null;
RuntimeException lastFailure = null;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response response =
sendCommand(endpoint, clientId, requestBody);
lastResponse = response;
if (isCommandSuccess(response)) {
return response;
}
} catch (RuntimeException e) {
lastFailure = e;
}
}
if (lastResponse != null) {
return lastResponse;
}
throw new ManagementUnavailableException(
"MQTT管理服务均不可用", lastFailure);
}
MqttManagementHttpClient.Response sendMutatingCommand(
String clientId, Object requestBody) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return null;
}
List<MqttManagementEndpointResolver.Endpoint> online =
new ArrayList<>();
int successfulStatusBackends = 0;
RuntimeException lastStatusFailure = null;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response statusResponse =
httpClient.post(
endpoint,
"/api/devices/status",
Map.of("clientIds", Set.of(clientId)));
if (!statusResponse.isSuccessfulHttp()) {
throw new IllegalStateException(
"MQTT管理状态查询失败: "
+ statusResponse.statusCode());
}
Map<String, BackendDeviceStatus> statuses =
parseStatusResponse(
statusResponse.body(),
Set.of(clientId));
successfulStatusBackends++;
if (statuses.get(clientId).exists()) {
online.add(endpoint);
}
} catch (RuntimeException e) {
lastStatusFailure = e;
}
}
if (lastStatusFailure != null
|| successfulStatusBackends != endpoints.size()) {
throw new ManagementUnavailableException(
"无法确认设备所在的MQTT后端",
lastStatusFailure);
}
if (online.isEmpty()) {
return offlineResponse(endpoints.get(0).backend());
}
if (online.size() > 1) {
return commandErrorResponse(
endpoints.get(0).backend(),
409,
"设备同时存在于多个MQTT后端",
"DEVICE_BACKEND_AMBIGUOUS");
}
return sendCommand(online.get(0), clientId, requestBody);
}
MqttManagementHttpClient.Response sendCallRequest(
String callerClientId,
String targetClientId,
Object requestBody) {
Set<String> clientIds = new java.util.LinkedHashSet<>();
clientIds.add(callerClientId);
clientIds.add(targetClientId);
return sendCallMutation(
clientIds,
"/api/call/request",
requestBody);
}
MqttManagementHttpClient.Response sendCallAccept(
String clientId, Object requestBody) {
return sendCallMutation(
Set.of(clientId),
"/api/call/accept",
requestBody);
}
private MqttManagementHttpClient.Response sendCallMutation(
Set<String> clientIds,
String path,
Object requestBody) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return null;
}
Map<String, List<MqttManagementEndpointResolver.Endpoint>> owners =
new LinkedHashMap<>();
clientIds.forEach(clientId ->
owners.put(clientId, new ArrayList<>()));
RuntimeException statusFailure = null;
int successfulStatusBackends = 0;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response response =
httpClient.post(
endpoint,
"/api/devices/status",
Map.of("clientIds", clientIds));
if (!response.isSuccessfulHttp()) {
throw new IllegalStateException(
"MQTT管理状态查询失败: "
+ response.statusCode());
}
Map<String, BackendDeviceStatus> statuses =
parseStatusResponse(response.body(), clientIds);
owners.forEach((clientId, matches) -> {
if (statuses.get(clientId).exists()) {
matches.add(endpoint);
}
});
successfulStatusBackends++;
} catch (RuntimeException e) {
statusFailure = e;
}
}
if (statusFailure != null
|| successfulStatusBackends != endpoints.size()) {
throw new ManagementUnavailableException(
"无法确认通话设备所在的MQTT后端",
statusFailure);
}
for (Map.Entry<String, List<MqttManagementEndpointResolver.Endpoint>>
owner : owners.entrySet()) {
if (owner.getValue().isEmpty()) {
return callErrorResponse(
endpoints.get(0).backend(),
404,
"offline",
"设备不在线",
"DEVICE_OFFLINE");
}
if (owner.getValue().size() > 1) {
return callErrorResponse(
endpoints.get(0).backend(),
409,
"error",
"设备同时存在于多个MQTT后端",
"DEVICE_BACKEND_AMBIGUOUS");
}
}
MqttManagementEndpointResolver.Endpoint selected =
owners.values().iterator().next().get(0);
boolean sameBackend = owners.values().stream()
.allMatch(matches -> matches.get(0).equals(selected));
if (!sameBackend) {
return callErrorResponse(
selected.backend(),
409,
"error",
"跨MQTT后端通话暂不支持",
"CROSS_BACKEND_CALL_UNSUPPORTED");
}
return httpClient.post(selected, path, requestBody);
}
private MqttManagementHttpClient.Response sendCommand(
MqttManagementEndpointResolver.Endpoint endpoint,
String clientId,
Object requestBody) {
String encodedClientId = URLEncoder.encode(
clientId, StandardCharsets.UTF_8)
.replace("+", "%20");
return httpClient.post(
endpoint,
"/api/commands/" + encodedClientId,
requestBody);
}
private static Map<String, MutableDeviceStatus> initializeStatuses(
Set<String> clientIds) {
Map<String, MutableDeviceStatus> result = new LinkedHashMap<>();
clientIds.forEach(clientId ->
result.put(clientId, new MutableDeviceStatus()));
return result;
}
private static void mergeStatusResponse(
Map<String, MutableDeviceStatus> merged,
MqttManagementEndpointResolver.Backend backend,
String body) {
Map<String, BackendDeviceStatus> response =
parseStatusResponse(body, merged.keySet());
merged.forEach((clientId, status) -> {
BackendDeviceStatus value = response.get(clientId);
status.exists |= value.exists();
status.alive |= value.alive();
if (value.exists()) {
status.backends.add(
backend.name().toLowerCase());
}
});
}
private static Map<String, BackendDeviceStatus> parseStatusResponse(
String body, Set<String> clientIds) {
JSONObject response;
try {
response = JSONUtil.parseObj(body);
} catch (RuntimeException e) {
throw new IllegalStateException(
"MQTT管理状态响应不是JSON对象", e);
}
Map<String, BackendDeviceStatus> statuses =
new LinkedHashMap<>();
for (String clientId : clientIds) {
Object rawStatus = response.get(clientId);
if (!(rawStatus instanceof JSONObject status)
|| !status.containsKey("exists")
|| !status.containsKey("isAlive")) {
throw new IllegalStateException(
"MQTT管理状态响应缺少设备状态: " + clientId);
}
Object rawExists = status.get("exists");
Object rawAlive = status.get("isAlive");
if (!(rawExists instanceof Boolean exists)
|| !(rawAlive instanceof Boolean alive)
|| (!exists && alive)) {
throw new IllegalStateException(
"MQTT管理状态响应字段无效: " + clientId);
}
statuses.put(
clientId,
new BackendDeviceStatus(exists, alive));
}
return statuses;
}
private static boolean isCommandSuccess(
MqttManagementHttpClient.Response response) {
if (response == null || !response.isSuccessfulHttp()) {
return false;
}
try {
return JSONUtil.parseObj(response.body())
.getBool("success", false);
} catch (Exception e) {
return false;
}
}
private static MqttManagementHttpClient.Response offlineResponse(
MqttManagementEndpointResolver.Backend backend) {
return new MqttManagementHttpClient.Response(
backend,
404,
JSONUtil.toJsonStr(Map.of(
"success", false,
"error", "设备未连接",
"code", "DEVICE_OFFLINE",
"dispatchAttempted", false)));
}
private static MqttManagementHttpClient.Response callErrorResponse(
MqttManagementEndpointResolver.Backend backend,
int statusCode,
String status,
String message,
String code) {
return new MqttManagementHttpClient.Response(
backend,
statusCode,
JSONUtil.toJsonStr(Map.of(
"status", status,
"message", message,
"code", code)));
}
private static MqttManagementHttpClient.Response commandErrorResponse(
MqttManagementEndpointResolver.Backend backend,
int statusCode,
String error,
String code) {
return new MqttManagementHttpClient.Response(
backend,
statusCode,
JSONUtil.toJsonStr(Map.of(
"success", false,
"error", error,
"code", code,
"dispatchAttempted", false)));
}
static final class ManagementUnavailableException
extends RuntimeException {
ManagementUnavailableException(
String message, Throwable cause) {
super(message, cause);
}
}
private static final class MutableDeviceStatus {
private boolean exists;
private boolean alive;
private final List<String> backends = new ArrayList<>();
Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("isAlive", alive);
result.put("exists", exists);
result.put("backends", backends);
return result;
}
}
private record BackendDeviceStatus(boolean exists, boolean alive) {
}
}
@@ -56,7 +56,7 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
@Override
@@ -82,4 +82,4 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
.last("LIMIT 1");
return baseDao.selectOne(wrapper);
}
}
}
@@ -32,17 +32,20 @@ public class UserShowDeviceListVO {
@Schema(description = "设备别名")
private String alias;
@Schema(description = "开启OTA")
private Integer otaUpgrade;
@Schema(description = "自动更新开关(0关闭/1开启)")
private Integer autoUpdate;
@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;
}
}
@@ -149,8 +149,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId);
// 使用 Jackson 将 DTO 转为 Map 作为查询参数
@SuppressWarnings("unchecked")
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
});
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
@@ -174,13 +174,12 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
.pageSize(1)
.build();
@SuppressWarnings("unchecked")
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
});
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
Object dataObj = response.get("data");
if (dataObj instanceof Map) {
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
if (dataObj instanceof Map<?, ?> dataMap) {
List<?> documents = (List<?>) dataMap.get("docs");
if (documents != null && !documents.isEmpty()) {
return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class);
@@ -567,8 +566,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
return new PageData<>(new ArrayList<>(), 0);
}
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
List<Map<String, Object>> documents = (List<Map<String, Object>>) dataMap.get("docs");
Map<?, ?> dataMap = Map.class.cast(dataObj);
List<?> documents = List.class.cast(dataMap.get("docs"));
if (documents == null || documents.isEmpty()) {
// RAGFlow 明确返回了空文档列表,这是合法的"真空"
return new PageData<>(new ArrayList<>(), 0);
@@ -679,7 +678,9 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
dto.setChunkMethod(info.getChunkMethod().name().toLowerCase());
}
if (info.getParserConfig() != null) {
dto.setParserConfig(objectMapper.convertValue(info.getParserConfig(), Map.class));
dto.setParserConfig(objectMapper.convertValue(info.getParserConfig(),
new TypeReference<Map<String, Object>>() {
}));
}
return dto;
@@ -717,4 +718,4 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
return !multipartFile.isEmpty();
}
}
}
}
@@ -116,13 +116,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("messages", List.of(message));
requestBody.put("temperature", temperature != null ? temperature : 0.7);
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
@@ -212,13 +210,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("messages", List.of(message));
requestBody.put("temperature", 0.2);
requestBody.put("max_tokens", 2000);
@@ -368,13 +364,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("messages", List.of(message));
requestBody.put("temperature", 0.3);
requestBody.put("max_tokens", 50);
@@ -422,4 +416,4 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
return null;
}
}
}
@@ -362,14 +362,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
if (SensitiveDataUtils.isSensitiveField(key)) {
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
updatedJson.put(key, value);
updatedJson.set(key, value);
}
} else if (value instanceof JSONObject) {
// 递归处理嵌套JSON
mergeJson(updatedJson, key, (JSONObject) value);
} else {
// 非敏感字段直接更新
updatedJson.put(key, value);
updatedJson.set(key, value);
}
}
@@ -405,7 +405,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
// 如果 original 中不存在 key,创建一个新的 JSON 对象
if (!original.containsKey(key)) {
original.put(key, new JSONObject());
original.set(key, new JSONObject());
}
// 获取 original 中的子对象
@@ -420,7 +420,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象",
key, originalValue != null ? originalValue.getClass().getSimpleName() : "null");
originalChild = new JSONObject();
original.put(key, originalChild);
original.set(key, originalChild);
}
for (String childKey : updated.keySet()) {
@@ -430,7 +430,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
} else {
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
(childValue instanceof String && !isMaskedValue((String) childValue))) {
originalChild.put(childKey, childValue);
originalChild.set(childKey, childValue);
}
}
}
@@ -164,7 +164,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
@Override
public void delete(List<String> ids) {
if (modelProviderDao.deleteBatchIds(ids) == 0) {
if (modelProviderDao.deleteByIds(ids) == 0) {
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
}
}
@@ -4,16 +4,19 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.config.ShiroFilterConfiguration;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Role;
import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter;
@@ -39,7 +42,7 @@ public class ShiroConfig {
}
@Bean("securityManager")
public SecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
public WebSecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(oAuth2Realm);
securityManager.setSessionManager(sessionManager);
@@ -48,7 +51,8 @@ public class ShiroConfig {
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
public static ShiroFilterFactoryBean shirFilter(@Lazy WebSecurityManager securityManager,
@Lazy SysParamsService sysParamsService) {
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
config.setFilterOncePerRequest(true);
@@ -101,12 +105,14 @@ public class ShiroConfig {
}
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public static AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
@Lazy WebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
@@ -85,6 +85,7 @@ public class SysParamsController {
public Result<Void> save(@RequestBody SysParamsDTO dto) {
// 效验数据
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
validateMqttSecretLength(dto.getParamCode(), dto.getParamValue());
sysParamsService.save(dto);
configService.getConfig(false);
@@ -277,7 +278,10 @@ public class SysParamsController {
// 校验mqtt密钥长度和复杂度
private void validateMqttSecretLength(String paramCode, String secret) {
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)) {
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)
&& !paramCode.equals(Constant.MQTT_SERVER_SIGNATURE_KEY)
&& !paramCode.equals(
Constant.MQTT_SERVER_MANAGER_API_SECRET)) {
return;
}
if (StringUtils.isBlank(secret) || secret.equals("null")) {
@@ -31,7 +31,7 @@ public class SysUserDTO implements Serializable {
@NotNull(message = "{id.require}", groups = UpdateGroup.class)
private Long id;
@Schema(description = "用户名", required = true)
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class)
private String username;
@@ -40,14 +40,14 @@ public class SysUserDTO implements Serializable {
@NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class)
private String password;
@Schema(description = "姓名", required = true)
@Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class)
private String realName;
@Schema(description = "头像")
private String headUrl;
@Schema(description = "性别 0:男 1:女 2:保密", required = true)
@Schema(description = "性别 0:男 1:女 2:保密", requiredMode = Schema.RequiredMode.REQUIRED)
@Range(min = 0, max = 2, message = "{sysuser.gender.range}", groups = DefaultGroup.class)
private Integer gender;
@@ -58,11 +58,11 @@ public class SysUserDTO implements Serializable {
@Schema(description = "手机号")
private String mobile;
@Schema(description = "部门ID", required = true)
@Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class)
private Long deptId;
@Schema(description = "状态 0:停用 1:正常", required = true)
@Schema(description = "状态 0:停用 1:正常", requiredMode = Schema.RequiredMode.REQUIRED)
@Range(min = 0, max = 1, message = "{sysuser.status.range}", groups = DefaultGroup.class)
private Integer status;
@@ -84,4 +84,4 @@ public class SysUserDTO implements Serializable {
@Schema(description = "部门名称")
private String deptName;
}
}
@@ -20,6 +20,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao;
@@ -138,7 +139,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
@@ -170,7 +171,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
// 先从Redis获取缓存
String key = RedisKeys.getDictDataByTypeKey(dictType);
List<SysDictDataItem> cachedData = (List<SysDictDataItem>) redisUtils.get(key);
List<SysDictDataItem> cachedData = JsonUtils.toList(redisUtils.get(key), SysDictDataItem.class);
if (cachedData != null) {
return cachedData;
}
@@ -185,4 +186,4 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
return data;
}
}
}
@@ -128,7 +128,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
@@ -151,4 +151,4 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE);
}
}
}
}
@@ -205,9 +205,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
public void initServerSecret() {
// 获取服务器密钥
String secretParam = getValue(Constant.SERVER_SECRET, false);
if (StringUtils.isBlank(secretParam) || "null".equals(secretParam)) {
if (StringUtils.isBlank(secretParam)
|| "null".equalsIgnoreCase(secretParam.trim())) {
String newSecret = UUID.randomUUID().toString();
updateValueByCode(Constant.SERVER_SECRET, newSecret);
} else {
// Startup must repair a stale Redis value because authentication
// reads this parameter from cache while initialization reads DB.
sysParamsRedis.set(Constant.SERVER_SECRET, secretParam);
}
// 初始化SM2密钥对
@@ -287,10 +292,10 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
try {
if (StringUtils.isNotBlank(currentConfig)) {
currentMap = JsonUtils.parseObject(currentConfig, Map.class);
currentMap = JsonUtils.parseMap(currentConfig);
}
if (StringUtils.isNotBlank(configJson)) {
newMap = JsonUtils.parseObject(configJson, Map.class);
newMap = JsonUtils.parseMap(configJson);
}
} catch (Exception e) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
@@ -298,8 +303,8 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
// 检查addressBook功能是否被关闭
if (currentMap != null && newMap != null) {
Map<String, Object> currentFeatures = (Map<String, Object>) currentMap.get("features");
Map<String, Object> newFeatures = (Map<String, Object>) newMap.get("features");
Map<?, ?> currentFeatures = Map.class.cast(currentMap.get("features"));
Map<?, ?> newFeatures = Map.class.cast(newMap.get("features"));
if (currentFeatures != null && newFeatures != null) {
Object currentAddressBookObj = currentFeatures.get("addressBook");
@@ -308,16 +313,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
Boolean currentEnabled = false;
Boolean newEnabled = false;
if (currentAddressBookObj instanceof Map) {
Map<String, Object> currentAddressBook = (Map<String, Object>) currentAddressBookObj;
currentEnabled = currentAddressBook.get("enabled") != null
? (Boolean) currentAddressBook.get("enabled") : false;
if (currentAddressBookObj instanceof Map<?, ?> currentAddressBook) {
Object enabled = currentAddressBook.get("enabled");
currentEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
}
if (newAddressBookObj instanceof Map) {
Map<String, Object> newAddressBook = (Map<String, Object>) newAddressBookObj;
newEnabled = newAddressBook.get("enabled") != null
? (Boolean) newAddressBook.get("enabled") : false;
if (newAddressBookObj instanceof Map<?, ?> newAddressBook) {
Object enabled = newAddressBook.get("enabled");
newEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
}
// 如果之前是启用状态,现在被禁用,删除所有call_device插件
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
/**
* 获取普通音色或克隆音色配置的首个有效语言。
*
* @param id 音色ID
* @return 默认语言;音色不存在或未配置有效语言时返回null
*/
String getDefaultLanguageById(String id);
/**
* 根据ID获取音色名称
*
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
* @return 音色信息
*/
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
}
}
@@ -1,6 +1,7 @@
package xiaozhi.modules.timbre.service.impl;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@Service
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,]");
private final TimbreDao timbreDao;
private final VoiceCloneDao voiceCloneDao;
private final RedisUtils redisUtils;
@@ -114,7 +117,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
@Override
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
}
@Override
public String getDefaultLanguageById(String id) {
if (StringUtils.isBlank(id)) {
return null;
}
TimbreEntity timbre = timbreDao.selectById(id);
if (timbre != null) {
return firstNonBlankLanguage(timbre.getLanguages());
}
VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id);
return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages());
}
private String firstNonBlankLanguage(String languages) {
if (StringUtils.isBlank(languages)) {
return null;
}
return LANGUAGE_SEPARATOR.splitAsStream(languages)
.map(StringUtils::trimToNull)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
/**
* 处理是不是tts模型的id
*/
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
dto.setIsClone(false); // 设置为普通音色
return dto;
}
}
}
@@ -127,7 +127,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
@Override
@@ -0,0 +1,8 @@
-- liquibase formatted sql
-- changeset tykechen:202607101200
ALTER TABLE `ai_agent_snapshot`
ADD COLUMN `redaction_version` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '快照脱敏规则版本' AFTER `created_at`,
ADD INDEX `idx_snapshot_redaction_version_id` (`redaction_version`, `id`);
-- rollback ALTER TABLE `ai_agent_snapshot` DROP INDEX `idx_snapshot_redaction_version_id`, DROP COLUMN `redaction_version`;
@@ -0,0 +1,15 @@
insert ignore into `sys_params`
(id, param_code, param_value, value_type, param_type, remark)
values
(775, 'protocols.enabled_protocols', '["websocket"]', 'string', 1, '启用的协议列表'),
(776, 'protocols.websocket_enabled', 'true', 'boolean', 1, 'WebSocket协议开关'),
(777, 'protocols.mqtt_enabled', 'false', 'boolean', 1, 'MQTT协议开关'),
(778, 'mqtt_server.enabled', 'false', 'boolean', 1, '是否启用MQTT服务器'),
(779, 'mqtt_server.host', '0.0.0.0', 'string', 1, 'MQTT服务器监听地址'),
(780, 'mqtt_server.port', '1883', 'number', 1, 'MQTT服务器端口'),
(781, 'mqtt_server.udp_port', '1883', 'number', 1, 'UDP音频端口'),
(782, 'mqtt_server.public_endpoint', '127.0.0.1', 'string', 1, 'MQTT公网/局域网访问地址'),
(783, 'mqtt_server.max_connections', '1000', 'number', 1, '最大连接数'),
(784, 'mqtt_server.heartbeat_interval', '30', 'number', 1, '心跳间隔(秒)'),
(785, 'mqtt_server.max_payload_size', '8192', 'number', 1, '最大消息大小'),
(786, 'mqtt_server.signature_key', 'null', 'string', 1, 'MQTT签名密钥');
@@ -0,0 +1,9 @@
insert ignore into `sys_params`
(id, param_code, param_value, value_type, param_type, remark)
values
(787, 'mqtt_server.udp_bind_host', '', 'string', 1, 'UDP音频监听地址(留空时自动选择)'),
(788, 'mqtt_server.message_queue_size', '128', 'number', 1, 'MQTT应用消息队列上限'),
(789, 'mqtt_server.business_ready_timeout', '30', 'number', 1, 'Hello等待业务运行时就绪超时(秒)'),
(790, 'mqtt_server.max_pending_connections', '128', 'number', 1, 'MQTT待认证连接上限'),
(791, 'mqtt_server.goodbye_timeout', '1', 'number', 1, 'MQTT goodbye发送超时(秒)'),
(792, 'mqtt_server.close_timeout', '2', 'number', 1, 'MQTT连接清理超时(秒)');
@@ -0,0 +1,11 @@
insert ignore into `sys_params`
(id, param_code, param_value, value_type, param_type, remark)
values
(793, 'mqtt_server.manager_api', 'null', 'string', 1, '原生MQTT管理API地址'),
(794, 'mqtt_server.manager_api_secret', 'null', 'string', 1, '原生MQTT管理API签名密钥');
update `sys_params`
set param_value = '',
remark = 'MQTT公网/局域网访问地址(需配置为设备可达地址)'
where param_code = 'mqtt_server.public_endpoint'
and param_value = '127.0.0.1';
@@ -704,3 +704,33 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607071530.sql
- changeSet:
id: 202607101200
author: tykechen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607101200.sql
- changeSet:
id: 202607190300
author: CAIXYPROMISE
validCheckSum:
- "8:5df1e1f3654e6271889d5579a3908f77"
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607190300.sql
- changeSet:
id: 202607260900
author: CAIXYPROMISE
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607260900.sql
- changeSet:
id: 202607262100
author: CAIXYPROMISE
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607262100.sql
@@ -1,20 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 启用JansiConsoleAppender以确保控制台输出有颜色 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
<!-- 确保日志目录存在 -->
<timestamp key="bySecond" datePattern="yyyyMMdd'T'HHmmss"/>
<!-- 定义日志文件存储位置 -->
<property name="LOG_HOME" value="./logs" />
<!-- 使用自定义的初始化监听器确保日志目录存在 -->
<define name="LOGBACK_DIR_CHECK" class="ch.qos.logback.core.property.FileExistsPropertyDefiner">
<path>${LOG_HOME}</path>
<createIfMissing>true</createIfMissing>
</define>
<!-- 引入Spring Boot默认配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
@@ -89,4 +89,32 @@
FOR UPDATE
</select>
<update id="updateSnapshotFields">
UPDATE ai_agent
SET agent_code = #{agent.agentCode,jdbcType=VARCHAR},
agent_name = #{agent.agentName,jdbcType=VARCHAR},
asr_model_id = #{agent.asrModelId,jdbcType=VARCHAR},
vad_model_id = #{agent.vadModelId,jdbcType=VARCHAR},
llm_model_id = #{agent.llmModelId,jdbcType=VARCHAR},
slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR},
vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR},
tts_model_id = #{agent.ttsModelId,jdbcType=VARCHAR},
tts_voice_id = #{agent.ttsVoiceId,jdbcType=VARCHAR},
tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR},
tts_volume = #{agent.ttsVolume,jdbcType=INTEGER},
tts_rate = #{agent.ttsRate,jdbcType=INTEGER},
tts_pitch = #{agent.ttsPitch,jdbcType=INTEGER},
mem_model_id = #{agent.memModelId,jdbcType=VARCHAR},
intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR},
chat_history_conf = #{agent.chatHistoryConf,jdbcType=INTEGER},
system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR},
summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR},
lang_code = #{agent.langCode,jdbcType=VARCHAR},
language = #{agent.language,jdbcType=VARCHAR},
sort = #{agent.sort,jdbcType=INTEGER},
updater = #{agent.updater,jdbcType=BIGINT},
updated_at = #{agent.updatedAt,jdbcType=TIMESTAMP}
WHERE id = #{agent.id}
</update>
</mapper>
@@ -19,7 +19,8 @@
restore_from_snapshot_id AS restoreFromSnapshotId,
restore_from_version_no AS restoreFromVersionNo,
creator,
created_at AS createdAt
created_at AS createdAt,
redaction_version AS redactionVersion
FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
ORDER BY version_no DESC
@@ -37,7 +38,8 @@
restore_from_snapshot_id AS restoreFromSnapshotId,
restore_from_version_no AS restoreFromVersionNo,
creator,
created_at AS createdAt
created_at AS createdAt,
redaction_version AS redactionVersion
FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
AND version_no &gt; #{versionNo}
@@ -57,7 +59,8 @@
restore_from_snapshot_id,
restore_from_version_no,
creator,
created_at
created_at,
redaction_version
)
SELECT #{snapshot.id},
#{snapshot.agentId},
@@ -69,7 +72,8 @@
#{snapshot.restoreFromSnapshotId},
#{snapshot.restoreFromVersionNo},
#{snapshot.creator},
#{snapshot.createdAt}
#{snapshot.createdAt},
#{snapshot.redactionVersion}
FROM ai_agent_snapshot
WHERE agent_id = #{snapshot.agentId}
</insert>
@@ -89,4 +93,31 @@
)
</delete>
<select id="selectLegacyRedactionBatch" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
SELECT id,
snapshot_data AS snapshotData,
redaction_version AS redactionVersion
FROM ai_agent_snapshot
WHERE redaction_version &lt; #{targetRedactionVersion}
AND (#{afterId} IS NULL OR id &gt; #{afterId})
ORDER BY id ASC
LIMIT #{limit}
</select>
<update id="updateRedactedSnapshots">
UPDATE ai_agent_snapshot
SET snapshot_data = CASE id
<foreach collection="snapshots" item="snapshot">
WHEN #{snapshot.id} THEN #{snapshot.snapshotData}
</foreach>
ELSE snapshot_data
END,
redaction_version = #{redactionVersion}
WHERE redaction_version &lt; #{redactionVersion}
AND id IN
<foreach collection="snapshots" item="snapshot" open="(" separator="," close=")">
#{snapshot.id}
</foreach>
</update>
</mapper>
@@ -21,7 +21,7 @@
WHERE mac_address = #{macAddress} AND target_mac = #{targetMac}
</update>
<insert id="insert">
<insert id="insertAddressBook">
INSERT INTO ai_device_address_book (mac_address, target_mac, alias, has_permission, creator, create_date, updater, update_date)
VALUES (#{macAddress}, #{targetMac}, #{alias}, #{hasPermission}, #{creator}, NOW(), #{updater}, NOW())
</insert>
@@ -36,4 +36,4 @@
#{mac}
</foreach>
</delete>
</mapper>
</mapper>
@@ -0,0 +1,57 @@
package xiaozhi.common.redis;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.serializer.RedisSerializer;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class RedisSerializationTest {
private final RedisSerializer<Object> serializer = RedisSerializer.json();
@Test
void serverConfigRoundTripRestoresStringKeyedMaps() {
Map<String, Object> features = new HashMap<>();
features.put("addressBook", Map.of("enabled", true));
Map<String, Object> config = new HashMap<>();
config.put("features", features);
Object restored = roundTrip(config);
Map<String, Object> restoredConfig = JsonUtils.toStringObjectMap(restored);
Map<String, Object> restoredFeatures = JsonUtils.toStringObjectMap(restoredConfig.get("features"));
Map<String, Object> addressBook = JsonUtils.toStringObjectMap(restoredFeatures.get("addressBook"));
assertEquals(true, addressBook.get("enabled"));
}
@Test
void dictionaryListRoundTripRestoresDtoElements() {
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
Object restored = roundTrip(new ArrayList<>(List.of(item)));
List<SysDictDataItem> restoredItems = JsonUtils.toList(restored, SysDictDataItem.class);
assertInstanceOf(SysDictDataItem.class, restoredItems.get(0));
assertEquals("enabled", restoredItems.get(0).getName());
assertEquals("1", restoredItems.get(0).getKey());
}
private Object roundTrip(Object value) {
byte[] bytes = serializer.serialize(value);
assertNotNull(bytes);
Object restored = serializer.deserialize(bytes);
assertNotNull(restored);
return restored;
}
}
@@ -0,0 +1,219 @@
package xiaozhi.common.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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.executor.BatchResult;
import org.apache.ibatis.logging.nologging.NoLoggingImpl;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
class BaseServiceImplTest {
private static final String INSERT_STATEMENT = TestMapper.class.getName() + ".insert";
private static final String UPDATE_STATEMENT = TestMapper.class.getName() + ".updateById";
private final TestService service = new TestService();
@AfterEach
void clearTransactionSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void insertBatchFlushesAtTheRequestedBatchSizeAndCommitsWithoutATransaction() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
when(sqlSession.flushStatements())
.thenReturn(List.of(batchResult(1, 1)))
.thenReturn(List.of(batchResult(1)));
TestEntity first = new TestEntity(1L);
TestEntity second = new TestEntity(2L);
TestEntity third = new TestEntity(3L);
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertTrue(service.insertBatch(List.of(first, second, third), 2));
}
verify(sqlSession).insert(INSERT_STATEMENT, first);
verify(sqlSession).insert(INSERT_STATEMENT, second);
verify(sqlSession).insert(INSERT_STATEMENT, third);
verify(sqlSession, times(2)).flushStatements();
verify(sqlSession).commit(true);
verify(sqlSession).close();
}
@Test
void updateBatchByIdPassesEachEntityAndReturnsSuccessfulFlushResult() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.update(eq(UPDATE_STATEMENT), any())).thenReturn(1);
when(sqlSession.flushStatements()).thenReturn(List.of(batchResult(1, 1)));
TestEntity first = new TestEntity(1L);
TestEntity second = new TestEntity(2L);
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertTrue(service.updateBatchById(List.of(first, second), 10));
}
ArgumentCaptor<Object> params = ArgumentCaptor.forClass(Object.class);
verify(sqlSession, times(2)).update(eq(UPDATE_STATEMENT), params.capture());
assertSame(first, entityFrom(params.getAllValues().get(0)));
assertSame(second, entityFrom(params.getAllValues().get(1)));
verify(sqlSession).flushStatements();
}
@Test
void batchCallbacksReturnActualAffectedRowCounts() {
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(3);
when(sqlSession.update(eq(UPDATE_STATEMENT), any())).thenReturn(2);
CallbackCapturingTestService capturingService = new CallbackCapturingTestService(sqlSession);
TestEntity entity = new TestEntity(1L);
assertTrue(capturingService.insertBatch(List.of(entity), 7));
assertEquals(7, capturingService.batchSize);
assertEquals(List.of(3), capturingService.affectedRows);
assertTrue(capturingService.updateBatchById(List.of(entity), 9));
assertEquals(9, capturingService.batchSize);
assertEquals(List.of(2), capturingService.affectedRows);
}
@Test
void emptyBatchesReturnFalseWithoutOpeningABatchSession() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertFalse(service.insertBatch(List.of(), 10));
assertFalse(service.updateBatchById(List.of(), 10));
}
verifyNoInteractions(sqlSessionFactory);
}
@Test
void deleteBatchIdsDelegatesToCompatibleApiAndPreservesAffectedRowResult() {
TestMapper mapper = mock(TestMapper.class);
List<Long> ids = List.of(1L, 2L);
service.baseDao = mapper;
when(mapper.deleteByIds(ids)).thenReturn(2).thenReturn(0);
assertTrue(service.deleteBatchIds(ids));
assertFalse(service.deleteBatchIds(ids));
verify(mapper, times(2)).deleteByIds(ids);
}
@Test
void activeTransactionSynchronizationUsesTransactionAwareCommitAndLifecycle() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
when(sqlSession.flushStatements()).thenReturn(List.of(batchResult(1)));
TransactionSynchronizationManager.initSynchronization();
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertTrue(service.insertBatch(List.of(new TestEntity(1L)), 1));
}
verify(sqlSession).flushStatements();
verify(sqlSession).commit(false);
verify(sqlSession, never()).commit(true);
verify(sqlSession, never()).close();
}
@SuppressWarnings("deprecation")
private static MockedStatic<SqlHelper> sqlHelperUsing(SqlSessionFactory sqlSessionFactory) {
MockedStatic<SqlHelper> sqlHelper = mockStatic(SqlHelper.class, CALLS_REAL_METHODS);
sqlHelper.when(() -> SqlHelper.sqlSessionFactory(TestEntity.class)).thenReturn(sqlSessionFactory);
return sqlHelper;
}
private static BatchResult batchResult(int... updateCounts) {
BatchResult result = new BatchResult(mock(MappedStatement.class), "batch");
result.setUpdateCounts(updateCounts);
return result;
}
private static Object entityFrom(Object parameter) {
assertTrue(parameter instanceof MapperMethod.ParamMap<?>);
return ((Map<?, ?>) parameter).get(Constants.ENTITY);
}
private interface TestMapper extends BaseMapper<TestEntity> {
}
private record TestEntity(Long id) {
}
private static class TestService extends BaseServiceImpl<TestMapper, TestEntity> {
private TestService() {
log = new NoLoggingImpl(TestService.class.getName());
}
@Override
protected String getSqlStatement(SqlMethod sqlMethod) {
return switch (sqlMethod) {
case INSERT_ONE -> INSERT_STATEMENT;
case UPDATE_BY_ID -> UPDATE_STATEMENT;
default -> throw new IllegalArgumentException("Unexpected SQL method: " + sqlMethod);
};
}
}
private static class CallbackCapturingTestService extends TestService {
private final SqlSession sqlSession;
private int batchSize;
private List<Integer> affectedRows;
private CallbackCapturingTestService(SqlSession sqlSession) {
this.sqlSession = sqlSession;
}
@Override
protected <E> boolean executeBatch(Collection<E> list, int batchSize,
BiFunction<SqlSession, E, Integer> operation) {
this.batchSize = batchSize;
this.affectedRows = list.stream().map(entity -> operation.apply(sqlSession, entity)).toList();
return true;
}
}
}
@@ -0,0 +1,77 @@
package xiaozhi.common.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonUtilsTest {
@Test
void parsesTypedMapsAndPreservesNestedCollectionShapes() {
Map<String, Object> map = JsonUtils.parseMap(
"{\"enabled\":true,\"nested\":{\"items\":[{\"name\":\"tool\"}]}}");
Map<?, ?> nested = assertInstanceOf(Map.class, map.get("nested"));
List<?> items = assertInstanceOf(List.class, nested.get("items"));
Map<?, ?> item = assertInstanceOf(Map.class, items.get(0));
assertEquals(true, map.get("enabled"));
assertEquals("tool", item.get("name"));
List<Map<String, Object>> maps = JsonUtils.parseMapList(
"[{\"name\":\"first\",\"values\":[1,2]},{\"name\":\"second\"}]");
assertEquals("first", maps.get(0).get("name"));
assertEquals(List.of(1, 2), maps.get(0).get("values"));
assertEquals("second", maps.get(1).get("name"));
}
@Test
void checkedConvertersAreShallowMutableCopies() {
List<Object> nested = new ArrayList<>(List.of(Map.of("name", "tool")));
Map<String, Object> source = new LinkedHashMap<>();
source.put("nested", nested);
Map<String, Object> map = JsonUtils.toStringObjectMap(source);
List<Map<String, Object>> maps = JsonUtils.toStringObjectMapList(List.of(source));
List<String> strings = JsonUtils.toList(Arrays.asList("a", null), String.class);
assertSame(nested, map.get("nested"));
assertSame(nested, maps.get(0).get("nested"));
map.put("enabled", true);
maps.add(Map.of("name", "second"));
strings.add("b");
assertEquals(true, map.get("enabled"));
assertEquals(2, maps.size());
assertEquals(Arrays.asList("a", null, "b"), strings);
}
@Test
void preservesNullInputs() {
assertNull(JsonUtils.parseMap(""));
assertNull(JsonUtils.parseMapList(null));
assertNull(JsonUtils.toStringObjectMap(null));
assertNull(JsonUtils.toStringObjectMapList(null));
assertNull(JsonUtils.toList(null, String.class));
}
@Test
void rejectsUnexpectedJsonShapesAndRuntimeTypes() {
assertThrows(RuntimeException.class, () -> JsonUtils.parseMap("[]"));
assertThrows(RuntimeException.class, () -> JsonUtils.parseMapList("{}"));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(List.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(Map.of(1, "value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(List.of("value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(Map.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(List.of(1), String.class));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(Map.of(), String.class));
}
}
@@ -0,0 +1,77 @@
package xiaozhi.modules.agent.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.scheduling.annotation.Scheduled;
import xiaozhi.modules.agent.service.AgentSnapshotService;
@ExtendWith(OutputCaptureExtension.class)
class AgentSnapshotRedactionRunnerTest {
@Test
void startupRedactionRunsSynchronouslyAndReportsCompensationWindow(CapturedOutput output) {
assertTrue(SmartInitializingSingleton.class.isAssignableFrom(AgentSnapshotRedactionRunner.class));
AgentSnapshotService service = mock(AgentSnapshotService.class);
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
when(service.redactLegacySnapshots()).thenReturn(0L);
runner.afterSingletonsInstantiated();
verify(service).redactLegacySnapshots();
assertTrue(output.getAll().contains("startup pass completed"));
assertTrue(output.getAll().contains("starts after 5000 ms and repeats every 15000 ms"));
}
@Test
void startupRedactionFailureIsLoggedAndPropagatedToKeepStartupFailClosed(CapturedOutput output) {
AgentSnapshotService service = mock(AgentSnapshotService.class);
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
IllegalStateException failure = new IllegalStateException("database unavailable");
when(service.redactLegacySnapshots()).thenThrow(failure);
IllegalStateException thrown = assertThrows(IllegalStateException.class,
runner::afterSingletonsInstantiated);
assertSame(failure, thrown);
assertTrue(output.getAll().contains("blocking application startup before it can accept traffic"));
}
@Test
void rollingDeploymentRedactionReportsTriggerCountAndCredentialRotation(CapturedOutput output) {
AgentSnapshotService service = mock(AgentSnapshotService.class);
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
when(service.redactLegacySnapshots()).thenReturn(3L);
runner.redactLateRollingDeploymentWrites();
assertTrue(output.getAll().contains("trigger=rolling-deployment migrated=3"));
assertTrue(output.getAll().contains("Rotate credentials"));
}
@Test
void rollingDeploymentScheduleKeepsLegacyWriteExposureWindowShort() throws Exception {
Method method = AgentSnapshotRedactionRunner.class.getMethod("redactLateRollingDeploymentWrites");
Scheduled scheduled = method.getAnnotation(Scheduled.class);
assertNotNull(scheduled);
assertEquals(5_000, scheduled.initialDelay());
assertEquals(15_000, scheduled.fixedDelay());
assertTrue(scheduled.initialDelay() <= 10_000);
assertTrue(scheduled.fixedDelay() <= 30_000);
}
}
@@ -2,36 +2,30 @@ package xiaozhi.modules.agent.typehandler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.List;
import org.apache.ibatis.type.TypeHandler;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import com.fasterxml.jackson.core.type.TypeReference;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
class ContextProviderListTypeHandlerTest {
private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler();
@Test
void parseKeepsContextProviderDtoElementType() {
List<ContextProviderDTO> providers = handler
.parse("[{\"url\":\"https://example.com/context\",\"headers\":{\"Authorization\":\"Bearer token\"}}]");
assertEquals(1, providers.size());
assertInstanceOf(ContextProviderDTO.class, providers.get(0));
assertEquals("https://example.com/context", providers.get(0).getUrl());
assertEquals("Bearer token", providers.get(0).getHeaders().get("Authorization"));
}
@Test
void parseBlankJsonAsEmptyList() {
assertTrue(handler.parse(" ").isEmpty());
}
@Test
void myBatisCanInstantiateHandlerForListField() {
TypeHandler<?> typeHandler = new TypeHandlerRegistry().getInstance(List.class,
@@ -39,4 +33,41 @@ class ContextProviderListTypeHandlerTest {
assertInstanceOf(ContextProviderListTypeHandler.class, typeHandler);
}
@Test
void resultSetDeserializationKeepsContextProviderDtoElementType() throws Exception {
ResultSet resultSet = mock(ResultSet.class);
when(resultSet.getString("context_providers"))
.thenReturn("[{\"url\":\"https://example.com/context\",\"headers\":{\"Authorization\":\"Bearer token\"}}]");
List<ContextProviderDTO> providers = handler.getNullableResult(resultSet, "context_providers");
assertEquals(1, providers.size());
assertInstanceOf(ContextProviderDTO.class, providers.get(0));
assertEquals("https://example.com/context", providers.get(0).getUrl());
assertEquals("Bearer token", providers.get(0).getHeaders().get("Authorization"));
}
@Test
void sqlNullRemainsNull() throws Exception {
ResultSet resultSet = mock(ResultSet.class);
assertNull(handler.getNullableResult(resultSet, 1));
}
@Test
void serializesProviderListAsJson() throws Exception {
ContextProviderDTO provider = new ContextProviderDTO();
provider.setUrl("https://example.com/context");
PreparedStatement statement = mock(PreparedStatement.class);
handler.setNonNullParameter(statement, 1, List.of(provider), null);
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
verify(statement).setString(eq(1), json.capture());
List<ContextProviderDTO> serialized = JsonUtils.parseObject(json.getValue(), new TypeReference<>() {
});
assertEquals(1, serialized.size());
assertEquals("https://example.com/context", serialized.get(0).getUrl());
}
}
@@ -0,0 +1,96 @@
package xiaozhi.modules.config.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.correctword.service.CorrectWordFileService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.timbre.service.TimbreService;
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
class ConfigServiceImplTest {
@Test
void cachedServerConfigIsCheckedAsAStringKeyedMapWithoutChangingNestedValues() {
RedisUtils redisUtils = mock(RedisUtils.class);
Map<String, Object> nested = new HashMap<>();
nested.put("enabled", true);
Map<String, Object> cached = new HashMap<>();
cached.put("features", nested);
when(redisUtils.get(RedisKeys.getServerConfigKey())).thenReturn(cached);
ConfigServiceImpl service = newService(mock(SysParamsService.class), redisUtils);
Map<String, Object> result = service.getConfig(true);
assertNotSame(cached, result);
assertSame(nested, result.get("features"));
assertEquals(true, ((Map<?, ?>) result.get("features")).get("enabled"));
}
@Test
void nestedSystemParametersStillShareAndPopulateTheSameConfigBranch() {
SysParamsService sysParamsService = mock(SysParamsService.class);
SysParamsDTO enabled = parameter("server.features.enabled", "true", "boolean");
SysParamsDTO labels = parameter("server.features.labels", "first;second", "array");
when(sysParamsService.list(anyMap())).thenReturn(List.of(enabled, labels));
ConfigServiceImpl service = newService(sysParamsService, mock(RedisUtils.class));
Map<String, Object> config = new HashMap<>();
Object returned = ReflectionTestUtils.invokeMethod(service, "buildConfig", config);
assertSame(config, returned);
Map<?, ?> server = assertInstanceOf(Map.class, config.get("server"));
Map<?, ?> features = assertInstanceOf(Map.class, server.get("features"));
assertEquals(true, features.get("enabled"));
assertEquals(List.of("first", "second"), features.get("labels"));
}
private static SysParamsDTO parameter(String code, String value, String type) {
SysParamsDTO parameter = new SysParamsDTO();
parameter.setParamCode(code);
parameter.setParamValue(value);
parameter.setValueType(type);
return parameter;
}
private static ConfigServiceImpl newService(SysParamsService sysParamsService, RedisUtils redisUtils) {
return new ConfigServiceImpl(
sysParamsService,
mock(DeviceService.class),
mock(ModelConfigService.class),
mock(AgentService.class),
mock(AgentTemplateService.class),
redisUtils,
mock(TimbreService.class),
mock(AgentPluginMappingService.class),
mock(AgentMcpAccessPointService.class),
mock(AgentContextProviderService.class),
mock(VoiceCloneService.class),
mock(AgentVoicePrintDao.class),
mock(CorrectWordFileService.class));
}
}
@@ -1,7 +1,6 @@
package xiaozhi.modules.device;
import java.util.HashMap;
import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
@@ -11,6 +10,8 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService;
@@ -27,11 +28,13 @@ public class DeviceTest {
private SysUserService sysUserService;
@Test
public void testSaveUser() {
public void testRejectWeakPassword() {
SysUserDTO userDTO = new SysUserDTO();
userDTO.setUsername("test");
userDTO.setPassword(UUID.randomUUID().toString());
sysUserService.save(userDTO);
userDTO.setPassword("weak-password-123");
RenException exception = Assertions.assertThrows(RenException.class, () -> sysUserService.save(userDTO));
Assertions.assertEquals(ErrorCode.PASSWORD_WEAK_ERROR, exception.getCode());
}
@Test
@@ -70,4 +73,4 @@ public class DeviceTest {
log.info("测试完成");
}
}
}
@@ -0,0 +1,78 @@
package xiaozhi.modules.device.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.device.dto.DeviceUpdateDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
@DisplayName("设备更新接口回归测试")
class DeviceControllerTest {
private static final String DEVICE_ID = "device-id";
private static final long USER_ID = 1L;
@Test
@DisplayName("数据库未更新时不误报自动升级状态修改成功")
void updateFailureIsReturnedToCaller() {
DeviceService deviceService = mock(DeviceService.class);
DeviceEntity entity = ownedDevice();
when(deviceService.selectById(DEVICE_ID)).thenReturn(entity);
when(deviceService.updateById(entity)).thenReturn(false);
DeviceController controller = controller(deviceService);
DeviceUpdateDTO update = new DeviceUpdateDTO();
update.setAutoUpdate(0);
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class);
MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(currentUser());
messageUtils.when(() -> MessageUtils.getMessage(ErrorCode.UPDATE_DATA_FAILED))
.thenReturn("Failed to update data");
Result<Void> result = controller.updateDeviceInfo(DEVICE_ID, update);
assertEquals(ErrorCode.UPDATE_DATA_FAILED, result.getCode());
assertEquals(0, entity.getAutoUpdate());
verify(deviceService).updateById(entity);
}
}
private DeviceController controller(DeviceService deviceService) {
return new DeviceController(
deviceService,
mock(DeviceAddressBookService.class),
mock(RedisUtils.class),
mock(SysParamsService.class));
}
private DeviceEntity ownedDevice() {
DeviceEntity entity = new DeviceEntity();
entity.setId(DEVICE_ID);
entity.setUserId(USER_ID);
entity.setAutoUpdate(1);
return entity;
}
private UserDetail currentUser() {
UserDetail user = new UserDetail();
user.setId(USER_ID);
return user;
}
}
@@ -0,0 +1,46 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
class DeviceAddressBookServiceImplTest {
@Test
void newEntryUsesTheDedicatedAddressBookInsertMapping() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
assertEquals("00:11:22:33:44:66", entity.getTargetMac());
assertEquals("living-room", entity.getAlias());
assertTrue(entity.getHasPermission());
verify(addressBookDao, never()).insert(any(DeviceAddressBookEntity.class));
}
}
@@ -0,0 +1,104 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.sys.service.SysParamsService;
@DisplayName("设备自动升级回归测试")
class DeviceAutoUpdateTest {
private static final String MAC_ADDRESS = "00:11:22:33:44:55";
private static final String BOARD_TYPE = "test-board";
private static final String CURRENT_VERSION = "1.0.0";
@Test
@DisplayName("#3299 自动升级关闭时 OTA 响应不包含固件")
void disabledAutoUpdateOmitsFirmware() {
OtaService otaService = mock(OtaService.class);
DeviceServiceImpl service = proxiedService(deviceWithAutoUpdate(0), otaService);
DeviceReportRespDTO response = service.checkDeviceActive(
MAC_ADDRESS, MAC_ADDRESS, deviceReport());
assertNull(response.getFirmware());
verifyNoInteractions(otaService);
}
@Test
@DisplayName("自动升级开启时继续执行固件查询")
void enabledAutoUpdateChecksFirmware() {
OtaService otaService = mock(OtaService.class);
when(otaService.getLatestOta(BOARD_TYPE)).thenReturn(null);
DeviceServiceImpl service = proxiedService(deviceWithAutoUpdate(1), otaService);
DeviceReportRespDTO response = service.checkDeviceActive(
MAC_ADDRESS, MAC_ADDRESS, deviceReport());
assertNotNull(response.getFirmware());
assertEquals(CURRENT_VERSION, response.getFirmware().getVersion());
assertEquals(Constant.INVALID_FIRMWARE_URL, response.getFirmware().getUrl());
verify(otaService).getLatestOta(BOARD_TYPE);
}
private DeviceServiceImpl proxiedService(DeviceEntity device, OtaService otaService) {
SysParamsService sysParamsService = mock(SysParamsService.class);
when(sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true))
.thenReturn("ws://127.0.0.1:8000/xiaozhi/v1/");
when(sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true)).thenReturn("false");
when(sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true)).thenReturn(null);
DeviceServiceImpl target = new DeviceServiceImpl(
null, null, sysParamsService, null, otaService, null) {
@Override
public DeviceEntity getDeviceByMacAddress(String macAddress) {
return device;
}
@Override
public void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion) {
// No-op: connection timestamps are outside this OTA decision test.
}
};
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true);
proxyFactory.setExposeProxy(true);
return (DeviceServiceImpl) proxyFactory.getProxy();
}
private DeviceEntity deviceWithAutoUpdate(int autoUpdate) {
DeviceEntity device = new DeviceEntity();
device.setId(MAC_ADDRESS);
device.setMacAddress(MAC_ADDRESS);
device.setBoard(BOARD_TYPE);
device.setAutoUpdate(autoUpdate);
return device;
}
private DeviceReportReqDTO deviceReport() {
DeviceReportReqDTO.Application application = new DeviceReportReqDTO.Application();
application.setVersion(CURRENT_VERSION);
DeviceReportReqDTO.BoardInfo board = new DeviceReportReqDTO.BoardInfo();
board.setType(BOARD_TYPE);
DeviceReportReqDTO report = new DeviceReportReqDTO();
report.setApplication(application);
report.setBoard(board);
return report;
}
}
@@ -0,0 +1,106 @@
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()));
}
@ParameterizedTest(name = "自动升级状态 {0}")
@ValueSource(ints = { 0, 1 })
@DisplayName("#3299 设备列表按 autoUpdate 契约返回真实开关状态")
void serializedDeviceContainsAutoUpdateState(int autoUpdate) {
DeviceEntity entity = new DeviceEntity();
entity.setAutoUpdate(autoUpdate);
DeviceServiceImpl deviceService = serviceReturning(entity);
UserShowDeviceListVO device = deviceService.getUserDeviceList(1L, "agent-id").getFirst();
ObjectMapper objectMapper = new WebMvcConfig().jackson2HttpMessageConverter().getObjectMapper();
JsonNode payload = objectMapper.valueToTree(device);
assertAll(
() -> assertEquals(autoUpdate, device.getAutoUpdate()),
() -> assertEquals(autoUpdate, payload.path("autoUpdate").asInt()),
() -> assertTrue(payload.path("otaUpgrade").isMissingNode(),
"设备列表不应继续暴露未映射的旧字段 otaUpgrade"));
}
private DeviceServiceImpl serviceReturning(DeviceEntity entity) {
return new DeviceServiceImpl(null, null, null, null, null, null) {
@Override
public List<DeviceEntity> getUserDevices(Long userId, String agentId) {
return List.of(entity);
}
};
}
}
@@ -0,0 +1,180 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import cn.hutool.crypto.digest.DigestUtil;
@DisplayName("MQTT Gateway 日期鉴权回归测试")
class MqttGatewayAuthorizationTest {
private static final String SIGNATURE_KEY = "test-signature-key";
private static final Instant FIXED_INSTANT = Instant.parse("2026-07-14T00:30:00Z");
private HttpServer server;
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
@Test
@DisplayName("以 UTC 日期生成当天、前一天和后一天三个候选 token")
void generatesUtcDateCandidates() {
List<String> tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, FIXED_INSTANT);
assertEquals(List.of(
tokenFor("2026-07-14"),
tokenFor("2026-07-13"),
tokenFor("2026-07-15")), tokens);
}
@ParameterizedTest(name = "Gateway 时区 {0}")
@MethodSource("gatewayTimeZones")
@DisplayName("候选 token 覆盖上海和圣保罗 Gateway 的本地日期")
void coversGatewayLocalDate(String gatewayTimeZone, Instant now, int expectedTokenIndex) {
String gatewayDate = now.atZone(ZoneId.of(gatewayTimeZone)).toLocalDate().toString();
List<String> tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, now);
assertEquals(tokenFor(gatewayDate), tokens.get(expectedTokenIndex));
}
@Test
@DisplayName("仅在 401 时按日期候选重试并保留请求体")
void retriesOnlyAuthenticationFailures() throws IOException {
AtomicInteger requestCount = new AtomicInteger();
List<String> authorizationHeaders = new ArrayList<>();
List<String> requestBodies = new ArrayList<>();
List<String> expectedTokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, FIXED_INSTANT);
startServer(exchange -> {
int attempt = requestCount.getAndIncrement();
authorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization"));
requestBodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
if (attempt == 0) {
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
} else if (attempt == 1) {
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
} else {
respond(exchange, 200, "{\"online\":true}");
}
});
String requestBody = "{\"clientIds\":[\"device-id\"]}";
String response = MqttGatewayAuthorization.postJson(
serverUrl(), requestBody, SIGNATURE_KEY, FIXED_INSTANT);
assertEquals("{\"online\":true}", response);
assertEquals(3, requestCount.get());
assertEquals(expectedTokens.stream().map(token -> "Bearer " + token).toList(), authorizationHeaders);
assertEquals(List.of(requestBody, requestBody, requestBody), requestBodies);
}
@ParameterizedTest(name = "HTTP {0}")
@ValueSource(ints = { 403, 500 })
@DisplayName("非 401 错误不重试且向上抛出")
void doesNotRetryNonAuthenticationFailure(int statusCode) throws IOException {
AtomicInteger requestCount = new AtomicInteger();
startServer(exchange -> {
requestCount.incrementAndGet();
exchange.getRequestBody().readAllBytes();
respond(exchange, statusCode, "{\"error\":\"request rejected\"}");
});
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
serverUrl(), "{}", SIGNATURE_KEY, FIXED_INSTANT));
assertEquals(statusCode, exception.statusCode());
assertEquals(1, requestCount.get());
}
@Test
@DisplayName("所有日期候选被拒绝时不把错误响应当作设备离线数据")
void propagatesAuthenticationFailureAfterAllCandidatesAreRejected() throws IOException {
AtomicInteger requestCount = new AtomicInteger();
startServer(exchange -> {
requestCount.incrementAndGet();
exchange.getRequestBody().readAllBytes();
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
});
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
serverUrl(), "{}", SIGNATURE_KEY, FIXED_INSTANT));
assertEquals(401, exception.statusCode());
assertEquals(3, requestCount.get());
}
@ParameterizedTest(name = "密钥值 [{0}]")
@NullAndEmptySource
@ValueSource(strings = { " ", "null", " NULL " })
@DisplayName("缺少或占位签名密钥时在发起 HTTP 请求前失败")
void rejectsMissingSignatureKeyBeforeSendingRequest(String signatureKey) {
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
"http://127.0.0.1:1", "{}", signatureKey, FIXED_INSTANT));
assertNull(exception.statusCode());
}
private static Stream<Arguments> gatewayTimeZones() {
return Stream.of(
Arguments.of("Asia/Shanghai", Instant.parse("2026-07-13T17:30:00Z"), 2),
Arguments.of("America/Sao_Paulo", Instant.parse("2026-07-14T00:30:00Z"), 1));
}
private void startServer(HttpHandler handler) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/mqtt", handler);
server.start();
}
private String serverUrl() {
assertTrue(server != null, "test server must be started");
return "http://127.0.0.1:" + server.getAddress().getPort() + "/mqtt";
}
private static void respond(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (var output = exchange.getResponseBody()) {
output.write(bytes);
}
}
private static String tokenFor(String date) {
return DigestUtil.sha256Hex(date + SIGNATURE_KEY);
}
}
@@ -0,0 +1,60 @@
package xiaozhi.modules.security.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Role;
import xiaozhi.modules.security.oauth2.Oauth2Realm;
import xiaozhi.modules.sys.service.SysParamsService;
class ShiroConfigTest {
@Test
void beanPostProcessorFactoriesDoNotInstantiateShiroConfigOrBusinessDependenciesEarly() throws Exception {
Method lifecycleFactory = ShiroConfig.class.getDeclaredMethod("lifecycleBeanPostProcessor");
Method filterFactory = ShiroConfig.class.getDeclaredMethod(
"shirFilter", WebSecurityManager.class, SysParamsService.class);
assertTrue(Modifier.isStatic(lifecycleFactory.getModifiers()));
assertTrue(BeanPostProcessor.class.isAssignableFrom(ShiroFilterFactoryBean.class));
assertTrue(Modifier.isStatic(filterFactory.getModifiers()));
Lazy securityManagerLazy = filterFactory.getParameters()[0].getAnnotation(Lazy.class);
Lazy sysParamsServiceLazy = filterFactory.getParameters()[1].getAnnotation(Lazy.class);
assertNotNull(securityManagerLazy);
assertNotNull(sysParamsServiceLazy);
assertTrue(securityManagerLazy.value());
assertTrue(sysParamsServiceLazy.value());
}
@Test
void authorizationAdvisorIsInfrastructureAndDefersItsSecurityManager() throws Exception {
Method advisorFactory = ShiroConfig.class.getDeclaredMethod(
"authorizationAttributeSourceAdvisor", WebSecurityManager.class);
assertTrue(Modifier.isStatic(advisorFactory.getModifiers()));
Lazy securityManagerLazy = advisorFactory.getParameters()[0].getAnnotation(Lazy.class);
assertNotNull(securityManagerLazy);
assertTrue(securityManagerLazy.value());
assertEquals(BeanDefinition.ROLE_INFRASTRUCTURE, advisorFactory.getAnnotation(Role.class).value());
}
@Test
void securityManagerRetainsTheWebSecurityContractRequiredByShiroFilter() throws Exception {
Method securityManagerFactory = ShiroConfig.class.getDeclaredMethod(
"securityManager", Oauth2Realm.class, SessionManager.class);
assertEquals(WebSecurityManager.class, securityManagerFactory.getReturnType());
}
}
@@ -1,15 +1,23 @@
package xiaozhi.modules.sys;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.security.controller.LoginController;
import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.service.SysUserService;
@Slf4j
@SpringBootTest
@@ -19,12 +27,19 @@ class loginControllerTest {
@Autowired
LoginController loginController;
@MockitoBean
SysUserService sysUserService;
@Test
public void testRegister() {
when(sysUserService.getAllowUserRegister()).thenReturn(false);
LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("手机号码");
loginDTO.setPassword("密码");
loginController.register(loginDTO);
RenException exception = assertThrows(RenException.class, () -> loginController.register(loginDTO));
assertEquals(ErrorCode.USER_REGISTER_DISABLED, exception.getCode());
}
@Test
@@ -54,4 +69,4 @@ class loginControllerTest {
}
}
}
@@ -0,0 +1,35 @@
package xiaozhi.modules.sys.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class SysDictDataServiceImplTest {
@Test
void cachedDictionaryItemsAreCheckedAndReturnedAsDtos() {
RedisUtils redisUtils = mock(RedisUtils.class);
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
List<SysDictDataItem> cached = new ArrayList<>(List.of(item));
when(redisUtils.get(RedisKeys.getDictDataByTypeKey("status"))).thenReturn(cached);
SysDictDataServiceImpl service = new SysDictDataServiceImpl(mock(SysUserDao.class), redisUtils);
List<SysDictDataItem> result = service.getDictDataByType("status");
assertNotSame(cached, result);
assertEquals(List.of(item), result);
}
}
@@ -0,0 +1,36 @@
package xiaozhi.modules.sys.service.impl;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.sys.dao.SysParamsDao;
import xiaozhi.modules.sys.redis.SysParamsRedis;
class SysParamsServiceImplTest {
@Test
void disablingAddressBookStillDeletesItsSystemPlugin() {
SysParamsRedis sysParamsRedis = mock(SysParamsRedis.class);
AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class);
SysParamsDao sysParamsDao = mock(SysParamsDao.class);
SysParamsServiceImpl service = new SysParamsServiceImpl(sysParamsRedis, pluginMappingService);
ReflectionTestUtils.setField(service, "baseDao", sysParamsDao);
String currentConfig = "{\"features\":{\"addressBook\":{\"enabled\":true}}}";
String newConfig = "{\"features\":{\"addressBook\":{\"enabled\":false}}}";
when(sysParamsDao.getValueByCode(Constant.SYSTEM_WEB_MENU)).thenReturn(currentConfig);
when(sysParamsDao.updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig)).thenReturn(1);
service.updateSystemWebMenu(newConfig);
verify(pluginMappingService).deleteByPluginId("SYSTEM_PLUGIN_CALL_DEVICE");
verify(sysParamsDao).updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig);
verify(sysParamsRedis).set(Constant.SYSTEM_WEB_MENU, newConfig);
}
}
@@ -0,0 +1,57 @@
package xiaozhi.modules.timbre.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.timbre.dao.TimbreDao;
import xiaozhi.modules.timbre.entity.TimbreEntity;
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
class TimbreServiceImplTest {
@Test
void defaultLanguageUsesFirstValidRegularTimbreLanguageWithoutCloneQuery() {
TimbreDao timbreDao = mock(TimbreDao.class);
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
TimbreEntity timbre = new TimbreEntity();
timbre.setLanguages(",, ; 普通话;粤语");
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
assertEquals("普通话", service.getDefaultLanguageById("voice-id"));
verify(voiceCloneDao, never()).selectById("voice-id");
}
@Test
void defaultLanguageFallsBackToCloneTimbre() {
TimbreDao timbreDao = mock(TimbreDao.class);
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
VoiceCloneEntity voiceClone = new VoiceCloneEntity();
voiceClone.setLanguages("、, English,中文");
when(voiceCloneDao.selectById("clone-id")).thenReturn(voiceClone);
assertEquals("English", service.getDefaultLanguageById("clone-id"));
}
@Test
void delimiterOnlyLanguageConfigurationReturnsNull() {
TimbreDao timbreDao = mock(TimbreDao.class);
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
TimbreEntity timbre = new TimbreEntity();
timbre.setLanguages(",,、;;;,,");
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
assertNull(service.getDefaultLanguageById("voice-id"));
}
}
@@ -1,4 +1,7 @@
spring:
messages:
encoding: UTF-8
basename: i18n/messages
data:
redis:
host: localhost
@@ -19,4 +22,4 @@ renren:
logging:
level:
xiaozhi.modules.device: DEBUG
org.springframework.data.redis: DEBUG
org.springframework.data.redis: DEBUG
+1
View File
@@ -69,6 +69,7 @@
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit",
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
"openapi-ts-request": "openapi-ts",
"prepare": "git init && husky",
"lint": "eslint",
+9 -8
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { watch, onMounted } from 'vue'
import { onMounted, watch } from 'vue'
import { usePageAuth } from '@/hooks/usePageAuth'
import { useConfigStore } from '@/store'
import { t } from '@/i18n'
import { useConfigStore } from '@/store'
import { useLangStore } from '@/store/lang'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
@@ -37,9 +37,9 @@ function updateTabBarText() {
success: () => {},
fail: (err) => {
console.log('设置首页tabBar文本失败:', err)
}
},
})
// 设置配网tabBar文本
uni.setTabBarItem({
index: 1,
@@ -47,9 +47,9 @@ function updateTabBarText() {
success: () => {},
fail: (err) => {
console.log('设置配网tabBar文本失败:', err)
}
},
})
// 设置系统tabBar文本
uni.setTabBarItem({
index: 2,
@@ -57,9 +57,10 @@ function updateTabBarText() {
success: () => {},
fail: (err) => {
console.log('设置系统tabBar文本失败:', err)
}
},
})
} catch (error) {
}
catch (error) {
console.log('更新tabBar文本时出错:', error)
}
}
+65 -1
View File
@@ -2,7 +2,11 @@ import type {
Agent,
AgentCreateData,
AgentDetail,
AgentSnapshot,
AgentSnapshotPageParams,
CorrectWordFile,
ModelOption,
PageData,
RoleTemplate,
} from './types'
import { http } from '@/http/request/alova'
@@ -100,7 +104,7 @@ export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
}
// 更新智能体
export function updateAgent(id: string, data: Partial<AgentDetail>) {
export function updateAgent(id: string, data: Partial<AgentDetail> & { tagNames?: string[] }) {
return http.Put(`/agent/${id}`, data, {
meta: {
ignoreAuth: false,
@@ -220,3 +224,63 @@ export function getAllLanguage(modelId: string) {
},
})
}
// 获取智能体历史版本列表
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
params,
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取智能体历史版本详情
export function getAgentSnapshot(agentId: string, snapshotId: string) {
return http.Get<AgentSnapshot>(`/agent/${agentId}/snapshots/${snapshotId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 恢复智能体历史版本
export function restoreAgentSnapshot(agentId: string, snapshotId: string, currentStateToken: string) {
return http.Post(`/agent/${agentId}/snapshots/${snapshotId}/restore`, { currentStateToken }, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 删除智能体历史版本
export function deleteAgentSnapshot(agentId: string, snapshotId: string) {
return http.Delete(`/agent/${agentId}/snapshots/${snapshotId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 获取所有替换词文件
export function getCorrectWordFiles() {
return http.Get<CorrectWordFile[]>('/correct-word/file/select', {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
+52 -4
View File
@@ -44,10 +44,12 @@ export interface AgentDetail {
createdAt: string
updater: string
updatedAt: string
ttsLanguage: string
ttsVolume: number
ttsRate: number
ttsPitch: number
ttsLanguage: string | null
ttsVolume: number | null
ttsRate: number | null
ttsPitch: number | null
currentVersionNo?: number | null
tagNames?: string[]
functions: AgentFunction[]
contextProviders: Providers[]
}
@@ -67,6 +69,51 @@ export interface AgentFunction {
paramInfo: Record<string, string | number | boolean> | null
}
export interface PageData<T> {
list: T[]
total: number
}
export interface AgentSnapshotData extends Partial<AgentDetail> {
correctWordFileIds?: string[]
tagNames?: string[]
tags?: Array<{
tagName?: string
[key: string]: any
}>
[key: string]: any
}
export interface AgentSnapshot {
id: string
agentId: string
userId?: string
versionNo: number
changedFields?: string[]
fieldOrder?: string[]
source?: string
restoreFromSnapshotId?: string | null
restoreFromVersionNo?: number | null
currentStateToken?: string
currentSnapshotData?: AgentSnapshotData
creator?: string
createdAt?: string
snapshotData?: AgentSnapshotData
afterSnapshotData?: AgentSnapshotData
}
export interface AgentSnapshotPageParams {
page?: number
limit?: number
maxVersionNo?: number
}
export interface CorrectWordFile {
id: string
fileName: string
wordCount?: number
}
// 角色模板数据类型
export interface RoleTemplate {
id: string
@@ -78,6 +125,7 @@ export interface RoleTemplate {
vllmModelId: string
ttsModelId: string
ttsVoiceId: string
ttsLanguage?: string | null
memModelId: string
intentModelId: string
chatHistoryConf: number
File diff suppressed because it is too large Load Diff
+87
View File
@@ -130,6 +130,7 @@ export default {
'agent.saving': 'Saving...',
'agent.saveSuccess': 'Save successful',
'agent.saveFail': 'Save failed',
'agent.ttsOptionsLoadFailed': 'Voice options could not be loaded. The previous voice settings were kept.',
'agent.loadFail': 'Load failed',
'agent.pleaseInputAgentName': 'Please input agent name',
'agent.pleaseInputRoleDescription': 'Please input role description',
@@ -146,6 +147,92 @@ export default {
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
// Agent snapshots
'agentSnapshot.title': 'Version History',
'agentSnapshot.empty': 'No versions yet',
'agentSnapshot.emptyTip': 'Saved configs will appear here',
'agentSnapshot.version': 'Version',
'agentSnapshot.createdAt': 'Saved At',
'agentSnapshot.source': 'Source',
'agentSnapshot.changedFields': 'Changes',
'agentSnapshot.view': 'View',
'agentSnapshot.restore': 'Restore',
'agentSnapshot.delete': 'Delete',
'agentSnapshot.loadMore': 'Load More',
'agentSnapshot.detailTitle': 'Change Details',
'agentSnapshot.restorePreviewTitle': 'Restore Preview',
'agentSnapshot.confirmRestore': 'Confirm Restore',
'agentSnapshot.currentVersion': 'Latest Snapshot',
'agentSnapshot.beforeChange': 'Before',
'agentSnapshot.afterChange': 'After',
'agentSnapshot.beforeRestore': 'Before Restore',
'agentSnapshot.afterRestore': 'After Restore',
'agentSnapshot.configValue': 'Config Value',
'agentSnapshot.emptyValue': 'None',
'agentSnapshot.secretRedacted': 'Secret hidden',
'agentSnapshot.redactedValueChanged': 'The value is hidden, but it did change',
'agentSnapshot.noChangedContent': 'No displayable changes',
'agentSnapshot.recordedChange': 'Changed when recorded',
'agentSnapshot.noRestoreNeeded': 'The current configuration already matches this version',
'agentSnapshot.unsavedChangesTitle': 'Unsaved changes',
'agentSnapshot.unsavedChangesWarning': 'Continuing will discard the unsaved changes on this page.',
'agentSnapshot.discardAndRestore': 'Discard and restore',
'agentSnapshot.restoreConfirm': 'Restore to version #{version}? The current configuration will remain available in history.',
'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Please confirm the risk.',
'agentSnapshot.restoreChatHistoryDestructiveWarning': 'This restore will permanently delete this agent\'s existing chat history. Chat history is not included in configuration snapshots and cannot be recovered from version history.',
'agentSnapshot.restoreSuccess': 'Version restored',
'agentSnapshot.restoreFailed': 'Failed to restore version',
'agentSnapshot.reloadAfterRestorePending': 'Reloading the restored configuration and tags. Saving is disabled until this completes.',
'agentSnapshot.reloadAfterRestoreFailed': 'The restored configuration or tags could not be reloaded. Saving remains disabled to prevent stale data from overwriting the restore. Please retry.',
'agentSnapshot.retryReload': 'Reload',
'agentSnapshot.mutationBusy': 'The configuration is being saved or reloaded. Wait for it to finish before opening version history or restoring.',
'agentSnapshot.deleteConfirm': 'Delete version #{version}? This cannot be undone.',
'agentSnapshot.deleteSuccess': 'Version deleted',
'agentSnapshot.deleteFailed': 'Failed to delete version',
'agentSnapshot.fetchFailed': 'Failed to fetch versions',
'agentSnapshot.detailFailed': 'Failed to fetch version details',
'agentSnapshot.correctWordCount': '{count} replacement words',
'agentSnapshot.source.config': 'Config Save',
'agentSnapshot.source.current': 'Current Config',
'agentSnapshot.source.restore': 'Restored',
'agentSnapshot.source.initial': 'Initial Version',
'agentSnapshot.field.initial': 'Initial Snapshot',
'agentSnapshot.field.agentCode': 'Agent Code',
'agentSnapshot.field.agentName': 'Nickname',
'agentSnapshot.field.asrModelId': 'Speech Recognition',
'agentSnapshot.field.vadModelId': 'Voice Activity Detection',
'agentSnapshot.field.llmModelId': 'Main Language Model',
'agentSnapshot.field.slmModelId': 'Small Language Model',
'agentSnapshot.field.vllmModelId': 'Vision Model',
'agentSnapshot.field.ttsModelId': 'Text-to-Speech',
'agentSnapshot.field.ttsVoiceId': 'Voice',
'agentSnapshot.field.ttsLanguage': 'Language',
'agentSnapshot.field.ttsVolume': 'Volume',
'agentSnapshot.field.ttsRate': 'Speed',
'agentSnapshot.field.ttsPitch': 'Pitch',
'agentSnapshot.field.memModelId': 'Memory Mode',
'agentSnapshot.field.intentModelId': 'Intent Recognition',
'agentSnapshot.field.chatHistoryConf': 'Chat History Config',
'agentSnapshot.field.systemPrompt': 'Role Description',
'agentSnapshot.field.summaryMemory': 'Memory',
'agentSnapshot.field.langCode': 'Language Code',
'agentSnapshot.field.language': 'Interaction Language',
'agentSnapshot.field.sort': 'Sort',
'agentSnapshot.field.functions': 'Plugins',
'agentSnapshot.field.contextProviders': 'Context Sources',
'agentSnapshot.field.correctWordFileIds': 'Replacement Words',
'agentSnapshot.field.tagNames': 'Agent Tags',
'agentSnapshot.chatHistoryConf.none': 'Do not record chat history',
'agentSnapshot.chatHistoryConf.text': 'Report text',
'agentSnapshot.chatHistoryConf.textVoice': 'Report text and voice',
'agentSnapshot.model.Memory_nomem': 'No memory',
'agentSnapshot.model.Memory_mem_local_short': 'Local short memory',
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI memory',
'agentSnapshot.model.Memory_mem_report_only': 'Report only',
'agentSnapshot.model.Intent_nointent': 'No intent recognition',
'agentSnapshot.model.Intent_intent_llm': 'External LLM intent recognition',
'agentSnapshot.model.Intent_function_call': 'LLM function calling',
// Context provider dialog related
'contextProviderDialog.title': 'Edit Source',
'contextProviderDialog.noContextApi': 'No Context API',
+10 -10
View File
@@ -1,23 +1,23 @@
import type { Language } from '@/store/lang'
import { ref } from 'vue'
import { useLangStore } from '@/store/lang'
import type { Language } from '@/store/lang'
import de from './de'
import en from './en'
import pt_BR from './pt_BR'
import vi from './vi'
// 导入各个语言的翻译文件
import zh_CN from './zh_CN'
import en from './en'
import zh_TW from './zh_TW'
import de from './de'
import vi from './vi'
import pt_BR from './pt_BR'
// 语言包映射
const messages = {
zh_CN: zh_CN,
zh_CN,
en,
zh_TW: zh_TW,
zh_TW,
de,
vi,
pt_BR: pt_BR,
pt_BR,
}
// 当前使用的语言
@@ -42,7 +42,7 @@ export function t(key: string, params?: Record<string, string | number>): string
// 直接查找扁平键名
if (langMessages && typeof langMessages === 'object' && key in langMessages) {
let value = langMessages[key]
const value = langMessages[key]
if (typeof value === 'string') {
// 处理参数替换
if (params) {
@@ -76,4 +76,4 @@ export function getSupportedLanguages(): { code: Language, name: string }[] {
{ code: 'vi', name: 'Tiếng Việt' },
{ code: 'pt_BR', name: 'Português (Brasil)' },
]
}
}

Some files were not shown because too many files have changed in this diff Show More