feat(ai): 写作/生图/创意/活动策划也保存历史记录

- ai_chat_list 新增 chat_type / params,旧数据默认 chat
- 新增 AiGenerationRecorder:成功后同事务写 list + history,末帧回传 chatId
- 聊天改为成功收尾才建行,失败/中止不再留下空记录
- 写作(含自由创作)、生图、创意助手、活动策划四处接入,软失败与断流不入库
- 历史列表只取最近 30 条(SQL LIMIT),并强制按登录用户过滤
- 修复历史排序用了不存在的 sort_number 列(多行会 NPE)
- 新增 4 个测试类:记录器规则、排序回归、创意成功/软失败、30 条上限
This commit is contained in:
2026-10-24 17:37:39 +08:00
parent d54f559d34
commit 4c2a787d81
18 changed files with 1012 additions and 46 deletions
+50
View File
@@ -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;
@@ -0,0 +1,28 @@
package com.gxwebsoft.ai.constant;
/**
* 「历史记录」的类型取值,与 {@code ai_chat_list.chat_type} 一一对应。
*
* <p>契约见 {@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() {
}
}
@@ -34,6 +34,9 @@ public class AiChatHistoryController extends BaseController {
@ApiOperation("分页查询")
@GetMapping("/page")
public ApiResult<PageResult<AiChatHistory>> page(AiChatHistoryParam param) {
if (getLoginUserId() == null) {
return new ApiResult<PageResult<AiChatHistory>>(1, "请先登录");
}
// 使用关联查询
return success(aiChatHistoryService.pageRel(param));
}
@@ -41,6 +44,12 @@ public class AiChatHistoryController extends BaseController {
@ApiOperation("查询全部")
@GetMapping()
public ApiResult<List<AiChatHistory>> list(AiChatHistoryParam param) {
Integer loginUserId = getLoginUserId();
if (loginUserId == null) {
return success(java.util.Collections.emptyList());
}
// 只能读自己的记录:忽略客户端传入的 userId。
param.setUserId(loginUserId);
// 使用关联查询
return success(aiChatHistoryService.listRel(param));
}
@@ -34,6 +34,10 @@ public class AiChatListController extends BaseController {
@ApiOperation("分页查询")
@GetMapping("/page")
public ApiResult<PageResult<AiChatList>> page(AiChatListParam param) {
// 管理端视角:允许按 userId 过滤,但不允许匿名读取(历史正文含长文与图片)。
if (getLoginUserId() == null) {
return new ApiResult<PageResult<AiChatList>>(1, "请先登录");
}
// 使用关联查询
return success(aiChatListService.pageRel(param));
}
@@ -41,8 +45,15 @@ public class AiChatListController extends BaseController {
@ApiOperation("查询全部")
@GetMapping()
public ApiResult<List<AiChatList>> 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')")
@@ -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<String, Object> 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<String, String> 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<String, String> 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");
}
/**
* 聊天一轮问答落库:**只在成功收尾时调用**。
*
* <p>旧实现是请求一开始就建 {@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},端上据此高亮刚生成的记录。
*
* <p>{@code chatId} 用数字下发(端上会直接拿它做 key),仅在记录实际写入时出现。
*/
private void sendChatEndFrame(Integer chatId, String conversationId, String messageId, String taskId)
throws IOException {
Map<String, Object> 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,绝不让「存历史」失败拖垮生成结果。
*
* <p>{@code userId} 必须显式传入:创意助手的非流式入口在
* {@code CompletableFuture} 里执行,{@code SecurityContextHolder} 是
* THREAD_LOCAL,那个线程上 {@code getLoginUserId()} 只会拿到 null。
*
* @return 新记录 id;未写入(含失败)时为 null
*/
private Integer recordGeneration(Integer userId, String chatType, String title, Map<String, Object> 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<String, Object> 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<String, String> 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<String> 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<String, Object> manuscriptParams(ManuscriptGenInputs message) {
Map<String, Object> 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<String> 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<String, Object> activityOutlineParams(ActivityOutlineInputs message) {
Map<String, Object> 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<String> parts, String label, String value) {
if (StrUtil.isNotBlank(value)) {
parts.add(label + ":" + value.trim());
}
}
private static void putIfNotBlank(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> requestBody, Integer loginUserId,
String chatType, Map<String, Object> 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<String, Object> 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<String, Object> requestBody) {
Object mode = requestBody == null ? null : requestBody.get("mode");
return "image".equalsIgnoreCase(stringValue(mode)) ? AiChatType.IMAGE : AiChatType.CREATIVE;
}
/** 创意助手记录的默认入参快照。 */
private Map<String, Object> creativeParams(Map<String, Object> requestBody) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> result, String workflowRunId,
String taskId) {
return new CreativeRun(result, workflowRunId, taskId, null, null, false, false);
}
private static CreativeRun failed(Map<String, Object> 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));
}
/**
@@ -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;
}
@@ -34,4 +34,16 @@ public interface AiChatListMapper extends BaseMapper<AiChatList> {
*/
List<AiChatList> selectListRel(@Param("param") AiChatListParam param);
/**
* 查询最近 N 条(历史面板用)。
*
* <p>历史列表不分页,只取最近的一批:单用户的记录会随「生成记录」持续增长,
* 全量返回会让这个接口越来越重。排序在 SQL 里做,避免先拉全量再截断。
*
* @param param 查询参数
* @param limit 最多返回条数
* @return List<AiChatList>
*/
List<AiChatList> selectLatestRel(@Param("param") AiChatListParam param, @Param("limit") int limit);
}
@@ -43,6 +43,9 @@
<if test="param.title != null">
AND a.title LIKE CONCAT('%', #{param.title}, '%')
</if>
<if test="param.chatType != null">
AND a.chat_type = #{param.chatType}
</if>
<if test="param.keywords != null">
AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%')
)
@@ -60,4 +63,15 @@
<include refid="selectSql"></include>
</select>
<!--
历史面板:只取最近 N 条。
排序必须落在 SQL 里:先拉全量再在内存截断,等于把「不分页」的代价
全留给数据库和网络,记录一多就白白拖慢。
-->
<select id="selectLatestRel" resultType="com.gxwebsoft.ai.entity.AiChatList">
<include refid="selectSql"></include>
ORDER BY a.create_time DESC, a.id DESC
LIMIT #{limit}
</select>
</mapper>
@@ -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;
}
@@ -15,6 +15,13 @@ import java.util.List;
*/
public interface AiChatListService extends IService<AiChatList> {
/**
* 历史面板一次返回的最大条数。
*
* <p>列表不分页,只取最近这批;再早的记录需要翻页能力时才做,不在当前范围内。
*/
int HISTORY_LIMIT = 30;
/**
* 分页关联查询
*
@@ -31,6 +38,15 @@ public interface AiChatListService extends IService<AiChatList> {
*/
List<AiChatList> listRel(AiChatListParam param);
/**
* 查询最近 N 条(按创建时间倒序,SQL 内 LIMIT)
*
* @param param 查询参数
* @param limit 最多返回条数,超过 {@link #HISTORY_LIMIT} 时按上限截断
* @return List<AiChatList>
*/
List<AiChatList> listRecentRel(AiChatListParam param, int limit);
/**
* 根据id查询
*
@@ -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}。
*
* <p>决策与理由见 {@code docs/adr/0001-ai-generation-records.md},三端契约见
* {@code docs/ai-history-contract.md}。要点:
* <ul>
* <li>只存成功结果:调用方负责在失败 / 软失败 / 用户中止时不调用本类;</li>
* <li>记录类型由调用方判定({@link AiChatType}),前端不传;</li>
* <li>同一事务内先写 list 再写 history,避免只落一半;</li>
* <li>标题取用户提示词截断,参数快照写成 JSON 供「再来一次」回填。</li>
* </ul>
*/
@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<String, Object> 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) + "…";
}
}
@@ -23,7 +23,7 @@ public class AiChatHistoryServiceImpl extends ServiceImpl<AiChatHistoryMapper, A
@Override
public PageResult<AiChatHistory> pageRel(AiChatHistoryParam param) {
PageParam<AiChatHistory, AiChatHistoryParam> page = new PageParam<>(param);
page.setDefaultOrder("sort_number asc, create_time desc");
page.setDefaultOrder("create_time asc");
List<AiChatHistory> list = baseMapper.selectPageRel(page, param);
return new PageResult<>(list, page.getTotal());
}
@@ -32,8 +32,11 @@ public class AiChatHistoryServiceImpl extends ServiceImpl<AiChatHistoryMapper, A
public List<AiChatHistory> listRel(AiChatHistoryParam param) {
List<AiChatHistory> list = baseMapper.selectListRel(param);
// 排序
// 原先是 "sort_number asc":ai_chat_history 没有这一列,sortRecords 反射取到 null,
// 多行时 Comparator.comparing(null) 直接 NPE。单行查询侥幸返回,才一直没暴露。
// 改成按创建时间升序,与前端 formatHistoryMessages 的假设一致。
PageParam<AiChatHistory, AiChatHistoryParam> page = new PageParam<>();
page.setDefaultOrder("sort_number asc, create_time desc");
page.setDefaultOrder("create_time asc");
return page.sortRecords(list);
}
@@ -38,6 +38,12 @@ public class AiChatListServiceImpl extends ServiceImpl<AiChatListMapper, AiChatL
return page.sortRecords(list);
}
@Override
public List<AiChatList> 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();
@@ -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}):
*
* <ol>
* <li>只返回登录用户自己的记录,忽略客户端传来的 {@code userId};</li>
* <li>不分页,只取最近 {@link AiChatListService#HISTORY_LIMIT} 条。</li>
* </ol>
*/
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<List<AiChatList>> result = controller.list(param);
ArgumentCaptor<AiChatListParam> 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<List<AiChatList>> 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;
}
}
@@ -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;
/**
* 创意助手「生成记录」落库的端到端验证。
*
* <p>起一个本地 HTTP server 扮演 Dify,跑真实的 {@code executeCreativeAssistant}:
* <ul>
* <li>成功:写一条 {@code image} 记录,并把 {@code chatId} 放进返回体,端上据此高亮;</li>
* <li>软失败(HTTP 200 + {@code data.status=failed}):一条都不写。</li>
* </ul>
* 软失败这条是本次改造最容易踩的坑:上游失败时正文是固定文案「生成失败,请稍后重试」,
* 不判 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<String, Object> 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<Object[]> calls = new ArrayList<>();
@Override
public Integer record(Integer userId, String chatType, String title, Map<String, Object> 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);
}
}
}
@@ -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} 排序回归。
*
* <p>原实现用 {@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<AiChatHistory, AiChatHistoryParam> page = new PageParam<>();
page.setDefaultOrder("create_time asc");
List<AiChatHistory> 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;
}
}
@@ -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})的单元测试。
*
* <p>这几条是决策的硬约束:只存成功、类型由后端判定、标题截断、参数存快照、
* 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<String, Object> params = new HashMap<>();
params.put("docType", "新闻稿");
Integer chatId = newRecorder(listService, historyService)
.record(7, "writing", " 一篇\n很长的稿子 ", params, "用户提示", "生成结果", null);
assertEquals(42, chatId);
ArgumentCaptor<AiChatList> 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<AiChatHistory> 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<AiChatList> 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);
}
}
}
+4 -1
View File
@@ -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;
-- ----------------------------