From 4c2a787d8151a3a1ec320cb5b4d83f8c9f413826 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Sat, 24 Oct 2026 17:37:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=86=99=E4=BD=9C/=E7=94=9F?= =?UTF-8?q?=E5=9B=BE/=E5=88=9B=E6=84=8F/=E6=B4=BB=E5=8A=A8=E7=AD=96?= =?UTF-8?q?=E5=88=92=E4=B9=9F=E4=BF=9D=E5=AD=98=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai_chat_list 新增 chat_type / params,旧数据默认 chat - 新增 AiGenerationRecorder:成功后同事务写 list + history,末帧回传 chatId - 聊天改为成功收尾才建行,失败/中止不再留下空记录 - 写作(含自由创作)、生图、创意助手、活动策划四处接入,软失败与断流不入库 - 历史列表只取最近 30 条(SQL LIMIT),并强制按登录用户过滤 - 修复历史排序用了不存在的 sort_number 列(多行会 NPE) - 新增 4 个测试类:记录器规则、排序回归、创意成功/软失败、30 条上限 --- sql/20261024_add_ai_chat_type_params.sql | 50 +++ .../com/gxwebsoft/ai/constant/AiChatType.java | 28 ++ .../controller/AiChatHistoryController.java | 9 + .../ai/controller/AiChatListController.java | 15 +- .../gxwebsoft/ai/controller/AiController.java | 323 +++++++++++++++--- .../com/gxwebsoft/ai/entity/AiChatList.java | 6 + .../gxwebsoft/ai/mapper/AiChatListMapper.java | 12 + .../ai/mapper/xml/AiChatListMapper.xml | 14 + .../gxwebsoft/ai/param/AiChatListParam.java | 4 + .../ai/service/AiChatListService.java | 16 + .../ai/service/AiGenerationRecorder.java | 102 ++++++ .../impl/AiChatHistoryServiceImpl.java | 7 +- .../service/impl/AiChatListServiceImpl.java | 6 + .../AiChatListHistoryLimitTest.java | 89 +++++ .../AiCreativeHistoryRecordTest.java | 178 ++++++++++ .../AiChatHistorySortRegressionTest.java | 44 +++ .../ai/service/AiGenerationRecorderTest.java | 150 ++++++++ tuanwei.sql | 5 +- 18 files changed, 1012 insertions(+), 46 deletions(-) create mode 100644 sql/20261024_add_ai_chat_type_params.sql create mode 100644 src/main/java/com/gxwebsoft/ai/constant/AiChatType.java create mode 100644 src/main/java/com/gxwebsoft/ai/service/AiGenerationRecorder.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiChatListHistoryLimitTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeHistoryRecordTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/service/AiChatHistorySortRegressionTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/service/AiGenerationRecorderTest.java diff --git a/sql/20261024_add_ai_chat_type_params.sql b/sql/20261024_add_ai_chat_type_params.sql new file mode 100644 index 0000000..cc8f866 --- /dev/null +++ b/sql/20261024_add_ai_chat_type_params.sql @@ -0,0 +1,50 @@ +-- AI 历史记录:写作 / 生图 / 创意 / 活动策划也入库 +-- +-- 背景:ai_chat_list 原先只服务于聊天,没有区分记录来源的列,也没有保存生成参数。 +-- 现在「生成记录」与聊天的每轮提问共用这张表:写作(文稿生成)、生图(创意助手 +-- mode=image)、创意助手(独立页)、活动策划大纲各写一条,需要 chat_type 区分, +-- 并保存入参快照 params,供「按原参数再来一次」与回看时展示。 +-- +-- 决策见 docs/adr/0001-ai-generation-records.md,三端契约见 docs/ai-history-contract.md。 +-- +-- 幂等:重复执行只会命中 SELECT 1。 + +SET @db = DATABASE(); + +SET @sql = IF( + EXISTS( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'ai_chat_list' AND COLUMN_NAME = 'chat_type' + ), + 'SELECT 1', + 'ALTER TABLE ai_chat_list ADD COLUMN chat_type VARCHAR(32) NOT NULL DEFAULT ''chat'' COMMENT ''记录类型:chat/writing/image/creative/activity_outline'' AFTER title' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'ai_chat_list' AND COLUMN_NAME = 'params' + ), + 'SELECT 1', + 'ALTER TABLE ai_chat_list ADD COLUMN params TEXT COMMENT ''生成参数快照(JSON)'' AFTER chat_type' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 历史面板按「当前用户 + 类型 + 时间」取数,原表只有主键索引。 +SET @sql = IF( + EXISTS( + SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'ai_chat_list' + AND INDEX_NAME = 'idx_ai_chat_list_user_type_time' + ), + 'SELECT 1', + 'ALTER TABLE ai_chat_list ADD INDEX idx_ai_chat_list_user_type_time (user_id, chat_type, create_time)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/main/java/com/gxwebsoft/ai/constant/AiChatType.java b/src/main/java/com/gxwebsoft/ai/constant/AiChatType.java new file mode 100644 index 0000000..0aacd5c --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/constant/AiChatType.java @@ -0,0 +1,28 @@ +package com.gxwebsoft.ai.constant; + +/** + * 「历史记录」的类型取值,与 {@code ai_chat_list.chat_type} 一一对应。 + * + *

契约见 {@code docs/ai-history-contract.md}:类型由后端在生成成功时判定, + * 前端不传;旧数据默认 {@link #CHAT}。 + */ +public final class AiChatType { + + /** 聊天:助手页聊天模式,一轮提问一条记录 */ + public static final String CHAT = "chat"; + + /** 写作:助手页写作模式、两端文稿工作台、写作自由创作 */ + public static final String WRITING = "writing"; + + /** 生图:助手页图片模式(mode=image) */ + public static final String IMAGE = "image"; + + /** 创意助手:独立创意助手页(不传 mode) */ + public static final String CREATIVE = "creative"; + + /** 活动策划:活动策划大纲页 */ + public static final String ACTIVITY_OUTLINE = "activity_outline"; + + private AiChatType() { + } +} diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiChatHistoryController.java b/src/main/java/com/gxwebsoft/ai/controller/AiChatHistoryController.java index 277aedc..9c48995 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiChatHistoryController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiChatHistoryController.java @@ -34,6 +34,9 @@ public class AiChatHistoryController extends BaseController { @ApiOperation("分页查询") @GetMapping("/page") public ApiResult> page(AiChatHistoryParam param) { + if (getLoginUserId() == null) { + return new ApiResult>(1, "请先登录"); + } // 使用关联查询 return success(aiChatHistoryService.pageRel(param)); } @@ -41,6 +44,12 @@ public class AiChatHistoryController extends BaseController { @ApiOperation("查询全部") @GetMapping() public ApiResult> list(AiChatHistoryParam param) { + Integer loginUserId = getLoginUserId(); + if (loginUserId == null) { + return success(java.util.Collections.emptyList()); + } + // 只能读自己的记录:忽略客户端传入的 userId。 + param.setUserId(loginUserId); // 使用关联查询 return success(aiChatHistoryService.listRel(param)); } diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiChatListController.java b/src/main/java/com/gxwebsoft/ai/controller/AiChatListController.java index 12a04bf..260d36e 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiChatListController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiChatListController.java @@ -34,6 +34,10 @@ public class AiChatListController extends BaseController { @ApiOperation("分页查询") @GetMapping("/page") public ApiResult> page(AiChatListParam param) { + // 管理端视角:允许按 userId 过滤,但不允许匿名读取(历史正文含长文与图片)。 + if (getLoginUserId() == null) { + return new ApiResult>(1, "请先登录"); + } // 使用关联查询 return success(aiChatListService.pageRel(param)); } @@ -41,8 +45,15 @@ public class AiChatListController extends BaseController { @ApiOperation("查询全部") @GetMapping() public ApiResult> list(AiChatListParam param) { - // 使用关联查询 - return success(aiChatListService.listRel(param)); + Integer loginUserId = getLoginUserId(); + if (loginUserId == null) { + // 未登录不下发任何历史:原先 GET 全放行且 userId 由客户端指定,等于匿名可枚举。 + return success(java.util.Collections.emptyList()); + } + // 忽略客户端传入的 userId:这个接口只服务「我的历史记录」。 + param.setUserId(loginUserId); + // 不分页,只取最近 30 条:记录会随「生成记录」持续增长,全量返回会越来越重。 + return success(aiChatListService.listRecentRel(param, AiChatListService.HISTORY_LIMIT)); } @PreAuthorize("hasAuthority('ai:aiChatList:list')") diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index 2827a59..273c1b7 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -19,7 +19,9 @@ import com.gxwebsoft.common.core.utils.JSONUtil; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.ai.config.WebSocketServer; +import com.gxwebsoft.ai.constant.AiChatType; import com.gxwebsoft.ai.entity.ChatMessage; +import com.gxwebsoft.ai.service.AiGenerationRecorder; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.slf4j.Logger; @@ -204,6 +206,8 @@ public class AiController extends BaseController { @Resource private AiChatListService aiChatListService; @Resource + private AiGenerationRecorder aiGenerationRecorder; + @Resource private DifyProperties difyProperties; /** @@ -252,13 +256,6 @@ public class AiController extends BaseController { connection.setReadTimeout(aiTimeout()); Integer chatId = message.getChatId(); - if (chatId == null || chatId == 0) { - AiChatList aiChatList = new AiChatList(); - aiChatList.setUserId(getLoginUserId()); - aiChatList.setTitle(message.getQuery()); - aiChatListService.save(aiChatList); - chatId = aiChatList.getId(); - } Map payload = new HashMap<>(); payload.put("query", message.getQuery()); @@ -280,10 +277,6 @@ public class AiController extends BaseController { throw new RuntimeException(result != null ? result.toJSONString() : responseBody); } - AiChatHistory aiChatHistory = new AiChatHistory(); - aiChatHistory.setUserId(getLoginUserId()); - aiChatHistory.setChatId(chatId); - aiChatHistory.setContent(message.getQuery()); StringBuilder reply = new StringBuilder(); String conversationId = null; String messageId = null; @@ -323,7 +316,9 @@ public class AiController extends BaseController { if (StrUtil.isNotBlank(currentConversationId)) { conversationId = currentConversationId; - if (!conversationSaved) { + // 只有调用方显式传了 chatId 才需要回填会话 id; + // 新记录在成功收尾时一次性落库(见 persistChatTurn)。 + if (!conversationSaved && chatId != null && chatId != 0) { aiChatListService.updateConversationId(chatId, conversationId); conversationSaved = true; } @@ -361,17 +356,9 @@ public class AiController extends BaseController { } else if ("message_end".equals(event) || "workflow_finished".equals(event)) { if (!historySaved) { historySaved = true; - aiChatHistory.setConversationId(conversationId); - aiChatHistory.setReply(reply.toString()); - aiChatHistoryService.save(aiChatHistory); + chatId = persistChatTurn(chatId, conversationId, message.getQuery(), reply.toString()); } - HashMap endData = new HashMap<>(); - endData.put("answer", "__END__"); - endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); - endData.put("messageId", StrUtil.blankToDefault(messageId, "0")); - endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); - endData.put("stopType", "chat"); - webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); + sendChatEndFrame(chatId, conversationId, messageId, taskId); clearActiveAiTask(getLoginUserId(), taskId); } else if ("error".equals(event)) { throw new RuntimeException(json); @@ -380,16 +367,8 @@ public class AiController extends BaseController { } if (!historySaved) { - aiChatHistory.setConversationId(conversationId); - aiChatHistory.setReply(reply.toString()); - aiChatHistoryService.save(aiChatHistory); - HashMap endData = new HashMap<>(); - endData.put("answer", "__END__"); - endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); - endData.put("messageId", StrUtil.blankToDefault(messageId, "0")); - endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); - endData.put("stopType", "chat"); - webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); + chatId = persistChatTurn(chatId, conversationId, message.getQuery(), reply.toString()); + sendChatEndFrame(chatId, conversationId, messageId, taskId); clearActiveAiTask(getLoginUserId(), taskId); } } catch (Exception e) { @@ -407,6 +386,85 @@ public class AiController extends BaseController { return success("Stream processing completed"); } + /** + * 聊天一轮问答落库:**只在成功收尾时调用**。 + * + *

旧实现是请求一开始就建 {@code ai_chat_list} 行,上游失败会留下一条 + * 没有正文的空记录({@code tuanwei.sql} 里能翻到实例)。现在改为成功后才写: + * 空回复、异常、用户中止都不会留下垃圾行。 + * + * @param chatId 调用方显式传入的列表 id;为 null/0 时新建一条 + * @param conversationId 上游会话 id,可为空 + * @param query 用户提问,作为历史详情里的用户气泡 + * @param reply 助手答复,为空则不写 + * @return 最终记录 id(未写入时原样返回入参 chatId) + */ + private Integer persistChatTurn(Integer chatId, String conversationId, String query, String reply) { + Integer userId = getLoginUserId(); + if (userId == null || StrUtil.isBlank(reply)) { + return chatId; + } + try { + if (chatId == null || chatId == 0) { + return aiGenerationRecorder.record(userId, AiChatType.CHAT, query, null, query, reply, conversationId); + } + // 兼容显式传 chatId 的调用方:续写同一条列表行的会话 id。 + if (StrUtil.isNotBlank(conversationId)) { + aiChatListService.updateConversationId(chatId, conversationId); + } + AiChatHistory history = new AiChatHistory(); + history.setUserId(userId); + history.setChatId(chatId); + history.setConversationId(conversationId); + history.setContent(query); + history.setReply(reply); + aiChatHistoryService.save(history); + return chatId; + } catch (Exception e) { + // 落库失败不能把已经生成的正文变成一次「请求失败」。 + logger.error("save chat history failed, chatId={}", chatId, e); + return chatId; + } + } + + /** + * 聊天收尾帧。除原有字段外带上 {@code chatId},端上据此高亮刚生成的记录。 + * + *

{@code chatId} 用数字下发(端上会直接拿它做 key),仅在记录实际写入时出现。 + */ + private void sendChatEndFrame(Integer chatId, String conversationId, String messageId, String taskId) + throws IOException { + Map endData = new LinkedHashMap<>(); + endData.put("answer", "__END__"); + endData.put("conversationId", StrUtil.blankToDefault(conversationId, "0")); + endData.put("messageId", StrUtil.blankToDefault(messageId, "0")); + endData.put("taskId", StrUtil.blankToDefault(taskId, "0")); + endData.put("stopType", "chat"); + if (chatId != null && chatId != 0) { + endData.put("chatId", chatId); + } + webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData)); + } + + /** + * 生成记录落库的统一入口:吞掉异常并返回 null,绝不让「存历史」失败拖垮生成结果。 + * + *

{@code userId} 必须显式传入:创意助手的非流式入口在 + * {@code CompletableFuture} 里执行,{@code SecurityContextHolder} 是 + * THREAD_LOCAL,那个线程上 {@code getLoginUserId()} 只会拿到 null。 + * + * @return 新记录 id;未写入(含失败)时为 null + */ + private Integer recordGeneration(Integer userId, String chatType, String title, Map params, + String content, String reply, String conversationId) { + try { + return aiGenerationRecorder.record(userId, chatType, title, params, content, reply, conversationId); + } catch (Exception e) { + logger.error("save ai generation record failed, chatType={}", chatType, e); + return null; + } + } + @PostMapping("/manuscriptGen") public ApiResult manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException { // 未选文稿模板 = 自由模式。此时不调「内容创作与宣传助手」:它开始节点的 @@ -616,8 +674,18 @@ public class AiController extends BaseController { // 前端(uni-app 的 apiRequest 会回落 body.message)会把 // "Stream processing completed" 当成正文显示(见 issue 08)。 // 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。 - return success("Stream processing completed", - buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed)); + Map result = buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed); + if (!workflowFailed && finalEventSeen) { + // 写历史只认「成功且正文非空」:失败收场在上面已 continue 过; + // 没见到收尾事件的断流同样不算成功(正文可能只有半截)。 + Integer recordId = recordGeneration(getLoginUserId(), AiChatType.WRITING, + manuscriptTitle(message), manuscriptParams(message), + manuscriptPrompt(message), reply.toString(), null); + if (recordId != null) { + result.put("chatId", recordId); + } + } + return success("Stream processing completed", result); } catch (Exception e) { logger.error("manuscript request failed", e); HashMap answerData = new HashMap<>(); @@ -628,6 +696,114 @@ public class AiController extends BaseController { } } + // --------------------------------------------------------------------- + // 生成记录:标题 / 参数快照 / 用户可见提示词 + // --------------------------------------------------------------------- + + /** 写作记录的列表标题:优先核心亮点,其次活动主题,再退到原话或文体。 */ + private String manuscriptTitle(ManuscriptGenInputs message) { + if (message == null) { + return "写作生成"; + } + return firstNonBlankText(message.getHighlights(), message.getTheme(), + message.getUserContent(), message.getEventType(), message.getDocType(), "写作生成"); + } + + /** 写作记录在历史详情里的「用户」气泡:把表单里的有效项拼成可读文本。 */ + private String manuscriptPrompt(ManuscriptGenInputs message) { + if (message == null) { + return "写作生成"; + } + if (StrUtil.isBlank(message.getDocType())) { + // 自由创作:原话整段就是提示词 + return freeModeText(message); + } + List parts = new ArrayList<>(); + addPart(parts, "文体", message.getDocType()); + addPart(parts, "活动类型", message.getEventType()); + addPart(parts, "活动主题", message.getTheme()); + addPart(parts, "活动时间", message.getTime()); + addPart(parts, "活动地点", message.getLocation()); + addPart(parts, "参与人群", message.getParticipants()); + addPart(parts, "核心亮点与要求", message.getHighlights()); + if (message.getNumber() != null) { + addPart(parts, "字数", message.getNumber().toString()); + } + return parts.isEmpty() ? "写作生成" : String.join("\n", parts); + } + + /** 写作记录的入参快照,供「按原参数再来一次」回填。 */ + private Map manuscriptParams(ManuscriptGenInputs message) { + Map params = new LinkedHashMap<>(); + if (message == null) { + return params; + } + putIfNotBlank(params, "docType", message.getDocType()); + putIfNotBlank(params, "eventType", message.getEventType()); + putIfNotBlank(params, "theme", message.getTheme()); + putIfNotBlank(params, "time", message.getTime()); + putIfNotBlank(params, "location", message.getLocation()); + putIfNotBlank(params, "participants", message.getParticipants()); + putIfNotBlank(params, "highlights", message.getHighlights()); + putIfNotBlank(params, "userContent", message.getUserContent()); + if (message.getNumber() != null) { + params.put("number", message.getNumber()); + } + return params; + } + + /** 活动策划记录在历史详情里的「用户」气泡:把表单里的有效项拼成可读文本。 */ + private String activityOutlinePrompt(ActivityOutlineInputs message) { + List parts = new ArrayList<>(); + if (message != null) { + addPart(parts, "活动主题", message.getEventTheme()); + addPart(parts, "参与人群", message.getPeople()); + addPart(parts, "活动时间", message.getEventTime()); + addPart(parts, "活动地点", message.getEventLocation()); + addPart(parts, "活动预算", message.getEventFunding()); + addPart(parts, "活动类型", message.getEventType()); + addPart(parts, "活动目的", message.getEventMeme()); + } + return parts.isEmpty() ? "活动策划大纲" : String.join("\n", parts); + } + + /** 活动策划记录的入参快照,供回看与「再来一次」展示。 */ + private Map activityOutlineParams(ActivityOutlineInputs message) { + Map params = new LinkedHashMap<>(); + if (message == null) { + return params; + } + putIfNotBlank(params, "eventTheme", message.getEventTheme()); + putIfNotBlank(params, "people", message.getPeople()); + putIfNotBlank(params, "eventTime", message.getEventTime()); + putIfNotBlank(params, "eventLocation", message.getEventLocation()); + putIfNotBlank(params, "eventFunding", message.getEventFunding()); + putIfNotBlank(params, "eventType", message.getEventType()); + putIfNotBlank(params, "eventMeme", message.getEventMeme()); + return params; + } + + private static void addPart(List parts, String label, String value) { + if (StrUtil.isNotBlank(value)) { + parts.add(label + ":" + value.trim()); + } + } + + private static void putIfNotBlank(Map target, String key, String value) { + if (StrUtil.isNotBlank(value)) { + target.put(key, value); + } + } + + private static String firstNonBlankText(String... values) { + for (String value : values) { + if (StrUtil.isNotBlank(value)) { + return value; + } + } + return ""; + } + /** * 稿件生成的 HTTP 返回体。 * @@ -1194,8 +1370,17 @@ public class AiController extends BaseController { // 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方 // (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket, // 返回体是它唯一的正文来源(见 issue 08)。 - return success("Stream processing completed", - buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed)); + Map result = buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed); + if (!workflowFailed && finalEventSeen) { + Integer recordId = recordGeneration(getLoginUserId(), AiChatType.ACTIVITY_OUTLINE, + StrUtil.blankToDefault(message.getEventTheme(), "活动策划大纲"), + activityOutlineParams(message), activityOutlinePrompt(message), + reply.toString(), conversationId); + if (recordId != null) { + result.put("chatId", recordId); + } + } + return success("Stream processing completed", result); } catch (Exception e) { logger.error("activity outline request failed", e); sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false); @@ -1289,6 +1474,18 @@ public class AiController extends BaseController { * @return 成功结果为工作流产出;失败时为失败态结果(HTTP 仍是 200,靠 status 表达) */ private ApiResult executeCreativeAssistant(Map requestBody, Integer loginUserId) { + return executeCreativeAssistant(requestBody, loginUserId, null, null); + } + + /** + * 带记录类型的执行入口。 + * + * @param chatType 记录类型;为 null 时按 {@code mode} 推断(image / creative)。 + * 写作自由创作显式传 {@link AiChatType#WRITING},否则会被记成创意助手。 + * @param params 入参快照,为 null 时只记 {@code user_content} 与 {@code mode} + */ + private ApiResult executeCreativeAssistant(Map requestBody, Integer loginUserId, + String chatType, Map params) { String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content")); if (StrUtil.isBlank(userContent)) { return fail("user_content不能为空"); @@ -1307,10 +1504,43 @@ public class AiController extends BaseController { logger.error("creative assistant workflow failed, workflowRunId={}, taskId={}, " + "failedNode={}, upstreamError={}", run.workflowRunId, run.taskId, run.failedNode, run.failureReason); + // 失败不入库:result 里只有失败文案,没有可回看的内容。 + return success(run.result); + } + + if (!run.finished) { + logger.error("creative assistant stream ended without finish event, workflowRunId={}, taskId={}", + run.workflowRunId, run.taskId); + return success(run.result); + } + + String resolvedType = StrUtil.blankToDefault(chatType, resolveCreativeChatType(requestBody)); + Map snapshot = params == null ? creativeParams(requestBody) : params; + Integer recordId = recordGeneration(loginUserId, resolvedType, userContent, snapshot, + userContent, stringValue(run.result.get("answer")), null); + if (recordId != null) { + run.result.put("chatId", recordId); } return success(run.result); } + /** 创意助手类记录的类型:助手页图片模式带 {@code mode=image},独立页不带。 */ + private String resolveCreativeChatType(Map requestBody) { + Object mode = requestBody == null ? null : requestBody.get("mode"); + return "image".equalsIgnoreCase(stringValue(mode)) ? AiChatType.IMAGE : AiChatType.CREATIVE; + } + + /** 创意助手记录的默认入参快照。 */ + private Map creativeParams(Map requestBody) { + Map params = new LinkedHashMap<>(); + if (requestBody == null) { + return params; + } + putIfNotBlank(params, "userContent", stringValue(requestBody.get("user_content"))); + putIfNotBlank(params, "mode", stringValue(requestBody.get("mode"))); + return params; + } + /** * 上游接口直接以非 2xx 拒绝时抛出的信号。 * @@ -1342,24 +1572,33 @@ public class AiController extends BaseController { private final String failedNode; private final String failureReason; private final boolean failed; + /** 是否收到过 workflow_finished / message_end:没收到说明流被提前截断 */ + private final boolean finished; private CreativeRun(Map result, String workflowRunId, String taskId, - String failedNode, String failureReason, boolean failed) { + String failedNode, String failureReason, boolean failed, boolean finished) { this.result = result; this.workflowRunId = workflowRunId; this.taskId = taskId; this.failedNode = failedNode; this.failureReason = briefUpstreamError(failureReason); this.failed = failed; + this.finished = finished; } private static CreativeRun succeeded(Map result, String workflowRunId, String taskId) { - return new CreativeRun(result, workflowRunId, taskId, null, null, false); + return new CreativeRun(result, workflowRunId, taskId, null, null, false, true); + } + + /** 流正常结束但没见到收尾事件:正文可能只有半截,不当作成功记录入库。 */ + private static CreativeRun succeededIncomplete(Map result, String workflowRunId, + String taskId) { + return new CreativeRun(result, workflowRunId, taskId, null, null, false, false); } private static CreativeRun failed(Map result, String workflowRunId, String taskId, String failedNode, String failureReason) { - return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true); + return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true, false); } } @@ -1498,7 +1737,8 @@ public class AiController extends BaseController { result.put("taskId", taskId); } clearActiveAiTask(loginUserId, taskId); - return CreativeRun.succeeded(result, workflowRunId, taskId); + // 没见过收尾事件:正文可能是半截,接口仍返回内容,但不写历史。 + return CreativeRun.succeededIncomplete(result, workflowRunId, taskId); } finally { if (connection != null) { connection.disconnect(); @@ -1688,7 +1928,8 @@ public class AiController extends BaseController { body.put("user_content", userContent); logger.info("manuscript free mode (docType blank), delegate to creative assistant, chars={}", userContent.length()); - return executeCreativeAssistant(body, getLoginUserId()); + // 类型显式传 writing:用户是在「写作」里点的,不该被记成创意助手。 + return executeCreativeAssistant(body, getLoginUserId(), AiChatType.WRITING, manuscriptParams(message)); } /** diff --git a/src/main/java/com/gxwebsoft/ai/entity/AiChatList.java b/src/main/java/com/gxwebsoft/ai/entity/AiChatList.java index 2c15a17..3aee273 100644 --- a/src/main/java/com/gxwebsoft/ai/entity/AiChatList.java +++ b/src/main/java/com/gxwebsoft/ai/entity/AiChatList.java @@ -55,4 +55,10 @@ public class AiChatList implements Serializable { private String title; + @ApiModelProperty(value = "记录类型: chat/writing/image/creative/activity_outline") + private String chatType; + + @ApiModelProperty(value = "生成参数快照(JSON)") + private String params; + } diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiChatListMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiChatListMapper.java index 26f003f..7e1430d 100644 --- a/src/main/java/com/gxwebsoft/ai/mapper/AiChatListMapper.java +++ b/src/main/java/com/gxwebsoft/ai/mapper/AiChatListMapper.java @@ -34,4 +34,16 @@ public interface AiChatListMapper extends BaseMapper { */ List selectListRel(@Param("param") AiChatListParam param); + /** + * 查询最近 N 条(历史面板用)。 + * + *

历史列表不分页,只取最近的一批:单用户的记录会随「生成记录」持续增长, + * 全量返回会让这个接口越来越重。排序在 SQL 里做,避免先拉全量再截断。 + * + * @param param 查询参数 + * @param limit 最多返回条数 + * @return List + */ + List selectLatestRel(@Param("param") AiChatListParam param, @Param("limit") int limit); + } diff --git a/src/main/java/com/gxwebsoft/ai/mapper/xml/AiChatListMapper.xml b/src/main/java/com/gxwebsoft/ai/mapper/xml/AiChatListMapper.xml index 951deba..66e3e2b 100644 --- a/src/main/java/com/gxwebsoft/ai/mapper/xml/AiChatListMapper.xml +++ b/src/main/java/com/gxwebsoft/ai/mapper/xml/AiChatListMapper.xml @@ -43,6 +43,9 @@ AND a.title LIKE CONCAT('%', #{param.title}, '%') + + AND a.chat_type = #{param.chatType} + AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%') ) @@ -60,4 +63,15 @@ + + + diff --git a/src/main/java/com/gxwebsoft/ai/param/AiChatListParam.java b/src/main/java/com/gxwebsoft/ai/param/AiChatListParam.java index dfc2ecd..1486bf5 100644 --- a/src/main/java/com/gxwebsoft/ai/param/AiChatListParam.java +++ b/src/main/java/com/gxwebsoft/ai/param/AiChatListParam.java @@ -48,4 +48,8 @@ public class AiChatListParam extends BaseParam { private String title; + @ApiModelProperty(value = "记录类型: chat/writing/image/creative/activity_outline") + @QueryField(type = QueryType.EQ) + private String chatType; + } diff --git a/src/main/java/com/gxwebsoft/ai/service/AiChatListService.java b/src/main/java/com/gxwebsoft/ai/service/AiChatListService.java index 4500c74..21cdec8 100644 --- a/src/main/java/com/gxwebsoft/ai/service/AiChatListService.java +++ b/src/main/java/com/gxwebsoft/ai/service/AiChatListService.java @@ -15,6 +15,13 @@ import java.util.List; */ public interface AiChatListService extends IService { + /** + * 历史面板一次返回的最大条数。 + * + *

列表不分页,只取最近这批;再早的记录需要翻页能力时才做,不在当前范围内。 + */ + int HISTORY_LIMIT = 30; + /** * 分页关联查询 * @@ -31,6 +38,15 @@ public interface AiChatListService extends IService { */ List listRel(AiChatListParam param); + /** + * 查询最近 N 条(按创建时间倒序,SQL 内 LIMIT) + * + * @param param 查询参数 + * @param limit 最多返回条数,超过 {@link #HISTORY_LIMIT} 时按上限截断 + * @return List + */ + List listRecentRel(AiChatListParam param, int limit); + /** * 根据id查询 * diff --git a/src/main/java/com/gxwebsoft/ai/service/AiGenerationRecorder.java b/src/main/java/com/gxwebsoft/ai/service/AiGenerationRecorder.java new file mode 100644 index 0000000..0f0fa1f --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiGenerationRecorder.java @@ -0,0 +1,102 @@ +package com.gxwebsoft.ai.service; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.ai.constant.AiChatType; +import com.gxwebsoft.ai.entity.AiChatHistory; +import com.gxwebsoft.ai.entity.AiChatList; +import com.gxwebsoft.common.core.utils.JSONUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.util.Map; + +/** + * 「生成记录」落库:一次成功生成 = 一条 {@code ai_chat_list} + 一条 {@code ai_chat_history}。 + * + *

决策与理由见 {@code docs/adr/0001-ai-generation-records.md},三端契约见 + * {@code docs/ai-history-contract.md}。要点: + *

+ */ +@Component +public class AiGenerationRecorder { + + private static final Logger logger = LoggerFactory.getLogger(AiGenerationRecorder.class); + + /** 历史列表标题的最大长度,超出截断并加省略号 */ + private static final int TITLE_MAX_LENGTH = 50; + + @Resource + private AiChatListService aiChatListService; + + @Resource + private AiChatHistoryService aiChatHistoryService; + + /** + * 写入一条生成记录。 + * + * @param userId 记录归属用户,为空则不写(匿名请求没有历史) + * @param chatType {@link AiChatType} 之一 + * @param title 列表标题,取用户提示词 + * @param params 入参快照,可为 null + * @param content 历史详情里的「用户」气泡正文 + * @param reply 历史详情里的「助手」气泡正文(markdown) + * @param conversationId 上游会话 id,非聊天记录通常为 null + * @return 新建的 {@code ai_chat_list.id};未写入时返回 null + */ + @Transactional(rollbackFor = Exception.class) + public Integer record(Integer userId, String chatType, String title, Map params, + String content, String reply, String conversationId) { + if (userId == null || StrUtil.isBlank(reply)) { + return null; + } + AiChatList list = new AiChatList(); + list.setUserId(userId); + list.setChatType(StrUtil.blankToDefault(chatType, AiChatType.CHAT)); + list.setTitle(shorten(title)); + if (StrUtil.isNotBlank(conversationId)) { + list.setConversationId(conversationId); + } + if (params != null && !params.isEmpty()) { + list.setParams(JSONUtil.toJSONString(params)); + } + aiChatListService.save(list); + Integer chatId = list.getId(); + if (chatId == null) { + throw new IllegalStateException("ai_chat_list 未返回自增主键"); + } + + AiChatHistory history = new AiChatHistory(); + history.setUserId(userId); + history.setChatId(chatId); + if (StrUtil.isNotBlank(conversationId)) { + history.setConversationId(conversationId); + } + history.setContent(StrUtil.nullToEmpty(content)); + history.setReply(reply); + aiChatHistoryService.save(history); + + logger.info("ai generation record saved, chatId={}, chatType={}, title={}", + chatId, list.getChatType(), list.getTitle()); + return chatId; + } + + /** 标题:压平换行与多余空白,超过 {@value #TITLE_MAX_LENGTH} 字截断。 */ + static String shorten(String text) { + if (StrUtil.isBlank(text)) { + return "未命名记录"; + } + String flat = text.replaceAll("\\s+", " ").trim(); + if (flat.length() <= TITLE_MAX_LENGTH) { + return flat; + } + return flat.substring(0, TITLE_MAX_LENGTH) + "…"; + } +} diff --git a/src/main/java/com/gxwebsoft/ai/service/impl/AiChatHistoryServiceImpl.java b/src/main/java/com/gxwebsoft/ai/service/impl/AiChatHistoryServiceImpl.java index fbab1c4..ed94765 100644 --- a/src/main/java/com/gxwebsoft/ai/service/impl/AiChatHistoryServiceImpl.java +++ b/src/main/java/com/gxwebsoft/ai/service/impl/AiChatHistoryServiceImpl.java @@ -23,7 +23,7 @@ public class AiChatHistoryServiceImpl extends ServiceImpl pageRel(AiChatHistoryParam param) { PageParam page = new PageParam<>(param); - page.setDefaultOrder("sort_number asc, create_time desc"); + page.setDefaultOrder("create_time asc"); List list = baseMapper.selectPageRel(page, param); return new PageResult<>(list, page.getTotal()); } @@ -32,8 +32,11 @@ public class AiChatHistoryServiceImpl extends ServiceImpl listRel(AiChatHistoryParam param) { List list = baseMapper.selectListRel(param); // 排序 + // 原先是 "sort_number asc":ai_chat_history 没有这一列,sortRecords 反射取到 null, + // 多行时 Comparator.comparing(null) 直接 NPE。单行查询侥幸返回,才一直没暴露。 + // 改成按创建时间升序,与前端 formatHistoryMessages 的假设一致。 PageParam page = new PageParam<>(); - page.setDefaultOrder("sort_number asc, create_time desc"); + page.setDefaultOrder("create_time asc"); return page.sortRecords(list); } diff --git a/src/main/java/com/gxwebsoft/ai/service/impl/AiChatListServiceImpl.java b/src/main/java/com/gxwebsoft/ai/service/impl/AiChatListServiceImpl.java index f4ac415..5cdc4a0 100644 --- a/src/main/java/com/gxwebsoft/ai/service/impl/AiChatListServiceImpl.java +++ b/src/main/java/com/gxwebsoft/ai/service/impl/AiChatListServiceImpl.java @@ -38,6 +38,12 @@ public class AiChatListServiceImpl extends ServiceImpl listRecentRel(AiChatListParam param, int limit) { + int size = limit <= 0 ? HISTORY_LIMIT : Math.min(limit, HISTORY_LIMIT); + return baseMapper.selectLatestRel(param, size); + } + @Override public AiChatList getByIdRel(Integer id) { AiChatListParam param = new AiChatListParam(); diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiChatListHistoryLimitTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiChatListHistoryLimitTest.java new file mode 100644 index 0000000..26045f1 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiChatListHistoryLimitTest.java @@ -0,0 +1,89 @@ +package com.gxwebsoft.ai.controller; + +import com.gxwebsoft.ai.entity.AiChatList; +import com.gxwebsoft.ai.param.AiChatListParam; +import com.gxwebsoft.ai.service.AiChatListService; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.system.entity.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 历史列表的两个硬约束(见 {@code docs/ai-history-contract.md}): + * + *
    + *
  1. 只返回登录用户自己的记录,忽略客户端传来的 {@code userId};
  2. + *
  3. 不分页,只取最近 {@link AiChatListService#HISTORY_LIMIT} 条。
  4. + *
+ */ +class AiChatListHistoryLimitTest { + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void 登录用户只取最近30条_且忽略客户端userId() { + loginAs(7); + AiChatListService service = mock(AiChatListService.class); + when(service.listRecentRel(any(AiChatListParam.class), eq(AiChatListService.HISTORY_LIMIT))) + .thenReturn(Collections.emptyList()); + AiChatListController controller = controllerWith(service); + + AiChatListParam param = new AiChatListParam(); + param.setUserId(999); + ApiResult> result = controller.list(param); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiChatListParam.class); + verify(service).listRecentRel(captor.capture(), eq(30)); + assertEquals(7, captor.getValue().getUserId(), "必须用登录用户覆盖客户端传入的 userId"); + assertEquals(0, result.getCode()); + } + + @Test + void 未登录返回空列表_不查库() { + AiChatListService service = mock(AiChatListService.class); + AiChatListController controller = controllerWith(service); + + ApiResult> result = controller.list(new AiChatListParam()); + + assertTrue(result.getData().isEmpty()); + verifyNoInteractions(service); + } + + private static void loginAs(Integer userId) { + User user = new User(); + user.setUserId(userId); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(user, null)); + } + + private static AiChatListController controllerWith(AiChatListService service) { + AiChatListController controller = new AiChatListController(); + try { + Field field = AiChatListController.class.getDeclaredField("aiChatListService"); + field.setAccessible(true); + field.set(controller, service); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + return controller; + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeHistoryRecordTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeHistoryRecordTest.java new file mode 100644 index 0000000..42b7bb2 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeHistoryRecordTest.java @@ -0,0 +1,178 @@ +package com.gxwebsoft.ai.controller; + +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.ai.config.DifyProperties; +import com.gxwebsoft.ai.service.AiGenerationRecorder; +import com.gxwebsoft.common.core.web.ApiResult; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 创意助手「生成记录」落库的端到端验证。 + * + *

起一个本地 HTTP server 扮演 Dify,跑真实的 {@code executeCreativeAssistant}: + *

    + *
  • 成功:写一条 {@code image} 记录,并把 {@code chatId} 放进返回体,端上据此高亮;
  • + *
  • 软失败(HTTP 200 + {@code data.status=failed}):一条都不写。
  • + *
+ * 软失败这条是本次改造最容易踩的坑:上游失败时正文是固定文案「生成失败,请稍后重试」, + * 不判 status 就会把失败文案当成作品存进历史。 + */ +class AiCreativeHistoryRecordTest { + + @Test + void 成功生成_写一条image记录并回传chatId() throws Exception { + try (MockDify dify = MockDify.start(successStream())) { + CapturingRecorder recorder = new CapturingRecorder(); + ApiResult result = executeCreative(dify.baseUrl(), recorder); + + assertEquals(1, recorder.calls.size(), "成功应写入恰好一条记录"); + Object[] call = recorder.calls.get(0); + assertEquals(1, call[0], "userId 必须取自登录用户参数,而不是线程上下文"); + assertEquals("image", call[1], "mode=image 应记为 image 类型"); + assertEquals("生成一张迎新晚会海报", call[2], "标题取用户原话"); + assertEquals("生成一张迎新晚会海报", call[4], "用户气泡取原话"); + + Map payload = (Map) result.getData(); + assertEquals(99, payload.get("chatId"), "成功结果必须回传 chatId 供端上高亮"); + } + } + + @Test + void 软失败_不写任何记录且不带chatId() throws Exception { + try (MockDify dify = MockDify.start(failureStream())) { + CapturingRecorder recorder = new CapturingRecorder(); + ApiResult result = executeCreative(dify.baseUrl(), recorder); + + assertEquals(0, recorder.calls.size(), "失败不得入库,否则历史里会出现失败文案"); + Map payload = (Map) result.getData(); + assertEquals("failed", payload.get("status")); + assertFalse(payload.containsKey("chatId"), "没有记录就没有 chatId"); + assertNull(payload.get("raw"), "失败态仍不得下发整包"); + } + } + + // ------------------------------------------------------------------ 辅助 + + private static ApiResult executeCreative(String baseUrl, AiGenerationRecorder recorder) throws Exception { + DifyProperties properties = new DifyProperties(); + properties.setBaseUrl(baseUrl); + properties.getApps().setCreativeAssistant("app-history-test"); + + AiController controller = new AiController(); + set(controller, "difyProperties", properties); + set(controller, "aiGenerationRecorder", recorder); + + Map requestBody = new HashMap<>(); + requestBody.put("user_content", "生成一张迎新晚会海报"); + requestBody.put("mode", "image"); + + Method method = AiController.class.getDeclaredMethod( + "executeCreativeAssistant", Map.class, Integer.class); + method.setAccessible(true); + return (ApiResult) method.invoke(controller, requestBody, 1); + } + + private static void set(Object target, String field, Object value) { + try { + Field f = AiController.class.getDeclaredField(field); + f.setAccessible(true); + f.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static String successStream() { + JSONObject outputs = new JSONObject(true); + outputs.put("text", "## 迎新晚会海报\n\n![](https://example.com/poster.png)"); + + JSONObject data = new JSONObject(true); + data.put("status", "succeeded"); + data.put("outputs", outputs); + + JSONObject finished = new JSONObject(true); + finished.put("event", "workflow_finished"); + finished.put("workflow_run_id", "run-1"); + finished.put("task_id", "task-1"); + finished.put("data", data); + return "data: " + finished.toJSONString() + "\n\n"; + } + + private static String failureStream() { + JSONObject data = new JSONObject(true); + data.put("status", "failed"); + data.put("outputs", new JSONObject(true)); + data.put("error", "req_id: test PluginInvokeError: API request failed"); + + JSONObject finished = new JSONObject(true); + finished.put("event", "workflow_finished"); + finished.put("workflow_run_id", "run-2"); + finished.put("task_id", "task-2"); + finished.put("data", data); + return "data: " + finished.toJSONString() + "\n\n"; + } + + /** 捕获写入参数,不落库。 */ + private static final class CapturingRecorder extends AiGenerationRecorder { + private final List calls = new ArrayList<>(); + + @Override + public Integer record(Integer userId, String chatType, String title, Map params, + String content, String reply, String conversationId) { + calls.add(new Object[]{userId, chatType, title, params, content, reply, conversationId}); + return 99; + } + } + + /** 扮演 Dify 的极简 SSE 服务端。 */ + private static final class MockDify implements AutoCloseable { + + private final HttpServer server; + + private MockDify(HttpServer server) { + this.server = server; + } + + private static MockDify start(String body) throws Exception { + HttpServer server = HttpServer.create( + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/v1/workflows/run", exchange -> { + byte[] buffer = new byte[1024]; + while (exchange.getRequestBody().read(buffer) != -1) { + // 丢弃请求体,确保客户端写完 + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + return new MockDify(server); + } + + private String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/src/test/java/com/gxwebsoft/ai/service/AiChatHistorySortRegressionTest.java b/src/test/java/com/gxwebsoft/ai/service/AiChatHistorySortRegressionTest.java new file mode 100644 index 0000000..32a5871 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/service/AiChatHistorySortRegressionTest.java @@ -0,0 +1,44 @@ +package com.gxwebsoft.ai.service; + +import com.gxwebsoft.ai.entity.AiChatHistory; +import com.gxwebsoft.ai.param.AiChatHistoryParam; +import com.gxwebsoft.common.core.web.PageParam; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code AiChatHistoryServiceImpl} 排序回归。 + * + *

原实现用 {@code sort_number asc} 作为默认排序,而 {@code ai_chat_history} + * 根本没有这一列:{@code PageParam.sortRecords} 反射取到 null, + * {@code Comparator.comparing} 在多行时抛 NPE。前端一直按 {@code chatId} + * 只查一行,靠 {@code size() < 2} 提前返回侥幸没暴露。 + */ +class AiChatHistorySortRegressionTest { + + @Test + void 多行查询按创建时间升序_不抛NPE() { + AiChatHistory newer = row(2, LocalDateTime.of(2026, 10, 1, 12, 0)); + AiChatHistory older = row(1, LocalDateTime.of(2026, 10, 1, 10, 0)); + + PageParam page = new PageParam<>(); + page.setDefaultOrder("create_time asc"); + + List sorted = page.sortRecords(new ArrayList<>(List.of(newer, older))); + + assertEquals(1, sorted.get(0).getId(), "早创建的一行应排在前面"); + assertEquals(2, sorted.get(1).getId()); + } + + private static AiChatHistory row(int id, LocalDateTime createTime) { + AiChatHistory history = new AiChatHistory(); + history.setId(id); + history.setCreateTime(createTime); + return history; + } +} diff --git a/src/test/java/com/gxwebsoft/ai/service/AiGenerationRecorderTest.java b/src/test/java/com/gxwebsoft/ai/service/AiGenerationRecorderTest.java new file mode 100644 index 0000000..d084b6a --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/service/AiGenerationRecorderTest.java @@ -0,0 +1,150 @@ +package com.gxwebsoft.ai.service; + +import com.gxwebsoft.ai.entity.AiChatHistory; +import com.gxwebsoft.ai.entity.AiChatList; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 「生成记录」落库规则({@code docs/adr/0001-ai-generation-records.md})的单元测试。 + * + *

这几条是决策的硬约束:只存成功、类型由后端判定、标题截断、参数存快照、 + * list 与 history 一次写全——任何一条被改坏,历史面板就会出现空记录或错类型。 + */ +class AiGenerationRecorderTest { + + @Test + void 成功写入_list先于history_并回传主键() { + AiChatListService listService = mock(AiChatListService.class); + AiChatHistoryService historyService = mock(AiChatHistoryService.class); + when(listService.save(any(AiChatList.class))).thenAnswer(invocation -> { + AiChatList list = invocation.getArgument(0); + list.setId(42); + return true; + }); + when(historyService.save(any(AiChatHistory.class))).thenReturn(true); + + Map params = new HashMap<>(); + params.put("docType", "新闻稿"); + + Integer chatId = newRecorder(listService, historyService) + .record(7, "writing", " 一篇\n很长的稿子 ", params, "用户提示", "生成结果", null); + + assertEquals(42, chatId); + + ArgumentCaptor listCaptor = ArgumentCaptor.forClass(AiChatList.class); + verify(listService).save(listCaptor.capture()); + AiChatList saved = listCaptor.getValue(); + assertEquals(7, saved.getUserId()); + assertEquals("writing", saved.getChatType()); + assertEquals("一篇 很长的稿子", saved.getTitle(), "标题应压平换行"); + assertTrue(saved.getParams().contains("新闻稿"), "参数快照应序列化成 JSON"); + + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(AiChatHistory.class); + verify(historyService).save(historyCaptor.capture()); + assertEquals(42, historyCaptor.getValue().getChatId()); + assertEquals("生成结果", historyCaptor.getValue().getReply()); + assertEquals("用户提示", historyCaptor.getValue().getContent()); + } + + @Test + void 回复为空_不写任何记录() { + AiChatListService listService = mock(AiChatListService.class); + AiChatHistoryService historyService = mock(AiChatHistoryService.class); + + Integer chatId = newRecorder(listService, historyService) + .record(7, "image", "画一只猫", null, "画一只猫", " ", null); + + assertNull(chatId); + verifyNoInteractions(listService, historyService); + } + + @Test + void 未登录_不写记录() { + AiChatListService listService = mock(AiChatListService.class); + AiChatHistoryService historyService = mock(AiChatHistoryService.class); + + Integer chatId = newRecorder(listService, historyService) + .record(null, "chat", "你好", null, "你好", "你好呀", null); + + assertNull(chatId); + verifyNoInteractions(listService, historyService); + } + + @Test + void 类型缺省为chat() { + AiChatListService listService = mock(AiChatListService.class); + AiChatHistoryService historyService = mock(AiChatHistoryService.class); + when(listService.save(any(AiChatList.class))).thenAnswer(invocation -> { + ((AiChatList) invocation.getArgument(0)).setId(1); + return true; + }); + when(historyService.save(any(AiChatHistory.class))).thenReturn(true); + + newRecorder(listService, historyService) + .record(1, null, "你好", null, "你好", "你好呀", null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiChatList.class); + verify(listService).save(captor.capture()); + assertEquals("chat", captor.getValue().getChatType()); + assertNull(captor.getValue().getParams(), "没有参数时不应写空 JSON"); + assertNotNull(captor.getValue().getTitle()); + } + + @Test + void 超长标题截断到50字并加省略号() { + String longText = "一".repeat(60); + + assertEquals(51, AiGenerationRecorder.shorten(longText).length()); + assertTrue(AiGenerationRecorder.shorten(longText).endsWith("…")); + assertEquals("未命名记录", AiGenerationRecorder.shorten(" ")); + } + + @Test + void 写库失败不吞掉异常由调用方兜底() { + // 本类只负责事务内写入;异常向上抛,由 controller 的 recordGeneration 记录并降级。 + AiChatListService listService = mock(AiChatListService.class); + AiChatHistoryService historyService = mock(AiChatHistoryService.class); + when(listService.save(any(AiChatList.class))).thenThrow(new RuntimeException("db down")); + + try { + newRecorder(listService, historyService).record(1, "chat", "你好", null, "你好", "你好呀", null); + } catch (RuntimeException expected) { + assertEquals("db down", expected.getMessage()); + } + verify(historyService, never()).save(any(AiChatHistory.class)); + } + + private static AiGenerationRecorder newRecorder(AiChatListService listService, + AiChatHistoryService historyService) { + AiGenerationRecorder recorder = new AiGenerationRecorder(); + set(recorder, "aiChatListService", listService); + set(recorder, "aiChatHistoryService", historyService); + return recorder; + } + + private static void set(Object target, String field, Object value) { + try { + Field f = target.getClass().getDeclaredField(field); + f.setAccessible(true); + f.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/tuanwei.sql b/tuanwei.sql index 6dd1c5b..3393d24 100644 --- a/tuanwei.sql +++ b/tuanwei.sql @@ -138,7 +138,10 @@ CREATE TABLE `ai_chat_list` ( `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, - PRIMARY KEY (`id`) USING BTREE + `chat_type` varchar(32) NOT NULL DEFAULT 'chat' COMMENT '记录类型:chat/writing/image/creative/activity_outline', + `params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '生成参数快照(JSON)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_ai_chat_list_user_type_time` (`user_id`,`chat_type`,`create_time`) ) ENGINE=InnoDB AUTO_INCREMENT=408 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- ----------------------------