This commit is contained in:
nojava
2026-05-23 23:33:27 +08:00
parent 8b23d02531
commit 8b638f29c6
27 changed files with 2749 additions and 1 deletions
@@ -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);
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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)
);
}
}
@@ -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;
}
}