feat(chat): 实现人脸智能聊天核心功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good

- 新增小程序人脸聊天控制器 AppChatController,支持会话创建、消息收发、历史查询及会话关闭
- 集成智谱 GLM 模型客户端 GlmClient,支持流式文本生成与回调
- 新增聊天会话与消息实体类及 MyBatis 映射,实现数据持久化
- 提供 FaceChatService 接口及实现,封装聊天业务逻辑包括同步/流式消息发送
- 引入 zai-sdk 依赖以支持调用智谱 AI 大模型能力
- 支持基于人脸 ID 的唯一会话管理与用户权限校验
- 消息记录包含角色、内容、追踪 ID 及延迟信息,便于调试与分析
This commit is contained in:
2025-12-11 17:45:49 +08:00
parent 6e7b4729a8
commit 3b11ddef6a
18 changed files with 811 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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();
}
}