) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/DateUtils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/DateUtils.java
index bfa7afed..800733c1 100644
--- a/main/manager-api/src/main/java/xiaozhi/common/utils/DateUtils.java
+++ b/main/manager-api/src/main/java/xiaozhi/common/utils/DateUtils.java
@@ -21,6 +21,8 @@ public class DateUtils {
* 时间格式(yyyy-MM-dd HH:mm:ss)
*/
public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+ public final static String DATE_TIME_MILLIS_PATTERN = "yyyy-MM-dd HH:mm:ss.SSS";
+
/**
* 日期格式化 日期格式为:yyyy-MM-dd
@@ -63,6 +65,19 @@ public class DateUtils {
return null;
}
+
+ public static String getDateTimeNow() {
+ return getDateTimeNow(DATE_TIME_PATTERN);
+ }
+
+ public static String getDateTimeNow(String pattern) {
+ return format(new Date(), pattern);
+ }
+
+ public static String millsToSecond(long mills) {
+ return String.format("%.3f", mills / 1000.0);
+ }
+
/**
* 获取简短的时间字符串:10秒前返回刚刚,多少秒前,几小时前,超过一周返回年月日时分秒
* @param date
diff --git a/main/manager-api/src/main/java/xiaozhi/common/utils/ResourcesUtils.java b/main/manager-api/src/main/java/xiaozhi/common/utils/ResourcesUtils.java
new file mode 100644
index 00000000..49d9a727
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/common/utils/ResourcesUtils.java
@@ -0,0 +1,44 @@
+package xiaozhi.common.utils;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Component;
+import xiaozhi.common.exception.RenException;
+
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
+/**
+ * 资源处理工具
+ */
+@AllArgsConstructor
+@Slf4j
+@Component
+public class ResourcesUtils {
+ private ResourceLoader resourceLoader;
+
+ /**
+ * 读取资源,返回字符串
+ * @param fileName 资源路径:resources下开始
+ * @return 字符串
+ */
+ public String loadString(String fileName) {
+ Resource resource = resourceLoader.getResource("classpath:" + fileName);
+ StringBuilder luaScriptBuilder = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(resource.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ luaScriptBuilder.append(line).append("\n");
+ }
+ } catch (IOException e){
+ log.error("方法:loadString()读取资源失败--{}",e.getMessage());
+ throw new RenException("读取资源失败");
+ }
+ return luaScriptBuilder.toString();
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/common/validator/ValidatorUtils.java b/main/manager-api/src/main/java/xiaozhi/common/validator/ValidatorUtils.java
index 9e31c964..364da0d9 100644
--- a/main/manager-api/src/main/java/xiaozhi/common/validator/ValidatorUtils.java
+++ b/main/manager-api/src/main/java/xiaozhi/common/validator/ValidatorUtils.java
@@ -2,6 +2,7 @@ package xiaozhi.common.validator;
import java.util.Locale;
import java.util.Set;
+import java.util.regex.Pattern;
import org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator;
import org.springframework.context.i18n.LocaleContextHolder;
@@ -45,4 +46,32 @@ public class ValidatorUtils {
throw new RenException(constraint.getMessage());
}
}
+
+ /**
+ * 国际手机号正则表达式
+ * 要求必须带国际区号,格式:+[国家代码][手机号]
+ * 例如:
+ * - +8613800138000
+ * - +12345678900
+ * - +447123456789
+ */
+ private static final String INTERNATIONAL_PHONE_REGEX = "^\\+[1-9]\\d{0,3}[1-9]\\d{4,14}$";
+
+ /**
+ * 校验手机号是否有效
+ * 要求必须带国际区号,格式:+[国家代码][手机号]
+ * 例如:+8613800138000
+ *
+ * @param phone 手机号
+ * @return boolean
+ */
+ public static boolean isValidPhone(String phone) {
+ if (phone == null || phone.isEmpty()) {
+ return false;
+ }
+
+ // 验证必须带国际区号的手机号格式
+ Pattern pattern = Pattern.compile(INTERNATIONAL_PHONE_REGEX);
+ return pattern.matcher(phone).matches();
+ }
}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java
new file mode 100644
index 00000000..9d6542cc
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java
@@ -0,0 +1,36 @@
+package xiaozhi.modules.agent.controller;
+
+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.RequiredArgsConstructor;
+import xiaozhi.common.utils.Result;
+import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
+import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
+
+@Tag(name = "智能体聊天历史管理")
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/agent/chat-history")
+public class AgentChatHistoryController {
+ private final AgentChatHistoryBizService agentChatHistoryBizService;
+
+ /**
+ * 小智服务聊天上报请求
+ *
+ * 小智服务聊天上报请求,包含Base64编码的音频数据和相关信息。
+ *
+ * @param request 包含上传文件及相关信息的请求对象
+ */
+ @Operation(summary = "小智服务聊天上报请求")
+ @PostMapping("/report")
+ public Result uploadFile(@Valid @RequestBody AgentChatHistoryReportDTO request) {
+ Boolean result = agentChatHistoryBizService.report(request);
+ return new Result().ok(result);
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java
index eea2d7fe..54f3fa8c 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java
@@ -3,8 +3,13 @@ package xiaozhi.modules.agent.controller;
import java.util.Date;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
+import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -25,16 +30,24 @@ import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.Result;
+import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
+import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
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.entity.AgentTemplateEntity;
+import xiaozhi.modules.agent.service.AgentChatAudioService;
+import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
+import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser;
@@ -46,6 +59,9 @@ public class AgentController {
private final AgentService agentService;
private final AgentTemplateService agentTemplateService;
private final DeviceService deviceService;
+ private final AgentChatHistoryService agentChatHistoryService;
+ private final AgentChatAudioService agentChatAudioService;
+ private final RedisUtils redisUtils;
@GetMapping("/list")
@Operation(summary = "获取用户智能体列表")
@@ -80,7 +96,7 @@ public class AgentController {
@PostMapping
@Operation(summary = "创建智能体")
@RequiresPermissions("sys:role:normal")
- public Result save(@RequestBody @Valid AgentCreateDTO dto) {
+ public Result save(@RequestBody @Valid AgentCreateDTO dto) {
AgentEntity entity = ConvertUtils.sourceToTarget(dto, AgentEntity.class);
// 获取默认模板
@@ -95,6 +111,8 @@ public class AgentController {
entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
+ entity.setSummaryMemory(template.getSummaryMemory());
+ entity.setChatHistoryConf(template.getChatHistoryConf());
entity.setLangCode(template.getLangCode());
entity.setLanguage(template.getLanguage());
}
@@ -108,13 +126,29 @@ public class AgentController {
// ID、智能体编码和排序会在Service层自动生成
agentService.insert(entity);
- return new Result<>();
+ return new Result().ok(entity.getId());
+ }
+
+ @PutMapping("/saveMemory/{macAddress}")
+ @Operation(summary = "根据设备id更新智能体")
+ public Result 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());
+ return updateAgentById(device.getAgentId(), agentUpdateDTO);
}
@PutMapping("/{id}")
@Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal")
public Result update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
+ return updateAgentById(id, dto);
+ }
+
+ private Result updateAgentById(String id, AgentUpdateDTO dto) {
// 先查询现有实体
AgentEntity existingEntity = agentService.getAgentById(id);
if (existingEntity == null) {
@@ -152,6 +186,12 @@ public class AgentController {
if (dto.getSystemPrompt() != null) {
existingEntity.setSystemPrompt(dto.getSystemPrompt());
}
+ if (dto.getSummaryMemory() != null) {
+ existingEntity.setSummaryMemory(dto.getSummaryMemory());
+ }
+ if (dto.getChatHistoryConf() != null) {
+ existingEntity.setChatHistoryConf(dto.getChatHistoryConf());
+ }
if (dto.getLangCode() != null) {
existingEntity.setLangCode(dto.getLangCode());
}
@@ -167,8 +207,16 @@ public class AgentController {
existingEntity.setUpdater(user.getId());
existingEntity.setUpdatedAt(new Date());
+ // 更新记忆策略
+ if (existingEntity.getMemModelId() == null || existingEntity.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
+ // 删除所有记录
+ agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, true);
+ existingEntity.setSummaryMemory("");
+ } else if (existingEntity.getChatHistoryConf() != null && existingEntity.getChatHistoryConf() == 1) {
+ // 删除音频数据
+ agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false);
+ }
agentService.updateById(existingEntity);
-
return new Result<>();
}
@@ -178,6 +226,8 @@ public class AgentController {
public Result delete(@PathVariable String id) {
// 先删除关联的设备
deviceService.deleteByAgentId(id);
+ // 删除关联的聊天记录
+ agentChatHistoryService.deleteByAgentId(id, true, true);
// 再删除智能体
agentService.deleteById(id);
return new Result<>();
@@ -191,4 +241,72 @@ public class AgentController {
.list(new QueryWrapper().orderByAsc("sort"));
return new Result>().ok(list);
}
+
+ @GetMapping("/{id}/sessions")
+ @Operation(summary = "获取智能体会话列表")
+ @RequiresPermissions("sys:role:normal")
+ @Parameters({
+ @Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
+ @Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true),
+ })
+ public Result> getAgentSessions(
+ @PathVariable("id") String id,
+ @Parameter(hidden = true) @RequestParam Map params) {
+ params.put("agentId", id);
+ PageData page = agentChatHistoryService.getSessionListByAgentId(params);
+ return new Result>().ok(page);
+ }
+
+ @GetMapping("/{id}/chat-history/{sessionId}")
+ @Operation(summary = "获取智能体聊天记录")
+ @RequiresPermissions("sys:role:normal")
+ public Result> getAgentChatHistory(
+ @PathVariable("id") String id,
+ @PathVariable("sessionId") String sessionId) {
+ // 获取当前用户
+ UserDetail user = SecurityUser.getUser();
+
+ // 检查权限
+ if (!agentService.checkAgentPermission(id, user.getId())) {
+ return new Result>().error("没有权限查看该智能体的聊天记录");
+ }
+
+ // 查询聊天记录
+ List result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
+ return new Result>().ok(result);
+ }
+
+ @PostMapping("/audio/{audioId}")
+ @Operation(summary = "获取音频下载ID")
+ @RequiresPermissions("sys:role:normal")
+ public Result getAudioId(@PathVariable("audioId") String audioId) {
+ byte[] audioData = agentChatAudioService.getAudio(audioId);
+ if (audioData == null) {
+ return new Result().error("音频不存在");
+ }
+ String uuid = UUID.randomUUID().toString();
+ redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
+ return new Result().ok(uuid);
+ }
+
+ @GetMapping("/play/{uuid}")
+ @Operation(summary = "播放音频")
+ public ResponseEntity playAudio(@PathVariable("uuid") String uuid) {
+
+ String audioId = (String) redisUtils.get(RedisKeys.getAgentAudioIdKey(uuid));
+ if (StringUtils.isBlank(audioId)) {
+ return ResponseEntity.notFound().build();
+ }
+
+ byte[] audioData = agentChatAudioService.getAudio(audioId);
+ if (audioData == null) {
+ return ResponseEntity.notFound().build();
+ }
+ redisUtils.delete(RedisKeys.getAgentAudioIdKey(uuid));
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"play.wav\"")
+ .body(audioData);
+ }
+
}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java
index 8c80d6cc..3b4be452 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java
@@ -3,6 +3,7 @@ package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.agent.entity.AgentEntity;
@@ -15,4 +16,16 @@ public interface AgentDao extends BaseDao {
* @return 设备数量
*/
Integer getDeviceCountByAgentId(@Param("agentId") String agentId);
-}
\ No newline at end of file
+
+ /**
+ * 根据设备MAC地址查询对应设备的默认智能体信息
+ *
+ * @param macAddress 设备MAC地址
+ * @return 默认智能体信息
+ */
+ @Select(" SELECT a.* FROM ai_device d " +
+ " LEFT JOIN ai_agent a ON d.agent_id = a.id " +
+ " WHERE d.mac_address = #{macAddress} " +
+ " ORDER BY d.id DESC LIMIT 1")
+ AgentEntity getDefaultAgentByMacAddress(@Param("macAddress") String macAddress);
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AiAgentChatAudioDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AiAgentChatAudioDao.java
new file mode 100644
index 00000000..355b25ac
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AiAgentChatAudioDao.java
@@ -0,0 +1,18 @@
+package xiaozhi.modules.agent.dao;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
+
+/**
+ * {@link AgentChatAudioEntity} 智能体聊天音频数据Dao对象
+ *
+ * @author Goody
+ * @version 1.0, 2025/5/8
+ * @since 1.0.0
+ */
+@Mapper
+public interface AiAgentChatAudioDao extends BaseMapper {
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AiAgentChatHistoryDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AiAgentChatHistoryDao.java
new file mode 100644
index 00000000..b75312d2
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AiAgentChatHistoryDao.java
@@ -0,0 +1,38 @@
+package xiaozhi.modules.agent.dao;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
+
+/**
+ * {@link AgentChatHistoryEntity} 智能体聊天历史记录Dao对象
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
+@Mapper
+public interface AiAgentChatHistoryDao extends BaseMapper {
+ /**
+ * 根据智能体ID删除音频
+ *
+ * @param agentId 智能体ID
+ */
+ void deleteAudioByAgentId(String agentId);
+
+ /**
+ * 根据智能体ID删除聊天历史记录
+ *
+ * @param agentId 智能体ID
+ */
+ void deleteHistoryByAgentId(String agentId);
+
+ /**
+ * 根据智能体ID删除音频ID
+ *
+ * @param agentId 智能体ID
+ */
+ void deleteAudioIdByAgentId(String agentId);
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryDTO.java
new file mode 100644
index 00000000..fd49e52b
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryDTO.java
@@ -0,0 +1,28 @@
+package xiaozhi.modules.agent.dto;
+
+import java.util.Date;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 智能体聊天记录DTO
+ */
+@Data
+@Schema(description = "智能体聊天记录")
+public class AgentChatHistoryDTO {
+ @Schema(description = "创建时间")
+ private Date createdAt;
+
+ @Schema(description = "消息类型: 1-用户, 2-智能体")
+ private Byte chatType;
+
+ @Schema(description = "聊天内容")
+ private String content;
+
+ @Schema(description = "音频ID")
+ private String audioId;
+
+ @Schema(description = "MAC地址")
+ private String macAddress;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryReportDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryReportDTO.java
new file mode 100644
index 00000000..bdaed950
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatHistoryReportDTO.java
@@ -0,0 +1,31 @@
+package xiaozhi.modules.agent.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 小智设备聊天上报请求
+ *
+ * @author Haotian
+ * @version 1.0, 2025/5/8
+ */
+@Data
+@Schema(description = "小智设备聊天上报请求")
+public class AgentChatHistoryReportDTO {
+ @Schema(description = "MAC地址", example = "00:11:22:33:44:55")
+ @NotBlank
+ private String macAddress;
+ @Schema(description = "会话ID", example = "79578c31-f1fb-426a-900e-1e934215f05a")
+ @NotBlank
+ private String sessionId;
+ @Schema(description = "消息类型: 1-用户, 2-智能体", example = "1")
+ @NotNull
+ private Byte chatType;
+ @Schema(description = "聊天内容", example = "你好呀")
+ @NotBlank
+ private String content;
+ @Schema(description = "base64编码的opus音频数据", example = "")
+ private String audioBase64;
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSessionDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSessionDTO.java
new file mode 100644
index 00000000..c65f05c6
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSessionDTO.java
@@ -0,0 +1,26 @@
+package xiaozhi.modules.agent.dto;
+
+import java.time.LocalDateTime;
+
+import lombok.Data;
+
+/**
+ * 智能体会话列表DTO
+ */
+@Data
+public class AgentChatSessionDTO {
+ /**
+ * 会话ID
+ */
+ private String sessionId;
+
+ /**
+ * 会话时间
+ */
+ private LocalDateTime createdAt;
+
+ /**
+ * 聊天条数
+ */
+ private Integer chatCount;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
index a63c96e7..075db842 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentDTO.java
@@ -27,9 +27,16 @@ public class AgentDTO {
@Schema(description = "大语言模型名称", example = "llm_model_01")
private String llmModelName;
+ @Schema(description = "记忆模型ID", example = "mem_model_01")
+ private String memModelId;
+
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助")
private String systemPrompt;
+ @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
+ "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
+ private String summaryMemory;
+
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
private Date lastConnectedAt;
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentMemoryDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentMemoryDTO.java
new file mode 100644
index 00000000..ed210c60
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentMemoryDTO.java
@@ -0,0 +1,19 @@
+package xiaozhi.modules.agent.dto;
+
+import java.io.Serializable;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 智能体记忆更新DTO
+ */
+@Data
+@Schema(description = "智能体记忆更新对象")
+public class AgentMemoryDTO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
+ "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
+ private String summaryMemory;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java
index d54685d8..ccfbd53f 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java
@@ -45,6 +45,13 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
private String systemPrompt;
+ @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
+ "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
+ private String summaryMemory;
+
+ @Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false)
+ private Integer chatHistoryConf;
+
@Schema(description = "语言编码", example = "zh_CN", required = false)
private String langCode;
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentChatAudioEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentChatAudioEntity.java
new file mode 100644
index 00000000..ac30d9d4
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentChatAudioEntity.java
@@ -0,0 +1,29 @@
+package xiaozhi.modules.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import lombok.Data;
+
+/**
+ * 智能体聊天音频数据表
+ *
+ * @author Goody
+ * @version 1.0, 2025/5/8
+ * @since 1.0.0
+ */
+@Data
+@TableName("ai_agent_chat_audio")
+public class AgentChatAudioEntity {
+ /**
+ * 主键ID
+ */
+ @TableId(type = IdType.ASSIGN_UUID)
+ private String id;
+
+ /**
+ * 音频opus数据
+ */
+ private byte[] audio;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentChatHistoryEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentChatHistoryEntity.java
new file mode 100644
index 00000000..679c814a
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentChatHistoryEntity.java
@@ -0,0 +1,81 @@
+package xiaozhi.modules.agent.entity;
+
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 智能体聊天记录表
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@TableName(value = "ai_agent_chat_history")
+public class AgentChatHistoryEntity {
+ /**
+ * 主键ID
+ */
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /**
+ * MAC地址
+ */
+ @TableField(value = "mac_address")
+ private String macAddress;
+
+ /**
+ * 智能体id
+ */
+ @TableField(value = "agent_id")
+ private String agentId;
+
+ /**
+ * 会话ID
+ */
+ @TableField(value = "session_id")
+ private String sessionId;
+
+ /**
+ * 消息类型: 1-用户, 2-智能体
+ */
+ @TableField(value = "chat_type")
+ private Byte chatType;
+
+ /**
+ * 聊天内容
+ */
+ @TableField(value = "content")
+ private String content;
+
+ /**
+ * 音频base64数据
+ */
+ @TableField(value = "audio_id")
+ private String audioId;
+
+ /**
+ * 创建时间
+ */
+ @TableField(value = "created_at")
+ private Date createdAt;
+
+ /**
+ * 更新时间
+ */
+ @TableField(value = "updated_at")
+ private Date updatedAt;
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentEntity.java
index 917fe6d2..1ffc5550 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentEntity.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentEntity.java
@@ -48,9 +48,16 @@ public class AgentEntity {
@Schema(description = "意图模型标识")
private String intentModelId;
+ @Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)")
+ private Integer chatHistoryConf;
+
@Schema(description = "角色设定参数")
private String systemPrompt;
+ @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
+ "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
+ private String summaryMemory;
+
@Schema(description = "语言编码")
private String langCode;
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentTemplateEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentTemplateEntity.java
index 6d83e4fe..2528ca2c 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentTemplateEntity.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentTemplateEntity.java
@@ -69,11 +69,20 @@ public class AgentTemplateEntity implements Serializable {
*/
private String intentModelId;
+ /**
+ * 聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)
+ */
+ private Integer chatHistoryConf;
+
/**
* 角色设定参数
*/
private String systemPrompt;
+ /**
+ * 总结记忆
+ */
+ private String summaryMemory;
/**
* 语言编码
*/
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatAudioService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatAudioService.java
new file mode 100644
index 00000000..e284b69f
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatAudioService.java
@@ -0,0 +1,30 @@
+package xiaozhi.modules.agent.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
+
+/**
+ * 智能体聊天音频数据表处理service
+ *
+ * @author Goody
+ * @version 1.0, 2025/5/8
+ * @since 1.0.0
+ */
+public interface AgentChatAudioService extends IService {
+ /**
+ * 保存音频数据
+ *
+ * @param audioData 音频数据
+ * @return 音频ID
+ */
+ String saveAudio(byte[] audioData);
+
+ /**
+ * 获取音频数据
+ *
+ * @param audioId 音频ID
+ * @return 音频数据
+ */
+ byte[] getAudio(String audioId);
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatHistoryService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatHistoryService.java
new file mode 100644
index 00000000..21fce988
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatHistoryService.java
@@ -0,0 +1,47 @@
+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 xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
+import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
+import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
+
+/**
+ * 智能体聊天记录表处理service
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
+public interface AgentChatHistoryService extends IService {
+
+ /**
+ * 根据智能体ID获取会话列表
+ *
+ * @param params 查询参数,包含agentId、page、limit
+ * @return 分页的会话列表
+ */
+ PageData getSessionListByAgentId(Map params);
+
+ /**
+ * 根据会话ID获取聊天记录列表
+ *
+ * @param agentId 智能体ID
+ * @param sessionId 会话ID
+ * @return 聊天记录列表
+ */
+ List getChatHistoryBySessionId(String agentId, String sessionId);
+
+ /**
+ * 根据智能体ID删除聊天记录
+ *
+ * @param agentId 智能体ID
+ * @param deleteAudio 是否删除音频
+ * @param deleteText 是否删除文本
+ */
+ void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java
index 8d0b2959..2104d353 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java
@@ -8,38 +8,75 @@ import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
+/**
+ * 智能体表处理service
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
public interface AgentService extends BaseService {
-
/**
- * 管理员获取所有智能体列表(分页)
+ * 获取管理员智能体列表
+ *
+ * @param params 查询参数
+ * @return 分页数据
*/
PageData adminAgentList(Map params);
/**
- * 获取智能体详情
+ * 根据ID获取智能体
+ *
+ * @param id 智能体ID
+ * @return 智能体实体
*/
AgentEntity getAgentById(String id);
/**
- * 删除这个用户的所有
- *
- * @param userId
+ * 插入智能体
+ *
+ * @param entity 智能体实体
+ * @return 是否成功
+ */
+ boolean insert(AgentEntity entity);
+
+ /**
+ * 根据用户ID删除智能体
+ *
+ * @param userId 用户ID
*/
void deleteAgentByUserId(Long userId);
/**
* 获取用户智能体列表
- *
- * @param userId
- * @return
+ *
+ * @param userId 用户ID
+ * @return 智能体列表
*/
List getUserAgents(Long userId);
/**
- * 获取智能体的设备数量
- *
+ * 根据智能体ID获取设备数量
+ *
* @param agentId 智能体ID
* @return 设备数量
*/
Integer getDeviceCountByAgentId(String agentId);
-}
\ No newline at end of file
+
+ /**
+ * 根据设备MAC地址查询对应设备的默认智能体信息
+ *
+ * @param macAddress 设备MAC地址
+ * @return 默认智能体信息,不存在时返回null
+ */
+ AgentEntity getDefaultAgentByMacAddress(String macAddress);
+
+ /**
+ * 检查用户是否有权限访问智能体
+ *
+ * @param agentId 智能体ID
+ * @param userId 用户ID
+ * @return 是否有权限
+ */
+ boolean checkAgentPermission(String agentId, Long userId);
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentTemplateService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentTemplateService.java
index 197c860e..4e279c40 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentTemplateService.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentTemplateService.java
@@ -17,4 +17,12 @@ public interface AgentTemplateService extends IService {
* @return 默认模板实体
*/
AgentTemplateEntity getDefaultTemplate();
+
+ /**
+ * 更新默认模板中的模型ID
+ *
+ * @param modelType 模型类型
+ * @param modelId 模型ID
+ */
+ void updateDefaultTemplateModelId(String modelType, String modelId);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/AgentChatHistoryBizService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/AgentChatHistoryBizService.java
new file mode 100644
index 00000000..191b231b
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/AgentChatHistoryBizService.java
@@ -0,0 +1,22 @@
+package xiaozhi.modules.agent.service.biz;
+
+import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
+
+/**
+ * 智能体聊天历史业务逻辑层
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
+public interface AgentChatHistoryBizService {
+
+ /**
+ * 聊天上报方法
+ *
+ * @param agentChatHistoryReportDTO 包含聊天上报所需信息的输入对象
+ * 例如:设备MAC地址、文件类型、内容等
+ * @return 上传结果,true表示成功,false表示失败
+ */
+ Boolean report(AgentChatHistoryReportDTO agentChatHistoryReportDTO);
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java
new file mode 100644
index 00000000..bf6c188b
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java
@@ -0,0 +1,112 @@
+package xiaozhi.modules.agent.service.biz.impl;
+
+import java.util.Base64;
+import java.util.Date;
+import java.util.Objects;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
+import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
+import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
+import xiaozhi.modules.agent.entity.AgentEntity;
+import xiaozhi.modules.agent.service.AgentChatAudioService;
+import xiaozhi.modules.agent.service.AgentChatHistoryService;
+import xiaozhi.modules.agent.service.AgentService;
+import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
+
+/**
+ * {@link AgentChatHistoryBizService} impl
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizService {
+ private final AgentService agentService;
+ private final AgentChatHistoryService agentChatHistoryService;
+ private final AgentChatAudioService agentChatAudioService;
+ private final RedisUtils redisUtils;
+
+ /**
+ * 处理聊天记录上报,包括文件上传和相关信息记录
+ *
+ * @param report 包含聊天上报所需信息的输入对象
+ * @return 上传结果,true表示成功,false表示失败
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Boolean report(AgentChatHistoryReportDTO report) {
+ String macAddress = report.getMacAddress();
+ Byte chatType = report.getChatType();
+ log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
+
+ // 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
+ AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
+ if (agentEntity == null) {
+ return Boolean.FALSE;
+ }
+
+ Integer chatHistoryConf = agentEntity.getChatHistoryConf();
+ String agentId = agentEntity.getId();
+
+ if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
+ saveChatText(report, agentId, macAddress, null);
+ } else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
+ String audioId = saveChatAudio(report);
+ saveChatText(report, agentId, macAddress, audioId);
+ }
+
+ // 更新设备最后对话时间
+ redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
+ return Boolean.TRUE;
+ }
+
+ /**
+ * base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
+ */
+ private String saveChatAudio(AgentChatHistoryReportDTO report) {
+ String audioId = null;
+
+ if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
+ try {
+ byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
+ audioId = agentChatAudioService.saveAudio(audioData);
+ log.info("音频数据保存成功,audioId={}", audioId);
+ } catch (Exception e) {
+ log.error("音频数据保存失败", e);
+ return null;
+ }
+ }
+ return audioId;
+ }
+
+ /**
+ * 组装上报数据
+ */
+ private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
+
+ // 构建聊天记录实体
+ AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
+ .macAddress(macAddress)
+ .agentId(agentId)
+ .sessionId(report.getSessionId())
+ .chatType(report.getChatType())
+ .content(report.getContent())
+ .audioId(audioId)
+ .build();
+
+ // 保存数据
+ agentChatHistoryService.save(entity);
+
+ log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatAudioServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatAudioServiceImpl.java
new file mode 100644
index 00000000..cf804678
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatAudioServiceImpl.java
@@ -0,0 +1,34 @@
+package xiaozhi.modules.agent.service.impl;
+
+import org.springframework.stereotype.Service;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+
+import xiaozhi.modules.agent.dao.AiAgentChatAudioDao;
+import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
+import xiaozhi.modules.agent.service.AgentChatAudioService;
+
+/**
+ * 智能体聊天音频数据表处理service {@link AgentChatAudioService} impl
+ *
+ * @author Goody
+ * @version 1.0, 2025/5/8
+ * @since 1.0.0
+ */
+@Service
+public class AgentChatAudioServiceImpl extends ServiceImpl
+ implements AgentChatAudioService {
+ @Override
+ public String saveAudio(byte[] audioData) {
+ AgentChatAudioEntity entity = new AgentChatAudioEntity();
+ entity.setAudio(audioData);
+ save(entity);
+ return entity.getId();
+ }
+
+ @Override
+ public byte[] getAudio(String audioId) {
+ AgentChatAudioEntity entity = getById(audioId);
+ return entity != null ? entity.getAudio() : null;
+ }
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatHistoryServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatHistoryServiceImpl.java
new file mode 100644
index 00000000..b28e80ad
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatHistoryServiceImpl.java
@@ -0,0 +1,93 @@
+package xiaozhi.modules.agent.service.impl;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.springframework.stereotype.Service;
+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.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.utils.ConvertUtils;
+import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
+import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
+import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
+import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
+import xiaozhi.modules.agent.service.AgentChatHistoryService;
+
+/**
+ * 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
+ *
+ * @author Goody
+ * @version 1.0, 2025/4/30
+ * @since 1.0.0
+ */
+@Service
+public class AgentChatHistoryServiceImpl extends ServiceImpl
+ implements AgentChatHistoryService {
+
+ @Override
+ public PageData getSessionListByAgentId(Map params) {
+ String agentId = (String) params.get("agentId");
+ int page = Integer.parseInt(params.get(Constant.PAGE).toString());
+ int limit = Integer.parseInt(params.get(Constant.LIMIT).toString());
+
+ // 构建查询条件
+ QueryWrapper wrapper = new QueryWrapper<>();
+ wrapper.select("session_id", "MAX(created_at) as created_at", "COUNT(*) as chat_count")
+ .eq("agent_id", agentId)
+ .groupBy("session_id")
+ .orderByDesc("created_at");
+
+ // 执行分页查询
+ Page> pageParam = new Page<>(page, limit);
+ IPage> result = this.baseMapper.selectMapsPage(pageParam, wrapper);
+
+ List records = result.getRecords().stream().map(map -> {
+ AgentChatSessionDTO dto = new AgentChatSessionDTO();
+ dto.setSessionId((String) map.get("session_id"));
+ dto.setCreatedAt((LocalDateTime) map.get("created_at"));
+ dto.setChatCount(((Number) map.get("chat_count")).intValue());
+ return dto;
+ }).collect(Collectors.toList());
+
+ return new PageData<>(records, result.getTotal());
+ }
+
+ @Override
+ public List getChatHistoryBySessionId(String agentId, String sessionId) {
+ // 构建查询条件
+ QueryWrapper wrapper = new QueryWrapper<>();
+ wrapper.eq("agent_id", agentId)
+ .eq("session_id", sessionId)
+ .orderByAsc("created_at");
+
+ // 查询聊天记录
+ List historyList = list(wrapper);
+
+ // 转换为DTO
+ return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
+ if (deleteAudio) {
+ baseMapper.deleteAudioByAgentId(agentId);
+ }
+ if (deleteAudio && !deleteText) {
+ baseMapper.deleteAudioIdByAgentId(agentId);
+ }
+ if (deleteText) {
+ baseMapper.deleteHistoryByAgentId(agentId);
+ }
+
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
index bb2a3ed0..37bb7d93 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
@@ -6,13 +6,14 @@ import java.util.UUID;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
+import lombok.AllArgsConstructor;
+import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
@@ -21,37 +22,40 @@ import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentService;
+import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService;
+import xiaozhi.modules.security.user.SecurityUser;
+import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService;
@Service
+@AllArgsConstructor
public class AgentServiceImpl extends BaseServiceImpl implements AgentService {
private final AgentDao agentDao;
-
- @Autowired
- private TimbreService timbreModelService;
-
- @Autowired
- private ModelConfigService modelConfigService;
-
- @Autowired
- private RedisUtils redisUtils;
-
- public AgentServiceImpl(AgentDao agentDao) {
- this.agentDao = agentDao;
- }
+ private final TimbreService timbreModelService;
+ private final ModelConfigService modelConfigService;
+ private final RedisUtils redisUtils;
+ private final DeviceService deviceService;
@Override
public PageData adminAgentList(Map params) {
IPage page = agentDao.selectPage(
- getPage(params, "sort", true),
+ getPage(params, "agent_name", true),
new QueryWrapper<>());
return new PageData<>(page.getRecords(), page.getTotal());
}
@Override
public AgentEntity getAgentById(String id) {
- return agentDao.selectById(id);
+ AgentEntity agent = agentDao.selectById(id);
+ if (agent != null && agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
+ agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
+ } else if (agent != null && agent.getMemModelId() != null
+ && !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
+ && agent.getChatHistoryConf() == null) {
+ agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
+ }
+ return agent;
}
@Override
@@ -98,12 +102,17 @@ public class AgentServiceImpl extends BaseServiceImpl imp
// 获取 LLM 模型名称
dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId()));
+ // 获取记忆模型名称
+ dto.setMemModelId(agent.getMemModelId());
+
// 获取 TTS 音色名称
dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId()));
+ // 获取智能体最近的最后连接时长
+ dto.setLastConnectedAt(deviceService.getLatestLastConnectionTime(agent.getId()));
+
// 获取设备数量
dto.setDeviceCount(getDeviceCountByAgentId(agent.getId()));
-
return dto;
}).collect(Collectors.toList());
}
@@ -130,4 +139,29 @@ public class AgentServiceImpl extends BaseServiceImpl imp
return deviceCount != null ? deviceCount : 0;
}
-}
\ No newline at end of file
+
+ @Override
+ public AgentEntity getDefaultAgentByMacAddress(String macAddress) {
+ if (StringUtils.isEmpty(macAddress)) {
+ return null;
+ }
+ return agentDao.getDefaultAgentByMacAddress(macAddress);
+ }
+
+ @Override
+ public boolean checkAgentPermission(String agentId, Long userId) {
+ // 获取智能体信息
+ AgentEntity agent = getAgentById(agentId);
+ if (agent == null) {
+ return false;
+ }
+
+ // 如果是超级管理员,直接返回true
+ if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
+ return true;
+ }
+
+ // 检查是否是智能体的所有者
+ return userId.equals(agent.getUserId());
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentTemplateServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentTemplateServiceImpl.java
index 949a2f2d..9ac3de25 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentTemplateServiceImpl.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentTemplateServiceImpl.java
@@ -3,6 +3,7 @@ package xiaozhi.modules.agent.service.impl;
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 xiaozhi.modules.agent.dao.AgentTemplateDao;
@@ -29,4 +30,40 @@ public class AgentTemplateServiceImpl extends ServiceImpl wrapper = new UpdateWrapper<>();
+ switch (modelType) {
+ case "ASR":
+ wrapper.set("asr_model_id", modelId);
+ break;
+ case "VAD":
+ wrapper.set("vad_model_id", modelId);
+ break;
+ case "LLM":
+ wrapper.set("llm_model_id", modelId);
+ break;
+ case "TTS":
+ wrapper.set("tts_model_id", modelId);
+ wrapper.set("tts_voice_id", null);
+ break;
+ case "MEMORY":
+ wrapper.set("mem_model_id", modelId);
+ break;
+ case "INTENT":
+ wrapper.set("intent_model_id", modelId);
+ break;
+ }
+ wrapper.ge("sort", 0);
+ update(wrapper);
+ }
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java b/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java
new file mode 100644
index 00000000..f83efb3a
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/config/controller/ConfigController.java
@@ -0,0 +1,44 @@
+package xiaozhi.modules.config.controller;
+
+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.utils.Result;
+import xiaozhi.common.validator.ValidatorUtils;
+import xiaozhi.modules.config.dto.AgentModelsDTO;
+import xiaozhi.modules.config.service.ConfigService;
+
+/**
+ * xiaozhi-server 配置获取
+ *
+ * @since 1.0.0
+ */
+@RestController
+@RequestMapping("config")
+@Tag(name = "参数管理")
+@AllArgsConstructor
+public class ConfigController {
+ private final ConfigService configService;
+
+ @PostMapping("server-base")
+ @Operation(summary = "获取配置")
+ public Result getConfig() {
+ Object config = configService.getConfig(true);
+ return new Result().ok(config);
+ }
+
+ @PostMapping("agent-models")
+ @Operation(summary = "获取智能体模型")
+ public Result getAgentModels(@Valid @RequestBody AgentModelsDTO dto) {
+ // 效验数据
+ ValidatorUtils.validateEntity(dto);
+ Object models = configService.getAgentModels(dto.getMacAddress(), dto.getSelectedModule());
+ return new Result().ok(models);
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/dto/AgentModelsDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/config/dto/AgentModelsDTO.java
new file mode 100644
index 00000000..47c08a81
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/config/dto/AgentModelsDTO.java
@@ -0,0 +1,25 @@
+package xiaozhi.modules.config.dto;
+
+import java.util.Map;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+@Schema(description = "获取智能体模型配置DTO")
+public class AgentModelsDTO {
+
+ @NotBlank(message = "设备MAC地址不能为空")
+ @Schema(description = "设备MAC地址")
+ private String macAddress;
+
+ @NotBlank(message = "客户端ID不能为空")
+ @Schema(description = "客户端ID")
+ private String clientId;
+
+ @NotNull(message = "客户端已实例化的模型不能为空")
+ @Schema(description = "客户端已实例化的模型")
+ private Map selectedModule;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/init/SystemInitConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/config/init/SystemInitConfig.java
new file mode 100644
index 00000000..e47cd0c7
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/config/init/SystemInitConfig.java
@@ -0,0 +1,41 @@
+package xiaozhi.modules.config.init;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+
+import jakarta.annotation.PostConstruct;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
+import xiaozhi.modules.config.service.ConfigService;
+import xiaozhi.modules.sys.service.SysParamsService;
+
+@Configuration
+@DependsOn("liquibase")
+public class SystemInitConfig {
+
+ @Autowired
+ private SysParamsService sysParamsService;
+
+ @Autowired
+ private ConfigService configService;
+
+ @Autowired
+ private RedisUtils redisUtils;
+
+ @PostConstruct
+ public void init() {
+ // 检查版本号
+ String redisVersion = (String) redisUtils.get(RedisKeys.getVersionKey());
+ if (!Constant.VERSION.equals(redisVersion)) {
+ // 如果版本不一致,清空Redis
+ redisUtils.emptyAll();
+ // 存储新版本号
+ redisUtils.set(RedisKeys.getVersionKey(), Constant.VERSION);
+ }
+
+ sysParamsService.initServerSecret();
+ configService.getConfig(false);
+ }
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/service/ConfigService.java b/main/manager-api/src/main/java/xiaozhi/modules/config/service/ConfigService.java
new file mode 100644
index 00000000..93845e8e
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/config/service/ConfigService.java
@@ -0,0 +1,22 @@
+package xiaozhi.modules.config.service;
+
+import java.util.Map;
+
+public interface ConfigService {
+ /**
+ * 获取服务器配置
+ *
+ * @param isCache 是否缓存
+ * @return 配置信息
+ */
+ Object getConfig(Boolean isCache);
+
+ /**
+ * 获取智能体模型配置
+ *
+ * @param macAddress MAC地址
+ * @param selectedModule 客户端已实例化的模型
+ * @return 模型配置信息
+ */
+ Map getAgentModels(String macAddress, Map selectedModule);
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java
new file mode 100644
index 00000000..592b3c1d
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java
@@ -0,0 +1,302 @@
+package xiaozhi.modules.config.service.impl;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import lombok.AllArgsConstructor;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.exception.ErrorCode;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
+import xiaozhi.common.utils.JsonUtils;
+import xiaozhi.modules.agent.entity.AgentEntity;
+import xiaozhi.modules.agent.entity.AgentTemplateEntity;
+import xiaozhi.modules.agent.service.AgentService;
+import xiaozhi.modules.agent.service.AgentTemplateService;
+import xiaozhi.modules.config.service.ConfigService;
+import xiaozhi.modules.device.entity.DeviceEntity;
+import xiaozhi.modules.device.service.DeviceService;
+import xiaozhi.modules.model.entity.ModelConfigEntity;
+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.timbre.vo.TimbreDetailsVO;
+
+@Service
+@AllArgsConstructor
+public class ConfigServiceImpl implements ConfigService {
+ private final SysParamsService sysParamsService;
+ private final DeviceService deviceService;
+ private final ModelConfigService modelConfigService;
+ private final AgentService agentService;
+ private final AgentTemplateService agentTemplateService;
+ private final RedisUtils redisUtils;
+ private final TimbreService timbreService;
+
+ @Override
+ public Object getConfig(Boolean isCache) {
+ if (isCache) {
+ // 先从Redis获取配置
+ Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
+ if (cachedConfig != null) {
+ return cachedConfig;
+ }
+ }
+
+ // 构建配置信息
+ Map result = new HashMap<>();
+ buildConfig(result);
+
+ // 查询默认智能体
+ AgentTemplateEntity agent = agentTemplateService.getDefaultTemplate();
+ if (agent == null) {
+ throw new RenException("默认智能体未找到");
+ }
+
+ // 构建模块配置
+ buildModuleConfig(
+ null,
+ null,
+ null,
+ null,
+ agent.getVadModelId(),
+ agent.getAsrModelId(),
+ null,
+ null,
+ null,
+ null,
+ result,
+ isCache);
+
+ // 将配置存入Redis
+ redisUtils.set(RedisKeys.getServerConfigKey(), result);
+
+ return result;
+ }
+
+ @Override
+ public Map getAgentModels(String macAddress, Map selectedModule) {
+ // 根据MAC地址查找设备
+ DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
+ if (device == null) {
+ // 如果设备,去redis里看看有没有需要连接的设备
+ String cachedCode = deviceService.geCodeByDeviceId(macAddress);
+ if (StringUtils.isNotBlank(cachedCode)) {
+ throw new RenException(ErrorCode.OTA_DEVICE_NEED_BIND, cachedCode);
+ }
+ throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device");
+ }
+
+ // 获取智能体信息
+ AgentEntity agent = agentService.getAgentById(device.getAgentId());
+ if (agent == null) {
+ throw new RenException("智能体未找到");
+ }
+ // 获取音色信息
+ String voice = null;
+ TimbreDetailsVO timbre = timbreService.get(agent.getTtsVoiceId());
+ if (timbre != null) {
+ voice = timbre.getTtsVoice();
+ }
+ // 构建返回数据
+ Map result = new HashMap<>();
+ // 获取单台设备每天最多输出字数
+ String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
+ result.put("device_max_output_size", deviceMaxOutputSize);
+
+ // 获取聊天记录配置
+ Integer chatHistoryConf = agent.getChatHistoryConf();
+ if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
+ chatHistoryConf = Constant.ChatHistoryConfEnum.IGNORE.getCode();
+ } else if (agent.getMemModelId() != null
+ && !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
+ && agent.getChatHistoryConf() == null) {
+ chatHistoryConf = Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode();
+ }
+ result.put("chat_history_conf", chatHistoryConf);
+ // 如果客户端已实例化模型,则不返回
+ String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
+ if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
+ agent.setVadModelId(null);
+ }
+ String alreadySelectedAsrModelId = (String) selectedModule.get("ASR");
+ if (alreadySelectedAsrModelId != null && alreadySelectedAsrModelId.equals(agent.getAsrModelId())) {
+ agent.setAsrModelId(null);
+ }
+
+ // 构建模块配置
+ buildModuleConfig(
+ agent.getAgentName(),
+ agent.getSystemPrompt(),
+ agent.getSummaryMemory(),
+ voice,
+ agent.getVadModelId(),
+ agent.getAsrModelId(),
+ agent.getLlmModelId(),
+ agent.getTtsModelId(),
+ agent.getMemModelId(),
+ agent.getIntentModelId(),
+ result,
+ true);
+
+ return result;
+ }
+
+ /**
+ * 构建配置信息
+ *
+ * @param paramsList 系统参数列表
+ * @return 配置信息
+ */
+ private Object buildConfig(Map config) {
+
+ // 查询所有系统参数
+ List paramsList = sysParamsService.list(new HashMap<>());
+
+ for (SysParamsDTO param : paramsList) {
+ String[] keys = param.getParamCode().split("\\.");
+ Map current = config;
+
+ // 遍历除最后一个key之外的所有key
+ for (int i = 0; i < keys.length - 1; i++) {
+ String key = keys[i];
+ if (!current.containsKey(key)) {
+ current.put(key, new HashMap());
+ }
+ current = (Map) current.get(key);
+ }
+
+ // 处理最后一个key
+ String lastKey = keys[keys.length - 1];
+ String value = param.getParamValue();
+
+ // 根据valueType转换值
+ switch (param.getValueType().toLowerCase()) {
+ case "number":
+ try {
+ double doubleValue = Double.parseDouble(value);
+ // 如果数值是整数形式,则转换为Integer
+ if (doubleValue == (int) doubleValue) {
+ current.put(lastKey, (int) doubleValue);
+ } else {
+ current.put(lastKey, doubleValue);
+ }
+ } catch (NumberFormatException e) {
+ current.put(lastKey, value);
+ }
+ break;
+ case "boolean":
+ current.put(lastKey, Boolean.parseBoolean(value));
+ break;
+ case "array":
+ // 将分号分隔的字符串转换为数字数组
+ List list = new ArrayList<>();
+ for (String num : value.split(";")) {
+ if (StringUtils.isNotBlank(num)) {
+ list.add(num.trim());
+ }
+ }
+ current.put(lastKey, list);
+ break;
+ case "json":
+ try {
+ current.put(lastKey, JsonUtils.parseObject(value, Object.class));
+ } catch (Exception e) {
+ current.put(lastKey, value);
+ }
+ break;
+ default:
+ current.put(lastKey, value);
+ }
+ }
+
+ return config;
+ }
+
+ /**
+ * 构建模块配置
+ *
+ * @param prompt 提示词
+ * @param voice 音色
+ * @param vadModelId VAD模型ID
+ * @param asrModelId ASR模型ID
+ * @param llmModelId LLM模型ID
+ * @param ttsModelId TTS模型ID
+ * @param memModelId 记忆模型ID
+ * @param intentModelId 意图模型ID
+ * @param result 结果Map
+ */
+ private void buildModuleConfig(
+ String assistantName,
+ String prompt,
+ String summaryMemory,
+ String voice,
+ String vadModelId,
+ String asrModelId,
+ String llmModelId,
+ String ttsModelId,
+ String memModelId,
+ String intentModelId,
+ Map result,
+ boolean isCache) {
+ Map selectedModule = new HashMap<>();
+
+ String[] modelTypes = { "VAD", "ASR", "TTS", "Memory", "Intent", "LLM" };
+ String[] modelIds = { vadModelId, asrModelId, ttsModelId, memModelId, intentModelId, llmModelId };
+ String intentLLMModelId = null;
+
+ for (int i = 0; i < modelIds.length; i++) {
+ if (modelIds[i] == null) {
+ continue;
+ }
+ ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache);
+ Map typeConfig = new HashMap<>();
+ if (model.getConfigJson() != null) {
+ typeConfig.put(model.getId(), model.getConfigJson());
+ // 如果是TTS类型,添加private_voice属性
+ if ("TTS".equals(modelTypes[i]) && voice != null) {
+ ((Map) model.getConfigJson()).put("private_voice", voice);
+ }
+ // 如果是Intent类型,且type=intent_llm,则给他添加附加模型
+ if ("Intent".equals(modelTypes[i])) {
+ Map map = (Map) model.getConfigJson();
+ if ("intent_llm".equals(map.get("type"))) {
+ intentLLMModelId = (String) map.get("llm");
+ if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
+ intentLLMModelId = null;
+ }
+ }
+ if (map.get("functions") != null) {
+ String functionStr = (String) map.get("functions");
+ if (StringUtils.isNotBlank(functionStr)) {
+ String[] functions = functionStr.split("\\;");
+ map.put("functions", functions);
+ }
+ }
+ }
+ // 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型
+ if ("LLM".equals(modelTypes[i]) && intentLLMModelId != null) {
+ ModelConfigEntity intentLLM = modelConfigService.getModelById(intentLLMModelId, isCache);
+ typeConfig.put(intentLLM.getId(), intentLLM.getConfigJson());
+ }
+ }
+ result.put(modelTypes[i], typeConfig);
+
+ selectedModule.put(modelTypes[i], model.getId());
+ }
+
+ result.put("selected_module", selectedModule);
+ if (StringUtils.isNotBlank(prompt)) {
+ prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
+ }
+ result.put("prompt", prompt);
+ result.put("summaryMemory", summaryMemory);
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
index 1c68a756..5b0bf4bc 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java
@@ -7,6 +7,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -79,4 +80,16 @@ public class DeviceController {
return new Result();
}
+ @PutMapping("/enableOta/{id}/{status}")
+ @Operation(summary = "启用/关闭OTA自动升级")
+ @RequiresPermissions("sys:role:normal")
+ public Result enableOtaUpgrade(@PathVariable String id, @PathVariable Integer status) {
+ DeviceEntity entity = deviceService.selectById(id);
+ if (entity == null) {
+ return new Result().error("设备不存在");
+ }
+ entity.setAutoUpdate(status);
+ deviceService.updateById(entity);
+ return new Result();
+ }
}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java
index 4e3a2a95..05d36e9a 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java
@@ -15,48 +15,78 @@ import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+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.DeviceService;
-import xiaozhi.modules.device.utils.NetworkUtil;
+import xiaozhi.modules.sys.service.SysParamsService;
@Tag(name = "设备管理", description = "OTA 相关接口")
+@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/ota/")
public class OTAController {
private final DeviceService deviceService;
+ private final SysParamsService sysParamsService;
- @Operation(summary = "检查 OTA 版本和设备激活状态")
+ @Operation(summary = "OTA版本和设备激活状态检查")
@PostMapping
public ResponseEntity checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO,
-
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
-
- @Parameter(name = "Client-Id", description = "客户端标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Client-Id") String clientId) {
- if (StringUtils.isAnyBlank(deviceId, clientId)) {
+ @Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
+ if (StringUtils.isBlank(deviceId)) {
return createResponse(DeviceReportRespDTO.createError("Device ID is required"));
}
+ if (StringUtils.isBlank(clientId)) {
+ clientId = deviceId;
+ }
String macAddress = deviceReportReqDTO.getMacAddress();
- boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress);
+ boolean macAddressValid = isMacAddressValid(macAddress);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) {
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request"));
}
- return createResponse(deviceService.checkDeviceActive(macAddress, deviceId, clientId, deviceReportReqDTO));
+ return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
+ }
+
+ @Operation(summary = "设备快速检查激活状态")
+ @PostMapping("activate")
+ public ResponseEntity activateDevice(
+ @Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
+ @Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
+ if (StringUtils.isBlank(deviceId)) {
+ return ResponseEntity.status(202).build();
+ }
+ DeviceEntity device = deviceService.getDeviceByMacAddress(deviceId);
+ if (device == null) {
+ return ResponseEntity.status(202).build();
+ }
+ return ResponseEntity.ok("success");
}
- @Operation(summary = "获取 OTA 提示信息")
@GetMapping
- public ResponseEntity getOTAPrompt() {
- return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数"));
+ @Hidden
+ public ResponseEntity getOTA() {
+ String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
+ if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
+ return ResponseEntity.ok("OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置");
+ }
+ String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
+ if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
+ return ResponseEntity.ok("OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置");
+ }
+ return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
}
@SneakyThrows
@@ -71,4 +101,19 @@ public class OTAController {
.contentLength(jsonBytes.length)
.body(json);
}
+
+ /**
+ * 简单判断mac地址是否有效(非严格)
+ *
+ * @param macAddress
+ * @return
+ */
+ private boolean isMacAddressValid(String macAddress) {
+ if (StringUtils.isBlank(macAddress)) {
+ return false;
+ }
+ // MAC地址通常为12位十六进制数字,可以包含冒号或连字符分隔符
+ String macPattern = "^([0-9A-Za-z]{2}[:-]){5}([0-9A-Za-z]{2})$";
+ return macAddress.matches(macPattern);
+ }
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java
new file mode 100644
index 00000000..bbe0c66a
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAMagController.java
@@ -0,0 +1,291 @@
+package xiaozhi.modules.device.controller;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
+import xiaozhi.common.utils.Result;
+import xiaozhi.common.validator.ValidatorUtils;
+import xiaozhi.modules.device.entity.OtaEntity;
+import xiaozhi.modules.device.service.OtaService;
+
+@Tag(name = "设备管理", description = "OTA 相关接口")
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/otaMag")
+public class OTAMagController {
+ private static final Logger logger = LoggerFactory.getLogger(OTAController.class);
+ private final OtaService otaService;
+ private final RedisUtils redisUtils;
+
+ @GetMapping
+ @Operation(summary = "分页查询 OTA 固件信息")
+ @Parameters({
+ @Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
+ @Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
+ })
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result> page(@Parameter(hidden = true) @RequestParam Map params) {
+ ValidatorUtils.validateEntity(params);
+ PageData page = otaService.page(params);
+ return new Result>().ok(page);
+ }
+
+ @GetMapping("{id}")
+ @Operation(summary = "信息 OTA 固件信息")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result get(@PathVariable("id") String id) {
+ OtaEntity data = otaService.selectById(id);
+ return new Result().ok(data);
+ }
+
+ @PostMapping
+ @Operation(summary = "保存 OTA 固件信息")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result save(@RequestBody OtaEntity entity) {
+ if (entity == null) {
+ return new Result().error("固件信息不能为空");
+ }
+ if (StringUtils.isBlank(entity.getFirmwareName())) {
+ return new Result().error("固件名称不能为空");
+ }
+ if (StringUtils.isBlank(entity.getType())) {
+ return new Result().error("固件类型不能为空");
+ }
+ if (StringUtils.isBlank(entity.getVersion())) {
+ return new Result().error("版本号不能为空");
+ }
+ try {
+ otaService.save(entity);
+ return new Result();
+ } catch (RuntimeException e) {
+ return new Result().error(e.getMessage());
+ }
+ }
+
+ @DeleteMapping("/{id}")
+ @Operation(summary = "OTA 删除")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result delete(@PathVariable("id") String[] ids) {
+ if (ids == null || ids.length == 0) {
+ return new Result().error("删除的固件ID不能为空");
+ }
+ otaService.delete(ids);
+ return new Result();
+ }
+
+ @PutMapping("/{id}")
+ @Operation(summary = "修改 OTA 固件信息")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result> update(@PathVariable("id") String id, @RequestBody OtaEntity entity) {
+ if (entity == null) {
+ return new Result<>().error("固件信息不能为空");
+ }
+ entity.setId(id);
+ try {
+ otaService.update(entity);
+ return new Result<>();
+ } catch (RuntimeException e) {
+ return new Result<>().error(e.getMessage());
+ }
+ }
+
+ @GetMapping("/getDownloadUrl/{id}")
+ @Operation(summary = "获取 OTA 固件下载链接")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result getDownloadUrl(@PathVariable("id") String id) {
+ String uuid = UUID.randomUUID().toString();
+ redisUtils.set(RedisKeys.getOtaIdKey(uuid), id);
+ return new Result().ok(uuid);
+ }
+
+ @GetMapping("/download/{uuid}")
+ @Operation(summary = "下载固件文件")
+ public ResponseEntity downloadFirmware(@PathVariable("uuid") String uuid) {
+ String id = (String) redisUtils.get(RedisKeys.getOtaIdKey(uuid));
+ if (StringUtils.isBlank(id)) {
+ return ResponseEntity.notFound().build();
+ }
+
+ // 检查下载次数
+ String downloadCountKey = RedisKeys.getOtaDownloadCountKey(uuid);
+ Integer downloadCount = (Integer) redisUtils.get(downloadCountKey);
+ if (downloadCount == null) {
+ downloadCount = 0;
+ }
+
+ // 如果下载次数超过3次,返回404
+ if (downloadCount >= 3) {
+ redisUtils.delete(downloadCountKey);
+ redisUtils.delete(RedisKeys.getOtaIdKey(uuid));
+ logger.warn("Download limit exceeded for UUID: {}", uuid);
+ return ResponseEntity.notFound().build();
+ }
+
+ redisUtils.set(downloadCountKey, downloadCount + 1);
+
+ try {
+ // 获取固件信息
+ OtaEntity otaEntity = otaService.selectById(id);
+ if (otaEntity == null || StringUtils.isBlank(otaEntity.getFirmwarePath())) {
+ logger.warn("Firmware not found or path is empty for ID: {}", id);
+ return ResponseEntity.notFound().build();
+ }
+
+ // 获取文件路径 - 确保路径是绝对路径或正确的相对路径
+ String firmwarePath = otaEntity.getFirmwarePath();
+ Path path;
+
+ // 检查是否是绝对路径
+ if (Paths.get(firmwarePath).isAbsolute()) {
+ path = Paths.get(firmwarePath);
+ } else {
+ // 如果是相对路径,则从当前工作目录解析
+ path = Paths.get(System.getProperty("user.dir"), firmwarePath);
+ }
+
+ logger.info("Attempting to download firmware for ID: {}, DB path: {}, resolved path: {}",
+ id, firmwarePath, path.toAbsolutePath());
+
+ if (!Files.exists(path) || !Files.isRegularFile(path)) {
+ // 尝试直接从firmware目录下查找文件名
+ String fileName = new File(firmwarePath).getName();
+ Path altPath = Paths.get(System.getProperty("user.dir"), "firmware", fileName);
+
+ logger.info("File not found at primary path, trying alternative path: {}", altPath.toAbsolutePath());
+
+ if (Files.exists(altPath) && Files.isRegularFile(altPath)) {
+ path = altPath;
+ } else {
+ logger.error("Firmware file not found at either path: {} or {}",
+ path.toAbsolutePath(), altPath.toAbsolutePath());
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ // 读取文件内容
+ byte[] fileContent = Files.readAllBytes(path);
+
+ // 设置响应头
+ String originalFilename = otaEntity.getType() + "_" + otaEntity.getVersion();
+ if (firmwarePath.contains(".")) {
+ String extension = firmwarePath.substring(firmwarePath.lastIndexOf("."));
+ originalFilename += extension;
+ }
+
+ // 清理文件名,移除不安全字符
+ String safeFilename = originalFilename.replaceAll("[^a-zA-Z0-9._-]", "_");
+
+ logger.info("Providing download for firmware ID: {}, filename: {}, size: {} bytes",
+ id, safeFilename, fileContent.length);
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeFilename + "\"")
+ .body(fileContent);
+ } catch (IOException e) {
+ logger.error("Error reading firmware file for ID: {}", id, e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+ } catch (Exception e) {
+ logger.error("Unexpected error during firmware download for ID: {}", id, e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+ }
+ }
+
+ @PostMapping("/upload")
+ @Operation(summary = "上传固件文件")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result uploadFirmware(@RequestParam("file") MultipartFile file) {
+ if (file.isEmpty()) {
+ return new Result().error("上传文件不能为空");
+ }
+
+ // 检查文件扩展名
+ String originalFilename = file.getOriginalFilename();
+ if (originalFilename == null) {
+ return new Result().error("文件名不能为空");
+ }
+
+ String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
+ if (!extension.equals(".bin") && !extension.equals(".apk")) {
+ return new Result().error("只允许上传.bin和.apk格式的文件");
+ }
+
+ try {
+ // 计算文件的MD5值
+ String md5 = calculateMD5(file);
+
+ // 设置存储路径
+ String uploadDir = "uploadfile";
+ Path uploadPath = Paths.get(uploadDir);
+
+ // 如果目录不存在,创建目录
+ if (!Files.exists(uploadPath)) {
+ Files.createDirectories(uploadPath);
+ }
+
+ // 使用MD5作为文件名,固定使用.bin扩展名
+ String uniqueFileName = md5 + extension;
+ Path filePath = uploadPath.resolve(uniqueFileName);
+
+ // 检查文件是否已存在
+ if (Files.exists(filePath)) {
+ return new Result().ok(filePath.toString());
+ }
+
+ // 保存文件
+ Files.copy(file.getInputStream(), filePath);
+
+ // 返回文件路径
+ return new Result().ok(filePath.toString());
+ } catch (IOException | NoSuchAlgorithmException e) {
+ return new Result().error("文件上传失败:" + e.getMessage());
+ }
+ }
+
+ private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ byte[] digest = md.digest(file.getBytes());
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/dao/DeviceDao.java b/main/manager-api/src/main/java/xiaozhi/modules/device/dao/DeviceDao.java
index 5ede3d6e..f3fdb7a3 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/dao/DeviceDao.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/dao/DeviceDao.java
@@ -1,5 +1,7 @@
package xiaozhi.modules.device.dao;
+import java.util.Date;
+
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
@@ -8,4 +10,12 @@ import xiaozhi.modules.device.entity.DeviceEntity;
@Mapper
public interface DeviceDao extends BaseMapper {
+ /**
+ * 获取此智能体全部设备的最后连接时间
+ *
+ * @param agentId 智能体id
+ * @return
+ */
+ Date getAllLastConnectedAtByAgentId(String agentId);
+
}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/dao/OtaDao.java b/main/manager-api/src/main/java/xiaozhi/modules/device/dao/OtaDao.java
new file mode 100644
index 00000000..975f69be
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/dao/OtaDao.java
@@ -0,0 +1,15 @@
+package xiaozhi.modules.device.dao;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+import xiaozhi.modules.device.entity.OtaEntity;
+
+/**
+ * OTA固件管理
+ */
+@Mapper
+public interface OtaDao extends BaseMapper {
+
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java
index 9f93a9b7..f938001e 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/dto/DeviceReportRespDTO.java
@@ -19,6 +19,9 @@ public class DeviceReportRespDTO {
@Schema(description = "固件版本信息")
private Firmware firmware;
+
+ @Schema(description = "WebSocket配置")
+ private Websocket websocket;
@Getter
@Setter
@@ -44,6 +47,8 @@ public class DeviceReportRespDTO {
@Schema(description = "激活码信息: 激活地址")
private String message;
+ @Schema(description = "挑战码")
+ private String challenge;
}
@Getter
@@ -58,4 +63,11 @@ public class DeviceReportRespDTO {
@Schema(description = "时区偏移量,单位为分钟")
private Integer timezone_offset;
}
+
+ @Getter
+ @Setter
+ public static class Websocket {
+ @Schema(description = "WebSocket服务器地址")
+ private String url;
+ }
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/entity/OtaEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/device/entity/OtaEntity.java
new file mode 100644
index 00000000..b93c608b
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/entity/OtaEntity.java
@@ -0,0 +1,61 @@
+package xiaozhi.modules.device.entity;
+
+import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("ai_ota")
+@Schema(description = "固件信息")
+public class OtaEntity {
+
+ @TableId(type = IdType.ASSIGN_UUID)
+ @Schema(description = "ID")
+ private String id;
+
+ @Schema(description = "固件名称")
+ private String firmwareName;
+
+ @Schema(description = "固件类型")
+ private String type;
+
+ @Schema(description = "版本号")
+ private String version;
+
+ @Schema(description = "文件大小(字节)")
+ private Long size;
+
+ @Schema(description = "备注/说明")
+ private String remark;
+
+ @Schema(description = "固件路径")
+ private String firmwarePath;
+
+ @Schema(description = "排序")
+ private Integer sort;
+
+ @Schema(description = "更新者")
+ @TableField(fill = FieldFill.UPDATE)
+ private Long updater;
+
+ @Schema(description = "更新时间")
+ @TableField(fill = FieldFill.UPDATE)
+ private Date updateDate;
+
+ @Schema(description = "创建者")
+ @TableField(fill = FieldFill.INSERT)
+ private Long creator;
+
+ @Schema(description = "创建时间")
+ @TableField(fill = FieldFill.INSERT)
+ private Date createDate;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java
index 37ff8d49..fb91ff89 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java
@@ -1,25 +1,22 @@
package xiaozhi.modules.device.service;
+import java.util.Date;
import java.util.List;
import xiaozhi.common.page.PageData;
+import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
-public interface DeviceService {
-
- /**
- * 根据Mac地址获取设备信息
- */
- DeviceEntity getDeviceById(String macAddress);
+public interface DeviceService extends BaseService {
/**
* 检查设备是否激活
*/
- DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
+ DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport);
/**
@@ -66,4 +63,29 @@ public interface DeviceService {
* @return 用户列表分页数据
*/
PageData page(DevicePageUserDTO dto);
+
+ /**
+ * 根据MAC地址获取设备信息
+ *
+ * @param macAddress MAC地址
+ * @return 设备信息
+ */
+ DeviceEntity getDeviceByMacAddress(String macAddress);
+
+ /**
+ * 根据设备ID获取激活码
+ *
+ * @param deviceId 设备ID
+ * @return 激活码
+ */
+ String geCodeByDeviceId(String deviceId);
+
+ /**
+ * 获取这个智能体设备理的最近的最后连接时间
+ * @param agentId 智能体id
+ * @return 返回设备最近的最后连接时间
+ */
+ Date getLatestLastConnectionTime(String agentId);
+
+
}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/OtaService.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/OtaService.java
new file mode 100644
index 00000000..cd35f6ae
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/OtaService.java
@@ -0,0 +1,22 @@
+package xiaozhi.modules.device.service;
+
+import java.util.Map;
+
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.service.BaseService;
+import xiaozhi.modules.device.entity.OtaEntity;
+
+/**
+ * OTA固件管理
+ */
+public interface OtaService extends BaseService {
+ PageData page(Map params);
+
+ boolean save(OtaEntity entity);
+
+ void update(OtaEntity entity);
+
+ void delete(String[] ids);
+
+ OtaEntity getLatestOta(String type);
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java
index a9797f42..ee31c41f 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java
@@ -6,22 +6,28 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
-import java.util.concurrent.TimeUnit;
+import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.aop.framework.AopContext;
+import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.util.RandomUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
@@ -31,37 +37,41 @@ import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
+import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.DeviceService;
+import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.security.user.SecurityUser;
+import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserUtilService;
+@Slf4j
@Service
+@AllArgsConstructor
public class DeviceServiceImpl extends BaseServiceImpl implements DeviceService {
private final DeviceDao deviceDao;
-
private final SysUserUtilService sysUserUtilService;
+ private final SysParamsService sysParamsService;
+ private final RedisUtils redisUtils;
+ private final OtaService otaService;
- private final String frontedUrl;
-
- private final RedisTemplate redisTemplate;
-
- // 添加构造函数来初始化 deviceMapper
- public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
- @Value("${app.fronted-url:http://localhost:8001}") String frontedUrl,
- RedisTemplate redisTemplate) {
- this.deviceDao = deviceDao;
- this.sysUserUtilService = sysUserUtilService;
- this.frontedUrl = frontedUrl;
- this.redisTemplate = redisTemplate;
- }
-
- @Override
- public DeviceEntity getDeviceById(String deviceId) {
- LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
- queryWrapper.eq(DeviceEntity::getId, deviceId);
- return deviceDao.selectOne(queryWrapper);
+ @Async
+ public void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion) {
+ try {
+ DeviceEntity device = new DeviceEntity();
+ device.setId(deviceId);
+ device.setLastConnectedAt(new Date());
+ if (StringUtils.isNotBlank(appVersion)) {
+ device.setAppVersion(appVersion);
+ }
+ deviceDao.updateById(device);
+ if (StringUtils.isNotBlank(agentId)) {
+ redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
+ }
+ } catch (Exception e) {
+ log.error("异步更新设备连接信息失败", e);
+ }
}
@Override
@@ -70,14 +80,14 @@ public class DeviceServiceImpl extends BaseServiceImpl
throw new RenException("激活码不能为空");
}
String deviceKey = "ota:activation:code:" + activationCode;
- Object cacheDeviceId = redisTemplate.opsForValue().get(deviceKey);
+ Object cacheDeviceId = redisUtils.get(deviceKey);
if (cacheDeviceId == null) {
throw new RenException("激活码错误");
}
String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
- Map cacheMap = redisTemplate.opsForHash().entries(cacheDeviceKey);
+ Map cacheMap = (Map) redisUtils.get(cacheDeviceKey);
if (cacheMap == null) {
throw new RenException("激活码错误");
}
@@ -107,6 +117,7 @@ public class DeviceServiceImpl extends BaseServiceImpl
deviceEntity.setMacAddress(macAddress);
deviceEntity.setUserId(user.getId());
deviceEntity.setCreator(user.getId());
+ deviceEntity.setAutoUpdate(1);
deviceEntity.setCreateDate(currentTime);
deviceEntity.setUpdater(user.getId());
deviceEntity.setUpdateDate(currentTime);
@@ -114,64 +125,66 @@ public class DeviceServiceImpl extends BaseServiceImpl
deviceDao.insert(deviceEntity);
// 清理redis缓存
- redisTemplate.delete(cacheDeviceKey);
- redisTemplate.delete(deviceKey);
+ redisUtils.delete(cacheDeviceKey);
+ redisUtils.delete(deviceKey);
return true;
}
@Override
- public DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId,
+ public DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO response = new DeviceReportRespDTO();
response.setServer_time(buildServerTime());
- // todo: 此处是固件信息,目前是针对固件上传上来的版本号再返回回去
- // 在未来开发了固件更新功能,需要更换此处代码,
- // 或写定时任务定期请求虾哥的OTA,获取最新的版本讯息保存到服务内
- DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
- firmware.setVersion(deviceReport.getApplication().getVersion());
- firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
- response.setFirmware(firmware);
- DeviceEntity deviceById = getDeviceById(deviceId);
- if (deviceById != null) { // 如果设备存在,则更新上次连接时间
- deviceById.setLastConnectedAt(new Date());
- deviceDao.updateById(deviceById);
- } else { // 如果设备不存在,则生成激活码
- String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
- String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
+ DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
- Map cacheMap = redisTemplate.opsForHash().entries(dataKey);
- DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
-
- if (cacheMap != null && cacheMap.containsKey("activation_code")) {
- String cachedCode = (String) cacheMap.get("activation_code");
- code.setCode(cachedCode);
- code.setMessage(frontedUrl + "\n" + cachedCode);
- } else {
- String newCode = RandomUtil.randomNumbers(6);
- code.setCode(newCode);
- code.setMessage(frontedUrl + "\n" + newCode);
-
- Map dataMap = new HashMap<>();
- dataMap.put("id", deviceId);
- dataMap.put("mac_address", macAddress);
- dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName()
- : (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown"));
- dataMap.put("app_version", (deviceReport.getApplication() != null)
- ? deviceReport.getApplication().getVersion()
- : null);
- dataMap.put("deviceId", deviceId);
- dataMap.put("activation_code", newCode);
-
- // 写入主数据 key
- redisTemplate.opsForHash().putAll(dataKey, dataMap);
- redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
-
- // 写入反查激活码 key
- String codeKey = "ota:activation:code:" + newCode;
- redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
+ // 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
+ if (deviceById == null) {
+ DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
+ firmware.setVersion(deviceReport.getApplication().getVersion());
+ 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,
+ deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
+ response.setFirmware(firmware);
}
+ }
+ // 添加WebSocket配置
+ DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
+ // 从系统参数获取WebSocket URL,如果未配置则使用默认值
+ String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
+ if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
+ log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
+ wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
+ websocket.setUrl(wsUrl);
+ } else {
+ String[] wsUrls = wsUrl.split("\\;");
+ if (wsUrls.length > 0) {
+ // 随机选择一个WebSocket URL
+ websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
+ } else {
+ log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
+ websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
+ }
+ }
+
+ response.setWebsocket(websocket);
+
+ if (deviceById != null) {
+ // 如果设备存在,则异步更新上次连接时间和版本信息
+ String appVersion = deviceReport.getApplication() != null ? deviceReport.getApplication().getVersion()
+ : null;
+ // 通过Spring代理调用异步方法
+ ((DeviceServiceImpl) AopContext.currentProxy()).updateDeviceConnectionInfo(deviceById.getAgentId(),
+ deviceById.getId(), appVersion);
+ } else {
+ // 如果设备不存在,则生成激活码
+ DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
response.setActivation(code);
}
@@ -221,7 +234,7 @@ public class DeviceServiceImpl extends BaseServiceImpl
params.put(Constant.PAGE, dto.getPage());
params.put(Constant.LIMIT, dto.getLimit());
IPage page = baseDao.selectPage(
- getPage(params, "sort", true),
+ getPage(params, "mac_address", true),
// 定义查询条件
new QueryWrapper()
// 必须设备关键词查找
@@ -240,6 +253,16 @@ public class DeviceServiceImpl extends BaseServiceImpl
return new PageData<>(list, page.getTotal());
}
+ @Override
+ public DeviceEntity getDeviceByMacAddress(String macAddress) {
+ if (StringUtils.isBlank(macAddress)) {
+ return null;
+ }
+ QueryWrapper wrapper = new QueryWrapper<>();
+ wrapper.eq("mac_address", macAddress);
+ return baseDao.selectOne(wrapper);
+ }
+
private DeviceReportRespDTO.ServerTime buildServerTime() {
DeviceReportRespDTO.ServerTime serverTime = new DeviceReportRespDTO.ServerTime();
TimeZone tz = TimeZone.getDefault();
@@ -248,4 +271,143 @@ public class DeviceServiceImpl extends BaseServiceImpl
serverTime.setTimezone_offset(tz.getOffset(System.currentTimeMillis()) / (60 * 1000));
return serverTime;
}
-}
\ No newline at end of file
+
+ @Override
+ public String geCodeByDeviceId(String deviceId) {
+ String dataKey = getDeviceCacheKey(deviceId);
+
+ Map cacheMap = (Map) redisUtils.get(dataKey);
+ if (cacheMap != null && cacheMap.containsKey("activation_code")) {
+ String cachedCode = (String) cacheMap.get("activation_code");
+ return cachedCode;
+ }
+ return null;
+ }
+
+ @Override
+ public Date getLatestLastConnectionTime(String agentId) {
+ // 查询是否有缓存时间,有则返回
+ Date cachedDate = (Date) redisUtils.get(RedisKeys.getAgentDeviceLastConnectedAtById(agentId));
+ if (cachedDate != null) {
+ return cachedDate;
+ }
+ Date maxDate = deviceDao.getAllLastConnectedAtByAgentId(agentId);
+ if (maxDate != null) {
+ redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), maxDate);
+ }
+ return maxDate;
+ }
+
+ private String getDeviceCacheKey(String deviceId) {
+ String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
+ String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
+ return dataKey;
+ }
+
+ public DeviceReportRespDTO.Activation buildActivation(String deviceId, DeviceReportReqDTO deviceReport) {
+ DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
+
+ String cachedCode = geCodeByDeviceId(deviceId);
+
+ if (StringUtils.isNotBlank(cachedCode)) {
+ code.setCode(cachedCode);
+ String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
+ code.setMessage(frontedUrl + "\n" + cachedCode);
+ code.setChallenge(deviceId);
+ } else {
+ String newCode = RandomUtil.randomNumbers(6);
+ code.setCode(newCode);
+ String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
+ code.setMessage(frontedUrl + "\n" + newCode);
+ code.setChallenge(deviceId);
+
+ Map dataMap = new HashMap<>();
+ dataMap.put("id", deviceId);
+ dataMap.put("mac_address", deviceId);
+
+ dataMap.put("board", (deviceReport.getBoard() != null && deviceReport.getBoard().getType() != null)
+ ? deviceReport.getBoard().getType()
+ : (deviceReport.getChipModelName() != null ? deviceReport.getChipModelName() : "unknown"));
+ dataMap.put("app_version", (deviceReport.getApplication() != null)
+ ? deviceReport.getApplication().getVersion()
+ : null);
+
+ dataMap.put("deviceId", deviceId);
+ dataMap.put("activation_code", newCode);
+
+ // 写入主数据 key
+ String dataKey = getDeviceCacheKey(deviceId);
+ redisUtils.set(dataKey, dataMap);
+
+ // 写入反查激活码 key
+ String codeKey = "ota:activation:code:" + newCode;
+ redisUtils.set(codeKey, deviceId);
+ }
+ return code;
+ }
+
+ private DeviceReportRespDTO.Firmware buildFirmwareInfo(String type, String currentVersion) {
+ if (StringUtils.isBlank(type)) {
+ return null;
+ }
+ if (StringUtils.isBlank(currentVersion)) {
+ currentVersion = "0.0.0";
+ }
+
+ OtaEntity ota = otaService.getLatestOta(type);
+ DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
+ String downloadUrl = null;
+
+ if (ota != null) {
+ // 如果设备没有版本信息,或者OTA版本比设备版本新,则返回下载地址
+ if (compareVersions(ota.getVersion(), currentVersion) > 0) {
+ String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
+ if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
+ log.error("OTA地址未配置,请登录智控台,在参数管理找到【server.ota】配置");
+ // 尝试从请求中获取
+ HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
+ .getRequestAttributes())
+ .getRequest();
+ otaUrl = request.getRequestURL().toString();
+ }
+ // 将URL中的/ota/替换为/otaMag/download/
+ String uuid = UUID.randomUUID().toString();
+ redisUtils.set(RedisKeys.getOtaIdKey(uuid), ota.getId());
+ downloadUrl = otaUrl.replace("/ota/", "/otaMag/download/") + uuid;
+ }
+ }
+
+ firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
+ firmware.setUrl(downloadUrl == null ? Constant.INVALID_FIRMWARE_URL : downloadUrl);
+ return firmware;
+ }
+
+ /**
+ * 比较两个版本号
+ *
+ * @param version1 版本1
+ * @param version2 版本2
+ * @return 如果version1 > version2返回1,version1 < version2返回-1,相等返回0
+ */
+ private static int compareVersions(String version1, String version2) {
+ if (version1 == null || version2 == null) {
+ return 0;
+ }
+
+ String[] v1Parts = version1.split("\\.");
+ String[] v2Parts = version2.split("\\.");
+
+ int length = Math.max(v1Parts.length, v2Parts.length);
+ for (int i = 0; i < length; i++) {
+ int v1 = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0;
+ int v2 = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0;
+
+ if (v1 > v2) {
+ return 1;
+ } else if (v1 < v2) {
+ return -1;
+ }
+ }
+ return 0;
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/OtaServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/OtaServiceImpl.java
new file mode 100644
index 00000000..ed9e8c7f
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/OtaServiceImpl.java
@@ -0,0 +1,85 @@
+package xiaozhi.modules.device.service.impl;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+
+import io.micrometer.common.util.StringUtils;
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.service.impl.BaseServiceImpl;
+import xiaozhi.modules.device.dao.OtaDao;
+import xiaozhi.modules.device.entity.OtaEntity;
+import xiaozhi.modules.device.service.OtaService;
+
+@Service
+public class OtaServiceImpl extends BaseServiceImpl implements OtaService {
+
+ @Override
+ public PageData page(Map params) {
+ IPage page = baseDao.selectPage(
+ getPage(params, "update_date", true),
+ getWrapper(params));
+
+ return new PageData<>(page.getRecords(), page.getTotal());
+ }
+
+ private QueryWrapper getWrapper(Map params) {
+ String firmwareName = (String) params.get("firmwareName");
+
+ QueryWrapper wrapper = new QueryWrapper<>();
+ wrapper.like(StringUtils.isNotBlank(firmwareName), "firmware_name", firmwareName);
+
+ return wrapper;
+ }
+
+ @Override
+ public void update(OtaEntity entity) {
+ // 检查是否存在相同类型和版本的固件(排除当前记录)
+ QueryWrapper queryWrapper = new QueryWrapper()
+ .eq("type", entity.getType())
+ .eq("version", entity.getVersion())
+ .ne("id", entity.getId()); // 排除当前记录
+
+ if (baseDao.selectCount(queryWrapper) > 0) {
+ throw new RuntimeException("已存在相同类型和版本的固件,请修改后重试");
+ }
+
+ entity.setUpdateDate(new Date());
+ baseDao.updateById(entity);
+ }
+
+ @Override
+ public void delete(String[] ids) {
+ baseDao.deleteBatchIds(Arrays.asList(ids));
+ }
+
+ @Override
+ public boolean save(OtaEntity entity) {
+ QueryWrapper queryWrapper = new QueryWrapper()
+ .eq("type", entity.getType());
+ // 同类固件只保留最新的一条
+ List otaList = baseDao.selectList(queryWrapper);
+ if (otaList != null && otaList.size() > 0) {
+ OtaEntity otaBefore = otaList.getFirst();
+ entity.setId(otaBefore.getId());
+ baseDao.updateById(entity);
+ return true;
+ }
+ return baseDao.insert(entity) > 0;
+ }
+
+ @Override
+ public OtaEntity getLatestOta(String type) {
+ QueryWrapper wrapper = new QueryWrapper<>();
+ wrapper.eq("type", type)
+ .orderByDesc("update_date")
+ .last("LIMIT 1");
+ return baseDao.selectOne(wrapper);
+ }
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/utils/NetworkUtil.java b/main/manager-api/src/main/java/xiaozhi/modules/device/utils/NetworkUtil.java
deleted file mode 100644
index ab269f7b..00000000
--- a/main/manager-api/src/main/java/xiaozhi/modules/device/utils/NetworkUtil.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package xiaozhi.modules.device.utils;
-
-import java.util.regex.Pattern;
-
-import org.apache.commons.lang3.StringUtils;
-
-/**
- * 网络工具类
- */
-public class NetworkUtil {
- /**
- * MAC地址正则表达式
- */
- private static final Pattern macPattern = Pattern.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$");
-
- /**
- * 判断MAC地址是否合法
- */
- public static boolean isMacAddressValid(String mac) {
- if (StringUtils.isBlank(mac)) {
- return false;
- }
- // 正则校验格式
- if (!macPattern.matcher(mac).matches()) {
- return false;
- }
- // 校验MAC地址是否为单播地址
- String normalized = mac.toLowerCase();
- String[] parts = normalized.split("[:-]");
- int firstByte = Integer.parseInt(parts[0], 16);
- return (firstByte & 1) == 0; // 最低位为0表示单播地址,合法
- }
-
-}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java b/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java
index 0ce42157..8baff29d 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelController.java
@@ -19,6 +19,8 @@ import lombok.AllArgsConstructor;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.Result;
+import xiaozhi.modules.agent.service.AgentTemplateService;
+import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
import xiaozhi.modules.model.dto.ModelConfigDTO;
@@ -38,6 +40,8 @@ public class ModelController {
private final ModelProviderService modelProviderService;
private final TimbreService timbreService;
private final ModelConfigService modelConfigService;
+ private final ConfigService configService;
+ private final AgentTemplateService agentTemplateService;
@GetMapping("/names")
@Operation(summary = "获取所有模型名称")
@@ -75,6 +79,7 @@ public class ModelController {
@PathVariable String provideCode,
@RequestBody ModelConfigBodyDTO modelConfigBodyDTO) {
ModelConfigDTO modelConfigDTO = modelConfigService.add(modelType, provideCode, modelConfigBodyDTO);
+ configService.getConfig(false);
return new Result().ok(modelConfigDTO);
}
@@ -86,6 +91,7 @@ public class ModelController {
@PathVariable String id,
@RequestBody ModelConfigBodyDTO modelConfigBodyDTO) {
ModelConfigDTO modelConfigDTO = modelConfigService.edit(modelType, provideCode, id, modelConfigBodyDTO);
+ configService.getConfig(false);
return new Result().ok(modelConfigDTO);
}
@@ -119,6 +125,27 @@ public class ModelController {
return new Result();
}
+ @PutMapping("/default/{id}")
+ @Operation(summary = "设置默认模型")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result setDefaultModel(@PathVariable String id) {
+ ModelConfigEntity entity = modelConfigService.selectById(id);
+ if (entity == null) {
+ return new Result().error("模型配置不存在");
+ }
+ // 将其他模型设置为非默认
+ modelConfigService.setDefaultModel(entity.getModelType(), 0);
+ entity.setIsEnabled(1);
+ entity.setIsDefault(1);
+ modelConfigService.updateById(entity);
+
+ // 更新模板表中对应的模型ID
+ agentTemplateService.updateDefaultTemplateModelId(entity.getModelType(), entity.getId());
+
+ configService.getConfig(false);
+ return new Result();
+ }
+
@GetMapping("/{modelId}/voices")
@Operation(summary = "获取模型音色")
@RequiresPermissions("sys:role:normal")
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java b/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java
new file mode 100644
index 00000000..fc534a0a
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/controller/ModelProviderController.java
@@ -0,0 +1,68 @@
+package xiaozhi.modules.model.controller;
+
+import java.util.List;
+
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.utils.Result;
+import xiaozhi.common.validator.group.UpdateGroup;
+import xiaozhi.modules.model.dto.ModelProviderDTO;
+import xiaozhi.modules.model.service.ModelProviderService;
+
+@AllArgsConstructor
+@RestController
+@RequestMapping("/models/provider")
+@Tag(name = "模型供应器")
+public class ModelProviderController {
+
+ private final ModelProviderService modelProviderService;
+
+ @GetMapping
+ @Operation(summary = "获取模型供应器列表")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result> getListPage(ModelProviderDTO modelProviderDTO,
+ @RequestParam(required = true, defaultValue = "0") String page,
+ @RequestParam(required = true, defaultValue = "10") String limit) {
+ return new Result>()
+ .ok(modelProviderService.getListPage(modelProviderDTO, page, limit));
+ }
+
+ @PostMapping
+ @Operation(summary = "新增模型供应器")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result add(@RequestBody @Validated ModelProviderDTO modelProviderDTO) {
+ ModelProviderDTO resp = modelProviderService.add(modelProviderDTO);
+ return new Result().ok(resp);
+ }
+
+ @PutMapping
+ @Operation(summary = "修改模型供应器")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result edit(@RequestBody @Validated(UpdateGroup.class) ModelProviderDTO modelProviderDTO) {
+ ModelProviderDTO resp = modelProviderService.edit(modelProviderDTO);
+ return new Result().ok(resp);
+ }
+
+ @PostMapping("/delete")
+ @Operation(summary = "删除模型供应器")
+ @RequiresPermissions("sys:role:superAdmin")
+ @Parameter(name = "ids", description = "ID数组", required = true)
+ public Result delete(@RequestBody List ids) {
+ modelProviderService.delete(ids);
+ return new Result<>();
+ }
+
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/dto/ModelProviderDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/model/dto/ModelProviderDTO.java
index 605ed13a..317820d5 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/dto/ModelProviderDTO.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/dto/ModelProviderDTO.java
@@ -8,29 +8,37 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
import lombok.Data;
+import xiaozhi.common.validator.group.UpdateGroup;
@Data
@Schema(description = "模型供应器/商")
public class ModelProviderDTO implements Serializable {
- //
- // @Schema(description = "主键")
- // private Long id;
+ @Schema(description = "主键")
+ @NotBlank(message = "id不能为空", groups = UpdateGroup.class)
+ private String id;
@Schema(description = "模型类型(Memory/ASR/VAD/LLM/TTS)")
+ @NotBlank(message = "modelType不能为空")
private String modelType;
@Schema(description = "供应器类型")
+ @NotBlank(message = "providerCode不能为空")
private String providerCode;
@Schema(description = "供应器名称")
+ @NotBlank(message = "name不能为空")
private String name;
@Schema(description = "供应器字段列表(JSON格式)")
@TableField(typeHandler = JacksonTypeHandler.class)
+ @NotBlank(message = "fields(JSON格式)不能为空")
private String fields;
@Schema(description = "排序")
+ @NotNull(message = "sort不能为空")
private Integer sort;
@Schema(description = "更新者")
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/entity/ModelProviderEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/model/entity/ModelProviderEntity.java
index 62e84f67..00904759 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/entity/ModelProviderEntity.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/entity/ModelProviderEntity.java
@@ -3,10 +3,8 @@ package xiaozhi.modules.model.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
-import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -30,7 +28,6 @@ public class ModelProviderEntity {
private String name;
@Schema(description = "供应器字段列表(JSON格式)")
- @TableField(typeHandler = JacksonTypeHandler.class)
private String fields;
@Schema(description = "排序")
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java
index b8cc38b1..9d15ac6d 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java
@@ -28,4 +28,21 @@ public interface ModelConfigService extends BaseService {
* @return 模型名称
*/
String getModelNameById(String id);
+
+ /**
+ * 根据ID获取模型配置
+ *
+ * @param id 模型ID
+ * @param isCache 是否缓存
+ * @return 模型配置实体
+ */
+ ModelConfigEntity getModelById(String id, boolean isCache);
+
+ /**
+ * 设置默认模型
+ *
+ * @param modelType 模型类型
+ * @param isDefault 是否默认
+ */
+ void setDefaultModel(String modelType, int isDefault);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java
index 2ace101e..d41766f6 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelProviderService.java
@@ -2,8 +2,8 @@ package xiaozhi.modules.model.service;
import java.util.List;
+import xiaozhi.common.page.PageData;
import xiaozhi.modules.model.dto.ModelProviderDTO;
-import xiaozhi.modules.model.entity.ModelProviderEntity;
public interface ModelProviderService {
@@ -11,11 +11,15 @@ public interface ModelProviderService {
List getListByModelType(String modelType);
- ModelProviderDTO add(ModelProviderEntity modelProviderEntity);
+ ModelProviderDTO add(ModelProviderDTO modelProviderDTO);
- ModelProviderDTO edit(ModelProviderEntity modelProviderEntity);
+ ModelProviderDTO edit(ModelProviderDTO modelProviderDTO);
- void delete();
+ void delete(String id);
+
+ void delete(List id);
+
+ PageData getListPage(ModelProviderDTO modelProviderDTO, String page, String limit);
List getList(String modelType, String provideCode);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java
index de8cbd4f..1f006d93 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java
@@ -3,6 +3,7 @@ package xiaozhi.modules.model.service.impl;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@@ -19,6 +20,8 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
+import xiaozhi.modules.agent.dao.AgentDao;
+import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.model.dao.ModelConfigDao;
import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
@@ -36,12 +39,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl getModelCodeList(String modelType, String modelName) {
List entities = modelConfigDao.selectList(
new QueryWrapper()
.eq("model_type", modelType)
+ .eq("is_enabled", 1)
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%")
.select("id", "model_name"));
return ConvertUtils.sourceToTarget(entities, ModelBasicInfoDTO.class);
@@ -74,6 +79,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl agents = agentDao.selectList(
+ new QueryWrapper()
+ .eq("vad_model_id", modelId)
+ .or()
+ .eq("asr_model_id", modelId)
+ .or()
+ .eq("llm_model_id", modelId)
+ .or()
+ .eq("tts_model_id", modelId)
+ .or()
+ .eq("mem_model_id", modelId)
+ .or()
+ .eq("intent_model_id", modelId));
+ if (!agents.isEmpty()) {
+ String agentNames = agents.stream()
+ .map(AgentEntity::getAgentName)
+ .collect(Collectors.joining("、"));
+ throw new RenException(String.format("该模型配置已被智能体[%s]引用,无法删除", agentNames));
+ }
+ }
+
+ /**
+ * 检查意图识别配置是否有引用
+ *
+ * @param modelId 模型ID
+ */
+ private void checkIntentConfigReference(String modelId) {
+ ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId);
+ if (modelConfig != null
+ && "LLM".equals(modelConfig.getModelType() == null ? null : modelConfig.getModelType().toUpperCase())) {
+ List intentConfigs = modelConfigDao.selectList(
+ new QueryWrapper()
+ .eq("model_type", "Intent")
+ .like("config_json", "%" + modelId + "%"));
+ if (!intentConfigs.isEmpty()) {
+ throw new RenException("该LLM模型已被意图识别配置引用,无法删除");
+ }
+ }
+ }
+
@Override
public String getModelNameById(String id) {
if (StringUtils.isBlank(id)) {
@@ -125,4 +188,30 @@ public class ModelConfigServiceImpl extends BaseServiceImpl()
+ .eq("model_type", modelType));
+ }
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java
index fb06e4d5..ffeec288 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelProviderServiceImpl.java
@@ -1,19 +1,29 @@
package xiaozhi.modules.model.service.impl;
+import java.util.Date;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import cn.hutool.json.JSONArray;
import lombok.AllArgsConstructor;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
+import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.model.dao.ModelProviderDao;
import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.entity.ModelProviderEntity;
import xiaozhi.modules.model.service.ModelProviderService;
+import xiaozhi.modules.security.user.SecurityUser;
@Service
@AllArgsConstructor
@@ -32,18 +42,78 @@ public class ModelProviderServiceImpl extends BaseServiceImpl getListPage(ModelProviderDTO modelProviderDTO, String page, String limit) {
+
+ Map params = new HashMap();
+ params.put(Constant.PAGE, page);
+ params.put(Constant.LIMIT, limit);
+ params.put(Constant.ORDER_FIELD, List.of("model_type", "sort"));
+ params.put(Constant.ORDER, "asc");
+
+ IPage pageParam = getPage(params, null, true);
+
+ QueryWrapper wrapper = new QueryWrapper();
+
+ if (StringUtils.isNotBlank(modelProviderDTO.getModelType())) {
+ wrapper.eq("model_type", modelProviderDTO.getModelType());
+ }
+
+ if (StringUtils.isNotBlank(modelProviderDTO.getName())) {
+ wrapper.and(w -> w.like("name", modelProviderDTO.getName())
+ .or()
+ .like("provider_code", modelProviderDTO.getName()));
+ }
+ return getPageData(modelProviderDao.selectPage(pageParam, wrapper), ModelProviderDTO.class);
+ }
+
+ public static void main(String[] args) {
+ String jsonString = "\"[]\"";
+ JSONArray jsonArray = new JSONArray(jsonString);
+ System.out.println("字符串转 JSONArray: " + jsonArray.toString());
}
@Override
- public ModelProviderDTO edit(ModelProviderEntity modelProviderEntity) {
- return null;
+ public ModelProviderDTO add(ModelProviderDTO modelProviderDTO) {
+ UserDetail user = SecurityUser.getUser();
+ modelProviderDTO.setCreator(user.getId());
+ modelProviderDTO.setUpdater(user.getId());
+ modelProviderDTO.setCreateDate(new Date());
+ modelProviderDTO.setUpdateDate(new Date());
+ // 去除Fields左右的双引号
+
+ modelProviderDTO.setFields(modelProviderDTO.getFields());
+ ModelProviderEntity entity = ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class);
+ if (modelProviderDao.insert(entity) == 0) {
+ throw new RenException("新增数据失败");
+ }
+
+ return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
}
@Override
- public void delete() {
+ public ModelProviderDTO edit(ModelProviderDTO modelProviderDTO) {
+ UserDetail user = SecurityUser.getUser();
+ modelProviderDTO.setUpdater(user.getId());
+ modelProviderDTO.setUpdateDate(new Date());
+ if (modelProviderDao
+ .updateById(ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderEntity.class)) == 0) {
+ throw new RenException("修改数据失败");
+ }
+ return ConvertUtils.sourceToTarget(modelProviderDTO, ModelProviderDTO.class);
+ }
+ @Override
+ public void delete(String id) {
+ if (modelProviderDao.deleteById(id) == 0) {
+ throw new RenException("删除数据失败");
+ }
+ }
+
+ @Override
+ public void delete(List ids) {
+ if (modelProviderDao.deleteBatchIds(ids) == 0) {
+ throw new RenException("删除数据失败");
+ }
}
@Override
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
index e5b89c99..c015240f 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java
@@ -18,6 +18,8 @@ import org.springframework.context.annotation.Configuration;
import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter;
import xiaozhi.modules.security.oauth2.Oauth2Realm;
+import xiaozhi.modules.security.secret.ServerSecretFilter;
+import xiaozhi.modules.sys.service.SysParamsService;
/**
* Shiro的配置文件
@@ -46,7 +48,7 @@ public class ShiroConfig {
}
@Bean("shiroFilter")
- public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
+ public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
config.setFilterOncePerRequest(true);
@@ -54,9 +56,11 @@ public class ShiroConfig {
shiroFilter.setSecurityManager(securityManager);
shiroFilter.setShiroFilterConfiguration(config);
- // oauth过滤
Map filters = new HashMap<>();
+ // oauth过滤
filters.put("oauth2", new Oauth2Filter());
+ // 服务密钥过滤
+ filters.put("server", new ServerSecretFilter(sysParamsService));
shiroFilter.setFilters(filters);
// 添加Shiro的内置过滤器
@@ -69,15 +73,23 @@ public class ShiroConfig {
*/
Map filterMap = new LinkedHashMap<>();
filterMap.put("/ota/**", "anon");
+ filterMap.put("/otaMag/download/**", "anon");
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/v3/api-docs/**", "anon");
filterMap.put("/doc.html", "anon");
filterMap.put("/favicon.ico", "anon");
filterMap.put("/user/captcha", "anon");
+ filterMap.put("/user/smsVerification", "anon");
filterMap.put("/user/login", "anon");
+ filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
- filterMap.put("/device/register", "anon");
+ filterMap.put("/user/retrieve-password", "anon");
+ // 将config路径使用server服务过滤器
+ filterMap.put("/config/**", "server");
+ filterMap.put("/agent/chat-history/report", "server");
+ filterMap.put("/agent/saveMemory/**", "server");
+ filterMap.put("/agent/play/**", "anon");
filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -95,4 +107,4 @@ public class ShiroConfig {
advisor.setSecurityManager(securityManager);
return advisor;
}
-}
\ No newline at end of file
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/WebMvcConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/WebMvcConfig.java
index 7cfb03d9..02538caf 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/WebMvcConfig.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/WebMvcConfig.java
@@ -1,5 +1,6 @@
package xiaozhi.modules.security.config;
+import java.text.SimpleDateFormat;
import java.util.List;
import java.util.TimeZone;
@@ -18,6 +19,15 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
+
+import xiaozhi.common.utils.DateUtils;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@@ -33,11 +43,15 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List> converters) {
+ // 特殊用途的转换器
converters.add(new ByteArrayHttpMessageConverter());
- converters.add(new StringHttpMessageConverter());
converters.add(new ResourceHttpMessageConverter());
- converters.add(new AllEncompassingFormHttpMessageConverter());
+
+ // 通用转换器
converters.add(new StringHttpMessageConverter());
+ converters.add(new AllEncompassingFormHttpMessageConverter());
+
+ // JSON 转换器
converters.add(jackson2HttpMessageConverter());
}
@@ -49,10 +63,29 @@ public class WebMvcConfig implements WebMvcConfigurer {
// 忽略未知属性
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
- // 日期格式转换
- // mapper.setDateFormat(new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN));
+ // 设置时区
mapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
+ // 配置Java8日期时间序列化
+ JavaTimeModule javaTimeModule = new JavaTimeModule();
+ javaTimeModule.addSerializer(java.time.LocalDateTime.class, new LocalDateTimeSerializer(
+ java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
+ javaTimeModule.addSerializer(java.time.LocalDate.class, new LocalDateSerializer(
+ java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
+ javaTimeModule.addSerializer(java.time.LocalTime.class,
+ new LocalTimeSerializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
+ javaTimeModule.addDeserializer(java.time.LocalDateTime.class, new LocalDateTimeDeserializer(
+ java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
+ javaTimeModule.addDeserializer(java.time.LocalDate.class, new LocalDateDeserializer(
+ java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
+ javaTimeModule.addDeserializer(java.time.LocalTime.class,
+ new LocalTimeDeserializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
+ mapper.registerModule(javaTimeModule);
+
+ // 配置java.util.Date的序列化和反序列化
+ SimpleDateFormat dateFormat = new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN);
+ mapper.setDateFormat(dateFormat);
+
// Long类型转String类型
SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java
index 7db612b5..0215cf7e 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java
@@ -1,6 +1,10 @@
package xiaozhi.modules.security.controller;
import java.io.IOException;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -13,6 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
+import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO;
@@ -21,13 +26,18 @@ import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.security.dto.LoginDTO;
+import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.security.password.PasswordUtils;
import xiaozhi.modules.security.service.CaptchaService;
import xiaozhi.modules.security.service.SysUserTokenService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.dto.PasswordDTO;
+import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO;
+import xiaozhi.modules.sys.service.SysDictDataService;
+import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserService;
+import xiaozhi.modules.sys.vo.SysDictDataItem;
/**
* 登录控制层
@@ -40,24 +50,43 @@ public class LoginController {
private final SysUserService sysUserService;
private final SysUserTokenService sysUserTokenService;
private final CaptchaService captchaService;
+ private final SysParamsService sysParamsService;
+ private final SysDictDataService sysDictDataService;
@GetMapping("/captcha")
@Operation(summary = "验证码")
public void captcha(HttpServletResponse response, String uuid) throws IOException {
// uuid不能为空
AssertUtils.isBlank(uuid, ErrorCode.IDENTIFIER_NOT_NULL);
-
// 生成验证码
captchaService.create(response, uuid);
}
+ @PostMapping("/smsVerification")
+ @Operation(summary = "短信验证码")
+ public Result smsVerification(@RequestBody SmsVerificationDTO dto) {
+ // 验证图形验证码
+ boolean validate = captchaService.validate(dto.getCaptchaId(), dto.getCaptcha(), true);
+ if (!validate) {
+ throw new RenException("图形验证码错误");
+ }
+ Boolean isMobileRegister = sysParamsService
+ .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
+ if (!isMobileRegister) {
+ throw new RenException("没有开启手机注册,没法使用短信验证码功能");
+ }
+ // 发送短信验证码
+ captchaService.sendSMSValidateCode(dto.getPhone());
+ return new Result<>();
+ }
+
@PostMapping("/login")
@Operation(summary = "登录")
public Result login(@RequestBody LoginDTO login) {
// 验证是否正确输入验证码
- boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha());
+ boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
if (!validate) {
- throw new RenException("验证码错误,请重新获取");
+ throw new RenException("图形验证码错误,请重新获取");
}
// 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
@@ -75,11 +104,32 @@ public class LoginController {
@PostMapping("/register")
@Operation(summary = "注册")
public Result register(@RequestBody LoginDTO login) {
- // 验证是否正确输入验证码
- boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha());
- if (!validate) {
- throw new RenException("验证码错误,请重新获取");
+ if (!sysUserService.getAllowUserRegister()) {
+ throw new RenException("当前不允许普通用户注册");
}
+ // 是否开启手机注册
+ Boolean isMobileRegister = sysParamsService
+ .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
+ boolean validate;
+ if (isMobileRegister) {
+ // 验证用户是否是手机号码
+ boolean validPhone = ValidatorUtils.isValidPhone(login.getUsername());
+ if (!validPhone) {
+ throw new RenException("用户名不是手机号码,请重新输入");
+ }
+ // 验证短信验证码是否正常
+ validate = captchaService.validateSMSValidateCode(login.getUsername(), login.getMobileCaptcha(), false);
+ if (!validate) {
+ throw new RenException("手机验证码错误,请重新获取");
+ }
+ } else {
+ // 验证是否正确输入验证码
+ validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha(), true);
+ if (!validate) {
+ throw new RenException("图形验证码错误,请重新获取");
+ }
+ }
+
// 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
if (userDTO != null) {
@@ -90,7 +140,6 @@ public class LoginController {
userDTO.setPassword(login.getPassword());
sysUserService.save(userDTO);
return new Result<>();
-
}
@GetMapping("/info")
@@ -111,4 +160,55 @@ public class LoginController {
sysUserTokenService.changePassword(userId, passwordDTO);
return new Result<>();
}
+
+ @PutMapping("/retrieve-password")
+ @Operation(summary = "找回密码")
+ public Result> retrievePassword(@RequestBody RetrievePasswordDTO dto) {
+ // 是否开启手机注册
+ Boolean isMobileRegister = sysParamsService
+ .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
+ if (!isMobileRegister) {
+ throw new RenException("没有开启手机注册,没法使用找回密码功能");
+ }
+ // 判断非空
+ ValidatorUtils.validateEntity(dto);
+ // 验证用户是否是手机号码
+ boolean validPhone = ValidatorUtils.isValidPhone(dto.getPhone());
+ if (!validPhone) {
+ throw new RenException("输入的手机号码格式不正确");
+ }
+
+ // 按照用户名获取用户
+ SysUserDTO userDTO = sysUserService.getByUsername(dto.getPhone());
+ if (userDTO == null) {
+ throw new RenException("输入的手机号码未注册");
+ }
+ // 验证短信验证码是否正常
+ boolean validate = captchaService.validateSMSValidateCode(dto.getPhone(), dto.getCode(), false);
+ // 判断是否通过验证
+ if (!validate) {
+ throw new RenException("输入的手机验证码错误");
+ }
+
+ sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
+ return new Result<>();
+ }
+
+ @GetMapping("/pub-config")
+ @Operation(summary = "公共配置")
+ public Result> pubConfig() {
+ Map config = new HashMap<>();
+ config.put("enableMobileRegister", sysParamsService
+ .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class));
+ config.put("version", Constant.VERSION);
+ config.put("year", "©" + Calendar.getInstance().get(Calendar.YEAR));
+ config.put("allowUserRegister", sysUserService.getAllowUserRegister());
+ List list = sysDictDataService.getDictDataByType(Constant.DictType.MOBILE_AREA.getValue());
+ config.put("mobileAreaList", list);
+ config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true));
+ config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true));
+ config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
+
+ return new Result>().ok(config);
+ }
}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java
index cab143be..12b99d32 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/dto/LoginDTO.java
@@ -25,6 +25,9 @@ public class LoginDTO implements Serializable {
@NotBlank(message = "{sysuser.captcha.require}")
private String captcha;
+ @Schema(description = "手机验证码")
+ private String mobileCaptcha;
+
@Schema(description = "唯一标识")
@NotBlank(message = "{sysuser.uuid.require}")
private String captchaId;
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/dto/SmsVerificationDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/security/dto/SmsVerificationDTO.java
new file mode 100644
index 00000000..425ef059
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/dto/SmsVerificationDTO.java
@@ -0,0 +1,28 @@
+package xiaozhi.modules.security.dto;
+
+import java.io.Serializable;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 短信验证码请求DTO
+ */
+@Data
+@Schema(description = "短信验证码请求")
+public class SmsVerificationDTO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "手机号码")
+ @NotBlank(message = "{sysuser.username.require}")
+ private String phone;
+
+ @Schema(description = "验证码")
+ @NotBlank(message = "{sysuser.captcha.require}")
+ private String captcha;
+
+ @Schema(description = "唯一标识")
+ @NotBlank(message = "{sysuser.uuid.require}")
+ private String captchaId;
+}
\ No newline at end of file
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/secret/ServerSecretFilter.java b/main/manager-api/src/main/java/xiaozhi/modules/security/secret/ServerSecretFilter.java
new file mode 100644
index 00000000..b2a18547
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/secret/ServerSecretFilter.java
@@ -0,0 +1,105 @@
+package xiaozhi.modules.security.secret;
+
+import java.io.IOException;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
+import org.springframework.web.bind.annotation.RequestMethod;
+
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.exception.ErrorCode;
+import xiaozhi.common.utils.HttpContextUtils;
+import xiaozhi.common.utils.JsonUtils;
+import xiaozhi.common.utils.Result;
+import xiaozhi.modules.sys.service.SysParamsService;
+
+/**
+ * Config API 过滤器
+ */
+@Slf4j
+@RequiredArgsConstructor
+public class ServerSecretFilter extends AuthenticatingFilter {
+ private final SysParamsService sysParamsService;
+
+ @Override
+ protected ServerSecretToken createToken(ServletRequest request, ServletResponse response) {
+ // 获取请求token
+ String token = getRequestToken((HttpServletRequest) request);
+
+ if (StringUtils.isBlank(token)) {
+ log.warn("createToken:token is empty");
+ return null;
+ }
+
+ return new ServerSecretToken(token);
+ }
+
+ @Override
+ protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
+ // 对OPTIONS请求放行
+ if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
+ // 获取token并校验
+ String token = getRequestToken((HttpServletRequest) servletRequest);
+ if (StringUtils.isBlank(token)) {
+ // token为空,返回401
+ this.sendUnauthorizedResponse((HttpServletResponse) servletResponse, "服务器密钥不能为空");
+ return false;
+ }
+
+ // 验证token是否匹配
+ String serverSecret = getServerSecret();
+ if (StringUtils.isBlank(serverSecret) || !serverSecret.equals(token)) {
+ // token无效,返回401
+ this.sendUnauthorizedResponse((HttpServletResponse) servletResponse, "无效的服务器密钥");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 发送未授权响应
+ */
+ private void sendUnauthorizedResponse(HttpServletResponse response, String message) {
+ response.setContentType("application/json;charset=utf-8");
+ response.setHeader("Access-Control-Allow-Credentials", "true");
+ response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
+
+ try {
+ String json = JsonUtils.toJsonString(new Result().error(ErrorCode.UNAUTHORIZED, message));
+ response.getWriter().print(json);
+ } catch (IOException e) {
+ log.error("响应输出失败", e);
+ }
+ }
+
+ /**
+ * 获取请求的token
+ */
+ private String getRequestToken(HttpServletRequest httpRequest) {
+ String token = null;
+ // 从header中获取token
+ String authorization = httpRequest.getHeader("Authorization");
+ if (StringUtils.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
+ token = authorization.replace("Bearer ", "");
+ }
+ return token;
+ }
+
+ private String getServerSecret() {
+ return sysParamsService.getValue(Constant.SERVER_SECRET, true);
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/secret/ServerSecretToken.java b/main/manager-api/src/main/java/xiaozhi/modules/security/secret/ServerSecretToken.java
new file mode 100644
index 00000000..9072eaf8
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/secret/ServerSecretToken.java
@@ -0,0 +1,25 @@
+package xiaozhi.modules.security.secret;
+
+import org.apache.shiro.authc.AuthenticationToken;
+
+/**
+ * Config API Token
+ */
+public class ServerSecretToken implements AuthenticationToken {
+ private static final long serialVersionUID = 1L;
+ private final String token;
+
+ public ServerSecretToken(String token) {
+ this.token = token;
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return token;
+ }
+
+ @Override
+ public Object getCredentials() {
+ return token;
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/service/CaptchaService.java b/main/manager-api/src/main/java/xiaozhi/modules/security/service/CaptchaService.java
index 9e5c153f..0db9d296 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/security/service/CaptchaService.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/service/CaptchaService.java
@@ -18,10 +18,28 @@ public interface CaptchaService {
/**
* 验证码效验
- *
- * @param uuid uuid
- * @param code 验证码
+ *
+ * @param uuid uuid
+ * @param code 验证码
+ * @param delete 是否删除验证码
* @return true:成功 false:失败
*/
- boolean validate(String uuid, String code);
+ boolean validate(String uuid, String code, Boolean delete);
+
+ /**
+ * 发送短信验证码
+ *
+ * @param phone 手机
+ */
+ void sendSMSValidateCode(String phone);
+
+ /**
+ * 验证短信验证码
+ *
+ * @param phone 手机
+ * @param code 验证码
+ * @param delete 是否删除验证码
+ * @return true:成功 false:失败
+ */
+ boolean validateSMSValidateCode(String phone, String code, Boolean delete);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/CaptchaServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/CaptchaServiceImpl.java
index 012b4d3e..e823d70e 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/CaptchaServiceImpl.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/security/service/impl/CaptchaServiceImpl.java
@@ -1,6 +1,7 @@
package xiaozhi.modules.security.service.impl;
import java.io.IOException;
+import java.util.Random;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils;
@@ -14,9 +15,13 @@ import com.wf.captcha.base.Captcha;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.security.service.CaptchaService;
+import xiaozhi.modules.sms.service.SmsService;
+import xiaozhi.modules.sys.service.SysParamsService;
/**
* 验证码
@@ -25,6 +30,10 @@ import xiaozhi.modules.security.service.CaptchaService;
public class CaptchaServiceImpl implements CaptchaService {
@Resource
private RedisUtils redisUtils;
+ @Resource
+ private SmsService smsService;
+ @Resource
+ private SysParamsService sysParamsService;
@Value("${renren.redis.open}")
private boolean open;
/**
@@ -51,12 +60,12 @@ public class CaptchaServiceImpl implements CaptchaService {
}
@Override
- public boolean validate(String uuid, String code) {
+ public boolean validate(String uuid, String code, Boolean delete) {
if (StringUtils.isBlank(code)) {
return false;
}
// 获取验证码
- String captcha = getCache(uuid);
+ String captcha = getCache(uuid, delete);
// 效验成功
if (code.equalsIgnoreCase(captcha)) {
@@ -66,21 +75,97 @@ public class CaptchaServiceImpl implements CaptchaService {
return false;
}
+ @Override
+ public void sendSMSValidateCode(String phone) {
+ // 检查发送间隔
+ String lastSendTimeKey = RedisKeys.getSMSLastSendTimeKey(phone);
+ // 获取是否发送过,如果没有设置最后发送时间(60秒)
+ String lastSendTime = redisUtils
+ .getKeyOrCreate(lastSendTimeKey,
+ String.valueOf(System.currentTimeMillis()), 60L);
+ if (lastSendTime != null) {
+ long lastSendTimeLong = Long.parseLong(lastSendTime);
+ long currentTime = System.currentTimeMillis();
+ long timeDiff = currentTime - lastSendTimeLong;
+ if (timeDiff < 60000) {
+ throw new RenException("发送太频繁,请" + (60000 - timeDiff) / 1000 + "秒后再试");
+ }
+ }
+
+ // 检查今日发送次数
+ String todayCountKey = RedisKeys.getSMSTodayCountKey(phone);
+ Integer todayCount = (Integer) redisUtils.get(todayCountKey);
+ if (todayCount == null) {
+ todayCount = 0;
+ }
+
+ // 获取最大发送次数限制
+ Integer maxSendCount = sysParamsService.getValueObject(
+ Constant.SysMSMParam.SERVER_SMS_MAX_SEND_COUNT.getValue(),
+ Integer.class);
+ if (maxSendCount == null) {
+ maxSendCount = 5; // 默认值
+ }
+
+ if (todayCount >= maxSendCount) {
+ throw new RenException("今日发送次数已达上限");
+ }
+
+ String key = RedisKeys.getSMSValidateCodeKey(phone);
+ String validateCodes = generateValidateCode(6);
+
+ // 设置验证码
+ setCache(key, validateCodes);
+
+ // 更新今日发送次数
+ if (todayCount == 0) {
+ redisUtils.increment(todayCountKey, RedisUtils.DEFAULT_EXPIRE);
+ } else {
+ redisUtils.increment(todayCountKey);
+ }
+
+ // 发送验证码短信
+ smsService.sendVerificationCodeSms(phone, validateCodes);
+ }
+
+ @Override
+ public boolean validateSMSValidateCode(String phone, String code, Boolean delete) {
+ String key = RedisKeys.getSMSValidateCodeKey(phone);
+ return validate(key, code, delete);
+ }
+
+ /**
+ * 生成指定数量的随机数验证码
+ *
+ * @param length 数量
+ * @return 随机码
+ */
+ private String generateValidateCode(Integer length) {
+ String chars = "0123456789"; // 字符范围可以自定义:数字
+ Random random = new Random();
+ StringBuilder code = new StringBuilder();
+ for (int i = 0; i < length; i++) {
+ code.append(chars.charAt(random.nextInt(chars.length())));
+ }
+ return code.toString();
+ }
+
private void setCache(String key, String value) {
if (open) {
key = RedisKeys.getCaptchaKey(key);
+ // 设置5分钟过期
redisUtils.set(key, value, 300);
} else {
localCache.put(key, value);
}
}
- private String getCache(String key) {
+ private String getCache(String key, Boolean delete) {
if (open) {
key = RedisKeys.getCaptchaKey(key);
String captcha = (String) redisUtils.get(key);
// 删除验证码
- if (captcha != null) {
+ if (captcha != null && delete) {
redisUtils.delete(key);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sms/service/SmsService.java b/main/manager-api/src/main/java/xiaozhi/modules/sms/service/SmsService.java
new file mode 100644
index 00000000..c33ad945
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/sms/service/SmsService.java
@@ -0,0 +1,17 @@
+package xiaozhi.modules.sms.service;
+
+/**
+ * 短信服务的方法定义接口
+ *
+ * @author zjy
+ * @since 2025-05-12
+ */
+public interface SmsService {
+
+ /**
+ * 发送验证码短信
+ * @param phone 手机号码
+ * @param VerificationCode 验证码
+ */
+ void sendVerificationCodeSms(String phone, String VerificationCode) ;
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sms/service/imp/ALiYunSmsService.java b/main/manager-api/src/main/java/xiaozhi/modules/sms/service/imp/ALiYunSmsService.java
new file mode 100644
index 00000000..e0ebb523
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/sms/service/imp/ALiYunSmsService.java
@@ -0,0 +1,76 @@
+package xiaozhi.modules.sms.service.imp;
+
+import com.aliyun.dysmsapi20170525.Client;
+import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
+import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
+import com.aliyun.teaopenapi.models.Config;
+import com.aliyun.teautil.models.RuntimeOptions;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.common.redis.RedisKeys;
+import xiaozhi.common.redis.RedisUtils;
+import xiaozhi.modules.sms.service.SmsService;
+import xiaozhi.modules.sys.service.SysParamsService;
+
+@Service
+@AllArgsConstructor
+@Slf4j
+public class ALiYunSmsService implements SmsService {
+ private final SysParamsService sysParamsService;
+ private final RedisUtils redisUtils;
+
+ @Override
+ public void sendVerificationCodeSms(String phone, String VerificationCode) {
+ Client client = createClient();
+ String SignName = sysParamsService.getValue(Constant.SysMSMParam
+ .ALIYUN_SMS_SIGN_NAME.getValue(),true);
+ String TemplateCode = sysParamsService.getValue(Constant.SysMSMParam
+ .ALIYUN_SMS_SMS_CODE_TEMPLATE_CODE.getValue(),true);
+ try {
+ SendSmsRequest sendSmsRequest = new SendSmsRequest()
+ .setSignName(SignName)
+ .setTemplateCode(TemplateCode)
+ .setPhoneNumbers(phone)
+ .setTemplateParam(String.format("{\"code\":\"%s\"}", VerificationCode));
+ RuntimeOptions runtime = new RuntimeOptions();
+ // 复制代码运行请自行打印 API 的返回值
+ SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
+ log.info("发送短信响应的requestID: {}", sendSmsResponse.getBody().getRequestId());
+ } catch (Exception e) {
+ // 如果发送失败了退还这次发送数
+ String todayCountKey = RedisKeys.getSMSTodayCountKey(phone);
+ redisUtils.delete(todayCountKey);
+ // 错误 message
+ log.error(e.getMessage());
+ throw new RenException("短信发送失败");
+ }
+
+ }
+
+
+ /**
+ * 创建阿里云连接
+ * @return 返回连接对象
+ */
+ private Client createClient(){
+ String ACCESS_KEY_ID = sysParamsService.getValue(Constant.SysMSMParam
+ .ALIYUN_SMS_ACCESS_KEY_ID.getValue(),true);
+ String ACCESS_KEY_SECRET = sysParamsService.getValue(Constant.SysMSMParam
+ .ALIYUN_SMS_ACCESS_KEY_SECRET.getValue(),true);
+ try {
+ Config config = new Config()
+ .setAccessKeyId(ACCESS_KEY_ID)
+ .setAccessKeySecret(ACCESS_KEY_SECRET);
+ // 配置 Endpoint。中国站请使用dysmsapi.aliyuncs.com
+ config.endpoint = "dysmsapi.aliyuncs.com";
+ return new Client(config);
+ }catch (Exception e){
+ // 错误 message
+ log.error(e.getMessage());
+ throw new RenException("短信连接建立失败");
+ }
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java
index e202e1e4..2ccc1e6a 100644
--- a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java
+++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/AdminController.java
@@ -58,7 +58,6 @@ public class AdminController {
dto.setLimit((String) params.get(Constant.LIMIT));
dto.setPage((String) params.get(Constant.PAGE));
ValidatorUtils.validateEntity(dto);
- ValidatorUtils.validateEntity(dto);
PageData page = sysUserService.page(dto);
return new Result>().ok(page);
}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/ServerSideManageController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/ServerSideManageController.java
new file mode 100644
index 00000000..a9664ced
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/ServerSideManageController.java
@@ -0,0 +1,120 @@
+package xiaozhi.modules.sys.controller;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.web.bind.annotation.GetMapping;
+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 org.springframework.web.socket.WebSocketHttpHeaders;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+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.annotation.LogOperation;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.exception.RenException;
+import xiaozhi.common.utils.Result;
+import xiaozhi.modules.sys.dto.EmitSeverActionDTO;
+import xiaozhi.modules.sys.dto.ServerActionPayloadDTO;
+import xiaozhi.modules.sys.dto.ServerActionResponseDTO;
+import xiaozhi.modules.sys.enums.ServerActionEnum;
+import xiaozhi.modules.sys.service.SysParamsService;
+import xiaozhi.modules.sys.utils.WebSocketClientManager;
+
+/**
+ * 服务端管理控制器
+ */
+@RestController
+@RequestMapping("admin/server")
+@Tag(name = "服务端管理")
+@AllArgsConstructor
+public class ServerSideManageController {
+ private final SysParamsService sysParamsService;
+ private static final ObjectMapper objectMapper;
+ static {
+ objectMapper = new ObjectMapper();
+ objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ }
+
+ @Operation(summary = "获取Ws服务端列表")
+ @GetMapping("/server-list")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result> getWsServerList() {
+ String cachedWs = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
+ return new Result>().ok(Arrays.asList(cachedWs.split(";")));
+ }
+
+ @Operation(summary = "通知python服务端更新配置")
+ @PostMapping("/emit-action")
+ @LogOperation("通知python服务端更新配置")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result emitServerAction(@RequestBody @Valid EmitSeverActionDTO emitSeverActionDTO) {
+ if (emitSeverActionDTO.getAction() == null) {
+ throw new RenException("无效服务端操作");
+ }
+ String wsText = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
+ if (StringUtils.isBlank(wsText)) {
+ throw new RenException("未配置服务端WebSocket地址");
+ }
+ String targetWs = emitSeverActionDTO.getTargetWs();
+ String[] wsList = wsText.split(";");
+ // 找到需要发起的
+ if (StringUtils.isBlank(targetWs) || !Arrays.asList(wsList).contains(targetWs)) {
+ throw new RenException("目标WebSocket地址不存在");
+ }
+ return new Result().ok(emitServerActionByWs(targetWs, emitSeverActionDTO.getAction()));
+ }
+
+ private Boolean emitServerActionByWs(String targetWsUri, ServerActionEnum actionEnum) {
+ if (StringUtils.isBlank(targetWsUri) || actionEnum == null) {
+ return false;
+ }
+ String serverSK = sysParamsService.getValue(Constant.SERVER_SECRET, true);
+ WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
+ headers.add("device-id", UUID.randomUUID().toString());
+ headers.add("client-id", UUID.randomUUID().toString());
+
+ try (WebSocketClientManager client = new WebSocketClientManager.Builder()
+ .connectTimeout(3, TimeUnit.SECONDS)
+ .maxSessionDuration(120, TimeUnit.SECONDS)
+ .uri(targetWsUri)
+ .headers(headers)
+ .build()) {
+ // 如果连接成功则发送一个json数据包并等待服务端响应
+ client.sendJson(
+ ServerActionPayloadDTO.build(
+ actionEnum,
+ Map.of("secret", serverSK)));
+ // 等待服务端响应并持续监听信息
+ client.listener((jsonText) -> {
+ if (StringUtils.isBlank(jsonText)) {
+ return false;
+ }
+ try {
+ ServerActionResponseDTO response = objectMapper.readValue(jsonText, ServerActionResponseDTO.class);
+ Boolean isSuccess = ServerActionResponseDTO.isSuccess(response);
+ return isSuccess;
+ } catch (JsonProcessingException e) {
+ return false;
+ }
+ });
+ } catch (Exception e) {
+ // 捕获全部错误,由全局异常处理器返回
+ throw new RenException("WebSocket连接失败或连接超时");
+ }
+ return true;
+ }
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java
new file mode 100644
index 00000000..30187fe8
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictDataController.java
@@ -0,0 +1,105 @@
+package xiaozhi.modules.sys.controller;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.utils.Result;
+import xiaozhi.common.validator.ValidatorUtils;
+import xiaozhi.modules.sys.dto.SysDictDataDTO;
+import xiaozhi.modules.sys.service.SysDictDataService;
+import xiaozhi.modules.sys.vo.SysDictDataItem;
+import xiaozhi.modules.sys.vo.SysDictDataVO;
+
+/**
+ * 字典数据管理
+ *
+ * @author czc
+ * @since 2025-04-30
+ */
+@AllArgsConstructor
+@RestController
+@RequestMapping("/admin/dict/data")
+@Tag(name = "字典数据管理")
+public class SysDictDataController {
+ private final SysDictDataService sysDictDataService;
+
+ @GetMapping("/page")
+ @Operation(summary = "分页查询字典数据")
+ @RequiresPermissions("sys:role:superAdmin")
+ @Parameters({ @Parameter(name = "dictTypeId", description = "字典类型ID", required = true),
+ @Parameter(name = "dictLabel", description = "数据标签"), @Parameter(name = "dictValue", description = "数据值"),
+ @Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
+ @Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true) })
+ public Result> page(@Parameter(hidden = true) @RequestParam Map params) {
+ ValidatorUtils.validateEntity(params);
+ // 强制校验dictTypeId是否存在
+ if (!params.containsKey("dictTypeId") || StringUtils.isEmpty(String.valueOf(params.get("dictTypeId")))) {
+ return new Result>().error("dictTypeId不能为空");
+ }
+
+ PageData page = sysDictDataService.page(params);
+ return new Result>().ok(page);
+ }
+
+ @GetMapping("/{id}")
+ @Operation(summary = "获取字典数据详情")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result get(@PathVariable("id") Long id) {
+ SysDictDataVO vo = sysDictDataService.get(id);
+ return new Result().ok(vo);
+ }
+
+ @PostMapping("/save")
+ @Operation(summary = "新增字典数据")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result save(@RequestBody SysDictDataDTO dto) {
+ ValidatorUtils.validateEntity(dto);
+ sysDictDataService.save(dto);
+ return new Result<>();
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "修改字典数据")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result update(@RequestBody SysDictDataDTO dto) {
+ ValidatorUtils.validateEntity(dto);
+ sysDictDataService.update(dto);
+ return new Result<>();
+ }
+
+ @PostMapping("/delete")
+ @Operation(summary = "删除字典数据")
+ @RequiresPermissions("sys:role:superAdmin")
+ @Parameter(name = "ids", description = "ID数组", required = true)
+ public Result delete(@RequestBody Long[] ids) {
+ sysDictDataService.delete(ids);
+ return new Result<>();
+ }
+
+ @GetMapping("/type/{dictType}")
+ @Operation(summary = "获取字典数据列表")
+ @RequiresPermissions("sys:role:normal")
+ public Result> getDictDataByType(@PathVariable("dictType") String dictType) {
+ List list = sysDictDataService.getDictDataByType(dictType);
+ return new Result>().ok(list);
+ }
+
+}
diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java
new file mode 100644
index 00000000..f16409b0
--- /dev/null
+++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysDictTypeController.java
@@ -0,0 +1,92 @@
+package xiaozhi.modules.sys.controller;
+
+import java.util.Map;
+
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
+import xiaozhi.common.constant.Constant;
+import xiaozhi.common.page.PageData;
+import xiaozhi.common.utils.Result;
+import xiaozhi.common.validator.ValidatorUtils;
+import xiaozhi.modules.sys.dto.SysDictTypeDTO;
+import xiaozhi.modules.sys.service.SysDictTypeService;
+import xiaozhi.modules.sys.vo.SysDictTypeVO;
+
+/**
+ * 字典类型管理
+ *
+ * @author czc
+ * @since 2025-04-30
+ */
+@AllArgsConstructor
+@RestController
+@RequestMapping("/admin/dict/type")
+@Tag(name = "字典类型管理")
+public class SysDictTypeController {
+ private final SysDictTypeService sysDictTypeService;
+
+ @GetMapping("/page")
+ @Operation(summary = "分页查询字典类型")
+ @RequiresPermissions("sys:role:superAdmin")
+ @Parameters({ @Parameter(name = "dictType", description = "字典类型编码"),
+ @Parameter(name = "dictName", description = "字典类型名称"),
+ @Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
+ @Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true) })
+ public Result> page(@Parameter(hidden = true) @RequestParam Map params) {
+ ValidatorUtils.validateEntity(params);
+ PageData page = sysDictTypeService.page(params);
+ return new Result>().ok(page);
+ }
+
+ @GetMapping("/{id}")
+ @Operation(summary = "获取字典类型详情")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result get(@PathVariable("id") Long id) {
+ SysDictTypeVO vo = sysDictTypeService.get(id);
+ return new Result().ok(vo);
+ }
+
+ @PostMapping("/save")
+ @Operation(summary = "保存字典类型")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result save(@RequestBody SysDictTypeDTO dto) {
+ // 参数校验
+ ValidatorUtils.validateEntity(dto);
+
+ sysDictTypeService.save(dto);
+ return new Result<>();
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "修改字典类型")
+ @RequiresPermissions("sys:role:superAdmin")
+ public Result update(@RequestBody SysDictTypeDTO dto) {
+ // 参数校验
+ ValidatorUtils.validateEntity(dto);
+
+ sysDictTypeService.update(dto);
+ return new Result<>();
+ }
+
+ @PostMapping("/delete")
+ @Operation(summary = "删除字典类型")
+ @RequiresPermissions("sys:role:superAdmin")
+ @Parameter(name = "ids", description = "ID数组", required = true)
+ public Result