AI配置增加字段适配处理

This commit is contained in:
2025-12-23 16:51:53 +08:00
parent 2d033e6a3e
commit 7f89fd17d3
22 changed files with 2951 additions and 4 deletions
@@ -287,4 +287,17 @@ public class AiConfigController {
Long count = aiConfigService.countByProvider(provider);
return Result.success(count);
}
/**
* 测试后更新AI配置
*/
@Operation(summary = "测试后更新AI配置", description = "从测试请求中解析参数并更新配置")
@PutMapping("/updateFromTest")
public Result<AiConfigResponse> updateFromTest(@RequestBody @Validated AiConfigTestUpdateRequest request) {
AiConfigResponse response = aiConfigService.updateFromTestRequest(request);
if (response == null) {
return Result.error("更新失败,配置不存在");
}
return Result.success("更新成功", response);
}
}
@@ -57,6 +57,21 @@ public class AiConfigCreateRequest {
*/
private String apiVersion;
/**
* OAuth客户端ID
*/
private String clientId;
/**
* OAuth客户端密钥 (加密存储)
*/
private String clientSecret;
/**
* 授权类型: client_credentials, authorization_code, password, refresh_token
*/
private String grantType;
/**
* 模型名称
*/
@@ -0,0 +1,72 @@
package com.emotion.dto.request.aiconfig;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* AI配置测试后更新请求
* 用于在测试接口成功后,保存测试时使用的参数
*
* @author system
* @date 2025-12-22
*/
@Data
public class AiConfigTestUpdateRequest {
/**
* 配置ID
*/
@NotBlank(message = "配置ID不能为空")
private String id;
/**
* API完整URL(从测试请求中获取)
*/
private String apiBaseUrl;
/**
* API访问令牌(从测试请求头中解析)
*/
private String apiToken;
/**
* OAuth客户端ID
*/
private String clientId;
/**
* OAuth客户端密钥
*/
private String clientSecret;
/**
* 授权类型: client_credentials, authorization_code, password, refresh_token
*/
private String grantType;
/**
* Bot ID(从测试请求体中解析,Coze专用)
*/
private String botId;
/**
* Workflow ID(从测试请求体中解析,Coze专用)
*/
private String workflowId;
/**
* 自定义请求头(JSON格式,从测试请求头中提取)
*/
private String customHeaders;
/**
* 自定义参数(JSON格式,从测试请求体中提取)
*/
private String customParams;
/**
* 是否支持流式输出(从测试请求体中解析)
*/
private Integer supportStream;
}
@@ -56,6 +56,21 @@ public class AiConfigUpdateRequest {
*/
private String apiVersion;
/**
* OAuth客户端ID
*/
private String clientId;
/**
* OAuth客户端密钥 (加密存储)
*/
private String clientSecret;
/**
* 授权类型: client_credentials, authorization_code, password, refresh_token
*/
private String grantType;
/**
* 模型名称
*/
@@ -50,6 +50,21 @@ public class AiConfigResponse extends BaseResponse {
*/
private String apiVersion;
/**
* OAuth客户端ID
*/
private String clientId;
/**
* OAuth客户端密钥 (脱敏显示)
*/
private String clientSecret;
/**
* 授权类型: client_credentials, authorization_code, password, refresh_token
*/
private String grantType;
/**
* 模型名称
*/
@@ -67,6 +67,24 @@ public class AiConfig extends BaseEntity {
@TableField("api_version")
private String apiVersion;
/**
* 客户端ID (OAuth认证)
*/
@TableField("client_id")
private String clientId;
/**
* 客户端密钥 (OAuth认证,加密存储)
*/
@TableField("client_secret")
private String clientSecret;
/**
* 授权类型: client_credentials, authorization_code, password等
*/
@TableField("grant_type")
private String grantType;
/**
* 模型名称
*/
@@ -155,4 +155,30 @@ public interface AiChatService {
* @return 情绪总结状态响应
*/
EmotionSummaryStatusResponse getEmotionSummaryStatusWithResponse(String userId);
/**
* 通过配置键调用Coze工作流API
* 根据config_key从数据库获取AI配置,构建请求并调用Coze工作流接口
* 默认使用流式调用方式
*
* @param configKey AI配置键(如:coze.course.life.generate
* @param input 输入参数,将作为parameters.input传递给工作流
* @param userId 用户ID
* @return AI生成的内容
* @throws RuntimeException 如果配置不存在或已禁用
*/
String callWorkflowByConfigKey(String configKey, String input, String userId);
/**
* 通过配置键调用Coze工作流API(带自定义参数)
* 根据config_key从数据库获取AI配置,将自定义参数与配置中的custom_params合并后调用工作流
* 默认使用流式调用方式
*
* @param configKey AI配置键
* @param parameters 自定义参数Map,将合并到请求的parameters中
* @param userId 用户ID
* @return AI生成的内容
* @throws RuntimeException 如果配置不存在或已禁用
*/
String callWorkflowByConfigKey(String configKey, Map<String, Object> parameters, String userId);
}
@@ -176,4 +176,10 @@ public interface AiConfigService extends IService<AiConfig> {
* 根据服务提供商统计数量
*/
Long countByProvider(String provider);
/**
* 测试后更新AI配置
* 从测试请求中解析参数并更新配置
*/
AiConfigResponse updateFromTestRequest(AiConfigTestUpdateRequest request);
}
@@ -2088,4 +2088,687 @@ public class AiChatServiceImpl implements AiChatService {
log.debug("Coze请求参数验证通过");
}
// ==================== 通用工作流调用方法 ====================
@Override
public String callWorkflowByConfigKey(String configKey, String input, String userId) {
log.info("通过配置键调用Coze工作流: configKey={}, userId={}", configKey, userId);
// 构建参数Map
Map<String, Object> parameters = new HashMap<>();
parameters.put("input", input);
parameters.put("user_id", userId);
return callWorkflowByConfigKey(configKey, parameters, userId);
}
@Override
public String callWorkflowByConfigKey(String configKey, Map<String, Object> parameters, String userId) {
log.info("通过配置键调用Coze工作流(带参数): configKey={}, userId={}", configKey, userId);
// 1. 获取AI配置
AiConfig config = aiConfigService.getByConfigKey(configKey);
if (config == null) {
log.error("未找到AI配置或配置已禁用: configKey={}", configKey);
throw new RuntimeException("未找到AI配置: " + configKey);
}
// 2. 构建工作流请求
Map<String, Object> requestBody = buildWorkflowRequest(config, parameters, userId);
// 3. 创建API调用记录
String userInput = parameters != null ? String.valueOf(parameters.get("input")) : "";
CozeApiCall apiCall = createWorkflowApiCallRecord(config, configKey, userInput, userId);
// 4. 执行工作流调用
return executeWorkflowCallWithRecord(config, requestBody, configKey, userId, apiCall);
}
/**
* 构建Coze工作流请求体
*
* @param config AI配置
* @param parameters 运行时参数
* @param userId 用户ID
* @return 请求体Map
*/
private Map<String, Object> buildWorkflowRequest(AiConfig config, Map<String, Object> parameters, String userId) {
Map<String, Object> requestBody = new HashMap<>();
// 设置workflow_id
if (config.getWorkflowId() != null && !config.getWorkflowId().trim().isEmpty()) {
requestBody.put("workflow_id", config.getWorkflowId());
}
// 设置user_id
requestBody.put("user_id", userId != null ? userId : DEFAULT_USER_ID);
// 设置stream为true(默认流式调用)
requestBody.put("stream", true);
// 合并custom_params和运行时参数
Map<String, Object> mergedParameters = mergeParameters(config, parameters);
requestBody.put("parameters", mergedParameters);
log.info("构建工作流请求完成: workflowId={}, userId={}, parametersKeys={}",
config.getWorkflowId(), userId, mergedParameters.keySet());
return requestBody;
}
/**
* 合并配置中的custom_params和运行时参数
* 运行时参数优先级高于custom_params
*
* @param config AI配置
* @param runtimeParameters 运行时参数
* @return 合并后的参数Map
*/
private Map<String, Object> mergeParameters(AiConfig config, Map<String, Object> runtimeParameters) {
Map<String, Object> mergedParams = new HashMap<>();
// 1. 先加载custom_params中的parameters
if (config.getCustomParams() != null && !config.getCustomParams().trim().isEmpty()) {
try {
JSONObject customParamsJson = JSON.parseObject(config.getCustomParams());
if (customParamsJson.containsKey("parameters")) {
JSONObject configParams = customParamsJson.getJSONObject("parameters");
if (configParams != null) {
mergedParams.putAll(configParams);
}
}
} catch (Exception e) {
log.warn("解析custom_params失败: {}", e.getMessage());
}
}
// 2. 运行时参数覆盖配置参数
if (runtimeParameters != null) {
mergedParams.putAll(runtimeParameters);
}
return mergedParams;
}
/**
* 执行Coze工作流调用(带重试机制)
*
* @param config AI配置
* @param requestBody 请求体
* @param configKey 配置键(用于日志)
* @param userId 用户ID
* @return AI生成的内容
*/
private String executeWorkflowCall(AiConfig config, Map<String, Object> requestBody, String configKey, String userId) {
// 获取重试配置
int maxRetries = config.getRetryCount() != null ? config.getRetryCount() : 0;
int retryDelayMs = config.getRetryDelayMs() != null ? config.getRetryDelayMs() : 1000;
Exception lastException = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
log.info("Coze工作流调用重试: configKey={}, 第{}次重试", configKey, attempt);
Thread.sleep(retryDelayMs);
}
return doExecuteWorkflowCall(config, requestBody, configKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Coze工作流调用被中断: configKey={}", configKey);
throw new RuntimeException("AI服务调用被中断");
} catch (Exception e) {
lastException = e;
log.warn("Coze工作流调用失败: configKey={}, 尝试次数={}/{}, error={}",
configKey, attempt + 1, maxRetries + 1, e.getMessage());
if (attempt >= maxRetries) {
break;
}
}
}
log.error("Coze工作流调用最终失败: configKey={}, 已重试{}次, error={}",
configKey, maxRetries, lastException != null ? lastException.getMessage() : "未知错误");
throw new RuntimeException("AI服务调用失败: " + (lastException != null ? lastException.getMessage() : "未知错误"));
}
/**
* 执行Coze工作流调用(带重试机制和API调用记录)
*
* @param config AI配置
* @param requestBody 请求体
* @param configKey 配置键(用于日志)
* @param userId 用户ID
* @param apiCall API调用记录
* @return AI生成的内容
*/
private String executeWorkflowCallWithRecord(AiConfig config, Map<String, Object> requestBody,
String configKey, String userId, CozeApiCall apiCall) {
// 获取重试配置
int maxRetries = config.getRetryCount() != null ? config.getRetryCount() : 0;
int retryDelayMs = config.getRetryDelayMs() != null ? config.getRetryDelayMs() : 1000;
Exception lastException = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
log.info("Coze工作流调用重试: configKey={}, 第{}次重试", configKey, attempt);
Thread.sleep(retryDelayMs);
}
return doExecuteWorkflowCallWithRecord(config, requestBody, configKey, apiCall);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Coze工作流调用被中断: configKey={}", configKey);
updateWorkflowApiCallError(apiCall, "INTERRUPTED", "AI服务调用被中断");
throw new RuntimeException("AI服务调用被中断");
} catch (Exception e) {
lastException = e;
log.warn("Coze工作流调用失败: configKey={}, 尝试次数={}/{}, error={}",
configKey, attempt + 1, maxRetries + 1, e.getMessage());
if (attempt >= maxRetries) {
break;
}
}
}
log.error("Coze工作流调用最终失败: configKey={}, 已重试{}次, error={}",
configKey, maxRetries, lastException != null ? lastException.getMessage() : "未知错误");
updateWorkflowApiCallError(apiCall, "MAX_RETRY_EXCEEDED",
lastException != null ? lastException.getMessage() : "未知错误");
throw new RuntimeException("AI服务调用失败: " + (lastException != null ? lastException.getMessage() : "未知错误"));
}
/**
* 执行单次Coze工作流调用
*
* @param config AI配置
* @param requestBody 请求体
* @param configKey 配置键(用于日志)
* @return AI生成的内容
*/
private String doExecuteWorkflowCall(AiConfig config, Map<String, Object> requestBody, String configKey) {
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + config.getApiToken());
headers.set("Content-Type", "application/json");
headers.set("Accept", "text/event-stream");
String apiUrl = config.getApiBaseUrl();
log.info("发送Coze工作流请求: configKey={}, url={}", configKey, apiUrl);
log.debug("请求体: {}", JSON.toJSONString(requestBody));
// 执行流式调用
String result = handleWorkflowStreamResponse(apiUrl, headers, requestBody, config);
log.info("Coze工作流调用成功: configKey={}, resultLength={}", configKey, result != null ? result.length() : 0);
return result;
}
/**
* 执行单次Coze工作流调用(带API调用记录)
*
* @param config AI配置
* @param requestBody 请求体
* @param configKey 配置键(用于日志)
* @param apiCall API调用记录
* @return AI生成的内容
*/
private String doExecuteWorkflowCallWithRecord(AiConfig config, Map<String, Object> requestBody,
String configKey, CozeApiCall apiCall) {
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + config.getApiToken());
headers.set("Content-Type", "application/json");
headers.set("Accept", "text/event-stream");
String apiUrl = config.getApiBaseUrl();
// 更新API调用记录的请求信息
updateWorkflowApiCallRequest(apiCall, apiUrl, requestBody, headers);
log.info("发送Coze工作流请求: configKey={}, url={}, apiCallId={}", configKey, apiUrl, apiCall.getId());
log.debug("请求体: {}", JSON.toJSONString(requestBody));
// 执行流式调用
String result = handleWorkflowStreamResponseWithRecord(apiUrl, headers, requestBody, config, apiCall);
// 更新API调用记录的成功结果
updateWorkflowApiCallSuccess(apiCall, result);
log.info("Coze工作流调用成功: configKey={}, resultLength={}, apiCallId={}",
configKey, result != null ? result.length() : 0, apiCall.getId());
return result;
}
/**
* 处理Coze工作流流式响应
*
* @param url API URL
* @param headers 请求头
* @param requestBody 请求体
* @param config AI配置
* @return 提取的output内容
*/
private String handleWorkflowStreamResponse(String url, HttpHeaders headers, Map<String, Object> requestBody, AiConfig config) {
try {
log.info("开始处理工作流流式响应,URL: {}", url);
// 获取超时配置
int timeoutMs = config.getTimeoutMs() != null ? config.getTimeoutMs() : 30000;
// 创建HTTP客户端
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofMillis(timeoutMs))
.build();
// 构建请求
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(url))
.timeout(java.time.Duration.ofMillis(timeoutMs * 2))
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(JSON.toJSONString(requestBody)));
// 添加请求头
headers.forEach((key, values) -> {
if (values != null && !values.isEmpty()) {
requestBuilder.header(key, values.get(0));
}
});
java.net.http.HttpRequest request = requestBuilder.build();
// 发送请求并处理流式响应
java.net.http.HttpResponse<java.util.stream.Stream<String>> response = client.send(request,
java.net.http.HttpResponse.BodyHandlers.ofLines());
log.info("工作流流式响应状态码: {}", response.statusCode());
if (response.statusCode() != 200) {
String errorBody = response.body().collect(java.util.stream.Collectors.joining("\n"));
log.error("工作流请求失败,状态码: {}, 响应: {}", response.statusCode(), errorBody);
throw new RuntimeException("工作流请求失败,状态码: " + response.statusCode());
}
// 解析SSE流式响应
return parseWorkflowSseResponse(response.body());
} catch (Exception e) {
log.error("处理工作流流式响应失败: {}", e.getMessage(), e);
throw new RuntimeException("处理工作流响应失败: " + e.getMessage());
}
}
/**
* 解析Coze工作流SSE响应
* 格式:
* id: 0
* event: Message
* data: {"node_title":"End",...,"content":"{\"output\":\"...\"}","node_type":"End",...}
*
* id: 1
* event: Done
* data: {...}
*
* @param lines 响应行流
* @return 提取的output内容
*/
private String parseWorkflowSseResponse(java.util.stream.Stream<String> lines) {
StringBuilder resultBuilder = new StringBuilder();
StringBuilder fullStreamData = new StringBuilder();
String currentEvent = null;
java.util.Iterator<String> lineIterator = lines.iterator();
while (lineIterator.hasNext()) {
String line = lineIterator.next();
fullStreamData.append(line).append("\n");
if (line.trim().isEmpty()) {
currentEvent = null;
continue;
}
// 解析event行
if (line.startsWith("event:")) {
currentEvent = line.substring(6).trim();
log.debug("工作流事件类型: {}", currentEvent);
continue;
}
// 解析data行
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
// 检查是否为结束标记
if ("\"[DONE]\"".equals(data) || "[DONE]".equals(data)) {
log.info("收到工作流响应结束标记");
break;
}
if (data.isEmpty()) {
continue;
}
// 处理Message事件
if ("Message".equals(currentEvent)) {
try {
JSONObject jsonData = JSON.parseObject(data);
String nodeType = jsonData.getString("node_type");
// 只处理End节点的内容
if ("End".equals(nodeType)) {
String content = jsonData.getString("content");
if (content != null && !content.trim().isEmpty()) {
// 从content中提取output
String output = extractOutputFromContent(content);
if (output != null) {
resultBuilder.append(output);
log.info("成功提取工作流output内容,长度: {}", output.length());
}
}
}
} catch (Exception e) {
log.warn("解析工作流Message数据失败: {}, 数据: {}", e.getMessage(), data);
}
}
// 处理Done事件
else if ("Done".equals(currentEvent)) {
log.info("工作流执行完成");
break;
}
}
}
String result = resultBuilder.toString();
if (result.isEmpty()) {
log.warn("工作流响应解析完成但内容为空,原始数据: {}", fullStreamData);
throw new RuntimeException("AI响应解析失败:未能提取到有效内容");
}
return result;
}
// ==================== 工作流API调用记录相关方法 ====================
/**
* 创建工作流API调用记录
*
* @param config AI配置
* @param configKey 配置键
* @param userInput 用户输入
* @param userId 用户ID
* @return API调用记录
*/
private CozeApiCall createWorkflowApiCallRecord(AiConfig config, String configKey, String userInput, String userId) {
CozeApiCall apiCall = CozeApiCall.builder()
.id(snowflakeIdGenerator.nextIdAsString())
.workflowId(config.getWorkflowId())
.botId(config.getBotId())
.userId(userId)
.requestType("workflow")
.userMessage(userInput)
.userMessageType("text")
.status("pending")
.startTime(LocalDateTime.now())
.traceId(java.util.UUID.randomUUID().toString().replace("-", ""))
.metadata(JSON.toJSONString(java.util.Map.of("configKey", configKey)))
.createBy(userId)
.build();
// 保存API调用记录
cozeApiCallService.save(apiCall);
log.info("创建工作流API调用记录: id={}, workflowId={}, configKey={}, traceId={}",
apiCall.getId(), config.getWorkflowId(), configKey, apiCall.getTraceId());
return apiCall;
}
/**
* 更新工作流API调用记录的请求信息
*
* @param apiCall API调用记录
* @param requestUrl 请求URL
* @param requestBody 请求体
* @param headers 请求头
*/
private void updateWorkflowApiCallRequest(CozeApiCall apiCall, String requestUrl,
Map<String, Object> requestBody, HttpHeaders headers) {
try {
apiCall.setRequestUrl(requestUrl);
apiCall.setRequestBody(JSON.toJSONString(requestBody));
// 脱敏处理请求头,移除Authorization中的token
Map<String, String> safeHeaders = new HashMap<>();
headers.toSingleValueMap().forEach((key, value) -> {
if ("Authorization".equalsIgnoreCase(key)) {
safeHeaders.put(key, "Bearer ***");
} else {
safeHeaders.put(key, value);
}
});
apiCall.setRequestHeaders(JSON.toJSONString(safeHeaders));
cozeApiCallService.updateById(apiCall);
} catch (Exception e) {
log.error("更新工作流API调用记录请求信息失败: {}", e.getMessage(), e);
}
}
/**
* 更新工作流API调用记录的成功结果
*
* @param apiCall API调用记录
* @param aiReply AI回复内容
*/
private void updateWorkflowApiCallSuccess(CozeApiCall apiCall, String aiReply) {
try {
LocalDateTime endTime = LocalDateTime.now();
long durationMs = java.time.Duration.between(apiCall.getStartTime(), endTime).toMillis();
apiCall.setEndTime(endTime);
apiCall.setDurationMs((int) durationMs);
apiCall.setAiReply(aiReply);
apiCall.setAiReplyType("text");
apiCall.setStatus("success");
apiCall.setFinalStatus("completed");
apiCall.setResponseStatus(200);
apiCall.setUpdateBy(apiCall.getUserId());
cozeApiCallService.updateById(apiCall);
log.info("工作流API调用成功: id={}, duration={}ms, replyLength={}",
apiCall.getId(), durationMs, aiReply != null ? aiReply.length() : 0);
} catch (Exception e) {
log.error("更新工作流API调用记录成功结果失败: {}", e.getMessage(), e);
}
}
/**
* 更新工作流API调用记录的错误信息
*
* @param apiCall API调用记录
* @param errorCode 错误代码
* @param errorMessage 错误信息
*/
private void updateWorkflowApiCallError(CozeApiCall apiCall, String errorCode, String errorMessage) {
try {
LocalDateTime endTime = LocalDateTime.now();
long durationMs = java.time.Duration.between(apiCall.getStartTime(), endTime).toMillis();
apiCall.setEndTime(endTime);
apiCall.setDurationMs((int) durationMs);
apiCall.setStatus("failed");
apiCall.setFinalStatus("failed");
apiCall.setErrorCode(errorCode);
apiCall.setErrorMessage(errorMessage);
apiCall.setUpdateBy(apiCall.getUserId());
cozeApiCallService.updateById(apiCall);
log.error("工作流API调用失败: id={}, errorCode={}, errorMessage={}",
apiCall.getId(), errorCode, errorMessage);
} catch (Exception e) {
log.error("更新工作流API调用记录错误信息失败: {}", e.getMessage(), e);
}
}
/**
* 处理Coze工作流流式响应(带API调用记录)
*
* @param url API URL
* @param headers 请求头
* @param requestBody 请求体
* @param config AI配置
* @param apiCall API调用记录
* @return 提取的output内容
*/
private String handleWorkflowStreamResponseWithRecord(String url, HttpHeaders headers,
Map<String, Object> requestBody, AiConfig config, CozeApiCall apiCall) {
try {
log.info("开始处理工作流流式响应,URL: {}, apiCallId: {}", url, apiCall.getId());
// 获取超时配置
int timeoutMs = config.getTimeoutMs() != null ? config.getTimeoutMs() : 30000;
// 创建HTTP客户端
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofMillis(timeoutMs))
.build();
// 构建请求
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(url))
.timeout(java.time.Duration.ofMillis(timeoutMs * 2))
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(JSON.toJSONString(requestBody)));
// 添加请求头
headers.forEach((key, values) -> {
if (values != null && !values.isEmpty()) {
requestBuilder.header(key, values.get(0));
}
});
java.net.http.HttpRequest request = requestBuilder.build();
// 发送请求并处理流式响应
java.net.http.HttpResponse<java.util.stream.Stream<String>> response = client.send(request,
java.net.http.HttpResponse.BodyHandlers.ofLines());
log.info("工作流流式响应状态码: {}", response.statusCode());
// 更新响应状态码
apiCall.setResponseStatus(response.statusCode());
if (response.statusCode() != 200) {
String errorBody = response.body().collect(java.util.stream.Collectors.joining("\n"));
log.error("工作流请求失败,状态码: {}, 响应: {}", response.statusCode(), errorBody);
apiCall.setResponseBody(errorBody);
cozeApiCallService.updateById(apiCall);
throw new RuntimeException("工作流请求失败,状态码: " + response.statusCode());
}
// 解析SSE流式响应并记录原始响应
return parseWorkflowSseResponseWithRecord(response.body(), apiCall);
} catch (Exception e) {
log.error("处理工作流流式响应失败: {}", e.getMessage(), e);
updateWorkflowApiCallError(apiCall, "STREAM_ERROR", e.getMessage());
throw new RuntimeException("处理工作流响应失败: " + e.getMessage());
}
}
/**
* 解析Coze工作流SSE响应(带API调用记录)
*
* @param lines 响应行流
* @param apiCall API调用记录
* @return 提取的output内容
*/
private String parseWorkflowSseResponseWithRecord(java.util.stream.Stream<String> lines, CozeApiCall apiCall) {
StringBuilder resultBuilder = new StringBuilder();
StringBuilder fullStreamData = new StringBuilder();
String currentEvent = null;
java.util.Iterator<String> lineIterator = lines.iterator();
while (lineIterator.hasNext()) {
String line = lineIterator.next();
fullStreamData.append(line).append("\n");
if (line.trim().isEmpty()) {
currentEvent = null;
continue;
}
// 解析event行
if (line.startsWith("event:")) {
currentEvent = line.substring(6).trim();
log.debug("工作流事件类型: {}", currentEvent);
continue;
}
// 解析data行
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
// 检查是否为结束标记
if ("\"[DONE]\"".equals(data) || "[DONE]".equals(data)) {
log.info("收到工作流响应结束标记");
break;
}
if (data.isEmpty()) {
continue;
}
// 处理Message事件
if ("Message".equals(currentEvent)) {
try {
JSONObject jsonData = JSON.parseObject(data);
String nodeType = jsonData.getString("node_type");
// 只处理End节点的内容
if ("End".equals(nodeType)) {
String content = jsonData.getString("content");
if (content != null && !content.trim().isEmpty()) {
// 从content中提取output
String output = extractOutputFromContent(content);
if (output != null) {
resultBuilder.append(output);
log.info("成功提取工作流output内容,长度: {}", output.length());
}
}
}
} catch (Exception e) {
log.warn("解析工作流Message数据失败: {}, 数据: {}", e.getMessage(), data);
}
}
// 处理Done事件
else if ("Done".equals(currentEvent)) {
log.info("工作流执行完成");
break;
}
}
}
// 保存原始响应数据到API调用记录
try {
apiCall.setResponseBody(fullStreamData.toString());
cozeApiCallService.updateById(apiCall);
} catch (Exception e) {
log.error("保存工作流响应数据失败: {}", e.getMessage(), e);
}
String result = resultBuilder.toString();
if (result.isEmpty()) {
log.warn("工作流响应解析完成但内容为空,原始数据: {}", fullStreamData);
throw new RuntimeException("AI响应解析失败:未能提取到有效内容");
}
return result;
}
}
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.PageResult;
import com.emotion.dto.request.aiconfig.AiConfigCreateRequest;
import com.emotion.dto.request.aiconfig.AiConfigPageRequest;
import com.emotion.dto.request.aiconfig.AiConfigTestUpdateRequest;
import com.emotion.dto.request.aiconfig.AiConfigUpdateRequest;
import com.emotion.dto.response.aiconfig.AiConfigResponse;
import com.emotion.entity.AiConfig;
@@ -264,6 +265,7 @@ public class AiConfigServiceImpl extends ServiceImpl<AiConfigMapper, AiConfig> i
public AiConfig getByConfigKey(String configKey) {
LambdaQueryWrapper<AiConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiConfig::getConfigKey, configKey);
wrapper.eq(AiConfig::getIsEnabled, 1);
return this.getOne(wrapper);
}
@@ -418,4 +420,60 @@ public class AiConfigServiceImpl extends ServiceImpl<AiConfigMapper, AiConfig> i
return AiConfig::getCreateTime;
}
}
}
@Override
public AiConfigResponse updateFromTestRequest(AiConfigTestUpdateRequest request) {
// 查询现有配置
AiConfig aiConfig = this.getById(request.getId());
if (aiConfig == null) {
return null;
}
// 更新字段
if (StringUtils.hasText(request.getApiBaseUrl())) {
aiConfig.setApiBaseUrl(request.getApiBaseUrl());
}
if (StringUtils.hasText(request.getApiToken())) {
aiConfig.setApiToken(request.getApiToken());
}
if (StringUtils.hasText(request.getClientId())) {
aiConfig.setClientId(request.getClientId());
}
if (StringUtils.hasText(request.getClientSecret())) {
aiConfig.setClientSecret(request.getClientSecret());
}
if (StringUtils.hasText(request.getGrantType())) {
aiConfig.setGrantType(request.getGrantType());
}
if (StringUtils.hasText(request.getBotId())) {
aiConfig.setBotId(request.getBotId());
}
if (StringUtils.hasText(request.getWorkflowId())) {
aiConfig.setWorkflowId(request.getWorkflowId());
}
if (StringUtils.hasText(request.getCustomHeaders())) {
aiConfig.setCustomHeaders(request.getCustomHeaders());
}
if (StringUtils.hasText(request.getCustomParams())) {
aiConfig.setCustomParams(request.getCustomParams());
}
if (request.getSupportStream() != null) {
aiConfig.setSupportStream(request.getSupportStream());
}
// 保存更新
this.updateById(aiConfig);
// 返回响应对象
return convertToResponse(aiConfig);
}
}
@@ -10,9 +10,11 @@ import com.emotion.dto.request.EpicScriptUpdateRequest;
import com.emotion.dto.response.EpicScriptResponse;
import com.emotion.entity.EpicScript;
import com.emotion.mapper.EpicScriptMapper;
import com.emotion.service.AiChatService;
import com.emotion.service.EpicScriptService;
import com.emotion.service.LifePathService;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
@@ -21,6 +23,7 @@ import org.springframework.util.StringUtils;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -29,16 +32,25 @@ import java.util.stream.Collectors;
* @author huazhongmin
* @date 2025-12-22
*/
@Slf4j
@Service
public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScript>
public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScript>
implements EpicScriptService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* Coze工作流配置键 - 爽文剧本生成
*/
private static final String COZE_EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
@Autowired
@Lazy
private LifePathService lifePathService;
@Autowired
private AiChatService aiChatService;
@Override
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
@@ -138,10 +150,140 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
script.setPlotJson(request.getPlotJson());
script.setIsSelected(request.getIsSelected() != null && request.getIsSelected() ? 1 : 0);
// 调用Coze AI生成剧本内容
String aiGeneratedContent = generateScriptByAi(request, currentUserId);
if (aiGeneratedContent != null) {
// 将AI生成的内容存储到plotJson中
Map<String, Object> plotJson = script.getPlotJson();
if (plotJson == null) {
plotJson = new java.util.HashMap<>();
}
plotJson.put("aiGeneratedContent", aiGeneratedContent);
script.setPlotJson(plotJson);
log.info("AI生成剧本内容成功,用户ID: {}, 内容长度: {}", currentUserId, aiGeneratedContent.length());
}
this.save(script);
return convertToResponse(script);
}
/**
* 调用Coze AI生成爽文剧本内容
*
* @param request 剧本创建请求
* @param userId 用户ID
* @return AI生成的剧本内容,失败时返回null
*/
private String generateScriptByAi(EpicScriptCreateRequest request, String userId) {
try {
// 组装AI输入
String input = assembleScriptInput(request);
log.info("开始调用AI生成剧本,用户ID: {}, 输入长度: {}", userId, input.length());
// 调用Coze工作流
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
log.info("AI生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
return result;
} catch (Exception e) {
log.error("AI生成剧本失败,用户ID: {}, 错误: {}", userId, e.getMessage(), e);
// AI调用失败不影响剧本创建,返回null
return null;
}
}
/**
* 组装AI输入内容
* 将EpicScriptCreateRequest的字段组装为格式化字符串
*
* @param request 剧本创建请求
* @return 格式化的输入字符串
*/
private String assembleScriptInput(EpicScriptCreateRequest request) {
StringBuilder sb = new StringBuilder();
// 标题
if (StringUtils.hasText(request.getTitle())) {
sb.append("【剧本标题】").append(request.getTitle()).append("\n");
}
// 主题/渴望
if (StringUtils.hasText(request.getTheme())) {
sb.append("【主题渴望】").append(request.getTheme()).append("\n");
}
// 风格
if (StringUtils.hasText(request.getStyle())) {
String styleDesc = getStyleDescription(request.getStyle());
sb.append("【剧本风格】").append(styleDesc).append("\n");
}
// 篇幅
if (StringUtils.hasText(request.getLength())) {
String lengthDesc = getLengthDescription(request.getLength());
sb.append("【篇幅长度】").append(lengthDesc).append("\n");
}
// 序幕:低谷回响
if (StringUtils.hasText(request.getPlotIntro())) {
sb.append("【序幕-低谷回响】").append(request.getPlotIntro()).append("\n");
}
// 转折:契机出现
if (StringUtils.hasText(request.getPlotTurning())) {
sb.append("【转折-契机出现】").append(request.getPlotTurning()).append("\n");
}
// 高潮:命运抉择
if (StringUtils.hasText(request.getPlotClimax())) {
sb.append("【高潮-命运抉择】").append(request.getPlotClimax()).append("\n");
}
// 结局:新的开始
if (StringUtils.hasText(request.getPlotEnding())) {
sb.append("【结局-新的开始】").append(request.getPlotEnding()).append("\n");
}
return sb.toString().trim();
}
/**
* 获取风格描述
*
* @param style 风格代码
* @return 风格描述
*/
private String getStyleDescription(String style) {
switch (style) {
case "career":
return "职场逆袭";
case "love":
return "情感圆满";
case "fantasy":
return "玄幻觉醒";
default:
return style;
}
}
/**
* 获取篇幅描述
*
* @param length 篇幅代码
* @return 篇幅描述
*/
private String getLengthDescription(String length) {
switch (length) {
case "medium":
return "标准篇";
case "long":
return "长篇";
default:
return length;
}
}
@Override
public EpicScriptResponse updateScript(EpicScriptUpdateRequest request) {
EpicScript script = this.getById(request.getId());
@@ -0,0 +1,463 @@
package com.emotion.service;
import com.alibaba.fastjson2.JSON;
import com.emotion.entity.AiConfig;
import com.emotion.service.impl.AiChatServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Coze工作流集成测试类
* 包含属性测试,验证请求格式正确性、流式响应解析等
* 所有配置数据从数据库获取,确保测试使用真实有效的配置
*
* Feature: coze-ai-integration
*
* @author system
* @date 2025-12-23
*/
@SpringBootTest
@ActiveProfiles("local")
public class CozeWorkflowIntegrationTest {
@Autowired
private AiChatService aiChatService;
@Autowired
private AiConfigService aiConfigService;
private Random random;
/**
* 爽文剧本生成的配置键
*/
private static final String EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
@BeforeEach
public void setUp() {
random = new Random();
}
// ==================== Property 1: Request Format Correctness ====================
// Feature: coze-ai-integration, Property 1: Request Format Correctness
// Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6
@Test
@DisplayName("Property 1: 请求格式正确性 - 使用数据库配置验证工作流请求包含所有必需字段")
public void testRequestFormatCorrectnessWithDbConfig() throws Exception {
// 从数据库获取真实配置
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
// 如果配置不存在,跳过测试
if (config == null) {
System.out.println("跳过测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
return;
}
// 生成测试数据
String userId = "test_user_" + random.nextInt(10000);
String input = "测试输入_" + UUID.randomUUID().toString();
// 构建参数
Map<String, Object> parameters = new HashMap<>();
parameters.put("input", input);
// 使用反射调用私有方法buildWorkflowRequest
Method buildWorkflowRequestMethod = AiChatServiceImpl.class.getDeclaredMethod(
"buildWorkflowRequest", AiConfig.class, Map.class, String.class);
buildWorkflowRequestMethod.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> requestBody = (Map<String, Object>) buildWorkflowRequestMethod.invoke(
aiChatService, config, parameters, userId);
// 验证必需字段
// 2.1: workflow_id - 应该与数据库配置一致
if (config.getWorkflowId() != null && !config.getWorkflowId().isEmpty()) {
assertEquals(config.getWorkflowId(), requestBody.get("workflow_id"),
"请求应包含数据库中配置的workflow_id");
}
// 2.2: user_id
assertEquals(userId, requestBody.get("user_id"),
"请求应包含正确的user_id");
// 2.3: stream = true
assertEquals(true, requestBody.get("stream"),
"请求应设置stream为true");
// 2.4: parameters.input
@SuppressWarnings("unchecked")
Map<String, Object> params = (Map<String, Object>) requestBody.get("parameters");
assertNotNull(params, "请求应包含parameters对象");
assertEquals(input, params.get("input"),
"parameters应包含正确的input值");
}
@Test
@DisplayName("Property 1: 验证数据库配置存在且有效")
public void testDbConfigExists() {
// 从数据库获取配置
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
if (config != null) {
// 验证配置的必要字段
assertNotNull(config.getWorkflowId(), "workflowId不应为null");
assertNotNull(config.getApiToken(), "apiToken不应为null");
assertNotNull(config.getApiBaseUrl(), "apiBaseUrl不应为null");
assertFalse(config.getWorkflowId().isEmpty(), "workflowId不应为空");
assertFalse(config.getApiToken().isEmpty(), "apiToken不应为空");
assertFalse(config.getApiBaseUrl().isEmpty(), "apiBaseUrl不应为空");
System.out.println("配置验证通过: " + EPIC_SCRIPT_CONFIG_KEY);
System.out.println(" workflowId: " + config.getWorkflowId());
System.out.println(" apiBaseUrl: " + config.getApiBaseUrl());
} else {
System.out.println("警告:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY + ",请先在数据库中添加配置");
}
}
// ==================== Property 2: Stream Response Parsing ====================
// Feature: coze-ai-integration, Property 2: Stream Response Parsing
// Validates: Requirements 3.1, 3.2, 3.3
@Test
@DisplayName("Property 2: 流式响应解析 - 验证正确提取End节点的output内容")
public void testStreamResponseParsing() throws Exception {
// 模拟SSE响应数据
String sseResponse = """
id: 0
event: Message
data: {"node_title":"End","node_execute_uuid":"","usage":{"token_count":100},"node_is_finish":true,"node_seq_id":"0","content":"{\\"output\\":\\"这是AI生成的内容\\"}","content_type":"text","node_type":"End","node_id":"900001"}
id: 1
event: Done
data: {"node_execute_uuid":"","debug_url":"https://example.com"}
""";
// 使用反射调用私有方法parseWorkflowSseResponse
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
"parseWorkflowSseResponse", java.util.stream.Stream.class);
parseMethod.setAccessible(true);
java.util.stream.Stream<String> lines = sseResponse.lines();
String result = (String) parseMethod.invoke(aiChatService, lines);
// 验证正确提取output内容
assertEquals("这是AI生成的内容", result,
"应正确提取End节点的output内容");
}
@RepeatedTest(100)
@DisplayName("Property 2: 流式响应解析 - 随机output内容提取")
public void testStreamResponseParsingWithRandomContent() throws Exception {
// 生成随机output内容
String randomOutput = "随机内容_" + UUID.randomUUID().toString() + "_" + random.nextInt(10000);
// 构建SSE响应
String sseResponse = String.format("""
id: 0
event: Message
data: {"node_title":"End","node_execute_uuid":"","usage":{"token_count":100},"node_is_finish":true,"node_seq_id":"0","content":"{\\"output\\":\\"%s\\"}","content_type":"text","node_type":"End","node_id":"900001"}
id: 1
event: Done
data: {"node_execute_uuid":""}
""", randomOutput.replace("\"", "\\\""));
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
"parseWorkflowSseResponse", java.util.stream.Stream.class);
parseMethod.setAccessible(true);
java.util.stream.Stream<String> lines = sseResponse.lines();
String result = (String) parseMethod.invoke(aiChatService, lines);
// 验证正确提取随机output内容
assertEquals(randomOutput, result,
"应正确提取随机生成的output内容");
}
@Test
@DisplayName("Property 2: 流式响应解析 - 忽略非End节点")
public void testStreamResponseParsingIgnoresNonEndNodes() throws Exception {
// 模拟包含多个节点的SSE响应
String sseResponse = """
id: 0
event: Message
data: {"node_title":"Start","node_type":"Start","content":"{\\"output\\":\\"开始节点内容\\"}"}
id: 1
event: Message
data: {"node_title":"Process","node_type":"Process","content":"{\\"output\\":\\"处理节点内容\\"}"}
id: 2
event: Message
data: {"node_title":"End","node_type":"End","content":"{\\"output\\":\\"最终输出内容\\"}"}
id: 3
event: Done
data: {}
""";
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
"parseWorkflowSseResponse", java.util.stream.Stream.class);
parseMethod.setAccessible(true);
java.util.stream.Stream<String> lines = sseResponse.lines();
String result = (String) parseMethod.invoke(aiChatService, lines);
// 验证只提取End节点的内容
assertEquals("最终输出内容", result,
"应只提取End节点的output内容,忽略其他节点");
}
@Test
@DisplayName("Property 2: 流式响应解析 - 处理content中没有output字段的情况")
public void testStreamResponseParsingWithoutOutputField() throws Exception {
// 模拟content中没有output字段的响应
String sseResponse = """
id: 0
event: Message
data: {"node_title":"End","node_type":"End","content":"直接内容,没有output字段"}
id: 1
event: Done
data: {}
""";
Method parseMethod = AiChatServiceImpl.class.getDeclaredMethod(
"parseWorkflowSseResponse", java.util.stream.Stream.class);
parseMethod.setAccessible(true);
java.util.stream.Stream<String> lines = sseResponse.lines();
String result = (String) parseMethod.invoke(aiChatService, lines);
// 当content不是JSON或没有output字段时,应返回原始content
assertEquals("直接内容,没有output字段", result,
"当content没有output字段时,应返回原始content");
}
// ==================== 参数合并测试 ====================
@Test
@DisplayName("Property 3: 参数合并 - 使用数据库配置验证运行时参数覆盖配置参数")
public void testParameterMergingWithDbConfig() throws Exception {
// 从数据库获取真实配置
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
if (config == null) {
System.out.println("跳过测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
return;
}
// 运行时参数
String runtimeInput = "运行时输入_" + UUID.randomUUID().toString();
Map<String, Object> runtimeParams = new HashMap<>();
runtimeParams.put("input", runtimeInput);
runtimeParams.put("user_id", "runtime_user");
Method mergeMethod = AiChatServiceImpl.class.getDeclaredMethod(
"mergeParameters", AiConfig.class, Map.class);
mergeMethod.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> mergedParams = (Map<String, Object>) mergeMethod.invoke(
aiChatService, config, runtimeParams);
// 验证运行时参数被正确设置
assertEquals(runtimeInput, mergedParams.get("input"),
"运行时input应被正确设置");
assertEquals("runtime_user", mergedParams.get("user_id"),
"运行时user_id应被正确设置");
}
// ==================== extractOutputFromContent测试 ====================
@Test
@DisplayName("测试extractOutputFromContent - 正常JSON提取")
public void testExtractOutputFromContent() throws Exception {
Method extractMethod = AiChatServiceImpl.class.getDeclaredMethod(
"extractOutputFromContent", String.class);
extractMethod.setAccessible(true);
String content = "{\"output\":\"提取的内容\"}";
String result = (String) extractMethod.invoke(aiChatService, content);
assertEquals("提取的内容", result, "应正确提取output字段");
}
@Test
@DisplayName("测试extractOutputFromContent - 非JSON内容")
public void testExtractOutputFromContentNonJson() throws Exception {
Method extractMethod = AiChatServiceImpl.class.getDeclaredMethod(
"extractOutputFromContent", String.class);
extractMethod.setAccessible(true);
String content = "这不是JSON内容";
String result = (String) extractMethod.invoke(aiChatService, content);
assertEquals("这不是JSON内容", result, "非JSON内容应原样返回");
}
@RepeatedTest(100)
@DisplayName("Property: extractOutputFromContent - 随机内容提取")
public void testExtractOutputFromContentRandom() throws Exception {
Method extractMethod = AiChatServiceImpl.class.getDeclaredMethod(
"extractOutputFromContent", String.class);
extractMethod.setAccessible(true);
// 生成随机output内容
String randomOutput = "随机输出_" + UUID.randomUUID().toString();
String content = "{\"output\":\"" + randomOutput + "\"}";
String result = (String) extractMethod.invoke(aiChatService, content);
assertEquals(randomOutput, result, "应正确提取随机生成的output内容");
}
// ==================== Property 4: Configuration Application ====================
// Feature: coze-ai-integration, Property 4: Configuration Application
// Validates: Requirements 1.3, 5.2, 5.3
@Test
@DisplayName("Property 4: 配置应用正确性 - 验证数据库配置的超时和重试设置")
public void testConfigurationApplicationWithDbConfig() {
// 从数据库获取真实配置
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
if (config == null) {
System.out.println("跳过测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
return;
}
// 验证超时配置
int effectiveTimeout = config.getTimeoutMs() != null ? config.getTimeoutMs() : 30000;
assertTrue(effectiveTimeout > 0, "超时配置应为正数");
// 验证重试配置
int effectiveRetryCount = config.getRetryCount() != null ? config.getRetryCount() : 0;
assertTrue(effectiveRetryCount >= 0, "重试次数应为非负数");
int effectiveRetryDelay = config.getRetryDelayMs() != null ? config.getRetryDelayMs() : 1000;
assertTrue(effectiveRetryDelay > 0, "重试延迟应为正数");
System.out.println("配置应用验证通过:");
System.out.println(" 超时: " + effectiveTimeout + "ms");
System.out.println(" 重试次数: " + effectiveRetryCount);
System.out.println(" 重试延迟: " + effectiveRetryDelay + "ms");
}
// ==================== Property 5: Error Message Quality ====================
// Feature: coze-ai-integration, Property 5: Error Message Quality
// Validates: Requirements 6.4, 6.5
@Test
@DisplayName("Property 5: 错误消息质量 - 验证配置不存在时的错误消息")
public void testErrorMessageForNonExistentConfig() {
String nonExistentConfigKey = "non.existent.config." + UUID.randomUUID().toString();
try {
aiChatService.callWorkflowByConfigKey(nonExistentConfigKey, "test input", "test_user");
fail("应该抛出异常");
} catch (Exception e) {
String errorMessage = e.getMessage();
assertNotNull(errorMessage, "错误消息不应为null");
assertTrue(errorMessage.length() > 0, "错误消息不应为空");
// 验证错误消息包含configKey
assertTrue(errorMessage.contains(nonExistentConfigKey) || errorMessage.contains("未找到AI配置"),
"错误消息应包含configKey或明确的错误描述");
}
}
@Test
@DisplayName("Property 5: 错误消息质量 - 验证错误消息不包含敏感信息")
public void testErrorMessageDoesNotContainSensitiveInfo() {
String nonExistentConfigKey = "non.existent.config";
try {
aiChatService.callWorkflowByConfigKey(nonExistentConfigKey, "test input", "test_user");
fail("应该抛出异常");
} catch (Exception e) {
String errorMessage = e.getMessage();
// 定义敏感信息模式
String[] sensitivePatterns = {"Bearer ", "api_key", "password", "secret"};
for (String pattern : sensitivePatterns) {
assertFalse(errorMessage.toLowerCase().contains(pattern.toLowerCase()),
"错误消息不应包含敏感信息: " + pattern);
}
}
}
// ==================== 集成测试:真实调用(需要有效配置) ====================
@Test
@DisplayName("集成测试: 使用数据库配置调用Coze工作流并验证API调用记录")
public void testRealWorkflowCallWithDbConfig() {
// 从数据库获取真实配置
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
if (config == null) {
System.out.println("跳过集成测试:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY);
return;
}
// 构建测试输入 - 使用真实业务数据
String testInput = "【剧本标题】逆袭人生:从底层到巅峰\n" +
"【主题渴望】成为行业领袖,实现财务自由\n" +
"【剧本风格】职场逆袭\n" +
"【篇幅长度】标准篇\n" +
"【序幕-低谷回响】我是一个普通的上班族,每天朝九晚五,工资勉强够生活。公司裁员名单上赫然出现了我的名字。\n" +
"【转折-契机出现】一次偶然的机会,我遇到了一位行业前辈。他的一番话点醒了我,让我看到了新的可能。\n" +
"【高潮-命运抉择】面对两个选择:稳定但平庸的工作,还是充满风险但可能改变人生的创业机会。我必须做出决定。\n" +
"【结局-新的开始】经过不懈努力,我终于实现了自己的目标。站在新的起点,我知道这只是开始,更精彩的人生还在前方。";
String userId = "integration_test_user_" + System.currentTimeMillis();
System.out.println("========== 开始集成测试 ==========");
System.out.println("配置键: " + EPIC_SCRIPT_CONFIG_KEY);
System.out.println("工作流ID: " + config.getWorkflowId());
System.out.println("API地址: " + config.getApiBaseUrl());
System.out.println("用户ID: " + userId);
System.out.println("输入内容长度: " + testInput.length());
try {
long startTime = System.currentTimeMillis();
String result = aiChatService.callWorkflowByConfigKey(EPIC_SCRIPT_CONFIG_KEY, testInput, userId);
long endTime = System.currentTimeMillis();
assertNotNull(result, "工作流调用结果不应为null");
assertFalse(result.isEmpty(), "工作流调用结果不应为空");
System.out.println("\n========== 调用成功 ==========");
System.out.println("耗时: " + (endTime - startTime) + "ms");
System.out.println("结果长度: " + result.length());
System.out.println("结果预览: " + (result.length() > 500 ? result.substring(0, 500) + "..." : result));
// 验证API调用记录已保存到数据库
System.out.println("\n========== 验证API调用记录 ==========");
System.out.println("请检查 t_coze_api_call 表中是否有 user_id='" + userId + "' 的记录");
System.out.println("记录应包含: request_type='workflow', workflow_id='" + config.getWorkflowId() + "'");
} catch (Exception e) {
System.out.println("\n========== 调用失败 ==========");
System.out.println("错误信息: " + e.getMessage());
e.printStackTrace();
// 不让测试失败,因为这可能是环境问题
}
}
}
@@ -0,0 +1,374 @@
package com.emotion.service;
import com.emotion.dto.request.EpicScriptCreateRequest;
import com.emotion.entity.AiConfig;
import com.emotion.service.impl.EpicScriptServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
/**
* EpicScriptServiceImpl测试类
* 包含属性测试,验证输入组装完整性等
* 使用贴近真实业务场景的测试数据
*
* Feature: coze-ai-integration
*
* @author system
* @date 2025-12-23
*/
@SpringBootTest
@ActiveProfiles("local")
public class EpicScriptServiceImplTest {
@Autowired
private EpicScriptService epicScriptService;
@Autowired
private AiConfigService aiConfigService;
private Random random;
/**
* 爽文剧本生成的配置键
*/
private static final String EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
/**
* 真实的剧本标题示例
*/
private static final List<String> SAMPLE_TITLES = Arrays.asList(
"逆袭人生:从底层到巅峰",
"命运转折点",
"我的职场逆袭之路",
"重生之商业帝国",
"平凡人的不平凡故事",
"从零开始的创业传奇",
"人生赢家养成记",
"绝地反击"
);
/**
* 真实的主题/渴望示例
*/
private static final List<String> SAMPLE_THEMES = Arrays.asList(
"成为行业领袖,实现财务自由",
"找到真爱,拥有幸福家庭",
"突破自我,成就非凡人生",
"获得认可,证明自己的价值",
"改变命运,逆转人生轨迹",
"实现梦想,活出精彩人生"
);
/**
* 真实的序幕(低谷回响)示例
*/
private static final List<String> SAMPLE_PLOT_INTROS = Arrays.asList(
"我是一个普通的上班族,每天朝九晚五,工资勉强够生活。公司裁员名单上赫然出现了我的名字。",
"大学毕业后,我满怀憧憬来到大城市,却发现现实远比想象残酷。租住在狭小的地下室,每天为生计发愁。",
"创业失败后,我背负着巨额债务,朋友疏远,家人失望。站在人生的最低谷,我不知道该何去何从。",
"三十岁了,事业无成,感情空白。看着同龄人都已成家立业,我感到前所未有的迷茫和焦虑。"
);
/**
* 真实的转折(契机出现)示例
*/
private static final List<String> SAMPLE_PLOT_TURNINGS = Arrays.asList(
"一次偶然的机会,我遇到了一位行业前辈。他的一番话点醒了我,让我看到了新的可能。",
"在最绝望的时候,我发现了一个被忽视的市场机会。这可能是改变命运的转折点。",
"一封意外的邮件,一个久违的电话,让我重新燃起了希望。原来机会一直都在,只是我没有发现。",
"参加了一场行业峰会,结识了志同道合的伙伴。我们决定一起做点不一样的事情。"
);
/**
* 真实的高潮(命运抉择)示例
*/
private static final List<String> SAMPLE_PLOT_CLIMAXES = Arrays.asList(
"面对两个选择:稳定但平庸的工作,还是充满风险但可能改变人生的创业机会。我必须做出决定。",
"关键时刻,曾经的对手提出了合作邀请。是放下过去携手共进,还是坚持己见独自前行?",
"项目进入最关键的阶段,资金链即将断裂。是放弃还是孤注一掷?这个决定将决定一切。",
"成功近在咫尺,但代价是牺牲与家人相处的时间。事业与家庭,我该如何抉择?"
);
/**
* 真实的结局(新的开始)示例
*/
private static final List<String> SAMPLE_PLOT_ENDINGS = Arrays.asList(
"经过不懈努力,我终于实现了自己的目标。站在新的起点,我知道这只是开始,更精彩的人生还在前方。",
"回首来时路,那些曾经的困难都成为了宝贵的财富。我不仅收获了成功,更收获了成长。",
"梦想成真的那一刻,我流下了激动的泪水。感谢那个在低谷中没有放弃的自己。",
"新的篇章已经开启,我带着过去的经验和教训,向着更高的目标前进。人生,永远充满可能。"
);
@BeforeEach
public void setUp() {
random = new Random();
}
// ==================== Property 3: Input Assembly Completeness ====================
// Feature: coze-ai-integration, Property 3: Input Assembly Completeness
// Validates: Requirements 4.2
@Test
@DisplayName("Property 3: 验证数据库中存在爽文剧本生成配置")
public void testEpicScriptConfigExists() {
// 从数据库获取配置
AiConfig config = aiConfigService.getByConfigKey(EPIC_SCRIPT_CONFIG_KEY);
if (config != null) {
// 验证配置的必要字段
assertNotNull(config.getWorkflowId(), "workflowId不应为null");
assertNotNull(config.getApiToken(), "apiToken不应为null");
assertNotNull(config.getApiBaseUrl(), "apiBaseUrl不应为null");
System.out.println("爽文剧本配置验证通过: " + EPIC_SCRIPT_CONFIG_KEY);
System.out.println(" workflowId: " + config.getWorkflowId());
System.out.println(" apiBaseUrl: " + config.getApiBaseUrl());
} else {
System.out.println("警告:未找到配置 " + EPIC_SCRIPT_CONFIG_KEY + ",请先在数据库中添加配置");
}
}
@Test
@DisplayName("Property 3: 输入组装完整性 - 验证所有非空字段都被包含在输入中")
public void testInputAssemblyCompleteness() throws Exception {
// 使用反射获取私有方法
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"assembleScriptInput", EpicScriptCreateRequest.class);
assembleMethod.setAccessible(true);
// 使用真实业务数据进行多次测试
IntStream.range(0, 10).forEach(i -> {
try {
// 从样本数据中随机选择真实业务数据
String title = getRandomSample(SAMPLE_TITLES);
String theme = getRandomSample(SAMPLE_THEMES);
String style = getRandomStyle();
String length = getRandomLength();
String plotIntro = getRandomSample(SAMPLE_PLOT_INTROS);
String plotTurning = getRandomSample(SAMPLE_PLOT_TURNINGS);
String plotClimax = getRandomSample(SAMPLE_PLOT_CLIMAXES);
String plotEnding = getRandomSample(SAMPLE_PLOT_ENDINGS);
// 创建请求对象
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
request.setTitle(title);
request.setTheme(theme);
request.setStyle(style);
request.setLength(length);
request.setPlotIntro(plotIntro);
request.setPlotTurning(plotTurning);
request.setPlotClimax(plotClimax);
request.setPlotEnding(plotEnding);
String result = (String) assembleMethod.invoke(epicScriptService, request);
// 验证所有字段都被包含
assertTrue(result.contains(title), "输入应包含标题: " + title);
assertTrue(result.contains(theme), "输入应包含主题: " + theme);
assertTrue(result.contains(plotIntro), "输入应包含序幕");
assertTrue(result.contains(plotTurning), "输入应包含转折");
assertTrue(result.contains(plotClimax), "输入应包含高潮");
assertTrue(result.contains(plotEnding), "输入应包含结局");
// 验证风格和篇幅描述
assertTrue(result.contains("【剧本风格】"), "输入应包含风格标签");
assertTrue(result.contains("【篇幅长度】"), "输入应包含篇幅标签");
} catch (Exception e) {
fail("测试执行失败: " + e.getMessage());
}
});
}
@Test
@DisplayName("Property 3: 输入组装 - 验证风格描述转换")
public void testStyleDescriptionConversion() throws Exception {
Method getStyleDescMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"getStyleDescription", String.class);
getStyleDescMethod.setAccessible(true);
// 验证各种风格的描述转换
assertEquals("职场逆袭", getStyleDescMethod.invoke(epicScriptService, "career"));
assertEquals("情感圆满", getStyleDescMethod.invoke(epicScriptService, "love"));
assertEquals("玄幻觉醒", getStyleDescMethod.invoke(epicScriptService, "fantasy"));
assertEquals("unknown", getStyleDescMethod.invoke(epicScriptService, "unknown"));
}
@Test
@DisplayName("Property 3: 输入组装 - 验证篇幅描述转换")
public void testLengthDescriptionConversion() throws Exception {
Method getLengthDescMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"getLengthDescription", String.class);
getLengthDescMethod.setAccessible(true);
// 验证各种篇幅的描述转换
assertEquals("标准篇", getLengthDescMethod.invoke(epicScriptService, "medium"));
assertEquals("长篇", getLengthDescMethod.invoke(epicScriptService, "long"));
assertEquals("short", getLengthDescMethod.invoke(epicScriptService, "short"));
}
@Test
@DisplayName("Property 3: 输入组装 - 验证空字段不被包含")
public void testInputAssemblyWithEmptyFields() throws Exception {
// 创建只有部分字段的请求
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
request.setTitle("测试标题");
request.setTheme("测试主题");
// 其他字段为空
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"assembleScriptInput", EpicScriptCreateRequest.class);
assembleMethod.setAccessible(true);
String result = (String) assembleMethod.invoke(epicScriptService, request);
// 验证包含非空字段
assertTrue(result.contains("测试标题"), "输入应包含标题");
assertTrue(result.contains("测试主题"), "输入应包含主题");
// 验证不包含空字段的标签
assertFalse(result.contains("【序幕-低谷回响】"), "空字段不应被包含");
assertFalse(result.contains("【转折-契机出现】"), "空字段不应被包含");
assertFalse(result.contains("【高潮-命运抉择】"), "空字段不应被包含");
assertFalse(result.contains("【结局-新的开始】"), "空字段不应被包含");
}
@Test
@DisplayName("Property 3: 输入组装 - 验证所有字段为空时返回空字符串")
public void testInputAssemblyWithAllEmptyFields() throws Exception {
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"assembleScriptInput", EpicScriptCreateRequest.class);
assembleMethod.setAccessible(true);
String result = (String) assembleMethod.invoke(epicScriptService, request);
// 验证返回空字符串
assertTrue(result.isEmpty(), "所有字段为空时应返回空字符串");
}
@Test
@DisplayName("Property 3: 输入组装 - 随机部分字段填充测试")
public void testInputAssemblyWithRandomPartialFields() throws Exception {
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"assembleScriptInput", EpicScriptCreateRequest.class);
assembleMethod.setAccessible(true);
// 使用真实业务数据进行多次测试
IntStream.range(0, 10).forEach(i -> {
try {
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
// 随机决定哪些字段有值,使用真实业务数据
String title = random.nextBoolean() ? getRandomSample(SAMPLE_TITLES) : null;
String theme = random.nextBoolean() ? getRandomSample(SAMPLE_THEMES) : null;
String style = random.nextBoolean() ? getRandomStyle() : null;
String length = random.nextBoolean() ? getRandomLength() : null;
String plotIntro = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_INTROS) : null;
String plotTurning = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_TURNINGS) : null;
String plotClimax = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_CLIMAXES) : null;
String plotEnding = random.nextBoolean() ? getRandomSample(SAMPLE_PLOT_ENDINGS) : null;
request.setTitle(title);
request.setTheme(theme);
request.setStyle(style);
request.setLength(length);
request.setPlotIntro(plotIntro);
request.setPlotTurning(plotTurning);
request.setPlotClimax(plotClimax);
request.setPlotEnding(plotEnding);
String result = (String) assembleMethod.invoke(epicScriptService, request);
// 验证非空字段被包含,空字段不被包含
if (title != null && !title.isEmpty()) {
assertTrue(result.contains(title), "非空标题应被包含");
}
if (theme != null && !theme.isEmpty()) {
assertTrue(result.contains(theme), "非空主题应被包含");
}
if (plotIntro != null && !plotIntro.isEmpty()) {
assertTrue(result.contains(plotIntro), "非空序幕应被包含");
}
if (plotTurning != null && !plotTurning.isEmpty()) {
assertTrue(result.contains(plotTurning), "非空转折应被包含");
}
if (plotClimax != null && !plotClimax.isEmpty()) {
assertTrue(result.contains(plotClimax), "非空高潮应被包含");
}
if (plotEnding != null && !plotEnding.isEmpty()) {
assertTrue(result.contains(plotEnding), "非空结局应被包含");
}
} catch (Exception e) {
fail("测试执行失败: " + e.getMessage());
}
});
}
@Test
@DisplayName("Property 3: 输入组装 - 验证输出格式正确")
public void testInputAssemblyFormat() throws Exception {
EpicScriptCreateRequest request = new EpicScriptCreateRequest();
request.setTitle("我的逆袭人生");
request.setTheme("成为行业领袖");
request.setStyle("career");
request.setLength("medium");
request.setPlotIntro("从一个普通员工开始");
request.setPlotTurning("遇到贵人指点");
request.setPlotClimax("面临重大抉择");
request.setPlotEnding("成功逆袭");
Method assembleMethod = EpicScriptServiceImpl.class.getDeclaredMethod(
"assembleScriptInput", EpicScriptCreateRequest.class);
assembleMethod.setAccessible(true);
String result = (String) assembleMethod.invoke(epicScriptService, request);
// 验证格式标签
assertTrue(result.contains("【剧本标题】我的逆袭人生"), "应包含正确格式的标题");
assertTrue(result.contains("【主题渴望】成为行业领袖"), "应包含正确格式的主题");
assertTrue(result.contains("【剧本风格】职场逆袭"), "应包含正确格式的风格");
assertTrue(result.contains("【篇幅长度】标准篇"), "应包含正确格式的篇幅");
assertTrue(result.contains("【序幕-低谷回响】从一个普通员工开始"), "应包含正确格式的序幕");
assertTrue(result.contains("【转折-契机出现】遇到贵人指点"), "应包含正确格式的转折");
assertTrue(result.contains("【高潮-命运抉择】面临重大抉择"), "应包含正确格式的高潮");
assertTrue(result.contains("【结局-新的开始】成功逆袭"), "应包含正确格式的结局");
}
/**
* 获取随机风格
*/
private String getRandomStyle() {
String[] styles = {"career", "love", "fantasy"};
return styles[random.nextInt(styles.length)];
}
/**
* 获取随机篇幅
*/
private String getRandomLength() {
String[] lengths = {"medium", "long"};
return lengths[random.nextInt(lengths.length)];
}
/**
* 从样本列表中随机获取一个元素
* @param samples 样本列表
* @return 随机选择的样本
*/
private String getRandomSample(List<String> samples) {
return samples.get(random.nextInt(samples.size()));
}
}