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}。要点:
+ *
+ * - 只存成功结果:调用方负责在失败 / 软失败 / 用户中止时不调用本类;
+ * - 记录类型由调用方判定({@link AiChatType}),前端不传;
+ * - 同一事务内先写 list 再写 history,避免只落一半;
+ * - 标题取用户提示词截断,参数快照写成 JSON 供「再来一次」回填。
+ *
+ */
+@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}):
+ *
+ *
+ * - 只返回登录用户自己的记录,忽略客户端传来的 {@code userId};
+ * - 不分页,只取最近 {@link AiChatListService#HISTORY_LIMIT} 条。
+ *
+ */
+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");
+
+ 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