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:
@@ -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("分页查询")
|
@ApiOperation("分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public ApiResult<PageResult<AiChatHistory>> page(AiChatHistoryParam param) {
|
public ApiResult<PageResult<AiChatHistory>> page(AiChatHistoryParam param) {
|
||||||
|
if (getLoginUserId() == null) {
|
||||||
|
return new ApiResult<PageResult<AiChatHistory>>(1, "请先登录");
|
||||||
|
}
|
||||||
// 使用关联查询
|
// 使用关联查询
|
||||||
return success(aiChatHistoryService.pageRel(param));
|
return success(aiChatHistoryService.pageRel(param));
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,12 @@ public class AiChatHistoryController extends BaseController {
|
|||||||
@ApiOperation("查询全部")
|
@ApiOperation("查询全部")
|
||||||
@GetMapping()
|
@GetMapping()
|
||||||
public ApiResult<List<AiChatHistory>> list(AiChatHistoryParam param) {
|
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));
|
return success(aiChatHistoryService.listRel(param));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public class AiChatListController extends BaseController {
|
|||||||
@ApiOperation("分页查询")
|
@ApiOperation("分页查询")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public ApiResult<PageResult<AiChatList>> page(AiChatListParam param) {
|
public ApiResult<PageResult<AiChatList>> page(AiChatListParam param) {
|
||||||
|
// 管理端视角:允许按 userId 过滤,但不允许匿名读取(历史正文含长文与图片)。
|
||||||
|
if (getLoginUserId() == null) {
|
||||||
|
return new ApiResult<PageResult<AiChatList>>(1, "请先登录");
|
||||||
|
}
|
||||||
// 使用关联查询
|
// 使用关联查询
|
||||||
return success(aiChatListService.pageRel(param));
|
return success(aiChatListService.pageRel(param));
|
||||||
}
|
}
|
||||||
@@ -41,8 +45,15 @@ public class AiChatListController extends BaseController {
|
|||||||
@ApiOperation("查询全部")
|
@ApiOperation("查询全部")
|
||||||
@GetMapping()
|
@GetMapping()
|
||||||
public ApiResult<List<AiChatList>> list(AiChatListParam param) {
|
public ApiResult<List<AiChatList>> list(AiChatListParam param) {
|
||||||
// 使用关联查询
|
Integer loginUserId = getLoginUserId();
|
||||||
return success(aiChatListService.listRel(param));
|
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')")
|
@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.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.ai.config.WebSocketServer;
|
import com.gxwebsoft.ai.config.WebSocketServer;
|
||||||
|
import com.gxwebsoft.ai.constant.AiChatType;
|
||||||
import com.gxwebsoft.ai.entity.ChatMessage;
|
import com.gxwebsoft.ai.entity.ChatMessage;
|
||||||
|
import com.gxwebsoft.ai.service.AiGenerationRecorder;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -204,6 +206,8 @@ public class AiController extends BaseController {
|
|||||||
@Resource
|
@Resource
|
||||||
private AiChatListService aiChatListService;
|
private AiChatListService aiChatListService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private AiGenerationRecorder aiGenerationRecorder;
|
||||||
|
@Resource
|
||||||
private DifyProperties difyProperties;
|
private DifyProperties difyProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,13 +256,6 @@ public class AiController extends BaseController {
|
|||||||
connection.setReadTimeout(aiTimeout());
|
connection.setReadTimeout(aiTimeout());
|
||||||
|
|
||||||
Integer chatId = message.getChatId();
|
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<>();
|
Map<String, Object> payload = new HashMap<>();
|
||||||
payload.put("query", message.getQuery());
|
payload.put("query", message.getQuery());
|
||||||
@@ -280,10 +277,6 @@ public class AiController extends BaseController {
|
|||||||
throw new RuntimeException(result != null ? result.toJSONString() : responseBody);
|
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();
|
StringBuilder reply = new StringBuilder();
|
||||||
String conversationId = null;
|
String conversationId = null;
|
||||||
String messageId = null;
|
String messageId = null;
|
||||||
@@ -323,7 +316,9 @@ public class AiController extends BaseController {
|
|||||||
|
|
||||||
if (StrUtil.isNotBlank(currentConversationId)) {
|
if (StrUtil.isNotBlank(currentConversationId)) {
|
||||||
conversationId = currentConversationId;
|
conversationId = currentConversationId;
|
||||||
if (!conversationSaved) {
|
// 只有调用方显式传了 chatId 才需要回填会话 id;
|
||||||
|
// 新记录在成功收尾时一次性落库(见 persistChatTurn)。
|
||||||
|
if (!conversationSaved && chatId != null && chatId != 0) {
|
||||||
aiChatListService.updateConversationId(chatId, conversationId);
|
aiChatListService.updateConversationId(chatId, conversationId);
|
||||||
conversationSaved = true;
|
conversationSaved = true;
|
||||||
}
|
}
|
||||||
@@ -361,17 +356,9 @@ public class AiController extends BaseController {
|
|||||||
} else if ("message_end".equals(event) || "workflow_finished".equals(event)) {
|
} else if ("message_end".equals(event) || "workflow_finished".equals(event)) {
|
||||||
if (!historySaved) {
|
if (!historySaved) {
|
||||||
historySaved = true;
|
historySaved = true;
|
||||||
aiChatHistory.setConversationId(conversationId);
|
chatId = persistChatTurn(chatId, conversationId, message.getQuery(), reply.toString());
|
||||||
aiChatHistory.setReply(reply.toString());
|
|
||||||
aiChatHistoryService.save(aiChatHistory);
|
|
||||||
}
|
}
|
||||||
HashMap<String, String> endData = new HashMap<>();
|
sendChatEndFrame(chatId, conversationId, messageId, taskId);
|
||||||
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));
|
|
||||||
clearActiveAiTask(getLoginUserId(), taskId);
|
clearActiveAiTask(getLoginUserId(), taskId);
|
||||||
} else if ("error".equals(event)) {
|
} else if ("error".equals(event)) {
|
||||||
throw new RuntimeException(json);
|
throw new RuntimeException(json);
|
||||||
@@ -380,16 +367,8 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!historySaved) {
|
if (!historySaved) {
|
||||||
aiChatHistory.setConversationId(conversationId);
|
chatId = persistChatTurn(chatId, conversationId, message.getQuery(), reply.toString());
|
||||||
aiChatHistory.setReply(reply.toString());
|
sendChatEndFrame(chatId, conversationId, messageId, taskId);
|
||||||
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));
|
|
||||||
clearActiveAiTask(getLoginUserId(), taskId);
|
clearActiveAiTask(getLoginUserId(), taskId);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -407,6 +386,85 @@ public class AiController extends BaseController {
|
|||||||
return success("Stream processing completed");
|
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")
|
@PostMapping("/manuscriptGen")
|
||||||
public ApiResult<?> manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException {
|
public ApiResult<?> manuscriptGen(@RequestBody ManuscriptGenInputs message) throws IOException {
|
||||||
// 未选文稿模板 = 自由模式。此时不调「内容创作与宣传助手」:它开始节点的
|
// 未选文稿模板 = 自由模式。此时不调「内容创作与宣传助手」:它开始节点的
|
||||||
@@ -616,8 +674,18 @@ public class AiController extends BaseController {
|
|||||||
// 前端(uni-app 的 apiRequest 会回落 body.message)会把
|
// 前端(uni-app 的 apiRequest 会回落 body.message)会把
|
||||||
// "Stream processing completed" 当成正文显示(见 issue 08)。
|
// "Stream processing completed" 当成正文显示(见 issue 08)。
|
||||||
// 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。
|
// 失败时同时带 status=failed,前端才能把失败文案与正文区分开(见 issue 10)。
|
||||||
return success("Stream processing completed",
|
Map<String, Object> result = buildManuscriptResult(reply, taskId, workflowRunId, workflowFailed);
|
||||||
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) {
|
} catch (Exception e) {
|
||||||
logger.error("manuscript request failed", e);
|
logger.error("manuscript request failed", e);
|
||||||
HashMap<String, String> answerData = new HashMap<>();
|
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 返回体。
|
* 稿件生成的 HTTP 返回体。
|
||||||
*
|
*
|
||||||
@@ -1194,8 +1370,17 @@ public class AiController extends BaseController {
|
|||||||
// 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方
|
// 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方
|
||||||
// (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket,
|
// (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket,
|
||||||
// 返回体是它唯一的正文来源(见 issue 08)。
|
// 返回体是它唯一的正文来源(见 issue 08)。
|
||||||
return success("Stream processing completed",
|
Map<String, Object> result = buildActivityOutlineResult(reply, conversationId, messageId, workflowFailed);
|
||||||
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) {
|
} catch (Exception e) {
|
||||||
logger.error("activity outline request failed", e);
|
logger.error("activity outline request failed", e);
|
||||||
sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false);
|
sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false);
|
||||||
@@ -1289,6 +1474,18 @@ public class AiController extends BaseController {
|
|||||||
* @return 成功结果为工作流产出;失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
|
* @return 成功结果为工作流产出;失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
|
||||||
*/
|
*/
|
||||||
private ApiResult<?> executeCreativeAssistant(Map<String, Object> requestBody, Integer loginUserId) {
|
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"));
|
String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content"));
|
||||||
if (StrUtil.isBlank(userContent)) {
|
if (StrUtil.isBlank(userContent)) {
|
||||||
return fail("user_content不能为空");
|
return fail("user_content不能为空");
|
||||||
@@ -1307,10 +1504,43 @@ public class AiController extends BaseController {
|
|||||||
logger.error("creative assistant workflow failed, workflowRunId={}, taskId={}, "
|
logger.error("creative assistant workflow failed, workflowRunId={}, taskId={}, "
|
||||||
+ "failedNode={}, upstreamError={}",
|
+ "failedNode={}, upstreamError={}",
|
||||||
run.workflowRunId, run.taskId, run.failedNode, run.failureReason);
|
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);
|
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 拒绝时抛出的信号。
|
* 上游接口直接以非 2xx 拒绝时抛出的信号。
|
||||||
*
|
*
|
||||||
@@ -1342,24 +1572,33 @@ public class AiController extends BaseController {
|
|||||||
private final String failedNode;
|
private final String failedNode;
|
||||||
private final String failureReason;
|
private final String failureReason;
|
||||||
private final boolean failed;
|
private final boolean failed;
|
||||||
|
/** 是否收到过 workflow_finished / message_end:没收到说明流被提前截断 */
|
||||||
|
private final boolean finished;
|
||||||
|
|
||||||
private CreativeRun(Map<String, Object> result, String workflowRunId, String taskId,
|
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.result = result;
|
||||||
this.workflowRunId = workflowRunId;
|
this.workflowRunId = workflowRunId;
|
||||||
this.taskId = taskId;
|
this.taskId = taskId;
|
||||||
this.failedNode = failedNode;
|
this.failedNode = failedNode;
|
||||||
this.failureReason = briefUpstreamError(failureReason);
|
this.failureReason = briefUpstreamError(failureReason);
|
||||||
this.failed = failed;
|
this.failed = failed;
|
||||||
|
this.finished = finished;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreativeRun succeeded(Map<String, Object> result, String workflowRunId, String taskId) {
|
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,
|
private static CreativeRun failed(Map<String, Object> result, String workflowRunId, String taskId,
|
||||||
String failedNode, String failureReason) {
|
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);
|
result.put("taskId", taskId);
|
||||||
}
|
}
|
||||||
clearActiveAiTask(loginUserId, taskId);
|
clearActiveAiTask(loginUserId, taskId);
|
||||||
return CreativeRun.succeeded(result, workflowRunId, taskId);
|
// 没见过收尾事件:正文可能是半截,接口仍返回内容,但不写历史。
|
||||||
|
return CreativeRun.succeededIncomplete(result, workflowRunId, taskId);
|
||||||
} finally {
|
} finally {
|
||||||
if (connection != null) {
|
if (connection != null) {
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
@@ -1688,7 +1928,8 @@ public class AiController extends BaseController {
|
|||||||
body.put("user_content", userContent);
|
body.put("user_content", userContent);
|
||||||
logger.info("manuscript free mode (docType blank), delegate to creative assistant, chars={}",
|
logger.info("manuscript free mode (docType blank), delegate to creative assistant, chars={}",
|
||||||
userContent.length());
|
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;
|
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);
|
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">
|
<if test="param.title != null">
|
||||||
AND a.title LIKE CONCAT('%', #{param.title}, '%')
|
AND a.title LIKE CONCAT('%', #{param.title}, '%')
|
||||||
</if>
|
</if>
|
||||||
|
<if test="param.chatType != null">
|
||||||
|
AND a.chat_type = #{param.chatType}
|
||||||
|
</if>
|
||||||
<if test="param.keywords != null">
|
<if test="param.keywords != null">
|
||||||
AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%')
|
AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
)
|
)
|
||||||
@@ -60,4 +63,15 @@
|
|||||||
<include refid="selectSql"></include>
|
<include refid="selectSql"></include>
|
||||||
</select>
|
</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>
|
</mapper>
|
||||||
|
|||||||
@@ -48,4 +48,8 @@ public class AiChatListParam extends BaseParam {
|
|||||||
|
|
||||||
private String title;
|
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> {
|
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);
|
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查询
|
* 根据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
|
@Override
|
||||||
public PageResult<AiChatHistory> pageRel(AiChatHistoryParam param) {
|
public PageResult<AiChatHistory> pageRel(AiChatHistoryParam param) {
|
||||||
PageParam<AiChatHistory, AiChatHistoryParam> page = new PageParam<>(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);
|
List<AiChatHistory> list = baseMapper.selectPageRel(page, param);
|
||||||
return new PageResult<>(list, page.getTotal());
|
return new PageResult<>(list, page.getTotal());
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,11 @@ public class AiChatHistoryServiceImpl extends ServiceImpl<AiChatHistoryMapper, A
|
|||||||
public List<AiChatHistory> listRel(AiChatHistoryParam param) {
|
public List<AiChatHistory> listRel(AiChatHistoryParam param) {
|
||||||
List<AiChatHistory> list = baseMapper.selectListRel(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<>();
|
PageParam<AiChatHistory, AiChatHistoryParam> page = new PageParam<>();
|
||||||
page.setDefaultOrder("sort_number asc, create_time desc");
|
page.setDefaultOrder("create_time asc");
|
||||||
return page.sortRecords(list);
|
return page.sortRecords(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ public class AiChatListServiceImpl extends ServiceImpl<AiChatListMapper, AiChatL
|
|||||||
return page.sortRecords(list);
|
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
|
@Override
|
||||||
public AiChatList getByIdRel(Integer id) {
|
public AiChatList getByIdRel(Integer id) {
|
||||||
AiChatListParam param = new AiChatListParam();
|
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");
|
||||||
|
|
||||||
|
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
@@ -138,7 +138,10 @@ CREATE TABLE `ai_chat_list` (
|
|||||||
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
|
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
|
||||||
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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,
|
`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;
|
) ENGINE=InnoDB AUTO_INCREMENT=408 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user