You've already forked FrameTour-BE
- 新增小程序人脸聊天控制器 AppChatController,支持会话创建、消息收发、历史查询及会话关闭 - 集成智谱 GLM 模型客户端 GlmClient,支持流式文本生成与回调 - 新增聊天会话与消息实体类及 MyBatis 映射,实现数据持久化 - 提供 FaceChatService 接口及实现,封装聊天业务逻辑包括同步/流式消息发送 - 引入 zai-sdk 依赖以支持调用智谱 AI 大模型能力 - 支持基于人脸 ID 的唯一会话管理与用户权限校验 - 消息记录包含角色、内容、追踪 ID 及延迟信息,便于调试与分析
This commit is contained in:
7
pom.xml
7
pom.xml
@@ -273,6 +273,13 @@
|
||||
<version>5.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 智谱AI SDK -->
|
||||
<dependency>
|
||||
<groupId>ai.z.openapi</groupId>
|
||||
<artifactId>zai-sdk</artifactId>
|
||||
<version>0.1.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Kafka -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.kafka</groupId>
|
||||
|
||||
@@ -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<ChatConversationVO> 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<ChatSendMessageResp> 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<ChatMessagePageResp> 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<String> closeConversation(@PathVariable Long conversationId) {
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
faceChatService.closeConversation(conversationId, worker.getUserId());
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
}
|
||||
17
src/main/java/com/ycwl/basic/integration/glm/GlmClient.java
Normal file
17
src/main/java/com/ycwl/basic/integration/glm/GlmClient.java
Normal file
@@ -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<ai.z.openapi.service.model.ChatMessage> messages,
|
||||
java.util.function.Consumer<String> chunkConsumer);
|
||||
}
|
||||
118
src/main/java/com/ycwl/basic/integration/glm/GlmClientImpl.java
Normal file
118
src/main/java/com/ycwl/basic/integration/glm/GlmClientImpl.java
Normal file
@@ -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<ChatMessage> messages,
|
||||
Consumer<String> chunkConsumer) {
|
||||
if (client == null) {
|
||||
String fallback = "GLM 未配置 API KEY,无法调用。";
|
||||
if (chunkConsumer != null) {
|
||||
chunkConsumer.accept(fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
List<ChatMessage> 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<ModelData> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<FaceChatMessageEntity> listByConversation(@Param("conversationId") Long conversationId,
|
||||
@Param("cursor") Integer cursor,
|
||||
@Param("limit") Integer limit);
|
||||
|
||||
/**
|
||||
* 按 seq 倒序获取最近若干条消息,用于拼接上下文。
|
||||
*/
|
||||
List<FaceChatMessageEntity> listRecentByConversation(@Param("conversationId") Long conversationId,
|
||||
@Param("limit") Integer limit);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ycwl.basic.model.mobile.chat;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 消息列表响应。
|
||||
*/
|
||||
@Data
|
||||
public class ChatMessagePageResp {
|
||||
private List<ChatMessageVO> messages;
|
||||
/**
|
||||
* 下一条游标(返回最后一条 seq)。
|
||||
*/
|
||||
private Integer nextCursor;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> chunks;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> chunkConsumer);
|
||||
|
||||
/**
|
||||
* 拉取历史消息,cursor 为最后一条 seq,limit 为条数。
|
||||
*/
|
||||
ChatMessagePageResp listMessages(Long conversationId, Integer cursor, Integer limit, Long memberId);
|
||||
|
||||
/**
|
||||
* 关闭会话。
|
||||
*/
|
||||
void closeConversation(Long conversationId, Long memberId);
|
||||
}
|
||||
@@ -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<String> 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<FaceChatMessageEntity> list = messageMapper.listByConversation(conversationId, cursor, pageSize);
|
||||
List<ChatMessageVO> 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<String> 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<FaceChatMessageEntity> recentDesc = messageMapper.listRecentByConversation(conversationId, HISTORY_LIMIT);
|
||||
Collections.reverse(recentDesc); // 按时间升序
|
||||
List<ChatMessage> chatMessages = recentDesc.stream()
|
||||
.map(this::toChatMessage)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
CopyOnWriteArrayList<String> chunks = new CopyOnWriteArrayList<>();
|
||||
java.util.function.Consumer<String> 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();
|
||||
}
|
||||
}
|
||||
43
src/main/resources/mapper/FaceChatConversationMapper.xml
Normal file
43
src/main/resources/mapper/FaceChatConversationMapper.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ycwl.basic.mapper.FaceChatConversationMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity">
|
||||
<id column="id" property="id"/>
|
||||
<result column="face_id" property="faceId"/>
|
||||
<result column="member_id" property="memberId"/>
|
||||
<result column="status" property="status"/>
|
||||
<result column="model" property="model"/>
|
||||
<result column="created_at" property="createdAt"/>
|
||||
<result column="updated_at" property="updatedAt"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByFaceId" resultMap="BaseResultMap">
|
||||
select id, face_id, member_id, status, model, created_at, updated_at
|
||||
from face_chat_conversation
|
||||
where face_id = #{faceId}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="getById" resultMap="BaseResultMap">
|
||||
select id, face_id, member_id, status, model, created_at, updated_at
|
||||
from face_chat_conversation
|
||||
where id = #{id}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<insert id="insert">
|
||||
insert into face_chat_conversation
|
||||
(id, face_id, member_id, status, model, created_at, updated_at)
|
||||
values
|
||||
(#{id}, #{faceId}, #{memberId}, #{status}, #{model}, now(), now())
|
||||
</insert>
|
||||
|
||||
<update id="updateStatus">
|
||||
update face_chat_conversation
|
||||
set status = #{status}, updated_at = now()
|
||||
where id = #{id}
|
||||
</update>
|
||||
</mapper>
|
||||
55
src/main/resources/mapper/FaceChatMessageMapper.xml
Normal file
55
src/main/resources/mapper/FaceChatMessageMapper.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ycwl.basic.mapper.FaceChatMessageMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity">
|
||||
<id column="id" property="id"/>
|
||||
<result column="conversation_id" property="conversationId"/>
|
||||
<result column="face_id" property="faceId"/>
|
||||
<result column="seq" property="seq"/>
|
||||
<result column="role" property="role"/>
|
||||
<result column="content" property="content"/>
|
||||
<result column="trace_id" property="traceId"/>
|
||||
<result column="latency_ms" property="latencyMs"/>
|
||||
<result column="created_at" property="createdAt"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="maxSeqForUpdate" resultType="java.lang.Integer">
|
||||
select ifnull(max(seq), 0)
|
||||
from face_chat_message
|
||||
where conversation_id = #{conversationId}
|
||||
for update
|
||||
</select>
|
||||
|
||||
<insert id="insert">
|
||||
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())
|
||||
</insert>
|
||||
|
||||
<select id="listByConversation" resultMap="BaseResultMap">
|
||||
select id, conversation_id, face_id, seq, role, content, trace_id, latency_ms, created_at
|
||||
from face_chat_message
|
||||
where conversation_id = #{conversationId}
|
||||
<if test="cursor != null">
|
||||
and seq > #{cursor}
|
||||
</if>
|
||||
order by seq asc
|
||||
<if test="limit != null">
|
||||
limit #{limit}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="listRecentByConversation" resultMap="BaseResultMap">
|
||||
select id, conversation_id, face_id, seq, role, content, trace_id, latency_ms, created_at
|
||||
from face_chat_message
|
||||
where conversation_id = #{conversationId}
|
||||
order by seq desc
|
||||
<if test="limit != null">
|
||||
limit #{limit}
|
||||
</if>
|
||||
</select>
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user