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())
+
+
+
+
+
+