feat: AI 场景路由、ASR 服务及前后端全链路同步
- 新增 AI 场景路由控制器和管理接口 - 新增 ASR 语音识别服务及前后端集成 - 同步 AI Runtime 客户端到 Web/小程序/Life-Script - 完善 AI 配置测试修复和管理后台路由配置 - 新增数据库迁移脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.common.Result;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.entity.AiCallLog;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import com.emotion.entity.AiSceneBinding;
|
||||
import com.emotion.service.AiCallLogService;
|
||||
import com.emotion.service.AiEndpointConfigService;
|
||||
import com.emotion.service.AiProviderService;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.AiSceneBindingService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/ai")
|
||||
public class AiRoutingController {
|
||||
|
||||
private final AiProviderService providerService;
|
||||
private final AiEndpointConfigService endpointConfigService;
|
||||
private final AiSceneBindingService sceneBindingService;
|
||||
private final AiCallLogService callLogService;
|
||||
private final AiRuntimeService runtimeService;
|
||||
|
||||
public AiRoutingController(AiProviderService providerService,
|
||||
AiEndpointConfigService endpointConfigService,
|
||||
AiSceneBindingService sceneBindingService,
|
||||
AiCallLogService callLogService,
|
||||
AiRuntimeService runtimeService) {
|
||||
this.providerService = providerService;
|
||||
this.endpointConfigService = endpointConfigService;
|
||||
this.sceneBindingService = sceneBindingService;
|
||||
this.callLogService = callLogService;
|
||||
this.runtimeService = runtimeService;
|
||||
}
|
||||
|
||||
@GetMapping("/providers")
|
||||
public Result<List<AiProvider>> providers() {
|
||||
return Result.success(providerService.listVisible());
|
||||
}
|
||||
|
||||
@PostMapping("/providers")
|
||||
public Result<AiProvider> createProvider(@RequestBody AiProvider provider) {
|
||||
return Result.success(providerService.saveProvider(provider));
|
||||
}
|
||||
|
||||
@PutMapping("/providers")
|
||||
public Result<AiProvider> updateProvider(@RequestBody AiProvider provider) {
|
||||
return Result.success(providerService.updateProvider(provider));
|
||||
}
|
||||
|
||||
@DeleteMapping("/providers")
|
||||
public Result<Void> deleteProvider(@RequestParam String id) {
|
||||
providerService.removeById(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints")
|
||||
public Result<List<AiEndpointConfig>> endpoints() {
|
||||
return Result.success(endpointConfigService.listVisible());
|
||||
}
|
||||
|
||||
@PostMapping("/endpoints")
|
||||
public Result<AiEndpointConfig> createEndpoint(@RequestBody AiEndpointConfig endpoint) {
|
||||
return Result.success(endpointConfigService.saveEndpoint(endpoint));
|
||||
}
|
||||
|
||||
@PutMapping("/endpoints")
|
||||
public Result<AiEndpointConfig> updateEndpoint(@RequestBody AiEndpointConfig endpoint) {
|
||||
return Result.success(endpointConfigService.updateEndpoint(endpoint));
|
||||
}
|
||||
|
||||
@DeleteMapping("/endpoints")
|
||||
public Result<Void> deleteEndpoint(@RequestParam String id) {
|
||||
endpointConfigService.removeById(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@GetMapping("/scenes")
|
||||
public Result<List<AiSceneBinding>> scenes() {
|
||||
return Result.success(sceneBindingService.listVisible());
|
||||
}
|
||||
|
||||
@PostMapping("/scenes")
|
||||
public Result<AiSceneBinding> createScene(@RequestBody AiSceneBinding scene) {
|
||||
if (scene.getIsEnabled() == null) {
|
||||
scene.setIsEnabled(1);
|
||||
}
|
||||
if (scene.getRequiredStream() == null) {
|
||||
scene.setRequiredStream(1);
|
||||
}
|
||||
sceneBindingService.save(scene);
|
||||
return Result.success(scene);
|
||||
}
|
||||
|
||||
@PutMapping("/scenes")
|
||||
public Result<AiSceneBinding> updateScene(@RequestBody AiSceneBinding scene) {
|
||||
sceneBindingService.updateById(scene);
|
||||
return Result.success(sceneBindingService.getById(scene.getId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/scenes")
|
||||
public Result<Void> deleteScene(@RequestParam String id) {
|
||||
sceneBindingService.removeById(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@GetMapping("/call-logs")
|
||||
public Result<List<AiCallLog>> callLogs(@RequestParam(required = false) Integer limit) {
|
||||
return Result.success(callLogService.latest(limit));
|
||||
}
|
||||
|
||||
@PostMapping("/runtime/test")
|
||||
public Result<AiRuntimeTestResponse> runtimeTest(@RequestBody JSONObject payload) {
|
||||
AiRuntimeRequest request = withCurrentUser(AiRuntimeRequest.fromPayload(payload));
|
||||
return Result.success(runtimeService.test(request));
|
||||
}
|
||||
|
||||
@PostMapping("/runtime/stream")
|
||||
public SseEmitter runtimeStream(@RequestBody JSONObject payload) {
|
||||
AiRuntimeRequest request = withCurrentUser(AiRuntimeRequest.fromPayload(payload));
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
CompletableFuture.runAsync(() -> {
|
||||
runtimeService.invokeStream(request, event -> sendEvent(emitter, event));
|
||||
emitter.complete();
|
||||
}).exceptionally(error -> {
|
||||
sendEvent(emitter, AiStreamEvent.error("AI_STREAM_INTERRUPTED", error.getMessage()));
|
||||
emitter.completeWithError(error);
|
||||
return null;
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private AiRuntimeRequest withCurrentUser(AiRuntimeRequest request) {
|
||||
request.setUserId(UserContextHolder.getCurrentUserId());
|
||||
request.setUserName(UserContextHolder.getCurrentUsername());
|
||||
request.setUserType(UserContextHolder.getCurrentUserType());
|
||||
request.setRequestId(UserContextHolder.getRequestId());
|
||||
return request;
|
||||
}
|
||||
|
||||
private void sendEvent(SseEmitter emitter, AiStreamEvent event) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name(event.getType())
|
||||
.data(event));
|
||||
} catch (IOException e) {
|
||||
log.warn("AI stream client disconnected: {}", e.getMessage());
|
||||
throw new IllegalStateException("AI_STREAM_CLIENT_DISCONNECTED", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.common.Result;
|
||||
import com.emotion.dto.response.asr.AsrTranscribeResponse;
|
||||
import com.emotion.service.AsrService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/asr")
|
||||
public class AsrController {
|
||||
|
||||
private final AsrService asrService;
|
||||
|
||||
public AsrController(AsrService asrService) {
|
||||
this.asrService = asrService;
|
||||
}
|
||||
|
||||
@PostMapping("/transcribe")
|
||||
public Result<AsrTranscribeResponse> transcribe(@RequestPart("file") MultipartFile file) {
|
||||
if (UserContextHolder.getCurrentUserId() == null) {
|
||||
return Result.unauthorized();
|
||||
}
|
||||
try {
|
||||
return Result.success(asrService.transcribe(file));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.badRequest(e.getMessage());
|
||||
} catch (IllegalStateException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.emotion.dto.request.ai;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.Data;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
public class AiRuntimeRequest {
|
||||
|
||||
private static final Set<String> RESERVED_KEYS = Set.of("sceneCode", "scene", "inputs", "userId", "userName", "username", "userType", "requestId");
|
||||
|
||||
private String sceneCode;
|
||||
|
||||
private String userId;
|
||||
|
||||
private String userName;
|
||||
|
||||
private String userType;
|
||||
|
||||
private String requestId;
|
||||
|
||||
private JSONObject inputs = new JSONObject();
|
||||
|
||||
public static AiRuntimeRequest fromPayload(JSONObject payload) {
|
||||
AiRuntimeRequest request = new AiRuntimeRequest();
|
||||
if (payload == null) {
|
||||
return request;
|
||||
}
|
||||
String sceneCode = payload.getString("sceneCode");
|
||||
if (!StringUtils.hasText(sceneCode)) {
|
||||
sceneCode = payload.getString("scene");
|
||||
}
|
||||
request.setSceneCode(sceneCode);
|
||||
|
||||
JSONObject inputs = payload.getJSONObject("inputs");
|
||||
if (inputs == null) {
|
||||
inputs = new JSONObject();
|
||||
JSONObject runtimeInputs = inputs;
|
||||
payload.forEach((key, value) -> {
|
||||
if (!RESERVED_KEYS.contains(key)) {
|
||||
runtimeInputs.put(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
request.setInputs(inputs);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.emotion.dto.response.ai;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class AiRuntimeTestResponse {
|
||||
|
||||
private String sceneCode;
|
||||
|
||||
private String status;
|
||||
|
||||
private String output;
|
||||
|
||||
private Long durationMs;
|
||||
|
||||
private Integer streamChunks;
|
||||
|
||||
private String errorCode;
|
||||
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.emotion.dto.response.ai;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiStreamEvent {
|
||||
|
||||
private String type;
|
||||
|
||||
private String content;
|
||||
|
||||
private String code;
|
||||
|
||||
private String message;
|
||||
|
||||
private Integer seq;
|
||||
|
||||
@Builder.Default
|
||||
private Long timestamp = System.currentTimeMillis();
|
||||
|
||||
@Builder.Default
|
||||
private Map<String, Object> metadata = new HashMap<>();
|
||||
|
||||
public static AiStreamEvent start(String sceneCode) {
|
||||
return AiStreamEvent.builder()
|
||||
.type("start")
|
||||
.message("stream started")
|
||||
.metadata(Map.of("sceneCode", sceneCode))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static AiStreamEvent delta(String content, int seq) {
|
||||
return AiStreamEvent.builder()
|
||||
.type("delta")
|
||||
.content(content)
|
||||
.seq(seq)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static AiStreamEvent error(String code, String message) {
|
||||
return AiStreamEvent.builder()
|
||||
.type("error")
|
||||
.code(code)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static AiStreamEvent done(Map<String, Object> metadata) {
|
||||
return AiStreamEvent.builder()
|
||||
.type("done")
|
||||
.message("stream completed")
|
||||
.metadata(metadata == null ? Map.of() : metadata)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.emotion.dto.response.asr;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class AsrTranscribeResponse {
|
||||
|
||||
private String text;
|
||||
private String language;
|
||||
private Long durationMs;
|
||||
private String engine;
|
||||
private String model;
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_ai_call_log")
|
||||
public class AiCallLog extends BaseEntity {
|
||||
|
||||
@TableField("scene_code")
|
||||
private String sceneCode;
|
||||
|
||||
@TableField("provider_code")
|
||||
private String providerCode;
|
||||
|
||||
@TableField("endpoint_code")
|
||||
private String endpointCode;
|
||||
|
||||
@TableField("user_id")
|
||||
private String userId;
|
||||
|
||||
@TableField("request_id")
|
||||
private String requestId;
|
||||
|
||||
@TableField("status")
|
||||
private String status;
|
||||
|
||||
@TableField("input_text")
|
||||
private String inputText;
|
||||
|
||||
@TableField("output_text")
|
||||
private String outputText;
|
||||
|
||||
@TableField("error_code")
|
||||
private String errorCode;
|
||||
|
||||
@TableField("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
@TableField("first_token_ms")
|
||||
private Long firstTokenMs;
|
||||
|
||||
@TableField("duration_ms")
|
||||
private Long durationMs;
|
||||
|
||||
@TableField("stream_chunks")
|
||||
private Integer streamChunks;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_ai_endpoint_config")
|
||||
public class AiEndpointConfig extends BaseEntity {
|
||||
|
||||
@TableField("endpoint_code")
|
||||
private String endpointCode;
|
||||
|
||||
@TableField("endpoint_name")
|
||||
private String endpointName;
|
||||
|
||||
@TableField("provider_id")
|
||||
private String providerId;
|
||||
|
||||
@TableField("endpoint_type")
|
||||
private String endpointType;
|
||||
|
||||
@TableField("api_path")
|
||||
private String apiPath;
|
||||
|
||||
@TableField("workflow_id")
|
||||
private String workflowId;
|
||||
|
||||
@TableField("bot_id")
|
||||
private String botId;
|
||||
|
||||
@TableField("model_name")
|
||||
private String modelName;
|
||||
|
||||
@TableField("response_mode")
|
||||
private String responseMode;
|
||||
|
||||
@TableField("request_template")
|
||||
private String requestTemplate;
|
||||
|
||||
@TableField("default_inputs")
|
||||
private String defaultInputs;
|
||||
|
||||
@TableField("custom_headers")
|
||||
private String customHeaders;
|
||||
|
||||
@TableField("timeout_ms")
|
||||
private Integer timeoutMs;
|
||||
|
||||
@TableField("support_stream")
|
||||
private Integer supportStream;
|
||||
|
||||
@TableField("is_enabled")
|
||||
private Integer isEnabled;
|
||||
|
||||
@TableField("description")
|
||||
private String description;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_ai_provider")
|
||||
public class AiProvider extends BaseEntity {
|
||||
|
||||
@TableField("provider_code")
|
||||
private String providerCode;
|
||||
|
||||
@TableField("provider_name")
|
||||
private String providerName;
|
||||
|
||||
@TableField("provider_type")
|
||||
private String providerType;
|
||||
|
||||
@TableField("base_url")
|
||||
private String baseUrl;
|
||||
|
||||
@TableField("api_key")
|
||||
private String apiKey;
|
||||
|
||||
@TableField("auth_type")
|
||||
private String authType;
|
||||
|
||||
@TableField("default_headers")
|
||||
private String defaultHeaders;
|
||||
|
||||
@TableField("timeout_ms")
|
||||
private Integer timeoutMs;
|
||||
|
||||
@TableField("is_enabled")
|
||||
private Integer isEnabled;
|
||||
|
||||
@TableField("description")
|
||||
private String description;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.emotion.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotion.common.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_ai_scene_binding")
|
||||
public class AiSceneBinding extends BaseEntity {
|
||||
|
||||
@TableField("scene_code")
|
||||
private String sceneCode;
|
||||
|
||||
@TableField("scene_name")
|
||||
private String sceneName;
|
||||
|
||||
@TableField("endpoint_id")
|
||||
private String endpointId;
|
||||
|
||||
@TableField("input_schema")
|
||||
private String inputSchema;
|
||||
|
||||
@TableField("prompt_template")
|
||||
private String promptTemplate;
|
||||
|
||||
@TableField("required_stream")
|
||||
private Integer requiredStream;
|
||||
|
||||
@TableField("priority")
|
||||
private Integer priority;
|
||||
|
||||
@TableField("is_enabled")
|
||||
private Integer isEnabled;
|
||||
|
||||
@TableField("version")
|
||||
private String version;
|
||||
|
||||
@TableField("description")
|
||||
private String description;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.AiCallLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AiCallLogMapper extends BaseMapper<AiCallLog> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AiEndpointConfigMapper extends BaseMapper<AiEndpointConfig> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AiProviderMapper extends BaseMapper<AiProvider> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.emotion.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotion.entity.AiSceneBinding;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AiSceneBindingMapper extends BaseMapper<AiSceneBinding> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.entity.AiCallLog;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AiCallLogService extends IService<AiCallLog> {
|
||||
|
||||
List<AiCallLog> latest(Integer limit);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AiEndpointConfigService extends IService<AiEndpointConfig> {
|
||||
|
||||
List<AiEndpointConfig> listVisible();
|
||||
|
||||
AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint);
|
||||
|
||||
AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint);
|
||||
|
||||
AiEndpointConfig getEnabledById(String id);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.entity.AiProvider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AiProviderService extends IService<AiProvider> {
|
||||
|
||||
List<AiProvider> listVisible();
|
||||
|
||||
AiProvider saveProvider(AiProvider provider);
|
||||
|
||||
AiProvider updateProvider(AiProvider provider);
|
||||
|
||||
AiProvider getEnabledById(String id);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public interface AiRuntimeService {
|
||||
|
||||
void invokeStream(AiRuntimeRequest request, Consumer<AiStreamEvent> consumer);
|
||||
|
||||
AiRuntimeTestResponse test(AiRuntimeRequest request);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.emotion.entity.AiSceneBinding;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AiSceneBindingService extends IService<AiSceneBinding> {
|
||||
|
||||
List<AiSceneBinding> listVisible();
|
||||
|
||||
AiSceneBinding resolveScene(String sceneCode);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.response.asr.AsrTranscribeResponse;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface AsrService {
|
||||
|
||||
AsrTranscribeResponse transcribe(MultipartFile file);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.emotion.service.ai;
|
||||
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.entity.AiProvider;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public interface AiProviderAdapter {
|
||||
|
||||
boolean supports(String providerType);
|
||||
|
||||
void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.emotion.service.ai;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class AiTemplateRenderer {
|
||||
|
||||
public Map<String, Object> mergeInputs(String defaultInputs, Map<String, Object> runtimeInputs) {
|
||||
Map<String, Object> inputs = new HashMap<>();
|
||||
if (StringUtils.hasText(defaultInputs)) {
|
||||
try {
|
||||
inputs.putAll(JSON.parseObject(defaultInputs));
|
||||
} catch (Exception ignored) {
|
||||
inputs.put("default_input", defaultInputs);
|
||||
}
|
||||
}
|
||||
if (runtimeInputs != null) {
|
||||
inputs.putAll(runtimeInputs);
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
public JSONObject renderObject(String template, Map<String, Object> inputs) {
|
||||
if (!StringUtils.hasText(template)) {
|
||||
return new JSONObject();
|
||||
}
|
||||
String rendered = template;
|
||||
for (Map.Entry<String, Object> entry : inputs.entrySet()) {
|
||||
String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
|
||||
rendered = rendered.replace("{{" + entry.getKey() + "}}", value);
|
||||
}
|
||||
return JSON.parseObject(rendered);
|
||||
}
|
||||
|
||||
public String firstText(Map<String, Object> inputs) {
|
||||
for (String key : new String[]{"prompt", "message", "query", "text", "input"}) {
|
||||
Object value = inputs.get(key);
|
||||
if (value != null && StringUtils.hasText(String.valueOf(value))) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.emotion.service.ai;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Component
|
||||
public class CozeProviderAdapter implements AiProviderAdapter {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final AiTemplateRenderer templateRenderer;
|
||||
private final ProviderHttpSupport httpSupport;
|
||||
|
||||
public CozeProviderAdapter(RestTemplate restTemplate, AiTemplateRenderer templateRenderer, ProviderHttpSupport httpSupport) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.templateRenderer = templateRenderer;
|
||||
this.httpSupport = httpSupport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(String providerType) {
|
||||
return "coze".equalsIgnoreCase(providerType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer) {
|
||||
Map<String, Object> inputs = templateRenderer.mergeInputs(endpoint.getDefaultInputs(), request.getInputs());
|
||||
JSONObject body = buildRequestBody(endpoint, request, inputs);
|
||||
String path = StringUtils.hasText(endpoint.getApiPath()) ? endpoint.getApiPath() : defaultPath(endpoint);
|
||||
String url = httpSupport.joinUrl(provider.getBaseUrl(), path);
|
||||
ProviderHttpSupport.Counter counter = new ProviderHttpSupport.Counter();
|
||||
|
||||
restTemplate.execute(url, HttpMethod.POST, clientRequest -> {
|
||||
HttpHeaders headers = clientRequest.getHeaders();
|
||||
httpSupport.applyHeaders(headers, provider, endpoint);
|
||||
clientRequest.getBody().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
|
||||
}, response -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("data:")) {
|
||||
httpSupport.emitSseData(line.substring(5).trim(), consumer, this::extractCozeDelta, counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (counter.get() == 0) {
|
||||
throw new IllegalStateException("AI_STREAM_NO_DELTA");
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject buildRequestBody(AiEndpointConfig endpoint, AiRuntimeRequest request, Map<String, Object> inputs) {
|
||||
if (StringUtils.hasText(endpoint.getRequestTemplate())) {
|
||||
JSONObject rendered = templateRenderer.renderObject(endpoint.getRequestTemplate(), inputs);
|
||||
rendered.putIfAbsent("stream", true);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||
body.put("workflow_id", endpoint.getWorkflowId());
|
||||
body.put("parameters", inputs);
|
||||
body.put("is_async", false);
|
||||
return body;
|
||||
}
|
||||
|
||||
body.put("bot_id", endpoint.getBotId());
|
||||
body.put("user_id", StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous");
|
||||
body.put("stream", true);
|
||||
body.put("auto_save_history", false);
|
||||
JSONArray messages = new JSONArray();
|
||||
JSONObject message = new JSONObject();
|
||||
message.put("role", "user");
|
||||
message.put("content_type", "text");
|
||||
message.put("content", templateRenderer.firstText(inputs));
|
||||
messages.add(message);
|
||||
body.put("additional_messages", messages);
|
||||
return body;
|
||||
}
|
||||
|
||||
private String defaultPath(AiEndpointConfig endpoint) {
|
||||
if ("workflow".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||
return "/v1/workflow/stream_run";
|
||||
}
|
||||
return "/v3/chat";
|
||||
}
|
||||
|
||||
private String extractCozeDelta(JSONObject json) {
|
||||
String type = json.getString("type");
|
||||
String content = json.getString("content");
|
||||
if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) {
|
||||
return content;
|
||||
}
|
||||
JSONObject message = json.getJSONObject("message");
|
||||
if (message != null && StringUtils.hasText(message.getString("content"))) {
|
||||
return message.getString("content");
|
||||
}
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
if (data != null) {
|
||||
String output = data.getString("output");
|
||||
if (StringUtils.hasText(output)) {
|
||||
return output;
|
||||
}
|
||||
String answer = data.getString("answer");
|
||||
if (StringUtils.hasText(answer)) {
|
||||
return answer;
|
||||
}
|
||||
String dataContent = data.getString("content");
|
||||
if (StringUtils.hasText(dataContent)) {
|
||||
return dataContent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.emotion.service.ai;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Component
|
||||
public class DifyProviderAdapter implements AiProviderAdapter {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final AiTemplateRenderer templateRenderer;
|
||||
private final ProviderHttpSupport httpSupport;
|
||||
|
||||
public DifyProviderAdapter(RestTemplate restTemplate, AiTemplateRenderer templateRenderer, ProviderHttpSupport httpSupport) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.templateRenderer = templateRenderer;
|
||||
this.httpSupport = httpSupport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(String providerType) {
|
||||
return "dify".equalsIgnoreCase(providerType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stream(AiProvider provider, AiEndpointConfig endpoint, AiRuntimeRequest request, Consumer<AiStreamEvent> consumer) {
|
||||
Map<String, Object> inputs = templateRenderer.mergeInputs(endpoint.getDefaultInputs(), request.getInputs());
|
||||
JSONObject body = buildRequestBody(endpoint, request, inputs);
|
||||
String path = StringUtils.hasText(endpoint.getApiPath()) ? endpoint.getApiPath() : defaultPath(endpoint);
|
||||
String url = httpSupport.joinUrl(provider.getBaseUrl(), path);
|
||||
ProviderHttpSupport.Counter counter = new ProviderHttpSupport.Counter();
|
||||
|
||||
restTemplate.execute(url, HttpMethod.POST, clientRequest -> {
|
||||
HttpHeaders headers = clientRequest.getHeaders();
|
||||
httpSupport.applyHeaders(headers, provider, endpoint);
|
||||
clientRequest.getBody().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
|
||||
}, response -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("data:")) {
|
||||
httpSupport.emitSseData(line.substring(5).trim(), consumer, this::extractDifyDelta, counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (counter.get() == 0) {
|
||||
throw new IllegalStateException("AI_STREAM_NO_DELTA");
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject buildRequestBody(AiEndpointConfig endpoint, AiRuntimeRequest request, Map<String, Object> inputs) {
|
||||
if (StringUtils.hasText(endpoint.getRequestTemplate())) {
|
||||
JSONObject rendered = templateRenderer.renderObject(endpoint.getRequestTemplate(), inputs);
|
||||
rendered.putIfAbsent("response_mode", "streaming");
|
||||
rendered.putIfAbsent("user", user(request));
|
||||
return rendered;
|
||||
}
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("response_mode", "streaming");
|
||||
body.put("user", user(request));
|
||||
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||
body.put("query", templateRenderer.firstText(inputs));
|
||||
body.put("inputs", inputs);
|
||||
} else {
|
||||
body.put("inputs", inputs);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String defaultPath(AiEndpointConfig endpoint) {
|
||||
if ("chat".equalsIgnoreCase(endpoint.getEndpointType())) {
|
||||
return "/chat-messages";
|
||||
}
|
||||
return "/workflows/run";
|
||||
}
|
||||
|
||||
private String user(AiRuntimeRequest request) {
|
||||
return StringUtils.hasText(request.getUserId()) ? request.getUserId() : "anonymous";
|
||||
}
|
||||
|
||||
private String extractDifyDelta(JSONObject json) {
|
||||
String event = json.getString("event");
|
||||
if ("message".equals(event) || "agent_message".equals(event)) {
|
||||
return json.getString("answer");
|
||||
}
|
||||
if ("text_chunk".equals(event)) {
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
return data == null ? null : data.getString("text");
|
||||
}
|
||||
if ("workflow_finished".equals(event)) {
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
JSONObject outputs = data == null ? null : data.getJSONObject("outputs");
|
||||
if (outputs != null) {
|
||||
Object text = outputs.get("text");
|
||||
if (text == null) {
|
||||
text = outputs.get("answer");
|
||||
}
|
||||
return text == null ? null : String.valueOf(text);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.emotion.service.ai;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Component
|
||||
public class ProviderHttpSupport {
|
||||
|
||||
public String joinUrl(String baseUrl, String path) {
|
||||
String base = StringUtils.hasText(baseUrl) ? baseUrl.trim() : "";
|
||||
String suffix = StringUtils.hasText(path) ? path.trim() : "";
|
||||
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||
return base + "/" + suffix;
|
||||
}
|
||||
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||
return base + suffix.substring(1);
|
||||
}
|
||||
return base + suffix;
|
||||
}
|
||||
|
||||
public void applyHeaders(HttpHeaders headers, AiProvider provider, AiEndpointConfig endpoint) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
if (StringUtils.hasText(provider.getApiKey())) {
|
||||
headers.setBearerAuth(provider.getApiKey());
|
||||
}
|
||||
applyJsonHeaders(headers, provider.getDefaultHeaders());
|
||||
applyJsonHeaders(headers, endpoint.getCustomHeaders());
|
||||
}
|
||||
|
||||
public void emitSseData(String data, Consumer<AiStreamEvent> consumer, DeltaExtractor extractor, Counter counter) {
|
||||
if (!StringUtils.hasText(data) || "[DONE]".equals(data.trim())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(data);
|
||||
String delta = extractor.extract(json);
|
||||
if (StringUtils.hasText(delta)) {
|
||||
counter.increment();
|
||||
consumer.accept(AiStreamEvent.delta(delta, counter.get()));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
counter.increment();
|
||||
consumer.accept(AiStreamEvent.delta(data, counter.get()));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyJsonHeaders(HttpHeaders headers, String jsonText) {
|
||||
if (!StringUtils.hasText(jsonText)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(jsonText);
|
||||
for (Map.Entry<String, Object> entry : json.entrySet()) {
|
||||
if (entry.getValue() != null) {
|
||||
headers.set(entry.getKey(), String.valueOf(entry.getValue()));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface DeltaExtractor {
|
||||
String extract(JSONObject json);
|
||||
}
|
||||
|
||||
public static class Counter {
|
||||
private int value;
|
||||
|
||||
public void increment() {
|
||||
value++;
|
||||
}
|
||||
|
||||
public int get() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.entity.AiCallLog;
|
||||
import com.emotion.mapper.AiCallLogMapper;
|
||||
import com.emotion.service.AiCallLogService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AiCallLogServiceImpl extends ServiceImpl<AiCallLogMapper, AiCallLog> implements AiCallLogService {
|
||||
|
||||
@Override
|
||||
public List<AiCallLog> latest(Integer limit) {
|
||||
int size = limit == null ? 50 : Math.max(1, Math.min(limit, 200));
|
||||
return list(new LambdaQueryWrapper<AiCallLog>()
|
||||
.eq(AiCallLog::getIsDeleted, 0)
|
||||
.orderByDesc(AiCallLog::getCreateTime)
|
||||
.last("limit " + size));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.entity.Message;
|
||||
import com.emotion.entity.Conversation;
|
||||
import com.emotion.entity.CozeApiCall;
|
||||
@@ -14,6 +16,7 @@ import com.emotion.service.CozeApiCallService;
|
||||
import com.emotion.service.EmotionRecordService;
|
||||
import com.emotion.service.EmotionAnalysisService;
|
||||
import com.emotion.service.AiConfigService;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.entity.AiConfig;
|
||||
import com.emotion.dto.request.*;
|
||||
import com.emotion.dto.response.*;
|
||||
@@ -79,6 +82,9 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
@Autowired
|
||||
private AiConfigService aiConfigService;
|
||||
|
||||
@Autowired
|
||||
private AiRuntimeService aiRuntimeService;
|
||||
|
||||
private static final String DEFAULT_USER_ID = "emotion-museum-user";
|
||||
|
||||
// 使用场景常量
|
||||
@@ -117,9 +123,10 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
userMessage.setSender("user");
|
||||
userMessage = messageService.createMessage(userMessage);
|
||||
|
||||
// 调用Coze API(传入messageId)
|
||||
String aiReply = sendMessageWithMessageId(request.getConversationId(), userMessage.getId(),
|
||||
request.getMessage(), request.getUserId());
|
||||
String aiReply = invokeRuntimeScene("chat", request.getMessage(), request.getUserId(), Map.of(
|
||||
"conversationId", request.getConversationId(),
|
||||
"userMessageId", userMessage.getId()
|
||||
));
|
||||
|
||||
// 保存AI回复
|
||||
Message aiMessage = new Message();
|
||||
@@ -170,8 +177,9 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
// 构建总结请求
|
||||
String summaryPrompt = "请为以下对话生成一个简洁的总结:\n\n" + conversationHistory;
|
||||
|
||||
// 调用AI生成总结 - 使用专门的总结bot
|
||||
String summary = sendSummaryMessage(request.getConversationId(), summaryPrompt, request.getUserId());
|
||||
String summary = invokeRuntimeScene("emotion_summary", summaryPrompt, request.getUserId(), Map.of(
|
||||
"conversationId", request.getConversationId()
|
||||
));
|
||||
|
||||
log.info("对话总结生成完成: conversationId={}", request.getConversationId());
|
||||
|
||||
@@ -445,16 +453,12 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
|
||||
@Override
|
||||
public String sendMessage(String conversationId, String userMessage, String userId) {
|
||||
log.info("发送消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
|
||||
|
||||
// 创建API调用记录(不包含messageId,用于向后兼容)
|
||||
CozeApiCall apiCall = createApiCallRecord(conversationId, null, userMessage, userId, "chat");
|
||||
log.info("发送聊天消息到AI运行时: conversationId={}, userId={}", conversationId, userId);
|
||||
|
||||
try {
|
||||
return executeCozeApiCall(apiCall, conversationId, userMessage, userId);
|
||||
return invokeRuntimeScene("chat", userMessage, userId, Map.of("conversationId", conversationId));
|
||||
} catch (Exception e) {
|
||||
log.error("发送消息失败", e);
|
||||
updateApiCallFailure(apiCall, e.getMessage());
|
||||
log.error("发送聊天消息失败", e);
|
||||
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
||||
}
|
||||
}
|
||||
@@ -535,10 +539,13 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
// Coze 中已经在工作流设置了提示词,目前不需要构建情绪分析提示词
|
||||
// String emotionPrompt = buildEmotionAnalysisPrompt(chatHistory);
|
||||
|
||||
// 调用Coze API进行情绪分析总结
|
||||
String conversationId = "emotion_summary_" + userId + "_"
|
||||
+ today.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
String emotionSummary = sendSummaryMessage(conversationId, chatHistory, userId);
|
||||
String emotionSummary = invokeRuntimeScene("emotion_summary", chatHistory, userId, Map.of(
|
||||
"conversationId", conversationId,
|
||||
"messageCount", todayMessages.size(),
|
||||
"recordDate", today.toString()
|
||||
));
|
||||
log.info("情绪分析总结生成完成: {}", emotionSummary);
|
||||
|
||||
// 解析AI返回的情绪分析结果
|
||||
@@ -572,18 +579,39 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
return CompletableFuture.completedFuture(result);
|
||||
}
|
||||
|
||||
private String invokeRuntimeScene(String sceneCode, String input, String userId, Map<String, Object> extraInputs) {
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("input", input);
|
||||
inputs.put("message", input);
|
||||
inputs.put("prompt", input);
|
||||
if (extraInputs != null) {
|
||||
inputs.putAll(extraInputs);
|
||||
}
|
||||
|
||||
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||
runtimeRequest.setSceneCode(sceneCode);
|
||||
runtimeRequest.setUserId(userId);
|
||||
runtimeRequest.setInputs(inputs);
|
||||
|
||||
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||
if (response == null) {
|
||||
throw new IllegalStateException("AI_RUNTIME_EMPTY_RESPONSE");
|
||||
}
|
||||
if (!"success".equals(response.getStatus())) {
|
||||
String message = response.getErrorMessage() != null ? response.getErrorMessage() : response.getErrorCode();
|
||||
throw new IllegalStateException(message != null ? message : "AI_RUNTIME_FAILED");
|
||||
}
|
||||
return response.getOutput();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String sendSummaryMessage(String conversationId, String userMessage, String userId) {
|
||||
log.info("发送总结消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
|
||||
|
||||
// 创建API调用记录(总结不需要messageId)
|
||||
CozeApiCall apiCall = createSummaryApiCallRecord(conversationId, null, userMessage, userId, "summary");
|
||||
log.info("发送总结消息到AI运行时: conversationId={}, userId={}", conversationId, userId);
|
||||
|
||||
try {
|
||||
return executeSummaryCozeApiCall(apiCall, conversationId, userMessage, userId);
|
||||
return invokeRuntimeScene("emotion_summary", userMessage, userId, Map.of("conversationId", conversationId));
|
||||
} catch (Exception e) {
|
||||
log.error("发送总结消息失败", e);
|
||||
updateApiCallFailure(apiCall, e.getMessage());
|
||||
return "抱歉,AI总结服务暂时不可用,请稍后再试。";
|
||||
}
|
||||
}
|
||||
@@ -759,34 +787,17 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
*/
|
||||
private String sendMessageWithMessageId(String conversationId, String messageId, String userMessage,
|
||||
String userId) {
|
||||
log.info("发送消息到Coze AI: conversationId={}, messageId={}, userId={}", conversationId, messageId, userId);
|
||||
|
||||
// 1. 获取AI配置
|
||||
AiConfig config = aiConfigService.getByConfigKey(COZE_CHAT_CONFIG_KEY);
|
||||
if (config == null) {
|
||||
log.error("未找到聊天场景的AI配置或配置已禁用: configKey={}", COZE_CHAT_CONFIG_KEY);
|
||||
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
||||
}
|
||||
|
||||
// 2. 创建API调用记录(包含conversationId和messageId)
|
||||
CozeApiCall apiCall = createChatWorkflowApiCallRecord(config, conversationId, messageId, userMessage, userId);
|
||||
log.info("发送聊天消息到AI运行时: conversationId={}, messageId={}, userId={}", conversationId, messageId, userId);
|
||||
|
||||
try {
|
||||
// 3. 构建工作流请求参数
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("input", userMessage);
|
||||
parameters.put("user_id", userId);
|
||||
|
||||
// 4. 构建工作流请求体
|
||||
Map<String, Object> requestBody = buildWorkflowRequest(config, parameters, userId);
|
||||
|
||||
// 5. 执行工作流调用(带API调用记录)
|
||||
return executeWorkflowCallWithRecord(config, requestBody, COZE_CHAT_CONFIG_KEY, userId, apiCall);
|
||||
return invokeRuntimeScene("chat", userMessage, userId, Map.of(
|
||||
"conversationId", conversationId,
|
||||
"userMessageId", messageId
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("发送消息失败: conversationId={}, messageId={}, error={}",
|
||||
log.error("发送聊天消息失败: conversationId={}, messageId={}, error={}",
|
||||
conversationId, messageId, e.getMessage(), e);
|
||||
updateApiCallFailure(apiCall, e.getMessage());
|
||||
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
||||
}
|
||||
}
|
||||
@@ -2860,4 +2871,4 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.entity.AiEndpointConfig;
|
||||
import com.emotion.mapper.AiEndpointConfigMapper;
|
||||
import com.emotion.service.AiEndpointConfigService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AiEndpointConfigServiceImpl extends ServiceImpl<AiEndpointConfigMapper, AiEndpointConfig> implements AiEndpointConfigService {
|
||||
|
||||
@Override
|
||||
public List<AiEndpointConfig> listVisible() {
|
||||
return list(new LambdaQueryWrapper<AiEndpointConfig>()
|
||||
.eq(AiEndpointConfig::getIsDeleted, 0)
|
||||
.orderByDesc(AiEndpointConfig::getCreateTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiEndpointConfig saveEndpoint(AiEndpointConfig endpoint) {
|
||||
applyDefaults(endpoint);
|
||||
save(endpoint);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiEndpointConfig updateEndpoint(AiEndpointConfig endpoint) {
|
||||
updateById(endpoint);
|
||||
return getById(endpoint.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiEndpointConfig getEnabledById(String id) {
|
||||
AiEndpointConfig endpoint = getById(id);
|
||||
if (endpoint == null || endpoint.getIsEnabled() == null || endpoint.getIsEnabled() != 1) {
|
||||
return null;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private void applyDefaults(AiEndpointConfig endpoint) {
|
||||
if (!StringUtils.hasText(endpoint.getResponseMode())) {
|
||||
endpoint.setResponseMode("streaming");
|
||||
}
|
||||
if (endpoint.getSupportStream() == null) {
|
||||
endpoint.setSupportStream(1);
|
||||
}
|
||||
if (endpoint.getIsEnabled() == null) {
|
||||
endpoint.setIsEnabled(1);
|
||||
}
|
||||
if (endpoint.getTimeoutMs() == null) {
|
||||
endpoint.setTimeoutMs(60000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.entity.AiProvider;
|
||||
import com.emotion.mapper.AiProviderMapper;
|
||||
import com.emotion.service.AiProviderService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AiProviderServiceImpl extends ServiceImpl<AiProviderMapper, AiProvider> implements AiProviderService {
|
||||
|
||||
@Override
|
||||
public List<AiProvider> listVisible() {
|
||||
return list(new LambdaQueryWrapper<AiProvider>()
|
||||
.eq(AiProvider::getIsDeleted, 0)
|
||||
.orderByDesc(AiProvider::getCreateTime))
|
||||
.stream()
|
||||
.map(this::maskSecret)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiProvider saveProvider(AiProvider provider) {
|
||||
if (provider.getIsEnabled() == null) {
|
||||
provider.setIsEnabled(1);
|
||||
}
|
||||
if (!StringUtils.hasText(provider.getAuthType())) {
|
||||
provider.setAuthType("bearer");
|
||||
}
|
||||
if (provider.getTimeoutMs() == null) {
|
||||
provider.setTimeoutMs(60000);
|
||||
}
|
||||
save(provider);
|
||||
return maskSecret(provider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiProvider updateProvider(AiProvider provider) {
|
||||
if (!StringUtils.hasText(provider.getApiKey()) || "******".equals(provider.getApiKey())) {
|
||||
provider.setApiKey(null);
|
||||
}
|
||||
updateById(provider);
|
||||
return maskSecret(getById(provider.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiProvider getEnabledById(String id) {
|
||||
AiProvider provider = getById(id);
|
||||
if (provider == null || provider.getIsEnabled() == null || provider.getIsEnabled() != 1) {
|
||||
return null;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private AiProvider maskSecret(AiProvider provider) {
|
||||
if (provider == null) {
|
||||
return null;
|
||||
}
|
||||
AiProvider copy = new AiProvider();
|
||||
BeanUtils.copyProperties(provider, copy);
|
||||
if (StringUtils.hasText(copy.getApiKey())) {
|
||||
copy.setApiKey("******");
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.entity.AiSceneBinding;
|
||||
import com.emotion.mapper.AiSceneBindingMapper;
|
||||
import com.emotion.service.AiSceneBindingService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AiSceneBindingServiceImpl extends ServiceImpl<AiSceneBindingMapper, AiSceneBinding> implements AiSceneBindingService {
|
||||
|
||||
@Override
|
||||
public List<AiSceneBinding> listVisible() {
|
||||
return list(new LambdaQueryWrapper<AiSceneBinding>()
|
||||
.eq(AiSceneBinding::getIsDeleted, 0)
|
||||
.orderByDesc(AiSceneBinding::getPriority)
|
||||
.orderByDesc(AiSceneBinding::getCreateTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiSceneBinding resolveScene(String sceneCode) {
|
||||
return getOne(new LambdaQueryWrapper<AiSceneBinding>()
|
||||
.eq(AiSceneBinding::getSceneCode, sceneCode)
|
||||
.eq(AiSceneBinding::getIsEnabled, 1)
|
||||
.eq(AiSceneBinding::getIsDeleted, 0)
|
||||
.orderByDesc(AiSceneBinding::getPriority)
|
||||
.last("limit 1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.dto.response.asr.AsrTranscribeResponse;
|
||||
import com.emotion.service.AsrService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AsrServiceImpl implements AsrService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${emotion.asr.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${emotion.asr.engine-url:http://127.0.0.1:19120}")
|
||||
private String engineUrl;
|
||||
|
||||
@Value("${emotion.asr.max-file-size:10485760}")
|
||||
private long maxFileSize;
|
||||
|
||||
@Value("${emotion.asr.allowed-types:wav,mp3,m4a,mp4,aac,amr}")
|
||||
private String allowedTypes;
|
||||
|
||||
public AsrServiceImpl(RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsrTranscribeResponse transcribe(MultipartFile file) {
|
||||
validate(file);
|
||||
try {
|
||||
ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return StringUtils.hasText(file.getOriginalFilename()) ? file.getOriginalFilename() : "voice.wav";
|
||||
}
|
||||
};
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", resource);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
||||
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/transcribe", request, Map.class);
|
||||
Map<?, ?> data = response.getBody();
|
||||
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
||||
if (!success) {
|
||||
String message = data == null ? "ASR service returned empty response" : String.valueOf(data.get("errorMessage"));
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
|
||||
return AsrTranscribeResponse.builder()
|
||||
.text(stringValue(data.get("text")))
|
||||
.language(stringValue(data.get("language")))
|
||||
.durationMs(longValue(data.get("durationMs")))
|
||||
.engine(stringValue(data.get("engine")))
|
||||
.model(stringValue(data.get("model")))
|
||||
.build();
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("语音识别服务暂时不可用: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(MultipartFile file) {
|
||||
if (!enabled) {
|
||||
throw new IllegalStateException("语音识别功能未启用");
|
||||
}
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("请上传语音文件");
|
||||
}
|
||||
if (file.getSize() > maxFileSize) {
|
||||
throw new IllegalArgumentException("语音文件过大,请控制在10MB以内");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
if (StringUtils.hasText(filename) && filename.contains(".")) {
|
||||
extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
Set<String> allowed = Set.of(allowedTypes.toLowerCase(Locale.ROOT).split(","));
|
||||
if (StringUtils.hasText(extension) && !allowed.contains(extension)) {
|
||||
throw new IllegalArgumentException("不支持的语音格式: " + extension);
|
||||
}
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
if (value == null) return null;
|
||||
String text = String.valueOf(value);
|
||||
return "null".equals(text) ? null : text;
|
||||
}
|
||||
|
||||
private Long longValue(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.request.EpicScriptCreateRequest;
|
||||
import com.emotion.dto.request.EpicScriptInspirationRequest;
|
||||
import com.emotion.dto.request.EpicScriptPageRequest;
|
||||
import com.emotion.dto.request.EpicScriptUpdateRequest;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.dto.response.EpicScriptInspirationResponse;
|
||||
import com.emotion.dto.response.EpicScriptResponse;
|
||||
import com.emotion.dto.response.InspirationSuggestionResponse;
|
||||
import com.emotion.entity.EpicScript;
|
||||
import com.emotion.mapper.EpicScriptMapper;
|
||||
import com.emotion.service.AiChatService;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.EpicScriptService;
|
||||
import com.emotion.service.LifePathService;
|
||||
import com.emotion.service.ScriptContextService;
|
||||
@@ -62,14 +65,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
/**
|
||||
* Coze工作流配置键 - 爽文剧本生成
|
||||
*/
|
||||
private static final String COZE_EPIC_SCRIPT_CONFIG_KEY = "coze.course.life.generate";
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private LifePathService lifePathService;
|
||||
|
||||
@Autowired
|
||||
private AiChatService aiChatService;
|
||||
private AiRuntimeService aiRuntimeService;
|
||||
|
||||
@Autowired
|
||||
private ScriptContextService scriptContextService;
|
||||
@@ -174,7 +175,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
script.setIsSelected(request.getIsSelected() != null && request.getIsSelected() ? 1 : 0);
|
||||
|
||||
// 调用Coze AI生成剧本内容
|
||||
String aiGeneratedContent = generateScriptByAi(request, currentUserId);
|
||||
String existingContent = extractExistingGeneratedContent(script.getPlotJson());
|
||||
String aiGeneratedContent = StringUtils.hasText(existingContent)
|
||||
? existingContent
|
||||
: generateScriptByAi(request, currentUserId);
|
||||
if (aiGeneratedContent != null) {
|
||||
// 将AI生成的内容存储到plotJson中
|
||||
Map<String, Object> plotJson = script.getPlotJson();
|
||||
@@ -278,8 +282,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
String input = assembleScriptInput(request, userId);
|
||||
log.info("开始调用AI生成剧本,用户ID: {}, 输入长度: {}", userId, input.length());
|
||||
|
||||
// 调用Coze工作流
|
||||
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
|
||||
String result = invokeScriptRuntime(request, input, userId);
|
||||
|
||||
log.info("AI生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
|
||||
return result;
|
||||
@@ -291,6 +294,49 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
}
|
||||
}
|
||||
|
||||
private String invokeScriptRuntime(EpicScriptCreateRequest request, String input, String userId) {
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("input", input);
|
||||
inputs.put("prompt", StringUtils.hasText(request.getTheme()) ? request.getTheme() : input);
|
||||
inputs.put("theme", request.getTheme());
|
||||
inputs.put("style", request.getStyle());
|
||||
inputs.put("length", request.getLength());
|
||||
inputs.put("useSocialInsights", request.getUseSocialInsights());
|
||||
return invokeRuntime("script_generate", inputs, userId);
|
||||
}
|
||||
|
||||
private String invokeScriptRuntime(EpicScriptUpdateRequest request, String input, String userId) {
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("input", input);
|
||||
inputs.put("prompt", StringUtils.hasText(request.getTheme()) ? request.getTheme() : input);
|
||||
inputs.put("theme", request.getTheme());
|
||||
inputs.put("style", request.getStyle());
|
||||
inputs.put("length", request.getLength());
|
||||
inputs.put("useSocialInsights", request.getUseSocialInsights());
|
||||
return invokeRuntime("script_generate", inputs, userId);
|
||||
}
|
||||
|
||||
private String invokeRuntime(String sceneCode, JSONObject inputs, String userId) {
|
||||
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||
runtimeRequest.setSceneCode(sceneCode);
|
||||
runtimeRequest.setUserId(userId);
|
||||
runtimeRequest.setInputs(inputs);
|
||||
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
|
||||
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
|
||||
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_EMPTY_RESPONSE");
|
||||
}
|
||||
return response.getOutput();
|
||||
}
|
||||
|
||||
private String extractExistingGeneratedContent(Map<String, Object> plotJson) {
|
||||
if (plotJson == null) {
|
||||
return null;
|
||||
}
|
||||
Object fullContent = plotJson.get("fullContent");
|
||||
return fullContent == null ? null : String.valueOf(fullContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装AI输入内容
|
||||
* 将EpicScriptCreateRequest的字段组装为格式化字符串
|
||||
@@ -311,7 +357,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
sb.append("【过往经历】").append(request.getLifeEventsSummary()).append("\n");
|
||||
}
|
||||
|
||||
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
||||
String socialContext = scriptContextService == null
|
||||
? null
|
||||
: scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
||||
if (StringUtils.hasText(socialContext)) {
|
||||
sb.append(socialContext).append("\n");
|
||||
}
|
||||
@@ -361,6 +409,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
private String assembleScriptInput(EpicScriptCreateRequest request) {
|
||||
return assembleScriptInput(request, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取风格描述
|
||||
*
|
||||
@@ -478,8 +530,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
String input = assembleUpdateScriptInput(request, script, userId);
|
||||
log.info("开始调用AI重新生成剧本,用户ID: {}, 剧本ID: {}", userId, script.getId());
|
||||
|
||||
// 调用Coze工作流
|
||||
String result = aiChatService.callWorkflowByConfigKey(COZE_EPIC_SCRIPT_CONFIG_KEY, input, userId);
|
||||
String result = invokeScriptRuntime(request, input, userId);
|
||||
|
||||
log.info("AI重新生成剧本完成,用户ID: {}, 结果长度: {}", userId, result != null ? result.length() : 0);
|
||||
return result;
|
||||
@@ -514,7 +565,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
||||
sb.append("【过往经历】").append(lifeEventsSummary).append("\n");
|
||||
}
|
||||
|
||||
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
||||
String socialContext = scriptContextService == null
|
||||
? null
|
||||
: scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
|
||||
if (StringUtils.hasText(socialContext)) {
|
||||
sb.append(socialContext).append("\n");
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ package com.emotion.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.common.PageResult;
|
||||
import com.emotion.dto.request.LifeEventCreateRequest;
|
||||
import com.emotion.dto.request.LifeEventPageRequest;
|
||||
import com.emotion.dto.request.LifeEventUpdateRequest;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
|
||||
import com.emotion.dto.response.LifeEventResponse;
|
||||
import com.emotion.entity.LifeEvent;
|
||||
import com.emotion.mapper.LifeEventMapper;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.LifeEventService;
|
||||
import com.emotion.util.UserContextHolder;
|
||||
import com.emotion.service.AiChatService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -44,13 +47,8 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
private static final DateTimeFormatter DATE_ONLY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
/**
|
||||
* Coze工作流配置键 - AI疗愈
|
||||
*/
|
||||
private static final String COZE_HEALING_CONFIG_KEY = "coze.user.dairy.summary";
|
||||
|
||||
@Autowired
|
||||
private AiChatService aiChatService;
|
||||
private AiRuntimeService aiRuntimeService;
|
||||
|
||||
@Override
|
||||
public PageResult<LifeEventResponse> getPageByCurrentUser(LifeEventPageRequest request) {
|
||||
@@ -158,10 +156,11 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
||||
}
|
||||
|
||||
// 调用Coze AI进行疗愈回复
|
||||
String aiGeneratedContent = generateHealingByAi(event);
|
||||
if (StringUtils.hasText(aiGeneratedContent)) {
|
||||
event.setAiReply(aiGeneratedContent);
|
||||
if (!StringUtils.hasText(event.getAiReply())) {
|
||||
String aiGeneratedContent = generateHealingByAi(event);
|
||||
if (StringUtils.hasText(aiGeneratedContent)) {
|
||||
event.setAiReply(aiGeneratedContent);
|
||||
}
|
||||
}
|
||||
|
||||
this.save(event);
|
||||
@@ -180,8 +179,23 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
String input = assembleHealingInput(event);
|
||||
log.info("开始调用AI生成疗愈回复,用户ID: {}, 输入长度: {}", event.getUserId(), input.length());
|
||||
|
||||
// 调用Coze工作流
|
||||
String result = aiChatService.callWorkflowByConfigKey(COZE_HEALING_CONFIG_KEY, input, event.getUserId());
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("mode", "life_event_analysis");
|
||||
inputs.put("input", input);
|
||||
inputs.put("message", input);
|
||||
inputs.put("content", event.getContent());
|
||||
inputs.put("title", event.getTitle());
|
||||
inputs.put("emotionType", event.getEmotionType());
|
||||
inputs.put("eventType", event.getEventType());
|
||||
inputs.put("eventDate", event.getEventDateText());
|
||||
|
||||
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||
runtimeRequest.setSceneCode("life_healing");
|
||||
runtimeRequest.setUserId(event.getUserId());
|
||||
runtimeRequest.setInputs(inputs);
|
||||
|
||||
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
|
||||
String result = response != null ? response.getOutput() : null;
|
||||
|
||||
log.info("AI生成疗愈回复完成,用户ID: {}, 结果长度: {}", event.getUserId(), result != null ? result.length() : 0);
|
||||
return result;
|
||||
@@ -238,6 +252,8 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean hasClientAiReply = request.getAiReply() != null;
|
||||
|
||||
// 更新字段
|
||||
if (StringUtils.hasText(request.getEventType())) {
|
||||
event.setEventType(request.getEventType());
|
||||
@@ -273,10 +289,11 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
||||
}
|
||||
|
||||
// 调用Coze AI进行疗愈回复
|
||||
String aiGeneratedContent = generateHealingByAi(event);
|
||||
if (StringUtils.hasText(aiGeneratedContent)) {
|
||||
event.setAiReply(aiGeneratedContent);
|
||||
if (!hasClientAiReply) {
|
||||
String aiGeneratedContent = generateHealingByAi(event);
|
||||
if (StringUtils.hasText(aiGeneratedContent)) {
|
||||
event.setAiReply(aiGeneratedContent);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateById(event);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.emotion.dto.request.ai.AiRuntimeRequest;
|
||||
import com.emotion.dto.request.WebSocketRequest;
|
||||
import com.emotion.dto.response.ai.AiStreamEvent;
|
||||
import com.emotion.dto.websocket.ChatRequest;
|
||||
import com.emotion.dto.websocket.ConnectRequest;
|
||||
import com.emotion.dto.websocket.WebSocketMessage;
|
||||
import com.emotion.entity.Message;
|
||||
import com.emotion.entity.Conversation;
|
||||
import com.emotion.service.WebSocketService;
|
||||
import com.emotion.service.AiChatService;
|
||||
import com.emotion.service.AiRuntimeService;
|
||||
import com.emotion.service.MessageService;
|
||||
import com.emotion.service.ConversationService;
|
||||
import com.emotion.util.SnowflakeIdGenerator;
|
||||
@@ -36,7 +39,7 @@ public class WebSocketServiceImpl implements WebSocketService {
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@Autowired
|
||||
private AiChatService aiChatService;
|
||||
private AiRuntimeService aiRuntimeService;
|
||||
|
||||
@Autowired
|
||||
private MessageService messageService;
|
||||
@@ -238,16 +241,11 @@ public class WebSocketServiceImpl implements WebSocketService {
|
||||
userMessage.setCozeContentType("text");
|
||||
userMessage = messageService.createMessage(userMessage);
|
||||
|
||||
// 调用AI服务(WebSocket专用方法,传递messageId)
|
||||
String aiReply = aiChatService.sendChatMessageForWebSocket(
|
||||
conversationId,
|
||||
userMessage.getId(), // 传递用户消息ID
|
||||
request.getContent(),
|
||||
userId
|
||||
);
|
||||
|
||||
// 根据换行符分割AI回复并按顺序发送多条消息
|
||||
sendAiReplyInParts(userId, conversationId, aiReply);
|
||||
String aiMessageId = snowflakeIdGenerator.nextIdAsString();
|
||||
String aiReply = streamChatReply(userId, conversationId, userMessage.getId(), aiMessageId, request.getContent());
|
||||
if (StringUtils.hasText(aiReply)) {
|
||||
saveAiMessageOnly(userId, conversationId, aiMessageId, aiReply);
|
||||
}
|
||||
|
||||
// 更新会话的最后活跃时间和消息数量
|
||||
updateConversationActivity(conversationId);
|
||||
@@ -262,6 +260,85 @@ public class WebSocketServiceImpl implements WebSocketService {
|
||||
/**
|
||||
* 发送错误消息
|
||||
*/
|
||||
private String streamChatReply(String userId, String conversationId, String userMessageId, String aiMessageId, String content) {
|
||||
StringBuilder output = new StringBuilder();
|
||||
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
|
||||
runtimeRequest.setSceneCode("chat");
|
||||
runtimeRequest.setUserId(userId);
|
||||
runtimeRequest.setRequestId(aiMessageId);
|
||||
|
||||
JSONObject inputs = new JSONObject();
|
||||
inputs.put("input", content);
|
||||
inputs.put("message", content);
|
||||
inputs.put("prompt", content);
|
||||
inputs.put("conversationId", conversationId);
|
||||
inputs.put("userMessageId", userMessageId);
|
||||
runtimeRequest.setInputs(inputs);
|
||||
|
||||
aiRuntimeService.invokeStream(runtimeRequest, event -> {
|
||||
if ("delta".equals(event.getType()) && event.getContent() != null) {
|
||||
output.append(event.getContent());
|
||||
}
|
||||
sendAiStreamEvent(userId, conversationId, aiMessageId, event);
|
||||
});
|
||||
return output.toString();
|
||||
}
|
||||
|
||||
private void sendAiStreamEvent(String userId, String conversationId, String aiMessageId, AiStreamEvent event) {
|
||||
String type = switch (event.getType()) {
|
||||
case "start" -> "AI_STREAM_START";
|
||||
case "delta" -> "AI_STREAM_DELTA";
|
||||
case "done" -> "AI_STREAM_DONE";
|
||||
case "error" -> "AI_STREAM_ERROR";
|
||||
default -> "AI_STREAM_EVENT";
|
||||
};
|
||||
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("eventType", event.getType());
|
||||
data.put("code", event.getCode());
|
||||
data.put("message", event.getMessage());
|
||||
data.put("seq", event.getSeq());
|
||||
data.put("timestamp", event.getTimestamp());
|
||||
data.put("metadata", event.getMetadata());
|
||||
|
||||
String messageContent = event.getContent();
|
||||
if (!StringUtils.hasText(messageContent)) {
|
||||
messageContent = StringUtils.hasText(event.getMessage()) ? event.getMessage() : "";
|
||||
}
|
||||
|
||||
WebSocketMessage wsMessage = WebSocketMessage.builder()
|
||||
.messageId(aiMessageId)
|
||||
.conversationId(conversationId)
|
||||
.type(type)
|
||||
.content(messageContent)
|
||||
.senderId("ai")
|
||||
.senderType("AI")
|
||||
.status("SENT")
|
||||
.createTime(LocalDateTime.now())
|
||||
.data(data)
|
||||
.build();
|
||||
|
||||
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", wsMessage);
|
||||
if (conversationId != null) {
|
||||
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, wsMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAiMessageOnly(String userId, String conversationId, String messageId, String content) {
|
||||
Message aiMessage = new Message();
|
||||
aiMessage.setId(messageId);
|
||||
aiMessage.setConversationId(conversationId);
|
||||
aiMessage.setUserId(userId);
|
||||
aiMessage.setCreateBy("ai");
|
||||
aiMessage.setContent(content);
|
||||
aiMessage.setType("text");
|
||||
aiMessage.setSender("ai");
|
||||
aiMessage.setCozeRole("assistant");
|
||||
aiMessage.setCozeContentType("text");
|
||||
aiMessage.setTimestamp(LocalDateTime.now());
|
||||
messageService.createMessage(aiMessage);
|
||||
}
|
||||
|
||||
private void sendErrorMessage(String userId, String errorContent) {
|
||||
WebSocketMessage errorMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
@@ -631,4 +708,4 @@ public class WebSocketServiceImpl implements WebSocketService {
|
||||
.timestamp(webSocketRequest.getTimestamp())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ server:
|
||||
port: 19089
|
||||
|
||||
spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
# 数据库配置 - 生产MySQL
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
@@ -64,6 +69,13 @@ emotion:
|
||||
public-url-prefix: /tts/audio
|
||||
max-text-length: 5000
|
||||
default-voice: default_zh_female
|
||||
|
||||
# Speech-to-text config
|
||||
asr:
|
||||
enabled: true
|
||||
engine-url: http://127.0.0.1:19120
|
||||
max-file-size: 10485760
|
||||
allowed-types: wav,mp3,m4a,mp4,aac,amr
|
||||
|
||||
# 生产模式配置
|
||||
prod:
|
||||
|
||||
@@ -7,6 +7,11 @@ spring:
|
||||
application:
|
||||
name: emotion-single
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
|
||||
@@ -102,6 +107,13 @@ emotion:
|
||||
max-text-length: 5000
|
||||
default-voice: default_zh_female
|
||||
|
||||
# Speech-to-text config
|
||||
asr:
|
||||
enabled: true
|
||||
engine-url: http://127.0.0.1:19120
|
||||
max-file-size: 10485760
|
||||
allowed-types: wav,mp3,m4a,mp4,aac,amr
|
||||
|
||||
# 安全配置
|
||||
security:
|
||||
ignore-urls:
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.service.AuthService;
|
||||
import com.emotion.service.TokenService;
|
||||
import com.emotion.util.JwtUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -17,7 +21,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
* @author huazhongmin
|
||||
* @date 2025-07-26
|
||||
*/
|
||||
@WebMvcTest(AuthController.class)
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
public class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
@@ -26,13 +31,28 @@ public class AuthControllerTest {
|
||||
@MockBean
|
||||
private AuthService authService;
|
||||
|
||||
@MockBean
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@MockBean
|
||||
private TokenService tokenService;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
when(jwtUtil.validateToken("test-token")).thenReturn(true);
|
||||
when(jwtUtil.getUserIdFromToken("test-token")).thenReturn("test-user");
|
||||
when(jwtUtil.getUsernameFromToken("test-token")).thenReturn("tester");
|
||||
when(jwtUtil.getUserTypeFromToken("test-token")).thenReturn("user");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckAccountExists() throws Exception {
|
||||
// 模拟账户存在的情况
|
||||
when(authService.existsByAccount("existingUser")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/auth/check-account")
|
||||
.param("account", "existingUser"))
|
||||
mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api")
|
||||
.param("account", "existingUser")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
@@ -43,8 +63,9 @@ public class AuthControllerTest {
|
||||
// 模拟账户不存在的情况
|
||||
when(authService.existsByAccount("nonExistingUser")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/auth/check-account")
|
||||
.param("account", "nonExistingUser"))
|
||||
mockMvc.perform(get("/api/auth/checkAccount").contextPath("/api")
|
||||
.param("account", "nonExistingUser")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(false));
|
||||
@@ -55,8 +76,9 @@ public class AuthControllerTest {
|
||||
// 模拟邮箱存在的情况
|
||||
when(authService.existsByEmail("existing@example.com")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/auth/check-email")
|
||||
.param("email", "existing@example.com"))
|
||||
mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api")
|
||||
.param("email", "existing@example.com")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
@@ -67,8 +89,9 @@ public class AuthControllerTest {
|
||||
// 模拟邮箱不存在的情况
|
||||
when(authService.existsByEmail("nonexisting@example.com")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/auth/check-email")
|
||||
.param("email", "nonexisting@example.com"))
|
||||
mockMvc.perform(get("/api/auth/checkEmail").contextPath("/api")
|
||||
.param("email", "nonexisting@example.com")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(false));
|
||||
@@ -79,8 +102,9 @@ public class AuthControllerTest {
|
||||
// 模拟手机号存在的情况
|
||||
when(authService.existsByPhone("13800138000")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/auth/check-phone")
|
||||
.param("phone", "13800138000"))
|
||||
mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api")
|
||||
.param("phone", "13800138000")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
@@ -91,8 +115,9 @@ public class AuthControllerTest {
|
||||
// 模拟手机号不存在的情况
|
||||
when(authService.existsByPhone("13900139000")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/auth/check-phone")
|
||||
.param("phone", "13900139000"))
|
||||
mockMvc.perform(get("/api/auth/checkPhone").contextPath("/api")
|
||||
.param("phone", "13900139000")
|
||||
.header("Authorization", "Bearer test-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(false));
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 org.springframework.test.util.AopTestUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
@@ -35,6 +36,8 @@ public class CozeWorkflowIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private AiChatService aiChatService;
|
||||
|
||||
private AiChatServiceImpl aiChatServiceImpl;
|
||||
|
||||
@Autowired
|
||||
private AiConfigService aiConfigService;
|
||||
@@ -49,6 +52,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
random = new Random();
|
||||
aiChatServiceImpl = AopTestUtils.getTargetObject(aiChatService);
|
||||
}
|
||||
|
||||
// ==================== Property 1: Request Format Correctness ====================
|
||||
@@ -82,7 +86,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> requestBody = (Map<String, Object>) buildWorkflowRequestMethod.invoke(
|
||||
aiChatService, config, parameters, userId);
|
||||
aiChatServiceImpl, config, parameters, userId);
|
||||
|
||||
// 验证必需字段
|
||||
// 2.1: workflow_id - 应该与数据库配置一致
|
||||
@@ -155,7 +159,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 验证正确提取output内容
|
||||
assertEquals("这是AI生成的内容", result,
|
||||
@@ -184,7 +188,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 验证正确提取随机output内容
|
||||
assertEquals(randomOutput, result,
|
||||
@@ -218,7 +222,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 验证只提取End节点的内容
|
||||
assertEquals("最终输出内容", result,
|
||||
@@ -244,7 +248,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
parseMethod.setAccessible(true);
|
||||
|
||||
java.util.stream.Stream<String> lines = sseResponse.lines();
|
||||
String result = (String) parseMethod.invoke(aiChatService, lines);
|
||||
String result = (String) parseMethod.invoke(aiChatServiceImpl, lines);
|
||||
|
||||
// 当content不是JSON或没有output字段时,应返回原始content
|
||||
assertEquals("直接内容,没有output字段", result,
|
||||
@@ -276,7 +280,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mergedParams = (Map<String, Object>) mergeMethod.invoke(
|
||||
aiChatService, config, runtimeParams);
|
||||
aiChatServiceImpl, config, runtimeParams);
|
||||
|
||||
// 验证运行时参数被正确设置
|
||||
assertEquals(runtimeInput, mergedParams.get("input"),
|
||||
@@ -295,7 +299,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
extractMethod.setAccessible(true);
|
||||
|
||||
String content = "{\"output\":\"提取的内容\"}";
|
||||
String result = (String) extractMethod.invoke(aiChatService, content);
|
||||
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||
|
||||
assertEquals("提取的内容", result, "应正确提取output字段");
|
||||
}
|
||||
@@ -308,7 +312,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
extractMethod.setAccessible(true);
|
||||
|
||||
String content = "这不是JSON内容";
|
||||
String result = (String) extractMethod.invoke(aiChatService, content);
|
||||
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||
|
||||
assertEquals("这不是JSON内容", result, "非JSON内容应原样返回");
|
||||
}
|
||||
@@ -324,7 +328,7 @@ public class CozeWorkflowIntegrationTest {
|
||||
String randomOutput = "随机输出_" + UUID.randomUUID().toString();
|
||||
String content = "{\"output\":\"" + randomOutput + "\"}";
|
||||
|
||||
String result = (String) extractMethod.invoke(aiChatService, content);
|
||||
String result = (String) extractMethod.invoke(aiChatServiceImpl, content);
|
||||
|
||||
assertEquals(randomOutput, result, "应正确提取随机生成的output内容");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user