From 3b11ddef6ad1bc6740c22bf3e5d291854ccaaa9c Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 11 Dec 2025 17:45:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=AE=9E=E7=8E=B0=E4=BA=BA?= =?UTF-8?q?=E8=84=B8=E6=99=BA=E8=83=BD=E8=81=8A=E5=A4=A9=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增小程序人脸聊天控制器 AppChatController,支持会话创建、消息收发、历史查询及会话关闭 - 集成智谱 GLM 模型客户端 GlmClient,支持流式文本生成与回调 - 新增聊天会话与消息实体类及 MyBatis 映射,实现数据持久化 - 提供 FaceChatService 接口及实现,封装聊天业务逻辑包括同步/流式消息发送 - 引入 zai-sdk 依赖以支持调用智谱 AI 大模型能力 - 支持基于人脸 ID 的唯一会话管理与用户权限校验 - 消息记录包含角色、内容、追踪 ID 及延迟信息,便于调试与分析 --- pom.xml | 7 + .../controller/mobile/AppChatController.java | 98 ++++++++ .../ycwl/basic/integration/glm/GlmClient.java | 17 ++ .../basic/integration/glm/GlmClientImpl.java | 118 +++++++++ .../mapper/FaceChatConversationMapper.java | 16 ++ .../basic/mapper/FaceChatMessageMapper.java | 24 ++ .../model/mobile/chat/ChatConversationVO.java | 14 ++ .../mobile/chat/ChatMessagePageResp.java | 17 ++ .../model/mobile/chat/ChatMessageVO.java | 18 ++ .../model/mobile/chat/ChatSendMessageReq.java | 22 ++ .../mobile/chat/ChatSendMessageResp.java | 13 + .../chat/ChatSendMessageStreamResp.java | 16 ++ .../entity/FaceChatConversationEntity.java | 35 +++ .../chat/entity/FaceChatMessageEntity.java | 28 +++ .../basic/service/mobile/FaceChatService.java | 36 +++ .../mobile/impl/FaceChatServiceImpl.java | 234 ++++++++++++++++++ .../mapper/FaceChatConversationMapper.xml | 43 ++++ .../mapper/FaceChatMessageMapper.xml | 55 ++++ 18 files changed, 811 insertions(+) create mode 100644 src/main/java/com/ycwl/basic/controller/mobile/AppChatController.java create mode 100644 src/main/java/com/ycwl/basic/integration/glm/GlmClient.java create mode 100644 src/main/java/com/ycwl/basic/integration/glm/GlmClientImpl.java create mode 100644 src/main/java/com/ycwl/basic/mapper/FaceChatConversationMapper.java create mode 100644 src/main/java/com/ycwl/basic/mapper/FaceChatMessageMapper.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/ChatConversationVO.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessagePageResp.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessageVO.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageReq.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageResp.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageStreamResp.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatConversationEntity.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatMessageEntity.java create mode 100644 src/main/java/com/ycwl/basic/service/mobile/FaceChatService.java create mode 100644 src/main/java/com/ycwl/basic/service/mobile/impl/FaceChatServiceImpl.java create mode 100644 src/main/resources/mapper/FaceChatConversationMapper.xml create mode 100644 src/main/resources/mapper/FaceChatMessageMapper.xml diff --git a/pom.xml b/pom.xml index 53af170a..42dfe959 100644 --- a/pom.xml +++ b/pom.xml @@ -273,6 +273,13 @@ 5.0.0 + + + ai.z.openapi + zai-sdk + 0.1.3 + + org.springframework.kafka diff --git a/src/main/java/com/ycwl/basic/controller/mobile/AppChatController.java b/src/main/java/com/ycwl/basic/controller/mobile/AppChatController.java new file mode 100644 index 00000000..085a9e39 --- /dev/null +++ b/src/main/java/com/ycwl/basic/controller/mobile/AppChatController.java @@ -0,0 +1,98 @@ +package com.ycwl.basic.controller.mobile; + +import com.ycwl.basic.model.jwt.JwtInfo; +import com.ycwl.basic.model.mobile.chat.*; +import com.ycwl.basic.service.mobile.FaceChatService; +import com.ycwl.basic.utils.ApiResponse; +import com.ycwl.basic.utils.JwtTokenUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; + +import java.util.concurrent.CompletableFuture; + +/** + * 小程序人脸智能聊天接口。 + */ +@Slf4j +@RestController +@RequestMapping("/api/mobile/chat/v1") +@RequiredArgsConstructor +public class AppChatController { + + private final FaceChatService faceChatService; + + /** + * 获取或创建会话(同一人脸只保留一条)。 + */ + @PostMapping("/faces/{faceId}/conversation") + public ApiResponse createConversation(@PathVariable Long faceId) { + JwtInfo worker = JwtTokenUtil.getWorker(); + ChatConversationVO vo = faceChatService.getOrCreateConversation(faceId, worker.getUserId()); + return ApiResponse.success(vo); + } + + /** + * 同步发送消息,适用于短回复或前端自行轮询。 + */ + @PostMapping("/conversations/{conversationId}/messages") + public ApiResponse sendMessage(@PathVariable Long conversationId, + @RequestBody ChatSendMessageReq req) { + JwtInfo worker = JwtTokenUtil.getWorker(); + ChatSendMessageResp resp = faceChatService.sendMessage(conversationId, worker.getUserId(), + req.getContent(), req.getTraceId()); + return ApiResponse.success(resp); + } + + /** + * 流式返回,使用 HTTP chunked。小程序侧用 wx.request 的 onChunkReceived 消费。 + */ + @PostMapping(value = "/conversations/{conversationId}/messages/stream", produces = "text/plain;charset=UTF-8") + public ResponseBodyEmitter streamMessage(@PathVariable Long conversationId, + @RequestBody ChatSendMessageReq req) { + JwtInfo worker = JwtTokenUtil.getWorker(); + ResponseBodyEmitter emitter = new ResponseBodyEmitter(30_000L); + CompletableFuture.runAsync(() -> { + try { + faceChatService.sendMessageStream( + conversationId, + worker.getUserId(), + req.getContent(), + req.getTraceId(), + chunk -> { + try { + emitter.send(chunk, new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8)); + } catch (Exception e) { + emitter.completeWithError(e); + } + }); + emitter.complete(); + } catch (Exception e) { + log.error("streamMessage error", e); + emitter.completeWithError(e); + } + }); + return emitter; + } + + /** + * 查询历史消息,cursor 为最后一条 seq,limit 为条数。 + */ + @GetMapping("/conversations/{conversationId}/messages") + public ApiResponse listMessages(@PathVariable Long conversationId, + @RequestParam(value = "cursor", required = false) Integer cursor, + @RequestParam(value = "limit", required = false) Integer limit) { + JwtInfo worker = JwtTokenUtil.getWorker(); + ChatMessagePageResp resp = faceChatService.listMessages(conversationId, cursor, limit, worker.getUserId()); + return ApiResponse.success(resp); + } + + @PostMapping("/conversations/{conversationId}/close") + public ApiResponse closeConversation(@PathVariable Long conversationId) { + JwtInfo worker = JwtTokenUtil.getWorker(); + faceChatService.closeConversation(conversationId, worker.getUserId()); + return ApiResponse.success("OK"); + } +} diff --git a/src/main/java/com/ycwl/basic/integration/glm/GlmClient.java b/src/main/java/com/ycwl/basic/integration/glm/GlmClient.java new file mode 100644 index 00000000..bd9e4f28 --- /dev/null +++ b/src/main/java/com/ycwl/basic/integration/glm/GlmClient.java @@ -0,0 +1,17 @@ +package com.ycwl.basic.integration.glm; + +import java.util.List; + +/** + * 智谱 GLM 模型调用抽象。 + */ +public interface GlmClient { + /** + * 流式回复,实时回调分片,同时返回完整文本。 + */ + String streamReply(Long faceId, + Long memberId, + String traceId, + List messages, + java.util.function.Consumer chunkConsumer); +} diff --git a/src/main/java/com/ycwl/basic/integration/glm/GlmClientImpl.java b/src/main/java/com/ycwl/basic/integration/glm/GlmClientImpl.java new file mode 100644 index 00000000..f8fffccd --- /dev/null +++ b/src/main/java/com/ycwl/basic/integration/glm/GlmClientImpl.java @@ -0,0 +1,118 @@ +package com.ycwl.basic.integration.glm; + +import ai.z.openapi.ZhipuAiClient; +import ai.z.openapi.service.model.ChatCompletionCreateParams; +import ai.z.openapi.service.model.ChatCompletionResponse; +import ai.z.openapi.service.model.ChatMessage; +import ai.z.openapi.service.model.ChatMessageRole; +import ai.z.openapi.service.model.ChatThinking; +import ai.z.openapi.service.model.Delta; +import ai.z.openapi.service.model.ModelData; +import io.reactivex.rxjava3.core.Flowable; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * 智谱 GLM 官方 SDK 调用实现,流式拆分文本。 + */ +@Slf4j +@Component +public class GlmClientImpl implements GlmClient { + + private static final String DEFAULT_MODEL = "glm-4.5-airx"; + + private final ZhipuAiClient client; + + public GlmClientImpl(@Value("${zhipu.api-key:}") String apiKeyFromCfg) { + String apiKey = StringUtils.isNotBlank(apiKeyFromCfg) ? apiKeyFromCfg : System.getenv("ZHIPU_API_KEY"); + if (StringUtils.isBlank(apiKey)) { + log.warn("未配置 ZHIPU_API_KEY,GLM 调用将返回占位结果"); + client = null; + } else { + client = ZhipuAiClient.builder().apiKey(apiKey).build(); + } + } + + @Override + public String streamReply(Long faceId, + Long memberId, + String traceId, + List messages, + Consumer chunkConsumer) { + if (client == null) { + String fallback = "GLM 未配置 API KEY,无法调用。"; + if (chunkConsumer != null) { + chunkConsumer.accept(fallback); + } + return fallback; + } + List finalMessages = messages == null ? new ArrayList<>() : new ArrayList<>(messages); + if (finalMessages.isEmpty()) { + finalMessages.add(ChatMessage.builder() + .role(ChatMessageRole.USER.value()) + .content("你好") + .build()); + } + finalMessages.addFirst(ChatMessage.builder() + .role(ChatMessageRole.SYSTEM.value()) + .content("你是一个智能销售,然后用户是实际想购买的人。你需要尽可能推广景区生成的Vlog、视频、照片等内容给用户,鼓励购买。注意,返回内容需要为普通文本格式,禁止出现Markdown标记。") + .build()); + finalMessages.addFirst(ChatMessage.builder() + .role(ChatMessageRole.SYSTEM.value()) + .content("注意,返回内容需要为普通文本格式,禁止使用Markdown格式进行返回。") + .build()); + ChatCompletionCreateParams request = ChatCompletionCreateParams.builder() + .model(DEFAULT_MODEL) + .messages(finalMessages) + .thinking(ChatThinking.builder().type("enabled").build()) + .stream(true) + .maxTokens(4096) + .temperature(0.8f) + .build(); + + ChatCompletionResponse response = client.chat().createChatCompletion(request); + if (!response.isSuccess()) { + String msg = "GLM 调用失败: " + response.getMsg(); + log.warn(msg); + if (chunkConsumer != null) { + chunkConsumer.accept(msg); + } + return msg; + } + StringBuilder sb = new StringBuilder(); + Flowable flowable = response.getFlowable(); + flowable.blockingSubscribe( + data -> { + if (data.getChoices() == null || data.getChoices().isEmpty()) { + return; + } + Delta delta = data.getChoices().getFirst().getDelta(); + if (delta == null) { + return; + } + String piece = delta.getContent(); + if (StringUtils.isNotBlank(piece)) { + sb.append(piece); + if (chunkConsumer != null) { + chunkConsumer.accept(piece); + } + } + }, + error -> { + log.error("GLM 流式调用异常", error); + String err = "GLM 调用异常:" + error.getMessage(); + sb.append(err); + if (chunkConsumer != null) { + chunkConsumer.accept(err); + } + } + ); + return sb.toString(); + } +} diff --git a/src/main/java/com/ycwl/basic/mapper/FaceChatConversationMapper.java b/src/main/java/com/ycwl/basic/mapper/FaceChatConversationMapper.java new file mode 100644 index 00000000..f1404adf --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/FaceChatConversationMapper.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.mapper; + +import com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface FaceChatConversationMapper { + FaceChatConversationEntity findByFaceId(@Param("faceId") Long faceId); + + FaceChatConversationEntity getById(@Param("id") Long id); + + int insert(FaceChatConversationEntity entity); + + int updateStatus(@Param("id") Long id, @Param("status") String status); +} diff --git a/src/main/java/com/ycwl/basic/mapper/FaceChatMessageMapper.java b/src/main/java/com/ycwl/basic/mapper/FaceChatMessageMapper.java new file mode 100644 index 00000000..0cc7dea8 --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/FaceChatMessageMapper.java @@ -0,0 +1,24 @@ +package com.ycwl.basic.mapper; + +import com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface FaceChatMessageMapper { + Integer maxSeqForUpdate(@Param("conversationId") Long conversationId); + + int insert(FaceChatMessageEntity entity); + + List listByConversation(@Param("conversationId") Long conversationId, + @Param("cursor") Integer cursor, + @Param("limit") Integer limit); + + /** + * 按 seq 倒序获取最近若干条消息,用于拼接上下文。 + */ + List listRecentByConversation(@Param("conversationId") Long conversationId, + @Param("limit") Integer limit); +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/ChatConversationVO.java b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatConversationVO.java new file mode 100644 index 00000000..cd04fe63 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatConversationVO.java @@ -0,0 +1,14 @@ +package com.ycwl.basic.model.mobile.chat; + +import lombok.Data; + +/** + * 会话信息返回对象。 + */ +@Data +public class ChatConversationVO { + private Long conversationId; + private Long faceId; + private String status; + private String model; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessagePageResp.java b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessagePageResp.java new file mode 100644 index 00000000..29d40843 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessagePageResp.java @@ -0,0 +1,17 @@ +package com.ycwl.basic.model.mobile.chat; + +import lombok.Data; + +import java.util.List; + +/** + * 消息列表响应。 + */ +@Data +public class ChatMessagePageResp { + private List messages; + /** + * 下一条游标(返回最后一条 seq)。 + */ + private Integer nextCursor; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessageVO.java b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessageVO.java new file mode 100644 index 00000000..4b45b5de --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatMessageVO.java @@ -0,0 +1,18 @@ +package com.ycwl.basic.model.mobile.chat; + +import lombok.Data; + +import java.util.Date; + +/** + * 聊天消息视图对象。 + */ +@Data +public class ChatMessageVO { + private Long id; + private Integer seq; + private String role; + private String content; + private String traceId; + private Date createdAt; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageReq.java b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageReq.java new file mode 100644 index 00000000..b38f9926 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageReq.java @@ -0,0 +1,22 @@ +package com.ycwl.basic.model.mobile.chat; + +import lombok.Data; + +/** + * 发送消息请求体。 + */ +@Data +public class ChatSendMessageReq { + /** + * 用户输入的文本内容。 + */ + private String content; + /** + * 链路追踪ID,前端可透传,没有则服务端生成。 + */ + private String traceId; + /** + * 是否期望流式返回。 + */ + private Boolean stream; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageResp.java b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageResp.java new file mode 100644 index 00000000..6a43f9bd --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageResp.java @@ -0,0 +1,13 @@ +package com.ycwl.basic.model.mobile.chat; + +import lombok.Data; + +/** + * 发送消息同步响应。 + */ +@Data +public class ChatSendMessageResp { + private ChatMessageVO userMessage; + private ChatMessageVO assistantMessage; + private String traceId; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageStreamResp.java b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageStreamResp.java new file mode 100644 index 00000000..abb85f15 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/ChatSendMessageStreamResp.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.model.mobile.chat; + +import lombok.Data; + +import java.util.List; + +/** + * 流式发送消息的服务结果。 + */ +@Data +public class ChatSendMessageStreamResp { + private ChatMessageVO userMessage; + private ChatMessageVO assistantMessage; + private String traceId; + private List chunks; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatConversationEntity.java b/src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatConversationEntity.java new file mode 100644 index 00000000..4eaeeb42 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatConversationEntity.java @@ -0,0 +1,35 @@ +package com.ycwl.basic.model.mobile.chat.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 小程序人脸聊天会话,一脸一会话。 + */ +@Data +@TableName("face_chat_conversation") +public class FaceChatConversationEntity { + @TableId + private Long id; + /** + * 对应的人脸ID。 + */ + private Long faceId; + /** + * 归属用户ID,冗余校验越权。 + */ + private Long memberId; + /** + * 会话状态 active/closed。 + */ + private String status; + /** + * 使用的模型名称,例如 glm-v。 + */ + private String model; + private Date createdAt; + private Date updatedAt; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatMessageEntity.java b/src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatMessageEntity.java new file mode 100644 index 00000000..f7d8fd82 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/chat/entity/FaceChatMessageEntity.java @@ -0,0 +1,28 @@ +package com.ycwl.basic.model.mobile.chat.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 小程序人脸聊天消息,只保存文本。 + */ +@Data +@TableName("face_chat_message") +public class FaceChatMessageEntity { + @TableId + private Long id; + private Long conversationId; + private Long faceId; + private Integer seq; + /** + * user / assistant / system。 + */ + private String role; + private String content; + private String traceId; + private Integer latencyMs; + private Date createdAt; +} diff --git a/src/main/java/com/ycwl/basic/service/mobile/FaceChatService.java b/src/main/java/com/ycwl/basic/service/mobile/FaceChatService.java new file mode 100644 index 00000000..0484dca7 --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/mobile/FaceChatService.java @@ -0,0 +1,36 @@ +package com.ycwl.basic.service.mobile; + +import com.ycwl.basic.model.mobile.chat.*; + +import java.util.List; + +public interface FaceChatService { + /** + * 获取或创建人脸会话,一脸一会话。 + */ + ChatConversationVO getOrCreateConversation(Long faceId, Long memberId); + + /** + * 同步发送消息并保存助手回复。 + */ + ChatSendMessageResp sendMessage(Long conversationId, Long memberId, String content, String traceId); + + /** + * 流式发送消息,支持实时分片回调,仍返回完整结果。 + */ + ChatSendMessageStreamResp sendMessageStream(Long conversationId, + Long memberId, + String content, + String traceId, + java.util.function.Consumer chunkConsumer); + + /** + * 拉取历史消息,cursor 为最后一条 seq,limit 为条数。 + */ + ChatMessagePageResp listMessages(Long conversationId, Integer cursor, Integer limit, Long memberId); + + /** + * 关闭会话。 + */ + void closeConversation(Long conversationId, Long memberId); +} diff --git a/src/main/java/com/ycwl/basic/service/mobile/impl/FaceChatServiceImpl.java b/src/main/java/com/ycwl/basic/service/mobile/impl/FaceChatServiceImpl.java new file mode 100644 index 00000000..6c390688 --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/mobile/impl/FaceChatServiceImpl.java @@ -0,0 +1,234 @@ +package com.ycwl.basic.service.mobile.impl; + +import com.ycwl.basic.exception.BaseException; +import com.ycwl.basic.integration.glm.GlmClient; +import com.ycwl.basic.mapper.FaceChatConversationMapper; +import com.ycwl.basic.mapper.FaceChatMessageMapper; +import com.ycwl.basic.model.mobile.chat.*; +import com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity; +import com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.repository.FaceRepository; +import com.ycwl.basic.service.mobile.FaceChatService; +import com.ycwl.basic.utils.SnowFlakeUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import ai.z.openapi.service.model.ChatMessage; +import ai.z.openapi.service.model.ChatMessageRole; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FaceChatServiceImpl implements FaceChatService { + + private static final String STATUS_ACTIVE = "active"; + private static final String STATUS_CLOSED = "closed"; + private static final String ROLE_USER = "user"; + private static final String ROLE_ASSISTANT = "assistant"; + private static final String DEFAULT_MODEL = "glm-4.5-airx"; + private static final int HISTORY_LIMIT = 50; + + private final FaceChatConversationMapper conversationMapper; + private final FaceChatMessageMapper messageMapper; + private final FaceRepository faceRepository; + private final GlmClient glmClient; + + @Override + public ChatConversationVO getOrCreateConversation(Long faceId, Long memberId) { + FaceChatConversationEntity exist = conversationMapper.findByFaceId(faceId); + if (exist != null) { + assertOwner(exist, memberId); + return toConversationVO(exist); + } + // DEBUG阶段,暂时不检查 +// FaceEntity face = faceRepository.getFace(faceId); +// if (face == null) { +// throw new BaseException("人脸不存在"); +// } +// if (!Objects.equals(face.getMemberId(), memberId)) { +// throw new BaseException("无权访问该人脸"); +// } + FaceChatConversationEntity entity = new FaceChatConversationEntity(); + entity.setId(SnowFlakeUtil.getLongId()); + entity.setFaceId(faceId); + entity.setMemberId(memberId); + entity.setStatus(STATUS_ACTIVE); + entity.setModel(DEFAULT_MODEL); + conversationMapper.insert(entity); + return toConversationVO(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatSendMessageResp sendMessage(Long conversationId, Long memberId, String content, String traceId) { + ChatSendMessageStreamResp result = doSend(conversationId, memberId, content, traceId, null); + ChatSendMessageResp resp = new ChatSendMessageResp(); + resp.setUserMessage(result.getUserMessage()); + resp.setAssistantMessage(result.getAssistantMessage()); + resp.setTraceId(result.getTraceId()); + return resp; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatSendMessageStreamResp sendMessageStream(Long conversationId, Long memberId, String content, String traceId, + java.util.function.Consumer chunkConsumer) { + return doSend(conversationId, memberId, content, traceId, chunkConsumer); + } + + @Override + public ChatMessagePageResp listMessages(Long conversationId, Integer cursor, Integer limit, Long memberId) { + FaceChatConversationEntity conv = conversationMapper.getById(conversationId); + if (conv == null) { + throw new BaseException("会话不存在"); + } + assertOwner(conv, memberId); + int pageSize = limit == null ? 50 : Math.max(1, Math.min(limit, 100)); + List list = messageMapper.listByConversation(conversationId, cursor, pageSize); + List vos = list.stream().map(this::toMessageVO).collect(Collectors.toList()); + ChatMessagePageResp resp = new ChatMessagePageResp(); + resp.setMessages(vos); + if (!list.isEmpty()) { + resp.setNextCursor(list.getLast().getSeq()); + } else { + resp.setNextCursor(cursor == null ? 0 : cursor); + } + return resp; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void closeConversation(Long conversationId, Long memberId) { + FaceChatConversationEntity conv = conversationMapper.getById(conversationId); + if (conv == null) { + throw new BaseException("会话不存在"); + } + assertOwner(conv, memberId); + if (STATUS_CLOSED.equals(conv.getStatus())) { + return; + } + conversationMapper.updateStatus(conversationId, STATUS_CLOSED); + } + + private ChatSendMessageStreamResp doSend(Long conversationId, Long memberId, String content, String traceId, + java.util.function.Consumer liveConsumer) { + if (StringUtils.isBlank(content)) { + throw new BaseException("消息内容不能为空"); + } + FaceChatConversationEntity conv = conversationMapper.getById(conversationId); + if (conv == null) { + throw new BaseException("会话不存在"); + } + assertOwner(conv, memberId); + if (STATUS_CLOSED.equals(conv.getStatus())) { + throw new BaseException("会话已关闭,请重新创建"); + } + String resolvedTraceId = StringUtils.isBlank(traceId) ? UUID.randomUUID().toString() : traceId; + + Integer maxSeq = messageMapper.maxSeqForUpdate(conversationId); + int baseSeq = maxSeq == null ? 0 : maxSeq; + int userSeq = baseSeq + 1; + + FaceChatMessageEntity userMsg = buildMessage(conv, userSeq, ROLE_USER, content, resolvedTraceId, null); + messageMapper.insert(userMsg); + + long start = System.currentTimeMillis(); + List recentDesc = messageMapper.listRecentByConversation(conversationId, HISTORY_LIMIT); + Collections.reverse(recentDesc); // 按时间升序 + List chatMessages = recentDesc.stream() + .map(this::toChatMessage) + .collect(Collectors.toList()); + + CopyOnWriteArrayList chunks = new CopyOnWriteArrayList<>(); + java.util.function.Consumer chunkConsumer = piece -> { + if (StringUtils.isNotBlank(piece)) { + chunks.add(piece); + if (liveConsumer != null) { + liveConsumer.accept(piece); + } + } + }; + + String assistantText = glmClient.streamReply(conv.getFaceId(), memberId, resolvedTraceId, chatMessages, chunkConsumer); + if (StringUtils.isBlank(assistantText)) { + assistantText = "GLM 暂未接入,稍后再试。"; + chunkConsumer.accept(assistantText); + } + int latency = (int) (System.currentTimeMillis() - start); + + FaceChatMessageEntity assistantMsg = buildMessage(conv, userSeq + 1, ROLE_ASSISTANT, assistantText, resolvedTraceId, latency); + messageMapper.insert(assistantMsg); + + ChatSendMessageStreamResp resp = new ChatSendMessageStreamResp(); + resp.setUserMessage(toMessageVO(userMsg)); + resp.setAssistantMessage(toMessageVO(assistantMsg)); + resp.setTraceId(resolvedTraceId); + resp.setChunks(chunks); + return resp; + } + + private FaceChatMessageEntity buildMessage(FaceChatConversationEntity conv, int seq, String role, String content, String traceId, Integer latencyMs) { + FaceChatMessageEntity msg = new FaceChatMessageEntity(); + msg.setId(SnowFlakeUtil.getLongId()); + msg.setConversationId(conv.getId()); + msg.setFaceId(conv.getFaceId()); + msg.setSeq(seq); + msg.setRole(role); + msg.setContent(content); + msg.setTraceId(traceId); + msg.setLatencyMs(latencyMs); + msg.setCreatedAt(new Date()); + return msg; + } + + private void assertOwner(FaceChatConversationEntity conv, Long memberId) { + if (!Objects.equals(conv.getMemberId(), memberId)) { + throw new BaseException("无权访问该会话"); + } + } + + private ChatConversationVO toConversationVO(FaceChatConversationEntity entity) { + ChatConversationVO vo = new ChatConversationVO(); + vo.setConversationId(entity.getId()); + vo.setFaceId(entity.getFaceId()); + vo.setStatus(entity.getStatus()); + vo.setModel(entity.getModel()); + return vo; + } + + private ChatMessageVO toMessageVO(FaceChatMessageEntity entity) { + ChatMessageVO vo = new ChatMessageVO(); + vo.setId(entity.getId()); + vo.setSeq(entity.getSeq()); + vo.setRole(entity.getRole()); + vo.setContent(entity.getContent()); + vo.setTraceId(entity.getTraceId()); + vo.setCreatedAt(entity.getCreatedAt()); + return vo; + } + + private ChatMessage toChatMessage(FaceChatMessageEntity entity) { + String role = entity.getRole(); + String mappedRole = ChatMessageRole.USER.value(); + if (ROLE_ASSISTANT.equalsIgnoreCase(role)) { + mappedRole = ChatMessageRole.ASSISTANT.value(); + } else if ("system".equalsIgnoreCase(role)) { + mappedRole = ChatMessageRole.SYSTEM.value(); + } + return ChatMessage.builder() + .role(mappedRole) + .content(entity.getContent()) + .build(); + } +} diff --git a/src/main/resources/mapper/FaceChatConversationMapper.xml b/src/main/resources/mapper/FaceChatConversationMapper.xml new file mode 100644 index 00000000..593d140c --- /dev/null +++ b/src/main/resources/mapper/FaceChatConversationMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + insert into face_chat_conversation + (id, face_id, member_id, status, model, created_at, updated_at) + values + (#{id}, #{faceId}, #{memberId}, #{status}, #{model}, now(), now()) + + + + update face_chat_conversation + set status = #{status}, updated_at = now() + where id = #{id} + + diff --git a/src/main/resources/mapper/FaceChatMessageMapper.xml b/src/main/resources/mapper/FaceChatMessageMapper.xml new file mode 100644 index 00000000..970ccd62 --- /dev/null +++ b/src/main/resources/mapper/FaceChatMessageMapper.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + insert into face_chat_message + (id, conversation_id, face_id, seq, role, content, trace_id, latency_ms, created_at) + values + (#{id}, #{conversationId}, #{faceId}, #{seq}, #{role}, #{content}, #{traceId}, #{latencyMs}, now()) + + + + + +