AI助手
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package fun.nojava.module.blog.controller;
|
||||
|
||||
import fun.nojava.common.model.ApiResult;
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import fun.nojava.module.blog.dto.*;
|
||||
import fun.nojava.module.blog.service.AiChatService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "AI 助手")
|
||||
@RestController
|
||||
@RequestMapping("/api/app/ai")
|
||||
@RequiredArgsConstructor
|
||||
public class AiChatController {
|
||||
|
||||
private final AiChatService aiChatService;
|
||||
|
||||
@Operation(summary = "创建 AI 对话会话")
|
||||
@PostMapping("/sessions")
|
||||
public ApiResult<CreateAiChatSessionVO> createSession(@AuthenticationPrincipal Long userId) {
|
||||
return ApiResult.success(aiChatService.createSession(userId));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询当前用户的 AI 会话列表")
|
||||
@GetMapping("/sessions")
|
||||
public ApiResult<PageResult<AiChatSessionVO>> listSessions(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
return ApiResult.success(aiChatService.listSessions(userId, page, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询会话全部消息")
|
||||
@GetMapping("/sessions/{sessionId}/messages")
|
||||
public ApiResult<List<AiChatMessageVO>> listMessages(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@PathVariable Long sessionId) {
|
||||
return ApiResult.success(aiChatService.listMessages(userId, sessionId));
|
||||
}
|
||||
|
||||
@Operation(summary = "发送消息并获取 AI 回复")
|
||||
@PostMapping("/chat")
|
||||
public ApiResult<AiChatResultVO> chat(
|
||||
@AuthenticationPrincipal Long userId,
|
||||
@Valid @RequestBody AiChatSendDTO dto) {
|
||||
return ApiResult.success(aiChatService.chat(userId, dto));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class AiChatMessageVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String role;
|
||||
|
||||
private String content;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class AiChatResultVO {
|
||||
|
||||
private Long sessionId;
|
||||
|
||||
private String reply;
|
||||
|
||||
private Integer roundCount;
|
||||
|
||||
private Integer maxRounds;
|
||||
|
||||
private String model;
|
||||
|
||||
private Integer promptTokens;
|
||||
|
||||
private Integer completionTokens;
|
||||
|
||||
private Long costMillis;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AiChatSendDTO {
|
||||
|
||||
@NotNull(message = "会话 ID 不能为空")
|
||||
private Long sessionId;
|
||||
|
||||
@NotBlank(message = "问题不能为空")
|
||||
@Size(max = 2000, message = "问题长度不能超过 2000 字符")
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class AiChatSessionVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
|
||||
private Integer roundCount;
|
||||
|
||||
private LocalDateTime lastMessageAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package fun.nojava.module.blog.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class CreateAiChatSessionVO {
|
||||
|
||||
private Long sessionId;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fun.nojava.module.blog.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ai_chat_message")
|
||||
public class AiChatMessage {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long sessionId;
|
||||
|
||||
/** user / assistant */
|
||||
private String role;
|
||||
|
||||
private String content;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package fun.nojava.module.blog.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ai_chat_session")
|
||||
public class AiChatSession {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String title;
|
||||
|
||||
private Integer roundCount;
|
||||
|
||||
private LocalDateTime lastMessageAt;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fun.nojava.module.blog.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import fun.nojava.module.blog.entity.AiChatMessage;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AiChatMessageMapper extends BaseMapper<AiChatMessage> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fun.nojava.module.blog.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import fun.nojava.module.blog.entity.AiChatSession;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AiChatSessionMapper extends BaseMapper<AiChatSession> {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package fun.nojava.module.blog.service;
|
||||
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import fun.nojava.module.blog.dto.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AiChatService {
|
||||
|
||||
CreateAiChatSessionVO createSession(Long userId);
|
||||
|
||||
PageResult<AiChatSessionVO> listSessions(Long userId, int page, int size);
|
||||
|
||||
List<AiChatMessageVO> listMessages(Long userId, Long sessionId);
|
||||
|
||||
AiChatResultVO chat(Long userId, AiChatSendDTO dto);
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.*;
|
||||
import fun.nojava.module.blog.entity.AiChatMessage;
|
||||
import fun.nojava.module.blog.entity.AiChatSession;
|
||||
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
|
||||
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
|
||||
import fun.nojava.module.blog.service.AiChatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiChatServiceImpl implements AiChatService {
|
||||
|
||||
private static final int MAX_ROUNDS = 5;
|
||||
private static final int MAX_MESSAGE_LENGTH = 2000;
|
||||
private static final int MAX_REPLY_LENGTH = 4000;
|
||||
private static final int TITLE_MAX_LENGTH = 30;
|
||||
private static final String RATE_LIMIT_KEY_PREFIX = "ai_chat:rate:";
|
||||
private static final int CHAT_RATE_LIMIT_PER_MINUTE = 10;
|
||||
private static final String ROLE_USER = "user";
|
||||
private static final String ROLE_ASSISTANT = "assistant";
|
||||
private static final String REPLY_TRUNCATE_SUFFIX = "…";
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"你是 Jog 博客系统的 AI 助手,友好、简洁地用中文回答用户问题。"
|
||||
+ "涉及博客写作、Markdown、技术常识时可结合场景作答;无法确定时请诚实说明。"
|
||||
+ "不要编造系统内部未公开的配置或用户数据。"
|
||||
+ "当前为同一会话的多轮对话,请结合上文回答,避免重复已说过的内容。";
|
||||
|
||||
private final AiChatSessionMapper sessionMapper;
|
||||
private final AiChatMessageMapper messageMapper;
|
||||
private final AiSummaryServiceImpl aiSummaryService;
|
||||
private final DashScopeProperties properties;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
@Override
|
||||
public CreateAiChatSessionVO createSession(Long userId) {
|
||||
AiChatSession session = new AiChatSession();
|
||||
session.setUserId(userId);
|
||||
session.setTitle("");
|
||||
session.setRoundCount(0);
|
||||
sessionMapper.insert(session);
|
||||
return CreateAiChatSessionVO.builder()
|
||||
.sessionId(session.getId())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<AiChatSessionVO> listSessions(Long userId, int page, int size) {
|
||||
Page<AiChatSession> pageParam = new Page<>(page, size);
|
||||
LambdaQueryWrapper<AiChatSession> wrapper = Wrappers.<AiChatSession>lambdaQuery()
|
||||
.eq(AiChatSession::getUserId, userId)
|
||||
.orderByDesc(AiChatSession::getLastMessageAt)
|
||||
.orderByDesc(AiChatSession::getId);
|
||||
Page<AiChatSession> result = sessionMapper.selectPage(pageParam, wrapper);
|
||||
List<AiChatSessionVO> records = result.getRecords().stream()
|
||||
.map(this::toSessionVO)
|
||||
.toList();
|
||||
return new PageResult<>(records, result.getTotal(), page, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AiChatMessageVO> listMessages(Long userId, Long sessionId) {
|
||||
getSessionForUser(sessionId, userId);
|
||||
List<AiChatMessage> messages = listMessagesBySession(sessionId);
|
||||
return messages.stream().map(this::toMessageVO).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AiChatResultVO chat(Long userId, AiChatSendDTO dto) {
|
||||
String message = dto.getMessage() == null ? "" : dto.getMessage().trim();
|
||||
if (message.isEmpty() || message.length() > MAX_MESSAGE_LENGTH) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "问题长度须在 1~2000 字符之间");
|
||||
}
|
||||
|
||||
AiChatSession session = getSessionForUser(dto.getSessionId(), userId);
|
||||
if (session.getRoundCount() != null && session.getRoundCount() >= MAX_ROUNDS) {
|
||||
throw new BusinessException(ErrorCode.AI_CHAT_SESSION_FULL);
|
||||
}
|
||||
|
||||
if (!hasApiKey()) {
|
||||
throw new BusinessException(ErrorCode.AI_API_KEY_MISSING);
|
||||
}
|
||||
checkRateLimit(userId);
|
||||
|
||||
AiChatMessage lastMessage = getLastMessage(session.getId());
|
||||
boolean retryPending = lastMessage != null && ROLE_USER.equals(lastMessage.getRole());
|
||||
|
||||
if (!retryPending) {
|
||||
int nextSort = nextSortOrder(session.getId());
|
||||
AiChatMessage userMsg = new AiChatMessage();
|
||||
userMsg.setSessionId(session.getId());
|
||||
userMsg.setRole(ROLE_USER);
|
||||
userMsg.setContent(message);
|
||||
userMsg.setSortOrder(nextSort);
|
||||
messageMapper.insert(userMsg);
|
||||
|
||||
if (session.getRoundCount() == null || session.getRoundCount() == 0) {
|
||||
session.setTitle(buildTitle(message));
|
||||
sessionMapper.updateById(session);
|
||||
}
|
||||
} else if (!message.equals(lastMessage.getContent())) {
|
||||
lastMessage.setContent(message);
|
||||
messageMapper.updateById(lastMessage);
|
||||
}
|
||||
|
||||
List<AiChatMessage> history = listMessagesBySession(session.getId());
|
||||
Map<String, Object> requestBody = buildRequestBody(history);
|
||||
long startMillis = System.currentTimeMillis();
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
response = aiSummaryService.callModel(requestBody);
|
||||
} catch (ResourceAccessException ex) {
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
log.error("AI 助手调用超时, userId={}, sessionId={}", userId, session.getId());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
|
||||
}
|
||||
log.error("AI 助手网络异常, userId={}, sessionId={}, error={}", userId, session.getId(), ex.getMessage());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (RestClientResponseException ex) {
|
||||
log.error("AI 助手 HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.error("AI 助手调用失败, userId={}, sessionId={}, error={}", userId, session.getId(), ex.getMessage(), ex);
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
long costMillis = System.currentTimeMillis() - startMillis;
|
||||
String reply = truncateReply(extractContent(response));
|
||||
|
||||
int nextSort = nextSortOrder(session.getId());
|
||||
AiChatMessage assistantMsg = new AiChatMessage();
|
||||
assistantMsg.setSessionId(session.getId());
|
||||
assistantMsg.setRole(ROLE_ASSISTANT);
|
||||
assistantMsg.setContent(reply);
|
||||
assistantMsg.setSortOrder(nextSort);
|
||||
messageMapper.insert(assistantMsg);
|
||||
|
||||
int newRoundCount = (session.getRoundCount() == null ? 0 : session.getRoundCount()) + 1;
|
||||
session.setRoundCount(newRoundCount);
|
||||
session.setLastMessageAt(LocalDateTime.now());
|
||||
sessionMapper.updateById(session);
|
||||
|
||||
Integer promptTokens = null;
|
||||
Integer completionTokens = null;
|
||||
Object usageObj = response.get("usage");
|
||||
if (usageObj instanceof Map<?, ?> usage) {
|
||||
promptTokens = toInt(usage.get("prompt_tokens"));
|
||||
completionTokens = toInt(usage.get("completion_tokens"));
|
||||
}
|
||||
String model = (String) response.getOrDefault("model", properties.getModel());
|
||||
|
||||
log.info("AI 助手回复成功 userId={}, sessionId={}, roundCount={}, replyLen={}, cost={}ms",
|
||||
userId, session.getId(), newRoundCount, reply.length(), costMillis);
|
||||
|
||||
return AiChatResultVO.builder()
|
||||
.sessionId(session.getId())
|
||||
.reply(reply)
|
||||
.roundCount(newRoundCount)
|
||||
.maxRounds(MAX_ROUNDS)
|
||||
.model(model)
|
||||
.promptTokens(promptTokens)
|
||||
.completionTokens(completionTokens)
|
||||
.costMillis(costMillis)
|
||||
.build();
|
||||
}
|
||||
|
||||
private AiChatSession getSessionForUser(Long sessionId, Long userId) {
|
||||
AiChatSession session = sessionMapper.selectById(sessionId);
|
||||
if (session == null || !userId.equals(session.getUserId())) {
|
||||
throw new BusinessException(ErrorCode.NOT_FOUND, "会话不存在");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private List<AiChatMessage> listMessagesBySession(Long sessionId) {
|
||||
return messageMapper.selectList(Wrappers.<AiChatMessage>lambdaQuery()
|
||||
.eq(AiChatMessage::getSessionId, sessionId)
|
||||
.orderByAsc(AiChatMessage::getSortOrder));
|
||||
}
|
||||
|
||||
private AiChatMessage getLastMessage(Long sessionId) {
|
||||
return messageMapper.selectOne(Wrappers.<AiChatMessage>lambdaQuery()
|
||||
.eq(AiChatMessage::getSessionId, sessionId)
|
||||
.orderByDesc(AiChatMessage::getSortOrder)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private int nextSortOrder(Long sessionId) {
|
||||
AiChatMessage last = getLastMessage(sessionId);
|
||||
return last == null ? 1 : last.getSortOrder() + 1;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildRequestBody(List<AiChatMessage> history) {
|
||||
List<Map<String, String>> messages = new ArrayList<>();
|
||||
messages.add(Map.of("role", "system", "content", SYSTEM_PROMPT));
|
||||
|
||||
List<AiChatMessage> trimmed = trimHistoryByMaxInput(history);
|
||||
for (AiChatMessage m : trimmed) {
|
||||
messages.add(Map.of("role", m.getRole(), "content", m.getContent()));
|
||||
}
|
||||
|
||||
return Map.of(
|
||||
"model", properties.getModel(),
|
||||
"messages", messages,
|
||||
"temperature", 0.7,
|
||||
"top_p", 0.9
|
||||
);
|
||||
}
|
||||
|
||||
List<AiChatMessage> trimHistoryByMaxInput(List<AiChatMessage> history) {
|
||||
if (history == null || history.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
int maxInput = properties.getMaxInput();
|
||||
List<AiChatMessage> working = new ArrayList<>(history);
|
||||
while (!working.isEmpty() && totalContentLength(working) > maxInput) {
|
||||
if (working.size() <= 2) {
|
||||
AiChatMessage last = working.get(working.size() - 1);
|
||||
if (ROLE_USER.equals(last.getRole()) && last.getContent().length() > maxInput) {
|
||||
last.setContent(last.getContent().substring(0, maxInput));
|
||||
}
|
||||
break;
|
||||
}
|
||||
working.remove(0);
|
||||
if (!working.isEmpty() && ROLE_ASSISTANT.equals(working.get(0).getRole())) {
|
||||
working.remove(0);
|
||||
}
|
||||
}
|
||||
return working;
|
||||
}
|
||||
|
||||
private int totalContentLength(List<AiChatMessage> messages) {
|
||||
int len = SYSTEM_PROMPT.length();
|
||||
for (AiChatMessage m : messages) {
|
||||
len += m.getContent() == null ? 0 : m.getContent().length();
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
private String buildTitle(String firstMessage) {
|
||||
String t = firstMessage.trim();
|
||||
if (t.length() <= TITLE_MAX_LENGTH) {
|
||||
return t;
|
||||
}
|
||||
return t.substring(0, TITLE_MAX_LENGTH) + "…";
|
||||
}
|
||||
|
||||
private String truncateReply(String text) {
|
||||
if (text.length() <= MAX_REPLY_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, MAX_REPLY_LENGTH - 1) + REPLY_TRUNCATE_SUFFIX;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String extractContent(Map<String, Object> response) {
|
||||
if (response == null) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
Map<String, Object> msg = (Map<String, Object>) choices.get(0).get("message");
|
||||
String content = msg == null ? null : (String) msg.get("content");
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
private AiChatSessionVO toSessionVO(AiChatSession session) {
|
||||
return AiChatSessionVO.builder()
|
||||
.id(session.getId())
|
||||
.title(session.getTitle() == null || session.getTitle().isBlank() ? "新会话" : session.getTitle())
|
||||
.roundCount(session.getRoundCount())
|
||||
.lastMessageAt(session.getLastMessageAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private AiChatMessageVO toMessageVO(AiChatMessage message) {
|
||||
return AiChatMessageVO.builder()
|
||||
.id(message.getId())
|
||||
.role(message.getRole())
|
||||
.content(message.getContent())
|
||||
.sortOrder(message.getSortOrder())
|
||||
.createdAt(message.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean hasApiKey() {
|
||||
return properties.getApiKey() != null && !properties.getApiKey().isBlank();
|
||||
}
|
||||
|
||||
private void checkRateLimit(Long userId) {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
String key = RATE_LIMIT_KEY_PREFIX + userId;
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
if (count != null && count == 1L) {
|
||||
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
|
||||
}
|
||||
if (count != null && count > CHAT_RATE_LIMIT_PER_MINUTE) {
|
||||
log.warn("AI 助手限流命中 userId={}, count={}", userId, count);
|
||||
throw new BusinessException(ErrorCode.RATE_LIMITED);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer toInt(Object value) {
|
||||
if (value instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import fun.nojava.module.blog.entity.AiChatMessage;
|
||||
import fun.nojava.module.blog.entity.AiChatSession;
|
||||
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
|
||||
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 内存版会话/消息存储,供集成测试与 LiveIT 模拟持久化,不依赖真实数据库。
|
||||
*
|
||||
* selectList / selectOne / selectPage 返回内存中全部数据(不分页不按 Wrapper 条件过滤),
|
||||
* 因为集成测试中每个用例只操作单一用户、单一会话,全量返回不影响语义正确性。
|
||||
*/
|
||||
final class AiChatInMemoryTestSupport {
|
||||
|
||||
private final AtomicLong sessionIdSeq = new AtomicLong(1);
|
||||
private final AtomicLong messageIdSeq = new AtomicLong(1);
|
||||
private final Map<Long, AiChatSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<Long, List<AiChatMessage>> messagesBySession = new ConcurrentHashMap<>();
|
||||
|
||||
void wire(AiChatSessionMapper sessionMapper, AiChatMessageMapper messageMapper) {
|
||||
// --- session ---
|
||||
lenient().doAnswer(inv -> {
|
||||
AiChatSession s = inv.getArgument(0);
|
||||
if (s.getId() == null) {
|
||||
s.setId(sessionIdSeq.getAndIncrement());
|
||||
}
|
||||
if (s.getDeleted() == null) s.setDeleted(0);
|
||||
if (s.getRoundCount() == null) s.setRoundCount(0);
|
||||
if (s.getTitle() == null) s.setTitle("");
|
||||
sessions.put(s.getId(), s);
|
||||
return 1;
|
||||
}).when(sessionMapper).insert(any(AiChatSession.class));
|
||||
|
||||
lenient().when(sessionMapper.selectById(anyLong())).thenAnswer(inv -> {
|
||||
AiChatSession s = sessions.get(inv.getArgument(0));
|
||||
return (s != null && Objects.equals(s.getDeleted(), 0)) ? s : null;
|
||||
});
|
||||
|
||||
lenient().doAnswer(inv -> {
|
||||
AiChatSession s = inv.getArgument(0);
|
||||
sessions.put(s.getId(), s);
|
||||
return 1;
|
||||
}).when(sessionMapper).updateById(any(AiChatSession.class));
|
||||
|
||||
lenient().when(sessionMapper.selectPage(any(Page.class), any(Wrapper.class))).thenAnswer(inv -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Page<AiChatSession> page = inv.getArgument(0);
|
||||
List<AiChatSession> all = sessions.values().stream()
|
||||
.filter(s -> Objects.equals(s.getDeleted(), 0))
|
||||
.sorted(Comparator
|
||||
.comparing(AiChatSession::getLastMessageAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(AiChatSession::getId, Comparator.reverseOrder()))
|
||||
.collect(Collectors.toList());
|
||||
long current = page.getCurrent();
|
||||
long size = page.getSize();
|
||||
int from = (int) Math.max(0, (current - 1) * size);
|
||||
int to = (int) Math.min(all.size(), from + (int) size);
|
||||
List<AiChatSession> slice = from >= all.size() ? List.of() : all.subList(from, to);
|
||||
Page<AiChatSession> result = new Page<>(current, size, all.size());
|
||||
result.setRecords(slice);
|
||||
return result;
|
||||
});
|
||||
|
||||
// --- message ---
|
||||
lenient().doAnswer(inv -> {
|
||||
AiChatMessage m = inv.getArgument(0);
|
||||
if (m.getId() == null) {
|
||||
m.setId(messageIdSeq.getAndIncrement());
|
||||
}
|
||||
if (m.getCreatedAt() == null) {
|
||||
m.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
messagesBySession.computeIfAbsent(m.getSessionId(), k -> new ArrayList<>()).add(m);
|
||||
return 1;
|
||||
}).when(messageMapper).insert(any(AiChatMessage.class));
|
||||
|
||||
lenient().doAnswer(inv -> {
|
||||
AiChatMessage m = inv.getArgument(0);
|
||||
List<AiChatMessage> list = messagesBySession.get(m.getSessionId());
|
||||
if (list != null) {
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
if (list.get(i).getId().equals(m.getId())) {
|
||||
list.set(i, m);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}).when(messageMapper).updateById(any(AiChatMessage.class));
|
||||
|
||||
// 返回所有会话的所有消息(测试中只有一个会话在用)
|
||||
lenient().when(messageMapper.selectList(any(Wrapper.class))).thenAnswer(inv ->
|
||||
messagesBySession.values().stream()
|
||||
.flatMap(Collection::stream)
|
||||
.sorted(Comparator.comparing(AiChatMessage::getSortOrder))
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
// 返回所有消息中 sortOrder 最大的那条
|
||||
lenient().when(messageMapper.selectOne(any(Wrapper.class))).thenAnswer(inv ->
|
||||
messagesBySession.values().stream()
|
||||
.flatMap(Collection::stream)
|
||||
.max(Comparator.comparing(AiChatMessage::getSortOrder))
|
||||
.orElse(null));
|
||||
}
|
||||
|
||||
List<AiChatMessage> messagesOf(Long sessionId) {
|
||||
return messagesBySession.getOrDefault(sessionId, List.of());
|
||||
}
|
||||
|
||||
void clear() {
|
||||
sessions.clear();
|
||||
messagesBySession.clear();
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.AiChatSendDTO;
|
||||
import fun.nojava.module.blog.dto.AiChatResultVO;
|
||||
import fun.nojava.module.blog.dto.CreateAiChatSessionVO;
|
||||
import fun.nojava.module.blog.entity.AiChatMessage;
|
||||
import fun.nojava.module.blog.entity.AiChatSession;
|
||||
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
|
||||
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AiChatServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private AiChatSessionMapper sessionMapper;
|
||||
@Mock
|
||||
private AiChatMessageMapper messageMapper;
|
||||
@Mock
|
||||
private AiSummaryServiceImpl aiSummaryService;
|
||||
@Mock
|
||||
private StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
private ValueOperations<String, String> valueOps;
|
||||
|
||||
private AiChatServiceImpl service;
|
||||
|
||||
private DashScopeProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new DashScopeProperties();
|
||||
properties.setApiKey("test-key");
|
||||
properties.setModel("qwen-turbo");
|
||||
properties.setMaxInput(8000);
|
||||
service = new AiChatServiceImpl(sessionMapper, messageMapper, aiSummaryService, properties, redisTemplate);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void stubRateLimitOk() {
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
when(valueOps.increment(anyString())).thenReturn(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("创建会话应返回 sessionId")
|
||||
void createSession_shouldReturnId() {
|
||||
doAnswer(inv -> {
|
||||
AiChatSession s = inv.getArgument(0);
|
||||
s.setId(100L);
|
||||
return 1;
|
||||
}).when(sessionMapper).insert(any(AiChatSession.class));
|
||||
|
||||
CreateAiChatSessionVO vo = service.createSession(1L);
|
||||
assertEquals(100L, vo.getSessionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("会话已满 5 轮应抛出 AI_CHAT_SESSION_FULL")
|
||||
void chat_shouldRejectWhenSessionFull() {
|
||||
AiChatSession session = new AiChatSession();
|
||||
session.setId(1L);
|
||||
session.setUserId(1L);
|
||||
session.setRoundCount(5);
|
||||
when(sessionMapper.selectById(1L)).thenReturn(session);
|
||||
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(1L);
|
||||
dto.setMessage("继续问一个问题看看会怎样");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(1L, dto));
|
||||
assertEquals(ErrorCode.AI_CHAT_SESSION_FULL.getCode(), ex.getCode());
|
||||
verify(messageMapper, never()).insert(any(AiChatMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("越权会话应抛出 NOT_FOUND")
|
||||
void chat_shouldRejectForeignSession() {
|
||||
AiChatSession session = new AiChatSession();
|
||||
session.setId(1L);
|
||||
session.setUserId(2L);
|
||||
session.setRoundCount(0);
|
||||
when(sessionMapper.selectById(1L)).thenReturn(session);
|
||||
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(1L);
|
||||
dto.setMessage("这是一条足够长的测试问题用于 AI 助手单元测试");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(1L, dto));
|
||||
assertEquals(ErrorCode.NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("首条消息成功应落库 user/assistant 且 roundCount=1")
|
||||
void chat_shouldSucceedOnFirstMessage() throws Exception {
|
||||
stubRateLimitOk();
|
||||
|
||||
AiChatSession session = new AiChatSession();
|
||||
session.setId(10L);
|
||||
session.setUserId(1L);
|
||||
session.setRoundCount(0);
|
||||
session.setTitle("");
|
||||
when(sessionMapper.selectById(10L)).thenReturn(session);
|
||||
|
||||
AiChatMessage userMsg = new AiChatMessage();
|
||||
userMsg.setRole("user");
|
||||
userMsg.setContent("什么是 REST API?请简要说明。");
|
||||
userMsg.setSortOrder(1);
|
||||
when(messageMapper.selectOne(any())).thenReturn(null, userMsg);
|
||||
when(messageMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(userMsg));
|
||||
|
||||
Map<String, Object> modelResponse = Map.of(
|
||||
"choices", List.of(Map.of("message", Map.of("content", "REST 是一种架构风格……"))),
|
||||
"model", "qwen-turbo",
|
||||
"usage", Map.of("prompt_tokens", 10, "completion_tokens", 20)
|
||||
);
|
||||
when(aiSummaryService.callModel(any())).thenReturn(modelResponse);
|
||||
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(10L);
|
||||
dto.setMessage("什么是 REST API?请简要说明。");
|
||||
|
||||
AiChatResultVO result = service.chat(1L, dto);
|
||||
|
||||
assertEquals(1, result.getRoundCount());
|
||||
assertEquals("REST 是一种架构风格……", result.getReply());
|
||||
verify(messageMapper, times(2)).insert(any(AiChatMessage.class));
|
||||
verify(sessionMapper, atLeastOnce()).updateById(any(AiChatSession.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("限流超过 10 次应抛出 RATE_LIMITED")
|
||||
void chat_shouldRateLimit() {
|
||||
stubRateLimitOk();
|
||||
when(valueOps.increment("ai_chat:rate:1")).thenReturn(11L);
|
||||
|
||||
AiChatSession session = new AiChatSession();
|
||||
session.setId(1L);
|
||||
session.setUserId(1L);
|
||||
session.setRoundCount(0);
|
||||
when(sessionMapper.selectById(1L)).thenReturn(session);
|
||||
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(1L);
|
||||
dto.setMessage("这是一条足够长的测试问题用于 AI 助手单元测试");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(1L, dto));
|
||||
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
|
||||
verify(aiSummaryService, never()).callModel(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("trimHistoryByMaxInput 应从最早消息丢弃")
|
||||
void trimHistoryByMaxInput_shouldDropOldest() {
|
||||
properties.setMaxInput(50);
|
||||
List<AiChatMessage> history = new ArrayList<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
AiChatMessage u = new AiChatMessage();
|
||||
u.setRole("user");
|
||||
u.setContent("用户消息内容比较长的一段文字" + i);
|
||||
AiChatMessage a = new AiChatMessage();
|
||||
a.setRole("assistant");
|
||||
a.setContent("助手回复内容也比较长的一段文字" + i);
|
||||
history.add(u);
|
||||
history.add(a);
|
||||
}
|
||||
List<AiChatMessage> trimmed = service.trimHistoryByMaxInput(history);
|
||||
assertTrue(trimmed.size() < history.size());
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.*;
|
||||
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
|
||||
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 基于内存 Mapper 的集成测试:覆盖创建会话 → 多轮对话 → 列表/消息查询 → 5 轮上限,
|
||||
* 不依赖真实数据库;大模型调用使用 Mock。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("integration")
|
||||
class AiChatServiceIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 42L;
|
||||
|
||||
@Mock
|
||||
private AiChatSessionMapper sessionMapper;
|
||||
@Mock
|
||||
private AiChatMessageMapper messageMapper;
|
||||
@Mock
|
||||
private AiSummaryServiceImpl aiSummaryService;
|
||||
@Mock
|
||||
private StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
private ValueOperations<String, String> valueOps;
|
||||
|
||||
private final AiChatInMemoryTestSupport store = new AiChatInMemoryTestSupport();
|
||||
private AiChatServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store.clear();
|
||||
store.wire(sessionMapper, messageMapper);
|
||||
|
||||
DashScopeProperties properties = new DashScopeProperties();
|
||||
properties.setApiKey("integration-test-key");
|
||||
properties.setModel("qwen-turbo");
|
||||
properties.setMaxInput(8000);
|
||||
|
||||
service = new AiChatServiceImpl(sessionMapper, messageMapper, aiSummaryService, properties, redisTemplate);
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
lenient().when(valueOps.increment(any())).thenReturn(1L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-CHAT-IT-01: 完整流程 3 轮对话后消息与 roundCount 正确")
|
||||
void shouldCompleteThreeRoundConversation() throws Exception {
|
||||
stubModelReply("回复一", "回复二", "回复三");
|
||||
|
||||
CreateAiChatSessionVO created = service.createSession(USER_ID);
|
||||
Long sessionId = created.getSessionId();
|
||||
|
||||
chat(sessionId, "问题一:什么是 REST?");
|
||||
chat(sessionId, "问题二:它和 GraphQL 区别?");
|
||||
AiChatResultVO r3 = chat(sessionId, "问题三:请用一句话总结");
|
||||
|
||||
assertEquals(3, r3.getRoundCount());
|
||||
assertEquals(5, r3.getMaxRounds());
|
||||
|
||||
List<AiChatMessageVO> messages = service.listMessages(USER_ID, sessionId);
|
||||
assertEquals(6, messages.size());
|
||||
assertEquals("user", messages.get(0).getRole());
|
||||
assertEquals("assistant", messages.get(1).getRole());
|
||||
assertEquals("问题三:请用一句话总结", messages.get(4).getContent());
|
||||
assertEquals("回复三", messages.get(5).getContent());
|
||||
|
||||
PageResult<AiChatSessionVO> page = service.listSessions(USER_ID, 1, 50);
|
||||
assertEquals(1, page.getTotal());
|
||||
assertEquals(3, page.getRecords().get(0).getRoundCount());
|
||||
assertFalse(page.getRecords().get(0).getTitle().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-CHAT-IT-02: 第 6 次发送应触发会话已满")
|
||||
void shouldRejectSixthRound() throws Exception {
|
||||
stubModelReply("r1", "r2", "r3", "r4", "r5");
|
||||
|
||||
Long sessionId = service.createSession(USER_ID).getSessionId();
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
chat(sessionId, "第 " + i + " 轮问题:请简要回答测试内容。");
|
||||
}
|
||||
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(sessionId);
|
||||
dto.setMessage("第 6 轮不应成功");
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(USER_ID, dto));
|
||||
assertEquals(ErrorCode.AI_CHAT_SESSION_FULL.getCode(), ex.getCode());
|
||||
assertEquals(10, store.messagesOf(sessionId).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AI-CHAT-IT-03: 模型失败后重试不重复插入 user 消息")
|
||||
void shouldRetryWithoutDuplicateUserMessage() throws Exception {
|
||||
Long sessionId = service.createSession(USER_ID).getSessionId();
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(sessionId);
|
||||
dto.setMessage("失败后重试的同一问题内容");
|
||||
|
||||
when(aiSummaryService.callModel(any()))
|
||||
.thenThrow(new RuntimeException("simulated model failure"))
|
||||
.thenReturn(modelResponse("重试成功回复"));
|
||||
|
||||
assertThrows(BusinessException.class, () -> service.chat(USER_ID, dto));
|
||||
assertEquals(1, store.messagesOf(sessionId).size());
|
||||
assertEquals("user", store.messagesOf(sessionId).get(0).getRole());
|
||||
|
||||
AiChatResultVO result = service.chat(USER_ID, dto);
|
||||
assertEquals(1, result.getRoundCount());
|
||||
assertEquals(2, store.messagesOf(sessionId).size());
|
||||
// 验证重试未重复插入 user(仅一条 user 消息)
|
||||
long userMsgCount = store.messagesOf(sessionId).stream()
|
||||
.filter(m -> "user".equals(m.getRole()))
|
||||
.count();
|
||||
assertEquals(1, userMsgCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-AI-CHAT-IT-04: 用户只能访问自己的会话消息")
|
||||
void shouldIsolateSessionsByUser() {
|
||||
Long sessionId = service.createSession(USER_ID).getSessionId();
|
||||
assertThrows(BusinessException.class, () -> service.listMessages(999L, sessionId));
|
||||
}
|
||||
|
||||
private AiChatResultVO chat(Long sessionId, String message) throws Exception {
|
||||
AiChatSendDTO dto = new AiChatSendDTO();
|
||||
dto.setSessionId(sessionId);
|
||||
dto.setMessage(message);
|
||||
return service.chat(USER_ID, dto);
|
||||
}
|
||||
|
||||
private void stubModelReply(String... replies) throws Exception {
|
||||
var stub = when(aiSummaryService.callModel(any()));
|
||||
for (String reply : replies) {
|
||||
stub = stub.thenReturn(modelResponse(reply));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> modelResponse(String content) {
|
||||
return Map.of(
|
||||
"choices", List.of(Map.of("message", Map.of("content", content))),
|
||||
"model", "qwen-turbo",
|
||||
"usage", Map.of("prompt_tokens", 5, "completion_tokens", 10)
|
||||
);
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import fun.nojava.module.blog.config.DashScopeProperties;
|
||||
import fun.nojava.module.blog.dto.AiChatMessageVO;
|
||||
import fun.nojava.module.blog.dto.AiChatResultVO;
|
||||
import fun.nojava.module.blog.dto.AiChatSendDTO;
|
||||
import fun.nojava.module.blog.dto.CreateAiChatSessionVO;
|
||||
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
|
||||
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 真实调用 DashScope + 内存持久化的联调测试。
|
||||
* <p>
|
||||
* 仅在 {@code DASHSCOPE_API_KEY} 存在时运行;不连接真实 MySQL。
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+")
|
||||
class AiChatServiceLiveIT {
|
||||
|
||||
private static final long FAKE_USER_ID = 9_999_999_997L;
|
||||
|
||||
private DashScopeProperties properties;
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOps;
|
||||
private AiSummaryServiceImpl aiSummaryService;
|
||||
private AiChatSessionMapper sessionMapper;
|
||||
private AiChatMessageMapper messageMapper;
|
||||
private AiChatServiceImpl service;
|
||||
private final AiChatInMemoryTestSupport store = new AiChatInMemoryTestSupport();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
String apiKey = System.getenv("DASHSCOPE_API_KEY");
|
||||
assertNotNull(apiKey, "DASHSCOPE_API_KEY 未设置");
|
||||
|
||||
properties = new DashScopeProperties();
|
||||
properties.setApiKey(apiKey);
|
||||
properties.setBaseUrl(envOrDefault("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"));
|
||||
properties.setModel(envOrDefault("DASHSCOPE_MODEL", "qwen-turbo"));
|
||||
properties.setTimeout(30000);
|
||||
properties.setMaxInput(8000);
|
||||
|
||||
redisTemplate = mock(StringRedisTemplate.class);
|
||||
valueOps = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
when(valueOps.increment(anyString())).thenReturn(1L);
|
||||
|
||||
sessionMapper = mock(AiChatSessionMapper.class);
|
||||
messageMapper = mock(AiChatMessageMapper.class);
|
||||
store.clear();
|
||||
store.wire(sessionMapper, messageMapper);
|
||||
|
||||
aiSummaryService = new AiSummaryServiceImpl(properties, redisTemplate);
|
||||
aiSummaryService.init();
|
||||
service = new AiChatServiceImpl(sessionMapper, messageMapper, aiSummaryService, properties, redisTemplate);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
store.clear();
|
||||
if (redisTemplate != null) {
|
||||
try {
|
||||
redisTemplate.delete("ai_chat:rate:" + FAKE_USER_ID);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("live")
|
||||
@DisplayName("TC-AI-CHAT-LIVE-01: 真实调用应完成 2 轮对话并持久化消息")
|
||||
void shouldChatTwoRoundsWithRealModel() {
|
||||
CreateAiChatSessionVO session = service.createSession(FAKE_USER_ID);
|
||||
Long sessionId = session.getSessionId();
|
||||
|
||||
AiChatSendDTO dto1 = new AiChatSendDTO();
|
||||
dto1.setSessionId(sessionId);
|
||||
dto1.setMessage("用一句话解释什么是 Markdown。");
|
||||
|
||||
AiChatResultVO r1 = service.chat(FAKE_USER_ID, dto1);
|
||||
assertNotNull(r1.getReply());
|
||||
assertFalse(r1.getReply().isBlank());
|
||||
assertEquals(1, r1.getRoundCount());
|
||||
|
||||
AiChatSendDTO dto2 = new AiChatSendDTO();
|
||||
dto2.setSessionId(sessionId);
|
||||
dto2.setMessage("它常用于什么场景?请结合上一句回答,不要超过 80 字。");
|
||||
|
||||
AiChatResultVO r2 = service.chat(FAKE_USER_ID, dto2);
|
||||
assertEquals(2, r2.getRoundCount());
|
||||
assertNotNull(r2.getReply());
|
||||
|
||||
List<AiChatMessageVO> messages = service.listMessages(FAKE_USER_ID, sessionId);
|
||||
assertEquals(4, messages.size());
|
||||
assertEquals("user", messages.get(0).getRole());
|
||||
assertEquals("assistant", messages.get(1).getRole());
|
||||
|
||||
System.out.println("=== AI Chat Live Test ===");
|
||||
System.out.println("sessionId = " + sessionId);
|
||||
System.out.println("roundCount = " + r2.getRoundCount());
|
||||
System.out.println("model = " + r2.getModel());
|
||||
System.out.println("costMillis = " + r2.getCostMillis());
|
||||
System.out.println("reply1 = " + r1.getReply());
|
||||
System.out.println("reply2 = " + r2.getReply());
|
||||
System.out.println("=========================");
|
||||
}
|
||||
|
||||
private String envOrDefault(String name, String defaultValue) {
|
||||
String value = System.getenv(name);
|
||||
return (value == null || value.isBlank()) ? defaultValue : value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user