马良AI写作初始化仓库
This commit is contained in:
4
AINovalServer/.gitignore
vendored
Normal file
4
AINovalServer/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.qodo
|
||||
target
|
||||
Hrepository
|
||||
|
||||
435
AINovalServer/pom.xml
Normal file
435
AINovalServer/pom.xml
Normal file
@@ -0,0 +1,435 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.ainovel</groupId>
|
||||
<artifactId>ai-novel-server</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>AI Novel Assistant Server</name>
|
||||
<packaging>jar</packaging>
|
||||
<description>AI驱动的小说创作管理系统后端服务</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<langchain4j.version>1.0.0-beta3</langchain4j.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Google Gen AI Java SDK (Gemini 官方 SDK) -->
|
||||
<dependency>
|
||||
<groupId>com.google.genai</groupId>
|
||||
<artifactId>google-genai</artifactId>
|
||||
<version>1.10.0</version>
|
||||
</dependency>
|
||||
<!-- Spring WebFlux -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<!-- Pin Reactor Netty to a version with fix for HttpOperations.initShortId ClassCastException -->
|
||||
<dependency>
|
||||
<groupId>io.projectreactor.netty</groupId>
|
||||
<artifactId>reactor-netty</artifactId>
|
||||
<version>1.2.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor.netty</groupId>
|
||||
<artifactId>reactor-netty-http</artifactId>
|
||||
<version>1.2.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-xml</artifactId>
|
||||
<version>2.17.1</version> </dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.woodstox</groupId>
|
||||
<artifactId>woodstox-core</artifactId>
|
||||
<version>6.6.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger UI -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
<version>2.6.0</version> <!-- Use a recent compatible version -->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mantoux</groupId>
|
||||
<artifactId>quill-delta</artifactId>
|
||||
<version>1.6.3</version>
|
||||
</dependency>
|
||||
<!-- Spring Data MongoDB Reactive -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT Support -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- Reactive Redis for idempotency/quotas -->
|
||||
<!-- <dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
||||
</dependency> -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-tools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Aliyun SDK for SMS -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>dysmsapi20170525</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Aliyun SDK Core -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||
<version>4.6.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Mail for Email -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine Cache for verification code storage -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Captcha generation -->
|
||||
<dependency>
|
||||
<groupId>com.github.penggle</groupId>
|
||||
<artifactId>kaptcha</artifactId>
|
||||
<version>2.3.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Actuator for monitoring -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Micrometer for metrics -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Micrometer Context Propagation (for Reactor MDC) -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>context-propagation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- AspectJ support for @Timed annotation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Gatling for performance testing -->
|
||||
<dependency>
|
||||
<groupId>io.gatling</groupId>
|
||||
<artifactId>gatling-app</artifactId>
|
||||
<version>3.10.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.gatling.highcharts</groupId>
|
||||
<artifactId>gatling-charts-highcharts</artifactId>
|
||||
<version>3.10.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j 依赖 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j OpenAI 集成 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-open-ai</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j Anthropic 集成 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-anthropic</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j Google AI Gemini 集成 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-google-ai-gemini</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-embeddings</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j Reactor 集成 (用于Flux支持) -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-reactor</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j Chroma 集成 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-chroma</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j 本地嵌入模型 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-embeddings</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- AllMiniLmL6V2 嵌入模型 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- AllMiniLmL6V2 量化嵌入模型 -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-embeddings-all-minilm-l6-v2-q</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Java Diff Utils -->
|
||||
<dependency>
|
||||
<groupId>io.github.java-diff-utils</groupId>
|
||||
<artifactId>java-diff-utils</artifactId>
|
||||
<version>4.12</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ByteBuddy for runtime code generation (tool bridge) -->
|
||||
<dependency>
|
||||
<groupId>net.bytebuddy</groupId>
|
||||
<artifactId>byte-buddy</artifactId>
|
||||
<version>1.15.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Jasypt -->
|
||||
<dependency>
|
||||
<groupId>com.github.ulisesbocchio</groupId>
|
||||
<artifactId>jasypt-spring-boot-starter</artifactId>
|
||||
<version>3.0.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里云OSS SDK -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.17.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里云所需依赖 -->
|
||||
<dependency>
|
||||
<groupId>javax.xml.bind</groupId>
|
||||
<artifactId>jaxb-api</artifactId>
|
||||
<version>2.3.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>javax.activation</groupId>
|
||||
<artifactId>activation</artifactId>
|
||||
<version>1.1.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cache -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine 缓存 -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Caffeine Cache -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context-support</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AMQP & RabbitMQ -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Java 延迟队列支持 (用于任务重试) -->
|
||||
<dependency>
|
||||
<groupId>net.jodah</groupId>
|
||||
<artifactId>expiringmap</artifactId>
|
||||
<version>0.5.11</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Google Guava库 (用于限流服务) -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>33.0.0-jre</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Resilience4j (用于限流服务) -->
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-ratelimiter</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-core</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SkyWalking Toolkit for manual tracing (optional) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.skywalking</groupId>
|
||||
<artifactId>apm-toolkit-trace</artifactId>
|
||||
<version>9.5.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.12.0</version>
|
||||
<configuration>
|
||||
<!-- 关键:保留参数名称用于MongoDB映射 -->
|
||||
<parameters>true</parameters>
|
||||
<compilerArgs>
|
||||
<arg>-parameters</arg>
|
||||
<!-- 保留调试信息 -->
|
||||
<arg>-g</arg>
|
||||
</compilerArgs>
|
||||
<!-- 启用注解处理器 -->
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>io.gatling</groupId>
|
||||
<artifactId>gatling-maven-plugin</artifactId>
|
||||
<version>4.6.0</version>
|
||||
<configuration>
|
||||
<simulationsFolder>src/test/java</simulationsFolder>
|
||||
<resultsFolder>target/gatling/results</resultsFolder>
|
||||
<runMultipleSimulations>true</runMultipleSimulations>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ainovel.server;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.config.MeterFilter;
|
||||
import io.micrometer.core.instrument.config.MeterFilterReply;
|
||||
|
||||
/**
|
||||
* AI小说助手系统后端服务主应用类
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class AiNovelServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AiNovelServerApplication.class, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* In production, only allow memory-related meters to be exported to minimize overhead.
|
||||
* This filters out all meters except those whose names start with known memory prefixes.
|
||||
*/
|
||||
@Bean
|
||||
public MeterFilter memoryOnlyMetersFilter() {
|
||||
return new MeterFilter() {
|
||||
@Override
|
||||
public MeterFilterReply accept(Meter.Id id) {
|
||||
String name = id.getName();
|
||||
if (name == null) {
|
||||
return MeterFilterReply.DENY;
|
||||
}
|
||||
|
||||
// JVM memory (existing)
|
||||
if (name.startsWith("jvm.memory.")
|
||||
|| name.startsWith("process.runtime.jvm.memory.")
|
||||
|| name.startsWith("system.memory.")
|
||||
|| name.startsWith("process.memory.")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// JVM GC / threads / classes / JIT compilation
|
||||
if (name.startsWith("jvm.gc.")
|
||||
|| name.startsWith("jvm.threads.")
|
||||
|| name.startsWith("jvm.classes.")
|
||||
|| name.startsWith("jvm.compilation.")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// CPU / Load / Uptime
|
||||
if (name.startsWith("process.cpu.")
|
||||
|| name.startsWith("system.cpu.")
|
||||
|| name.startsWith("system.load.")
|
||||
|| name.startsWith("process.uptime")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// Application level throughput/latency
|
||||
if (name.startsWith("http.server.requests")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// Logging throughput
|
||||
if (name.startsWith("logback.events")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// Cache metrics (Caffeine)
|
||||
if (name.startsWith("cache.")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// Reactor Netty (connections/throughput/timeout)
|
||||
if (name.startsWith("reactor.netty.")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
// Optional: RabbitMQ & MongoDB metrics
|
||||
if (name.startsWith("rabbitmq.") || name.startsWith("mongodb.")) {
|
||||
return MeterFilterReply.ACCEPT;
|
||||
}
|
||||
|
||||
return MeterFilterReply.DENY;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public org.springframework.boot.CommandLineRunner startupWarnings(
|
||||
org.springframework.core.env.Environment env) {
|
||||
return args -> {
|
||||
org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(AiNovelServerApplication.class);
|
||||
try {
|
||||
boolean rabbitEnabled = env.getProperty("spring.rabbitmq.enabled", Boolean.class, false);
|
||||
if (!rabbitEnabled) {
|
||||
logger.warn("RabbitMQ disabled by configuration (spring.rabbitmq.enabled=false). Background task queue will not start.");
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
|
||||
try {
|
||||
boolean chromaEnabled = env.getProperty("vectorstore.chroma.enabled", Boolean.class, false);
|
||||
if (!chromaEnabled) {
|
||||
logger.warn("Chroma vectorstore disabled by configuration (vectorstore.chroma.enabled=false). RAG features will be limited.");
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.ainovel.server.boot;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.ainovel.server.domain.model.SubscriptionPlan;
|
||||
import com.ainovel.server.domain.model.SubscriptionPlan.BillingCycle;
|
||||
import com.ainovel.server.repository.SubscriptionPlanRepository;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@Order(10)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SeedSubscriptionDataRunner implements ApplicationRunner {
|
||||
|
||||
private final SubscriptionPlanRepository planRepository;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
log.info("Starting subscription data seeding...");
|
||||
seedIfEmpty().subscribe(
|
||||
ok -> log.info("✅ Subscription seed completed successfully: {}", ok),
|
||||
err -> log.error("❌ Subscription seed failed (this may be due to MongoDB map-key-dot-replacement configuration)", err)
|
||||
);
|
||||
}
|
||||
|
||||
private Mono<Boolean> seedIfEmpty() {
|
||||
return planRepository.findByActiveTrue().hasElements().flatMap(exists -> {
|
||||
if (exists) return Mono.just(true);
|
||||
|
||||
// Free(展示为0元,受限能力)
|
||||
SubscriptionPlan free = SubscriptionPlan.builder()
|
||||
.planName("Free")
|
||||
.description("基础功能,适合体验与轻度使用")
|
||||
.price(BigDecimal.ZERO)
|
||||
.currency("CNY")
|
||||
.billingCycle(BillingCycle.MONTHLY)
|
||||
.priority(10)
|
||||
.active(true)
|
||||
.recommended(false)
|
||||
.features(new LinkedHashMap<>(Map.of(
|
||||
"ai.daily.calls", 10,
|
||||
"import.daily.limit", 1,
|
||||
"novel.max.count", 3
|
||||
)))
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Pro(月付)
|
||||
SubscriptionPlan pro = SubscriptionPlan.builder()
|
||||
.planName("Pro")
|
||||
.description("更高的AI调用与导入额度,适合稳定创作")
|
||||
.price(new BigDecimal("29.00"))
|
||||
.currency("CNY")
|
||||
.billingCycle(BillingCycle.MONTHLY)
|
||||
.priority(100)
|
||||
.active(true)
|
||||
.recommended(true)
|
||||
.creditsGranted(200000L)
|
||||
.features(new LinkedHashMap<>(Map.of(
|
||||
"ai.daily.calls", 200,
|
||||
"import.daily.limit", 10,
|
||||
"novel.max.count", 30
|
||||
)))
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Pro(年付)
|
||||
SubscriptionPlan proYearly = SubscriptionPlan.builder()
|
||||
.planName("Pro Yearly")
|
||||
.description("年度优惠,适合长期创作")
|
||||
.price(new BigDecimal("288.00"))
|
||||
.currency("CNY")
|
||||
.billingCycle(BillingCycle.YEARLY)
|
||||
.priority(90)
|
||||
.active(true)
|
||||
.recommended(false)
|
||||
.creditsGranted(2500000L)
|
||||
.features(new LinkedHashMap<>(Map.of(
|
||||
"ai.daily.calls", 300,
|
||||
"import.daily.limit", 20,
|
||||
"novel.max.count", 100
|
||||
)))
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Lifetime
|
||||
SubscriptionPlan lifetime = SubscriptionPlan.builder()
|
||||
.planName("Lifetime")
|
||||
.description("一次购买,长期使用")
|
||||
.price(new BigDecimal("999.00"))
|
||||
.currency("CNY")
|
||||
.billingCycle(BillingCycle.LIFETIME)
|
||||
.priority(80)
|
||||
.active(true)
|
||||
.recommended(false)
|
||||
.creditsGranted(10000000L)
|
||||
.features(new LinkedHashMap<>(Map.of(
|
||||
"ai.daily.calls", 1000,
|
||||
"import.daily.limit", 100,
|
||||
"novel.max.count", 1000
|
||||
)))
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
return planRepository.save(free)
|
||||
.then(planRepository.save(pro))
|
||||
.then(planRepository.save(proYearly))
|
||||
.then(planRepository.save(lifetime))
|
||||
.thenReturn(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.ainovel.server.common.exception;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.config.MappingExceptionLogger;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.mapping.MappingException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.bind.support.WebExchangeBindException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 全局异常处理器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@Autowired
|
||||
private MappingExceptionLogger mappingExceptionLogger;
|
||||
|
||||
/**
|
||||
* 处理验证异常
|
||||
*/
|
||||
@ExceptionHandler(ValidationException.class)
|
||||
public Mono<ResponseEntity<ApiResponse<?>>> handleValidationException(ValidationException e) {
|
||||
log.warn("验证异常: {}", e.getMessage());
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error(e.getMessage(), "VALIDATION_ERROR")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理绑定异常(请求参数验证失败)
|
||||
*/
|
||||
@ExceptionHandler(WebExchangeBindException.class)
|
||||
public Mono<ResponseEntity<ApiResponse<?>>> handleBindException(WebExchangeBindException e) {
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
e.getBindingResult().getAllErrors().forEach(error -> {
|
||||
String fieldName = ((FieldError) error).getField();
|
||||
String errorMessage = error.getDefaultMessage();
|
||||
errors.put(fieldName, errorMessage);
|
||||
});
|
||||
|
||||
String message = "请求参数验证失败";
|
||||
if (!errors.isEmpty()) {
|
||||
// 获取第一个错误信息作为主要错误提示
|
||||
message = errors.values().iterator().next();
|
||||
}
|
||||
|
||||
log.warn("请求参数验证失败: {}", errors);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error(message, "VALIDATION_ERROR", errors)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证失败异常(如用户名/密码错误、Token无效等)
|
||||
*/
|
||||
@ExceptionHandler(BadCredentialsException.class)
|
||||
public Mono<ResponseEntity<ApiResponse<?>>> handleBadCredentials(BadCredentialsException e) {
|
||||
log.warn("认证失败: {}", e.getMessage());
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.error("用户名或密码错误", "INVALID_CREDENTIALS")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理积分不足异常
|
||||
*/
|
||||
@ExceptionHandler(InsufficientCreditsException.class)
|
||||
public Mono<ResponseEntity<ApiResponse<?>>> handleInsufficientCreditsException(InsufficientCreditsException e) {
|
||||
log.warn("积分不足: {}", e.getMessage());
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
|
||||
.body(ApiResponse.error(e.getMessage(), "INSUFFICIENT_CREDITS")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 专门处理Spring Data MongoDB映射异常
|
||||
*/
|
||||
@ExceptionHandler(MappingException.class)
|
||||
public Mono<ResponseEntity<ApiResponse<?>>> handleMappingException(MappingException e) {
|
||||
log.error("🚨 MongoDB映射异常被全局异常处理器捕获");
|
||||
|
||||
// 使用详细的映射异常记录器
|
||||
try {
|
||||
// 尝试从异常堆栈和消息中提取更多信息
|
||||
Class<?> entityClass = null;
|
||||
String documentInfo = "无法获取原始文档 - 异常在映射过程中抛出";
|
||||
String operationContext = "未知操作";
|
||||
|
||||
log.error("🔍 开始分析MappingException堆栈...");
|
||||
|
||||
// 检查异常堆栈,寻找相关的实体类和上下文
|
||||
StackTraceElement[] stackTrace = e.getStackTrace();
|
||||
for (int i = 0; i < stackTrace.length; i++) {
|
||||
StackTraceElement element = stackTrace[i];
|
||||
String className = element.getClassName();
|
||||
String methodName = element.getMethodName();
|
||||
|
||||
log.error(" [{}] 堆栈: {}.{}", i, className, methodName);
|
||||
|
||||
// 寻找我们的domain model类
|
||||
if (className.contains("com.ainovel.server.domain.model")) {
|
||||
try {
|
||||
entityClass = Class.forName(className);
|
||||
documentInfo = "问题发生在: " + className + "." + methodName;
|
||||
operationContext = "实体类直接操作";
|
||||
log.error(" ✅ 找到domain model类: {}", className);
|
||||
break;
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
// 继续寻找
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是在处理LLMTrace相关的操作
|
||||
if (className.contains("LLMTraceService") ||
|
||||
className.contains("LLMObservability")) {
|
||||
documentInfo = "问题发生在LLM观测服务中: " + className + "." + methodName;
|
||||
operationContext = "LLM观测服务操作";
|
||||
// 如果没有找到具体的实体类,默认使用LLMTrace
|
||||
if (entityClass == null) {
|
||||
try {
|
||||
entityClass = Class.forName("com.ainovel.server.domain.model.observability.LLMTrace");
|
||||
log.error(" 🎯 LLMTrace操作推断: 设置实体类为LLMTrace");
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查ReactiveMongoTemplate操作
|
||||
if (className.contains("ReactiveMongoTemplate")) {
|
||||
operationContext = "MongoDB模板操作: " + methodName;
|
||||
log.error(" 📊 MongoDB操作检测: {}.{}", className, methodName);
|
||||
}
|
||||
|
||||
// 检查MappingMongoConverter
|
||||
if (className.contains("MappingMongoConverter")) {
|
||||
operationContext = "MongoDB映射转换: " + methodName;
|
||||
log.error(" 🔄 映射转换检测: {}.{}", className, methodName);
|
||||
}
|
||||
|
||||
// 如果找到了实体类,不要太早退出,继续查找更多上下文
|
||||
if (i > 10) break; // 但不要查找太深
|
||||
}
|
||||
|
||||
log.error("🎯 异常分析结果: entityClass={}, operationContext={}",
|
||||
entityClass != null ? entityClass.getSimpleName() : "null", operationContext);
|
||||
|
||||
// 记录详细的映射异常信息
|
||||
mappingExceptionLogger.logMappingException(
|
||||
entityClass != null ? entityClass : Object.class,
|
||||
documentInfo + " [" + operationContext + "]",
|
||||
e
|
||||
);
|
||||
|
||||
} catch (Exception logException) {
|
||||
log.error("记录映射异常时发生错误", logException);
|
||||
}
|
||||
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("数据映射错误,请稍后重试", "MAPPING_ERROR")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理其他异常
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Mono<ResponseEntity<ApiResponse<?>>> handleGenericException(Exception e) {
|
||||
// 检查是否包含MappingException作为根本原因
|
||||
Throwable rootCause = getRootCause(e);
|
||||
if (rootCause instanceof MappingException) {
|
||||
log.error("🔍 发现包装的MongoDB映射异常");
|
||||
return handleMappingException((MappingException) rootCause);
|
||||
}
|
||||
|
||||
// 检查异常链中是否有MappingException
|
||||
Throwable current = e;
|
||||
while (current != null) {
|
||||
if (current instanceof MappingException) {
|
||||
log.error("🔍 在异常链中发现MongoDB映射异常");
|
||||
return handleMappingException((MappingException) current);
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
|
||||
log.error("未处理的异常", e);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("服务器内部错误,请稍后重试", "INTERNAL_ERROR")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异常的根本原因
|
||||
*/
|
||||
private Throwable getRootCause(Throwable throwable) {
|
||||
Throwable cause = throwable.getCause();
|
||||
if (cause == null || cause == throwable) {
|
||||
return throwable;
|
||||
}
|
||||
return getRootCause(cause);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
package com.ainovel.server.common.exception;
|
||||
|
||||
/**
|
||||
* 积分不足异常
|
||||
* 当用户积分余额不足以完成AI请求时抛出
|
||||
*/
|
||||
public class InsufficientCreditsException extends RuntimeException {
|
||||
|
||||
private final long requiredCredits;
|
||||
private final long currentCredits;
|
||||
|
||||
public InsufficientCreditsException(long requiredCredits) {
|
||||
super(String.format("积分余额不足,需要 %d 积分", requiredCredits));
|
||||
this.requiredCredits = requiredCredits;
|
||||
this.currentCredits = 0; // 未知当前积分
|
||||
}
|
||||
|
||||
public InsufficientCreditsException(long requiredCredits, long currentCredits) {
|
||||
super(String.format("积分余额不足,需要 %d 积分,当前余额 %d 积分", requiredCredits, currentCredits));
|
||||
this.requiredCredits = requiredCredits;
|
||||
this.currentCredits = currentCredits;
|
||||
}
|
||||
|
||||
public InsufficientCreditsException(String message, long requiredCredits) {
|
||||
super(message);
|
||||
this.requiredCredits = requiredCredits;
|
||||
this.currentCredits = 0;
|
||||
}
|
||||
|
||||
public long getRequiredCredits() {
|
||||
return requiredCredits;
|
||||
}
|
||||
|
||||
public long getCurrentCredits() {
|
||||
return currentCredits;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
package com.ainovel.server.common.exception;
|
||||
|
||||
/**
|
||||
* 资源未找到异常
|
||||
*/
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ResourceNotFoundException(String resourceType, String resourceId) {
|
||||
super(String.format("未找到%s资源,ID: %s", resourceType, resourceId));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
package com.ainovel.server.common.exception;
|
||||
|
||||
/**
|
||||
* 验证异常
|
||||
*/
|
||||
public class ValidationException extends RuntimeException {
|
||||
|
||||
public ValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ValidationException(String field, String message) {
|
||||
super(String.format("字段 '%s' 验证失败: %s", field, message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ainovel.server.common.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 错误响应模型
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ErrorResponse {
|
||||
|
||||
private String message;
|
||||
private LocalDateTime timestamp = LocalDateTime.now();
|
||||
|
||||
public ErrorResponse(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.ainovel.server.common.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 通用API响应类
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiResponse<T> {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private T data;
|
||||
private String errorCode;
|
||||
|
||||
public static <T> ApiResponse<T> success() {
|
||||
return new ApiResponse<>(true, "操作成功", null, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, "操作成功", data, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(true, message, data, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message) {
|
||||
return new ApiResponse<>(false, message, null, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message, String errorCode) {
|
||||
return new ApiResponse<>(false, message, null, errorCode);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message, String errorCode, T data) {
|
||||
return new ApiResponse<>(false, message, data, errorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ainovel.server.common.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通用游标分页响应
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CursorPageResponse<T> {
|
||||
/** 当前返回的数据项 */
|
||||
private List<T> items;
|
||||
/** 下一页游标(可能为null表示没有更多) */
|
||||
private String nextCursor;
|
||||
/** 是否还有更多数据 */
|
||||
private boolean hasMore;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ainovel.server.common.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Builder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 通用分页响应类
|
||||
* @param <T> 数据类型
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PagedResponse<T> {
|
||||
|
||||
/**
|
||||
* 当前页数据内容
|
||||
*/
|
||||
private List<T> content;
|
||||
|
||||
/**
|
||||
* 当前页码(从0开始)
|
||||
*/
|
||||
private int page;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
private int size;
|
||||
|
||||
/**
|
||||
* 总元素数量
|
||||
*/
|
||||
private long totalElements;
|
||||
|
||||
/**
|
||||
* 总页数
|
||||
*/
|
||||
private int totalPages;
|
||||
|
||||
/**
|
||||
* 是否有下一页
|
||||
*/
|
||||
private boolean hasNext;
|
||||
|
||||
/**
|
||||
* 是否有上一页
|
||||
*/
|
||||
private boolean hasPrevious;
|
||||
|
||||
/**
|
||||
* 是否是第一页
|
||||
*/
|
||||
private boolean first;
|
||||
|
||||
/**
|
||||
* 是否是最后一页
|
||||
*/
|
||||
private boolean last;
|
||||
|
||||
/**
|
||||
* 创建分页响应的静态工厂方法
|
||||
* @param content 当前页数据
|
||||
* @param page 当前页码(从0开始)
|
||||
* @param size 每页大小
|
||||
* @param totalElements 总元素数量
|
||||
* @return 分页响应对象
|
||||
*/
|
||||
public static <T> PagedResponse<T> of(List<T> content, int page, int size, long totalElements) {
|
||||
int totalPages = (int) Math.ceil((double) totalElements / size);
|
||||
boolean hasNext = page < totalPages - 1;
|
||||
boolean hasPrevious = page > 0;
|
||||
boolean first = page == 0;
|
||||
boolean last = page == totalPages - 1 || totalPages == 0;
|
||||
|
||||
return PagedResponse.<T>builder()
|
||||
.content(content)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.totalElements(totalElements)
|
||||
.totalPages(totalPages)
|
||||
.hasNext(hasNext)
|
||||
.hasPrevious(hasPrevious)
|
||||
.first(first)
|
||||
.last(last)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的分页响应
|
||||
* @param page 当前页码
|
||||
* @param size 每页大小
|
||||
* @return 空的分页响应对象
|
||||
*/
|
||||
public static <T> PagedResponse<T> empty(int page, int size) {
|
||||
return of(List.of(), page, size, 0);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
package com.ainovel.server.common.security;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 用于标记方法参数获取当前用户ID的注解
|
||||
*/
|
||||
@Target({ElementType.PARAMETER})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface CurrentUser {
|
||||
// 空的标记注解
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import com.ainovel.server.domain.model.Novel;
|
||||
import com.ainovel.server.domain.model.Scene;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 章节与场景顺序工具:
|
||||
* - 提供章节顺序映射(chapterId -> order)
|
||||
* - 提供场景排序(按 sequence 升序,null 最后)
|
||||
* - 提供统一的场景顺序标签生成("chapterOrder-sceneIndex")
|
||||
*/
|
||||
public final class ChapterOrderUtil {
|
||||
|
||||
private ChapterOrderUtil() {}
|
||||
|
||||
/**
|
||||
* 根据小说结构构建章节顺序映射。
|
||||
*/
|
||||
public static Map<String, Integer> buildChapterOrderMap(Novel novel) {
|
||||
if (novel == null || novel.getStructure() == null || novel.getStructure().getActs() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
// 先按原始顺序构建映射
|
||||
Map<String, Integer> rawOrderMap = novel.getStructure().getActs().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(a -> a.getChapters().stream())
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toMap(Novel.Chapter::getId, Novel.Chapter::getOrder, (a, b) -> a, LinkedHashMap::new));
|
||||
|
||||
// 将章节序号统一转换为从1开始:
|
||||
// 若最小序号为0,则整体偏移+1;若更小(<0),则偏移到最小为1。
|
||||
int minOrder = rawOrderMap.values().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.min(Integer::compareTo)
|
||||
.orElse(1);
|
||||
int offset = 0;
|
||||
if (minOrder <= 0) {
|
||||
offset = 1 - minOrder;
|
||||
}
|
||||
|
||||
if (offset == 0) {
|
||||
return rawOrderMap;
|
||||
}
|
||||
|
||||
Map<String, Integer> adjusted = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Integer> entry : rawOrderMap.entrySet()) {
|
||||
Integer value = entry.getValue();
|
||||
// 避免出现null,确保后续取值不会发生空指针
|
||||
int normalized = (value == null ? 1 : value + offset);
|
||||
adjusted.put(entry.getKey(), normalized);
|
||||
}
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取章节顺序号,若不存在则返回 -1。
|
||||
*/
|
||||
public static int getChapterOrder(Map<String, Integer> chapterOrderMap, String chapterId) {
|
||||
if (chapterOrderMap == null || chapterId == null) {
|
||||
return -1;
|
||||
}
|
||||
return chapterOrderMap.getOrDefault(chapterId, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将场景按 sequence 升序排列,null sequence 排在最后。
|
||||
*/
|
||||
public static List<Scene> sortScenesBySequence(List<Scene> scenes) {
|
||||
if (scenes == null) return List.of();
|
||||
return scenes.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(Comparator.comparing(Scene::getSequence, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的场景顺序标签(示例:5-2)。
|
||||
*/
|
||||
public static String buildSceneOrderTag(int chapterOrder, int sceneIndex) {
|
||||
return chapterOrder + "-" + sceneIndex;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//package com.ainovel.server.common.util;
|
||||
//
|
||||
//import java.time.Instant;
|
||||
//import java.time.LocalDateTime;
|
||||
//import java.util.ArrayList;
|
||||
//import java.util.List;
|
||||
//import java.util.Random;
|
||||
//import java.util.UUID;
|
||||
//import java.util.stream.Collectors;
|
||||
//import java.util.stream.IntStream;
|
||||
//
|
||||
//import com.ainovel.server.domain.model.AIInteraction;
|
||||
//import com.ainovel.server.domain.model.Character;
|
||||
//import com.ainovel.server.domain.model.Novel;
|
||||
//import com.ainovel.server.domain.model.Scene;
|
||||
//
|
||||
///**
|
||||
// * 测试数据生成器,用于创建模拟数据进行性能测试
|
||||
// */
|
||||
//public class MockDataGenerator {
|
||||
//
|
||||
// private static final Random random = new Random();
|
||||
// private static final String[] NOVEL_TITLES = {
|
||||
// "龙族崛起", "星际迷航", "魔法学院", "末日求生", "江湖传说",
|
||||
// "未来战士", "古墓奇谭", "都市异能", "仙侠奇缘", "科技狂潮"
|
||||
// };
|
||||
//
|
||||
// private static final String[] NOVEL_GENRES = {
|
||||
// "奇幻", "科幻", "武侠", "仙侠", "都市",
|
||||
// "历史", "军事", "悬疑", "恐怖", "言情"
|
||||
// };
|
||||
//
|
||||
// private static final String[] CHARACTER_NAMES = {
|
||||
// "李明", "张伟", "王芳", "赵静", "陈强",
|
||||
// "林雪", "刘洋", "黄晓", "吴刚", "孙悟空",
|
||||
// "猪八戒", "沙僧", "唐僧", "白龙马", "如来佛",
|
||||
// "观音菩萨", "玉皇大帝", "太上老君", "二郎神", "哪吒"
|
||||
// };
|
||||
//
|
||||
// private static final String[] CHARACTER_ROLES = {
|
||||
// "主角", "配角", "反派", "导师", "助手",
|
||||
// "情感角色", "对手", "神秘人", "小丑", "智者"
|
||||
// };
|
||||
//
|
||||
// private static final String[] ACT_TITLES = {
|
||||
// "序章", "第一卷", "第二卷", "第三卷", "终章"
|
||||
// };
|
||||
//
|
||||
// private static final String[] CHAPTER_TITLES = {
|
||||
// "初入江湖", "危机四伏", "命运转折", "巅峰对决", "意外发现",
|
||||
// "神秘来客", "暗夜追踪", "秘密会面", "生死抉择", "最终决战"
|
||||
// };
|
||||
//
|
||||
// private static final String LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
|
||||
//
|
||||
// /**
|
||||
// * 生成指定数量的小说
|
||||
// */
|
||||
// public static List<Novel> generateNovels(int count) {
|
||||
// return IntStream.range(0, count)
|
||||
// .mapToObj(i -> generateNovel())
|
||||
// .collect(Collectors.toList());
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成单个小说
|
||||
// */
|
||||
// public static Novel generateNovel() {
|
||||
// String authorId = UUID.randomUUID().toString();
|
||||
// String username = "作者" + random.nextInt(1000);
|
||||
//
|
||||
// Novel.Author author = Novel.Author.builder()
|
||||
// .id(authorId)
|
||||
// .username(username)
|
||||
// .build();
|
||||
//
|
||||
// LocalDateTime createdAt = LocalDateTime.now().minusDays(random.nextInt(30));
|
||||
// LocalDateTime updatedAt = LocalDateTime.now();
|
||||
//
|
||||
// // 生成小说结构
|
||||
// Novel.Structure structure = generateStructure();
|
||||
//
|
||||
// // 生成元数据
|
||||
// int wordCount = random.nextInt(100000) + 10000;
|
||||
// Novel.Metadata metadata = Novel.Metadata.builder()
|
||||
// .wordCount(wordCount)
|
||||
// .readTime(wordCount / 300) // 假设每分钟阅读300字
|
||||
// .lastEditedAt(updatedAt)
|
||||
// .version(1 + random.nextInt(5))
|
||||
// .build();
|
||||
//
|
||||
// // 生成标签和分类
|
||||
// List<String> genres = new ArrayList<>();
|
||||
// genres.add(NOVEL_GENRES[random.nextInt(NOVEL_GENRES.length)]);
|
||||
//
|
||||
// List<String> tags = new ArrayList<>();
|
||||
// tags.add("热门");
|
||||
// tags.add("推荐");
|
||||
// if (random.nextBoolean()) {
|
||||
// tags.add("精品");
|
||||
// }
|
||||
//
|
||||
// return Novel.builder()
|
||||
// .id(UUID.randomUUID().toString())
|
||||
// .title(NOVEL_TITLES[random.nextInt(NOVEL_TITLES.length)])
|
||||
// .description("这是一部" + genres.get(0) + "小说,讲述了一个精彩的故事。")
|
||||
// .author(author)
|
||||
// .genre(genres)
|
||||
// .tags(tags)
|
||||
// .coverImage("https://example.com/covers/" + UUID.randomUUID().toString() + ".jpg")
|
||||
// .status("进行中")
|
||||
// .structure(structure)
|
||||
// .metadata(metadata)
|
||||
// .createdAt(createdAt)
|
||||
// .updatedAt(updatedAt)
|
||||
// .build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成小说结构
|
||||
// */
|
||||
// private static Novel.Structure generateStructure() {
|
||||
// int actCount = 1 + random.nextInt(3); // 1-3卷
|
||||
//
|
||||
// List<Novel.Act> acts = IntStream.range(0, actCount)
|
||||
// .mapToObj(actIndex -> {
|
||||
// int chapterCount = 3 + random.nextInt(5); // 3-7章
|
||||
//
|
||||
// List<Novel.Chapter> chapters = IntStream.range(0, chapterCount)
|
||||
// .mapToObj(chapterIndex -> {
|
||||
// return Novel.Chapter.builder()
|
||||
// .id(UUID.randomUUID().toString())
|
||||
// .title(CHAPTER_TITLES[random.nextInt(CHAPTER_TITLES.length)] + " "
|
||||
// + (chapterIndex + 1))
|
||||
// .description("这是第" + (actIndex + 1) + "卷第" + (chapterIndex + 1) + "章")
|
||||
// .order(chapterIndex + 1)
|
||||
// .sceneId(UUID.randomUUID().toString())// 生成一个场景ID
|
||||
// .build();
|
||||
// })
|
||||
// .collect(Collectors.toList());
|
||||
//
|
||||
// return Novel.Act.builder()
|
||||
// .id(UUID.randomUUID().toString())
|
||||
// .title(ACT_TITLES[Math.min(actIndex, ACT_TITLES.length - 1)])
|
||||
// .description("这是小说的第" + (actIndex + 1) + "卷")
|
||||
// .order(actIndex + 1)
|
||||
// .chapters(chapters)
|
||||
// .build();
|
||||
// })
|
||||
// .collect(Collectors.toList());
|
||||
//
|
||||
// return Novel.Structure.builder()
|
||||
// .acts(acts)
|
||||
// .build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成指定数量的场景
|
||||
// */
|
||||
// public static List<Scene> generateScenes(int count, String novelId) {
|
||||
// return IntStream.range(0, count)
|
||||
// .mapToObj(i -> generateScene(novelId, UUID.randomUUID().toString(), i + 1))
|
||||
// .collect(Collectors.toList());
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成单个场景
|
||||
// */
|
||||
// public static Scene generateScene(String novelId, String chapterId, int version) {
|
||||
// String content = generateRandomContent(500 + random.nextInt(1500));
|
||||
//
|
||||
// // 创建历史记录
|
||||
// Scene.HistoryEntry historyEntry = Scene.HistoryEntry.builder()
|
||||
// .content(content)
|
||||
// .updatedAt(LocalDateTime.now().minusDays(random.nextInt(10)))
|
||||
// .updatedBy("system")
|
||||
// .reason("初始创建")
|
||||
// .build();
|
||||
//
|
||||
// List<Scene.HistoryEntry> history = new ArrayList<>();
|
||||
// history.add(historyEntry);
|
||||
//
|
||||
// // 创建向量嵌入
|
||||
// Scene.VectorEmbedding vectorEmbedding = Scene.VectorEmbedding.builder()
|
||||
// .vector(new float[384]) // 假设使用384维向量
|
||||
// .model("text-embedding-3-small")
|
||||
// .build();
|
||||
//
|
||||
// // 随机填充向量
|
||||
// for (int i = 0; i < vectorEmbedding.getVector().length; i++) {
|
||||
// vectorEmbedding.getVector()[i] = random.nextFloat();
|
||||
// }
|
||||
//
|
||||
// return Scene.builder()
|
||||
// .id(UUID.randomUUID().toString())
|
||||
// .novelId(novelId)
|
||||
// .chapterId(chapterId)
|
||||
// .title(CHAPTER_TITLES[random.nextInt(CHAPTER_TITLES.length)] + " " + version)
|
||||
// .content(content)
|
||||
// .summary("这是一个场景的摘要,描述了主要内容。")
|
||||
// .vectorEmbedding(vectorEmbedding)
|
||||
// .characterIds(new ArrayList<>())
|
||||
// .locations(List.of("山洞", "森林", "城堡").subList(0, 1 + random.nextInt(2)))
|
||||
// .timeframe("第" + (1 + random.nextInt(10)) + "天")
|
||||
// .version(version)
|
||||
// .history(history)
|
||||
// .createdAt(Instant.now().minusSeconds(random.nextInt(30 * 24 * 60 * 60)))
|
||||
// .updatedAt(Instant.now())
|
||||
// .build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成指定数量的角色
|
||||
// */
|
||||
// public static List<Character> generateCharacters(int count, String novelId) {
|
||||
// return IntStream.range(0, count)
|
||||
// .mapToObj(i -> generateCharacter(novelId))
|
||||
// .collect(Collectors.toList());
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成单个角色
|
||||
// */
|
||||
// public static Character generateCharacter(String novelId) {
|
||||
// String name = CHARACTER_NAMES[random.nextInt(CHARACTER_NAMES.length)];
|
||||
// String roleType = CHARACTER_ROLES[random.nextInt(CHARACTER_ROLES.length)];
|
||||
//
|
||||
// // 创建角色详情
|
||||
// Character.Details details = Character.Details.builder()
|
||||
// .age(18 + random.nextInt(50))
|
||||
// .gender(random.nextBoolean() ? "男" : "女")
|
||||
// .occupation("职业" + random.nextInt(10))
|
||||
// .background("出身于一个普通家庭,年轻时经历了一些特殊事件...")
|
||||
// .personality("性格" + (random.nextBoolean() ? "开朗" : "内向") + "," +
|
||||
// (random.nextBoolean() ? "勇敢" : "谨慎"))
|
||||
// .appearance("外表" + (random.nextBoolean() ? "英俊" : "普通") + ",身材" +
|
||||
// (random.nextBoolean() ? "高大" : "中等"))
|
||||
// .goals(List.of("目标1", "目标2"))
|
||||
// .conflicts(List.of("冲突1", "冲突2"))
|
||||
// .build();
|
||||
//
|
||||
// // 创建关系网络
|
||||
// List<Character.Relationship> relationships = new ArrayList<>();
|
||||
// if (random.nextBoolean()) {
|
||||
// relationships.add(Character.Relationship.builder()
|
||||
// .characterId(UUID.randomUUID().toString())
|
||||
// .type(random.nextBoolean() ? "朋友" : "敌人")
|
||||
// .description("他们之间有着复杂的关系...")
|
||||
// .build());
|
||||
// }
|
||||
//
|
||||
// // 创建向量嵌入
|
||||
// Character.VectorEmbedding vectorEmbedding = Character.VectorEmbedding.builder()
|
||||
// .vector(IntStream.range(0, 384)
|
||||
// .mapToObj(i -> random.nextFloat())
|
||||
// .collect(Collectors.toList()))
|
||||
// .model("text-embedding-3-small")
|
||||
// .build();
|
||||
//
|
||||
// return Character.builder()
|
||||
// .id(UUID.randomUUID().toString())
|
||||
// .novelId(novelId)
|
||||
// .name(name)
|
||||
// .description("这是一个" + roleType + ",名叫" + name + "。")
|
||||
// .details(details)
|
||||
// .relationships(relationships)
|
||||
// .vectorEmbedding(vectorEmbedding)
|
||||
// .createdAt(Instant.now().minusSeconds(random.nextInt(30 * 24 * 60 * 60)))
|
||||
// .updatedAt(Instant.now())
|
||||
// .build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成指定数量的AI交互记录
|
||||
// */
|
||||
// public static List<AIInteraction> generateAIInteractions(int count, String sceneId) {
|
||||
// List<AIInteraction> interactions = new ArrayList<>();
|
||||
// for (int i = 0; i < count; i++) {
|
||||
// String userId = UUID.randomUUID().toString();
|
||||
// String novelId = UUID.randomUUID().toString();
|
||||
//
|
||||
// // 创建对话消息
|
||||
// List<AIInteraction.Message> conversation = new ArrayList<>();
|
||||
//
|
||||
// // 用户消息
|
||||
// AIInteraction.Message userMessage = AIInteraction.Message.builder()
|
||||
// .role("user")
|
||||
// .content("请帮我继续写这个场景")
|
||||
// .timestamp(LocalDateTime.now().minusMinutes(random.nextInt(60)))
|
||||
// .context(AIInteraction.Message.Context.builder()
|
||||
// .sceneIds(List.of(sceneId))
|
||||
// .characterIds(new ArrayList<>())
|
||||
// .retrievalScore(0.85 + random.nextDouble() * 0.15)
|
||||
// .build())
|
||||
// .build();
|
||||
//
|
||||
// // AI消息
|
||||
// AIInteraction.Message aiMessage = AIInteraction.Message.builder()
|
||||
// .role("assistant")
|
||||
// .content(generateRandomContent(200 + random.nextInt(500)))
|
||||
// .timestamp(LocalDateTime.now().minusMinutes(random.nextInt(30)))
|
||||
// .build();
|
||||
//
|
||||
// conversation.add(userMessage);
|
||||
// conversation.add(aiMessage);
|
||||
//
|
||||
// // 创建生成内容
|
||||
// AIInteraction.Generation.TokenUsage tokenUsage = AIInteraction.Generation.TokenUsage.builder()
|
||||
// .prompt(100 + random.nextInt(400))
|
||||
// .completion(200 + random.nextInt(800))
|
||||
// .total(300 + random.nextInt(1200))
|
||||
// .build();
|
||||
//
|
||||
// AIInteraction.Generation generation = AIInteraction.Generation.builder()
|
||||
// .prompt("请基于以下场景继续写作:...")
|
||||
// .result(aiMessage.getContent())
|
||||
// .model("gpt-4")
|
||||
// .tokenUsage(tokenUsage)
|
||||
// .cost(0.01 + random.nextDouble() * 0.05)
|
||||
// .createdAt(LocalDateTime.now().minusMinutes(random.nextInt(30)))
|
||||
// .build();
|
||||
//
|
||||
// List<AIInteraction.Generation> generations = new ArrayList<>();
|
||||
// generations.add(generation);
|
||||
//
|
||||
// // 创建AI交互
|
||||
// AIInteraction interaction = AIInteraction.builder()
|
||||
// .id(UUID.randomUUID().toString())
|
||||
// .userId(userId)
|
||||
// .novelId(novelId)
|
||||
// .conversation(conversation)
|
||||
// .generations(generations)
|
||||
// .createdAt(LocalDateTime.now().minusHours(random.nextInt(24)))
|
||||
// .updatedAt(LocalDateTime.now())
|
||||
// .build();
|
||||
//
|
||||
// interactions.add(interaction);
|
||||
// }
|
||||
// return interactions;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 生成随机内容
|
||||
// */
|
||||
// private static String generateRandomContent(int length) {
|
||||
// StringBuilder content = new StringBuilder();
|
||||
// while (content.length() < length) {
|
||||
// content.append(LOREM_IPSUM);
|
||||
// content.append(" ");
|
||||
// }
|
||||
// return content.substring(0, length);
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 性能测试工具类,用于测量操作执行时间和吞吐量
|
||||
*/
|
||||
public class PerformanceTestUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PerformanceTestUtil.class);
|
||||
|
||||
/**
|
||||
* 测量同步操作执行时间
|
||||
*
|
||||
* @param operation 要测量的操作
|
||||
* @param operationName 操作名称(用于日志)
|
||||
* @return 操作结果
|
||||
*/
|
||||
public static <T> T measureExecutionTime(Supplier<T> operation, String operationName) {
|
||||
Instant start = Instant.now();
|
||||
T result = operation.get();
|
||||
Duration duration = Duration.between(start, Instant.now());
|
||||
|
||||
log.info("操作 [{}] 执行时间: {} ms", operationName, duration.toMillis());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量响应式操作执行时间
|
||||
*
|
||||
* @param operation 要测量的响应式操作
|
||||
* @param operationName 操作名称(用于日志)
|
||||
* @return 包含操作结果的Mono
|
||||
*/
|
||||
public static <T> Mono<T> measureReactiveDuration(Mono<T> operation, String operationName) {
|
||||
Instant start = Instant.now();
|
||||
return operation
|
||||
.doOnSuccess(result -> {
|
||||
Duration duration = Duration.between(start, Instant.now());
|
||||
log.info("响应式操作 [{}] 执行时间: {} ms", operationName, duration.toMillis());
|
||||
})
|
||||
.doOnError(error -> {
|
||||
Duration duration = Duration.between(start, Instant.now());
|
||||
log.error("响应式操作 [{}] 失败,执行时间: {} ms, 错误: {}",
|
||||
operationName, duration.toMillis(), error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量批量操作的吞吐量
|
||||
*
|
||||
* @param operations 要执行的操作流
|
||||
* @param processor 处理每个操作项的函数
|
||||
* @param operationName 操作名称(用于日志)
|
||||
* @param concurrency 并发数
|
||||
* @return 处理结果流
|
||||
*/
|
||||
public static <T, R> Flux<R> measureThroughput(Flux<T> operations,
|
||||
Function<T, Mono<R>> processor,
|
||||
String operationName,
|
||||
int concurrency) {
|
||||
Instant start = Instant.now();
|
||||
AtomicInteger counter = new AtomicInteger(0);
|
||||
|
||||
return operations
|
||||
.flatMap(item -> processor.apply(item)
|
||||
.doOnSuccess(result -> {
|
||||
int count = counter.incrementAndGet();
|
||||
if (count % 100 == 0) {
|
||||
Duration elapsed = Duration.between(start, Instant.now());
|
||||
double itemsPerSecond = count / (elapsed.toMillis() / 1000.0);
|
||||
log.info("操作 [{}] 已处理: {}, 吞吐量: {}/秒",
|
||||
operationName, count, String.format("%.2f", itemsPerSecond));
|
||||
}
|
||||
}), concurrency)
|
||||
.doOnComplete(() -> {
|
||||
int totalCount = counter.get();
|
||||
Duration totalDuration = Duration.between(start, Instant.now());
|
||||
double overallItemsPerSecond = totalCount / (totalDuration.toMillis() / 1000.0);
|
||||
log.info("操作 [{}] 完成. 总处理: {}, 总时间: {} ms, 平均吞吐量: {}/秒",
|
||||
operationName, totalCount, totalDuration.toMillis(),
|
||||
String.format("%.2f", overallItemsPerSecond));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行并发负载测试
|
||||
*
|
||||
* @param operation 要测试的操作
|
||||
* @param operationName 操作名称
|
||||
* @param concurrentUsers 并发用户数
|
||||
* @param requestsPerUser 每个用户的请求数
|
||||
* @return 测试结果流
|
||||
*/
|
||||
public static <T> Flux<T> performLoadTest(Function<Integer, Mono<T>> operation,
|
||||
String operationName,
|
||||
int concurrentUsers,
|
||||
int requestsPerUser) {
|
||||
Instant start = Instant.now();
|
||||
AtomicInteger successCounter = new AtomicInteger(0);
|
||||
AtomicInteger errorCounter = new AtomicInteger(0);
|
||||
|
||||
log.info("开始负载测试 [{}]: {} 并发用户, 每用户 {} 请求",
|
||||
operationName, concurrentUsers, requestsPerUser);
|
||||
|
||||
return Flux.range(0, concurrentUsers)
|
||||
.flatMap(userId -> Flux.range(0, requestsPerUser)
|
||||
.flatMap(requestId -> {
|
||||
int requestNum = userId * requestsPerUser + requestId;
|
||||
return operation.apply(requestNum)
|
||||
.doOnSuccess(result -> successCounter.incrementAndGet())
|
||||
.doOnError(error -> errorCounter.incrementAndGet())
|
||||
.onErrorResume(e -> {
|
||||
log.error("请求 {} 失败: {}", requestNum, e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}))
|
||||
.doOnComplete(() -> {
|
||||
Duration totalDuration = Duration.between(start, Instant.now());
|
||||
int totalRequests = concurrentUsers * requestsPerUser;
|
||||
int successCount = successCounter.get();
|
||||
int errorCount = errorCounter.get();
|
||||
double requestsPerSecond = successCount / (totalDuration.toMillis() / 1000.0);
|
||||
|
||||
log.info("负载测试 [{}] 完成. 总请求: {}, 成功: {}, 失败: {}, 总时间: {} ms, 吞吐量: {}/秒",
|
||||
operationName, totalRequests, successCount, errorCount,
|
||||
totalDuration.toMillis(), String.format("%.2f", requestsPerSecond));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 提示词模板数据模型
|
||||
* 用于Jackson XML序列化和反序列化
|
||||
*/
|
||||
public class PromptTemplateModel {
|
||||
|
||||
/**
|
||||
* 系统提示词模板
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "system")
|
||||
public static class SystemPrompt {
|
||||
|
||||
@JacksonXmlProperty(localName = "role")
|
||||
private String role;
|
||||
|
||||
@JacksonXmlProperty(localName = "instructions")
|
||||
private String instructions;
|
||||
|
||||
@JacksonXmlProperty(localName = "context")
|
||||
private String context;
|
||||
|
||||
@JacksonXmlProperty(localName = "length")
|
||||
private String length;
|
||||
|
||||
@JacksonXmlProperty(localName = "style")
|
||||
private String style;
|
||||
|
||||
@JacksonXmlProperty(localName = "parameters")
|
||||
private Parameters parameters;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Parameters {
|
||||
@JacksonXmlProperty(localName = "temperature")
|
||||
private Double temperature;
|
||||
|
||||
@JacksonXmlProperty(localName = "max_tokens")
|
||||
private Integer maxTokens;
|
||||
|
||||
@JacksonXmlProperty(localName = "top_p")
|
||||
private Double topP;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户提示词模板
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "task")
|
||||
public static class UserPrompt {
|
||||
|
||||
@JacksonXmlProperty(localName = "action")
|
||||
private String action;
|
||||
|
||||
@JacksonXmlProperty(localName = "input")
|
||||
private String input;
|
||||
|
||||
@JacksonXmlProperty(localName = "message")
|
||||
private String message;
|
||||
|
||||
@JacksonXmlProperty(localName = "context")
|
||||
private String context;
|
||||
|
||||
@JacksonXmlProperty(localName = "requirements")
|
||||
private Requirements requirements;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Requirements {
|
||||
@JacksonXmlProperty(localName = "length")
|
||||
private String length;
|
||||
|
||||
@JacksonXmlProperty(localName = "style")
|
||||
private String style;
|
||||
|
||||
@JacksonXmlProperty(localName = "tone")
|
||||
private String tone;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息模板
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "message")
|
||||
public static class ChatMessage {
|
||||
|
||||
@JacksonXmlProperty(localName = "content")
|
||||
private String content;
|
||||
|
||||
@JacksonXmlProperty(localName = "context")
|
||||
private String context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小说内容结构
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "outline")
|
||||
public static class NovelOutline {
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "act")
|
||||
private List<Act> acts;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Act {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "chapter")
|
||||
private List<Chapter> chapters;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Chapter {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "summary")
|
||||
private String summary;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "scene")
|
||||
private List<Scene> scenes;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Scene {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "summary")
|
||||
private String summary;
|
||||
|
||||
@JacksonXmlProperty(localName = "content")
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文数据结构
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "selected_context")
|
||||
public static class SelectedContext {
|
||||
|
||||
@JacksonXmlProperty(localName = "full_novel_text")
|
||||
private NovelOutline fullNovelText;
|
||||
|
||||
@JacksonXmlProperty(localName = "full_novel_summary")
|
||||
private NovelSummary fullNovelSummary;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "act")
|
||||
private List<NovelOutline.Act> acts;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "chapter")
|
||||
private List<NovelOutline.Chapter> chapters;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "scene")
|
||||
private List<NovelOutline.Scene> scenes;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "setting")
|
||||
private List<Setting> settings;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "snippet")
|
||||
private List<Snippet> snippets;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Setting {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "type")
|
||||
private String type;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "name")
|
||||
private String name;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlProperty(localName = "attributes")
|
||||
private String attributes;
|
||||
|
||||
@JacksonXmlProperty(localName = "tags")
|
||||
private String tags;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Snippet {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "content")
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小说摘要结构
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "full_novel_summary")
|
||||
public static class NovelSummary {
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlElementWrapper(localName = "summary_content")
|
||||
@JacksonXmlProperty(localName = "chapter")
|
||||
private List<ChapterSummary> chapters;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ChapterSummary {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "scene_summary")
|
||||
private List<SceneSummary> scenes;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class SceneSummary {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "content")
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段数据结构
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "snippet")
|
||||
public static class Snippet {
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "notes")
|
||||
private String notes;
|
||||
|
||||
@JacksonXmlProperty(localName = "content")
|
||||
private String content;
|
||||
|
||||
@JacksonXmlProperty(localName = "category")
|
||||
private String category;
|
||||
|
||||
@JacksonXmlProperty(localName = "tags")
|
||||
private String tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 新增:完整小说文本结构(包含所有场景的实际内容)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "full_novel_text")
|
||||
public static class FullNovelText {
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "act")
|
||||
private List<ActContent> acts;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ActContent {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "chapter")
|
||||
private List<ChapterContent> chapters;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ChapterContent {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "scene")
|
||||
private List<SceneContent> scenes;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class SceneContent {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "id")
|
||||
private String id;
|
||||
|
||||
@JacksonXmlProperty(localName = "content")
|
||||
private String content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 新增:Act内容结构
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JacksonXmlRootElement(localName = "act")
|
||||
public static class ActStructure {
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "number")
|
||||
private Integer number;
|
||||
|
||||
@JacksonXmlProperty(localName = "title")
|
||||
private String title;
|
||||
|
||||
@JacksonXmlProperty(localName = "description")
|
||||
private String description;
|
||||
|
||||
@JacksonXmlElementWrapper(useWrapping = false)
|
||||
@JacksonXmlProperty(localName = "chapter")
|
||||
private List<FullNovelText.ChapterContent> chapters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
/**
|
||||
* 提示词工具类,用于处理提示词模板的格式化和富文本处理
|
||||
*/
|
||||
public class PromptUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PromptUtil.class);
|
||||
|
||||
// 富文本Quill格式处理相关的正则表达式
|
||||
private static final Pattern QUILL_HTML_PATTERN = Pattern.compile("<[^>]*>");
|
||||
private static final Pattern QUILL_JSON_PATTERN = Pattern.compile("^\\s*\\[\\s*\\{\\s*\"insert\"", Pattern.DOTALL);
|
||||
|
||||
// 默认的占位符格式,支持{变量}和{{变量}}两种格式
|
||||
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^{}]+)\\}|\\{\\{([^{}]+)\\}\\}");
|
||||
|
||||
// Jackson ObjectMapper 实例
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 处理富文本,将Quill格式或HTML格式转换为纯文本
|
||||
*
|
||||
* @param content 可能是富文本格式的内容
|
||||
* @return 转换后的纯文本
|
||||
*/
|
||||
public static String extractPlainTextFromRichText(String content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 1. 尝试解析为 Quill Delta JSON 数组
|
||||
// 增加更宽松的检查,只要是看起来像JSON数组的都尝试解析
|
||||
String trimmedContent = content.trim();
|
||||
if (trimmedContent.startsWith("[") && trimmedContent.endsWith("]")) {
|
||||
try {
|
||||
// 使用 TypeReference 来正确解析泛型列表
|
||||
List<Map<String, Object>> deltaOps = objectMapper.readValue(content,
|
||||
new TypeReference<List<Map<String, Object>>>() {});
|
||||
|
||||
StringBuilder textBuilder = new StringBuilder();
|
||||
for (Map<String, Object> op : deltaOps) {
|
||||
// 只处理包含 "insert" 键且值为 String 的操作
|
||||
if (op.containsKey("insert") && op.get("insert") instanceof String) {
|
||||
textBuilder.append((String) op.get("insert"));
|
||||
}
|
||||
// 可以根据需要扩展以处理其他类型的 insert (例如 embeds)
|
||||
}
|
||||
|
||||
// Quill Delta 格式通常在每个操作后加 \n,合并后可能末尾有多余空白符
|
||||
String extractedText = textBuilder.toString();
|
||||
// 返回前移除末尾的所有空白字符(包括换行符)
|
||||
return extractedText.replaceAll("\\s+$", "");
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
// 解析失败,记录日志(使用 trace 级别,因为这可能是正常情况,例如内容是HTML)
|
||||
log.trace("将内容解析为Quill Delta JSON失败: {}. 继续尝试其他格式...", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
// 捕获其他潜在的解析错误
|
||||
log.warn("解析内容时发生意外错误: {}. 继续尝试其他格式...", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果不是有效的 Quill Delta JSON 或解析失败,检查是否为 HTML
|
||||
// (保留原来的HTML处理逻辑)
|
||||
if (content.contains("<") && content.contains(">")) {
|
||||
log.trace("内容未成功解析为JSON,尝试作为HTML处理。");
|
||||
return QUILL_HTML_PATTERN.matcher(content)
|
||||
.replaceAll("")
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'") // 处理 ' 符号
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 3. 如果既不是可解析的JSON也不是HTML,则假定为纯文本并返回
|
||||
log.trace("内容既不是可解析的JSON也不是HTML,将其视为纯文本返回。");
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化提示词模板,根据变量映射替换占位符
|
||||
* 支持{变量}和{{变量}}两种占位符格式
|
||||
*
|
||||
* @param template 提示词模板
|
||||
* @param variables 变量映射
|
||||
* @return 格式化后的提示词
|
||||
*/
|
||||
public static String formatPromptTemplate(String template, Map<String, String> variables) {
|
||||
if (template == null || template.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 提取纯文本,移除富文本格式
|
||||
String plainTemplate = extractPlainTextFromRichText(template);
|
||||
|
||||
// 检测是否存在任何占位符
|
||||
if (!containsPlaceholder(plainTemplate)) {
|
||||
// 如果没有占位符但有变量,自动添加变量附加到模板末尾
|
||||
if (variables != null && !variables.isEmpty()) {
|
||||
StringBuilder builder = new StringBuilder(plainTemplate);
|
||||
builder.append("\n\n");
|
||||
|
||||
for (Map.Entry<String, String> entry : variables.entrySet()) {
|
||||
// 避免添加空值
|
||||
if (entry.getValue() != null && !entry.getValue().isEmpty()) {
|
||||
builder.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
return plainTemplate;
|
||||
}
|
||||
|
||||
// 替换所有占位符
|
||||
StringBuilder result = new StringBuilder();
|
||||
Matcher matcher = PLACEHOLDER_PATTERN.matcher(plainTemplate);
|
||||
|
||||
int lastEnd = 0;
|
||||
while (matcher.find()) {
|
||||
// 添加匹配前的文本
|
||||
result.append(plainTemplate, lastEnd, matcher.start());
|
||||
|
||||
// 获取占位符名称(支持两种格式)
|
||||
String placeholder = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
|
||||
|
||||
// 替换占位符
|
||||
if (variables != null && variables.containsKey(placeholder)) {
|
||||
result.append(variables.get(placeholder));
|
||||
} else {
|
||||
// 保留未匹配的占位符
|
||||
result.append(matcher.group());
|
||||
log.warn("找不到占位符对应的变量: {}", placeholder);
|
||||
}
|
||||
|
||||
lastEnd = matcher.end();
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastEnd < plainTemplate.length()) {
|
||||
result.append(plainTemplate.substring(lastEnd));
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测字符串中是否包含占位符
|
||||
*
|
||||
* @param text 要检查的文本
|
||||
* @return 是否包含占位符
|
||||
*/
|
||||
public static boolean containsPlaceholder(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return PLACEHOLDER_PATTERN.matcher(text).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板中的所有占位符
|
||||
*
|
||||
* @param template 提示词模板
|
||||
* @return 占位符列表
|
||||
*/
|
||||
public static Map<String, String> extractPlaceholders(String template) {
|
||||
Map<String, String> placeholders = new HashMap<>();
|
||||
|
||||
if (template == null || template.isEmpty()) {
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
// 提取纯文本,移除富文本格式
|
||||
String plainTemplate = extractPlainTextFromRichText(template);
|
||||
|
||||
// 查找所有占位符
|
||||
Matcher matcher = PLACEHOLDER_PATTERN.matcher(plainTemplate);
|
||||
while (matcher.find()) {
|
||||
String placeholder = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
|
||||
placeholders.put(placeholder, "");
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将纯文本转换为 Quill Delta JSON 格式字符串
|
||||
*
|
||||
* @param plainText 纯文本输入
|
||||
* @return Quill Delta JSON 格式的字符串,例如 "[{\"insert\":\"line1\\n\"},{\"insert\":\"line2\\n\"}]"
|
||||
*/
|
||||
public static String convertPlainTextToQuillDelta(String plainText) {
|
||||
if (plainText == null || plainText.isEmpty()) {
|
||||
// 返回一个表示空内容的有效 JSON 数组 (Quill Delta 格式)
|
||||
return "[{\"insert\":\"\n\"}]";
|
||||
}
|
||||
|
||||
List<Map<String, String>> deltaOps = new ArrayList<>();
|
||||
// 使用正则表达式按行分割,保留末尾空行
|
||||
String[] lines = plainText.split("\\r?\\n", -1);
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
Map<String, String> op = new HashMap<>();
|
||||
// Quill Delta 要求每个 insert 操作都以换行符结束
|
||||
// 即使是最后一行,也添加换行符,表示段落结束
|
||||
op.put("insert", line + "\n");
|
||||
deltaOps.add(op);
|
||||
}
|
||||
|
||||
// 如果原始文本仅包含换行符,上面的循环会产生多个 {"insert":"\n"},这是正确的。
|
||||
// 如果原始文本为空,则在开头处理了。
|
||||
// 如果deltaOps为空(理论上不应该发生,除非split有问题),确保返回有效JSON
|
||||
if (deltaOps.isEmpty()) {
|
||||
log.warn("Quill Delta 操作列表为空,即使输入非空,输入:'{}'", plainText); // 添加日志
|
||||
deltaOps.add(Map.of("insert", "\n")); // Fallback
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ObjectMapper 将操作列表序列化为 JSON 字符串
|
||||
return objectMapper.writeValueAsString(deltaOps);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("将纯文本转换为JSON富文本格式失败", e);
|
||||
// 返回一个表示错误的有效 JSON 数组
|
||||
return "[{\"insert\":\"转换内容时出错。\\n\"}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator;
|
||||
import com.ainovel.server.domain.model.Scene;
|
||||
import com.ainovel.server.domain.model.NovelSettingItem;
|
||||
import com.ainovel.server.domain.model.NovelSnippet;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.Comparator;
|
||||
|
||||
/**
|
||||
* 提示词XML格式化工具类
|
||||
* 使用Jackson XML进行正确的XML序列化
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PromptXmlFormatter {
|
||||
|
||||
private final XmlMapper xmlMapper;
|
||||
|
||||
public PromptXmlFormatter() {
|
||||
this.xmlMapper = XmlMapper.builder()
|
||||
.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
.disable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION)
|
||||
// 配置序列化规则:不包含null、空字符串、空集合等
|
||||
.serializationInclusion(JsonInclude.Include.NON_EMPTY)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共方法,确保文本内容被换行符包裹,用于XML格式化。
|
||||
* 如果文本不为空,此方法会移除其首尾的空白字符,然后在前后各添加一个换行符。
|
||||
* @param text 要处理的文本。
|
||||
* @return 如果文本为null或仅包含空白,则返回原始文本;否则返回处理后的文本。
|
||||
*/
|
||||
public static String ensureTextIsWrappedWithNewlines(String text) {
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
// 先trim清除首尾空白,然后包裹换行符
|
||||
return "\n" + text.trim() + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化系统提示词
|
||||
*/
|
||||
public String formatSystemPrompt(String role, String instructions, String context,
|
||||
String length, String style, Map<String, Object> parameters) {
|
||||
try {
|
||||
// 🚀 检查context是否包含XML内容,如果包含则直接构建XML避免转义
|
||||
if (context != null && !context.isEmpty() && isXmlContent(context)) {
|
||||
return buildSystemPromptXmlDirectly(role, instructions, context, length, style, parameters);
|
||||
}
|
||||
|
||||
PromptTemplateModel.SystemPrompt.SystemPromptBuilder builder = PromptTemplateModel.SystemPrompt.builder()
|
||||
.role(role)
|
||||
.instructions(ensureTextIsWrappedWithNewlines(instructions));
|
||||
|
||||
// 只在聊天类型时添加上下文到系统提示词
|
||||
if (context != null && !context.isEmpty()) {
|
||||
builder.context(ensureTextIsWrappedWithNewlines(context));
|
||||
}
|
||||
|
||||
if (length != null && !length.isEmpty()) {
|
||||
builder.length(length);
|
||||
}
|
||||
|
||||
if (style != null && !style.isEmpty()) {
|
||||
builder.style(style);
|
||||
}
|
||||
|
||||
// 添加参数信息
|
||||
if (parameters != null && !parameters.isEmpty()) {
|
||||
PromptTemplateModel.SystemPrompt.Parameters.ParametersBuilder paramBuilder =
|
||||
PromptTemplateModel.SystemPrompt.Parameters.builder();
|
||||
|
||||
boolean hasValidParam = false;
|
||||
|
||||
if (parameters.containsKey("temperature")) {
|
||||
Object tempValue = parameters.get("temperature");
|
||||
if (tempValue instanceof Number) {
|
||||
paramBuilder.temperature(((Number) tempValue).doubleValue());
|
||||
hasValidParam = true;
|
||||
}
|
||||
}
|
||||
if (parameters.containsKey("maxTokens")) {
|
||||
Object maxTokensValue = parameters.get("maxTokens");
|
||||
if (maxTokensValue instanceof Number) {
|
||||
paramBuilder.maxTokens(((Number) maxTokensValue).intValue());
|
||||
hasValidParam = true;
|
||||
}
|
||||
}
|
||||
if (parameters.containsKey("topP")) {
|
||||
Object topPValue = parameters.get("topP");
|
||||
if (topPValue instanceof Number) {
|
||||
paramBuilder.topP(((Number) topPValue).doubleValue());
|
||||
hasValidParam = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 只有存在有效参数时才设置parameters
|
||||
if (hasValidParam) {
|
||||
builder.parameters(paramBuilder.build());
|
||||
}
|
||||
}
|
||||
|
||||
PromptTemplateModel.SystemPrompt systemPrompt = builder.build();
|
||||
String result = xmlMapper.writeValueAsString(systemPrompt);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化系统提示词失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户提示词(任务类型)
|
||||
*/
|
||||
public String formatUserPrompt(String action, String input, String context,
|
||||
String length, String style, String tone) {
|
||||
try {
|
||||
// 🚀 检查context是否包含XML内容,如果包含则直接构建XML避免转义
|
||||
if (context != null && !context.isEmpty() && isXmlContent(context)) {
|
||||
return buildUserPromptXmlDirectly(action, input, context, length, style, tone);
|
||||
}
|
||||
|
||||
PromptTemplateModel.UserPrompt.UserPromptBuilder builder = PromptTemplateModel.UserPrompt.builder()
|
||||
.action(action)
|
||||
.input(ensureTextIsWrappedWithNewlines(input));
|
||||
|
||||
// 非聊天类型添加上下文到用户提示词
|
||||
if (context != null && !context.isEmpty()) {
|
||||
builder.context(ensureTextIsWrappedWithNewlines(context));
|
||||
}
|
||||
|
||||
// 添加要求信息
|
||||
if ((length != null && !length.isEmpty()) ||
|
||||
(style != null && !style.isEmpty()) ||
|
||||
(tone != null && !tone.isEmpty())) {
|
||||
|
||||
PromptTemplateModel.UserPrompt.Requirements requirements =
|
||||
PromptTemplateModel.UserPrompt.Requirements.builder()
|
||||
.length(length)
|
||||
.style(style)
|
||||
.tone(tone)
|
||||
.build();
|
||||
builder.requirements(requirements);
|
||||
}
|
||||
|
||||
PromptTemplateModel.UserPrompt userPrompt = builder.build();
|
||||
String result = xmlMapper.writeValueAsString(userPrompt);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化用户提示词失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化聊天消息
|
||||
*/
|
||||
public String formatChatMessage(String message, String context) {
|
||||
try {
|
||||
PromptTemplateModel.ChatMessage chatMessage = PromptTemplateModel.ChatMessage.builder()
|
||||
.content(ensureTextIsWrappedWithNewlines(message))
|
||||
.context(ensureTextIsWrappedWithNewlines(context))
|
||||
.build();
|
||||
String result = xmlMapper.writeValueAsString(chatMessage);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化聊天消息失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化小说大纲
|
||||
*/
|
||||
public String formatNovelOutline(String title, String description, List<Scene> scenes) {
|
||||
try {
|
||||
log.info("开始格式化小说大纲 - 标题: {}, 原始场景数量: {}", title, scenes != null ? scenes.size() : 0);
|
||||
|
||||
// 过滤并验证场景数据
|
||||
List<Scene> validScenes = (scenes == null ? java.util.List.<Scene>of() : scenes).stream()
|
||||
.filter(scene -> scene != null &&
|
||||
scene.getId() != null && !scene.getId().trim().isEmpty() &&
|
||||
scene.getChapterId() != null && !scene.getChapterId().trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("过滤后的有效场景数量: {}", validScenes.size());
|
||||
|
||||
if (validScenes.isEmpty()) {
|
||||
log.warn("没有有效的场景数据,使用回退方案");
|
||||
return "";
|
||||
}
|
||||
|
||||
// 按章节分组,并保持顺序
|
||||
Map<String, List<Scene>> chapterGroups = validScenes.stream()
|
||||
.collect(Collectors.groupingBy(Scene::getChapterId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
log.info("按章节分组后的章节数量: {}", chapterGroups.size());
|
||||
for (Map.Entry<String, List<Scene>> entry : chapterGroups.entrySet()) {
|
||||
log.debug("章节 {} 包含 {} 个场景", entry.getKey(), entry.getValue().size());
|
||||
}
|
||||
|
||||
// 🚀 使用AtomicInteger来为章节分配顺序号
|
||||
AtomicInteger chapterNumber = new AtomicInteger(1);
|
||||
|
||||
List<PromptTemplateModel.NovelOutline.Chapter> chapters = chapterGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String chapterId = entry.getKey();
|
||||
List<Scene> chapterScenes = entry.getValue();
|
||||
|
||||
log.debug("处理章节 {} 的 {} 个场景", chapterId, chapterScenes.size());
|
||||
|
||||
// 🚀 对章节内的场景按sequence排序,然后重新分配顺序号
|
||||
List<Scene> sortedScenes = chapterScenes.stream()
|
||||
.sorted(Comparator.comparing(Scene::getSequence, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
AtomicInteger sceneNumber = new AtomicInteger(1);
|
||||
List<PromptTemplateModel.NovelOutline.Scene> xmlScenes = sortedScenes.stream()
|
||||
.map(scene -> {
|
||||
String content = scene.getContent() != null ?
|
||||
RichTextUtil.deltaJsonToPlainText(scene.getContent()) : null;
|
||||
log.debug("场景 {} - 标题: {}, 内容长度: {}",
|
||||
scene.getId(), scene.getTitle(),
|
||||
content != null ? content.length() : 0);
|
||||
|
||||
return PromptTemplateModel.NovelOutline.Scene.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(sceneNumber.getAndIncrement()) // 🚀 使用章节内的顺序号
|
||||
.id(scene.getId())
|
||||
.summary(ensureTextIsWrappedWithNewlines(scene.getSummary()))
|
||||
.content(ensureTextIsWrappedWithNewlines(content))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return PromptTemplateModel.NovelOutline.Chapter.builder()
|
||||
.id(chapterId)
|
||||
.number(chapterNumber.getAndIncrement()) // 🚀 使用章节顺序号,而不是硬编码的1
|
||||
.scenes(xmlScenes)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 创建一个默认的Act(如果没有Act概念,可以都放在Act 1中)
|
||||
PromptTemplateModel.NovelOutline.Act act = PromptTemplateModel.NovelOutline.Act.builder()
|
||||
.number(1)
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
|
||||
PromptTemplateModel.NovelOutline outline = PromptTemplateModel.NovelOutline.builder()
|
||||
.title(title)
|
||||
.description(ensureTextIsWrappedWithNewlines(description))
|
||||
.acts(List.of(act))
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(outline);
|
||||
log.info("小说大纲格式化完成,最终XML长度: {}", result.length());
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化小说大纲失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化小说摘要
|
||||
*/
|
||||
public String formatNovelSummary(String title, String description, List<Scene> scenes) {
|
||||
try {
|
||||
// 过滤并验证场景数据 - 🚀 只保留有摘要的场景以节省token
|
||||
List<Scene> validScenes = (scenes == null ? java.util.List.<Scene>of() : scenes).stream()
|
||||
.filter(scene -> scene != null &&
|
||||
scene.getId() != null && !scene.getId().trim().isEmpty() &&
|
||||
scene.getChapterId() != null && !scene.getChapterId().trim().isEmpty() &&
|
||||
scene.getSummary() != null && !scene.getSummary().trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (validScenes.isEmpty()) {
|
||||
log.warn("没有有效的场景摘要数据,使用回退方案");
|
||||
return "";
|
||||
}
|
||||
|
||||
// 按章节分组,并保持顺序
|
||||
Map<String, List<Scene>> chapterGroups = validScenes.stream()
|
||||
.collect(Collectors.groupingBy(Scene::getChapterId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
// 🚀 使用AtomicInteger来为章节分配顺序号
|
||||
AtomicInteger chapterNumber = new AtomicInteger(1);
|
||||
|
||||
List<PromptTemplateModel.NovelSummary.ChapterSummary> chapterSummaries = chapterGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String chapterId = entry.getKey();
|
||||
List<Scene> chapterScenes = entry.getValue();
|
||||
|
||||
// 🚀 对章节内的场景按sequence排序,然后重新分配顺序号
|
||||
List<Scene> sortedScenes = chapterScenes.stream()
|
||||
.sorted(Comparator.comparing(Scene::getSequence, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
AtomicInteger sceneNumber = new AtomicInteger(1);
|
||||
List<PromptTemplateModel.NovelSummary.SceneSummary> sceneSummaries = sortedScenes.stream()
|
||||
.map(scene -> PromptTemplateModel.NovelSummary.SceneSummary.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(sceneNumber.getAndIncrement()) // 🚀 使用章节内的顺序号
|
||||
.id(scene.getId())
|
||||
.content(ensureTextIsWrappedWithNewlines(scene.getSummary()))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return PromptTemplateModel.NovelSummary.ChapterSummary.builder()
|
||||
.id(chapterId)
|
||||
.number(chapterNumber.getAndIncrement()) // 🚀 使用章节顺序号,而不是硬编码的1
|
||||
.scenes(sceneSummaries)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PromptTemplateModel.NovelSummary novelSummary = PromptTemplateModel.NovelSummary.builder()
|
||||
.title(title)
|
||||
.description(ensureTextIsWrappedWithNewlines(description))
|
||||
.chapters(chapterSummaries)
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(novelSummary);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化小说摘要失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化章节
|
||||
*/
|
||||
public String formatChapter(String chapterId, Integer chapterNumber, List<Scene> scenes) {
|
||||
try {
|
||||
// 🚀 过滤有效场景 - 只保留有内容或摘要的场景以节省token
|
||||
List<Scene> validScenes = (scenes == null ? java.util.List.<Scene>of() : scenes).stream()
|
||||
.filter(scene -> scene != null &&
|
||||
scene.getId() != null && !scene.getId().trim().isEmpty() &&
|
||||
scene.getChapterId() != null && !scene.getChapterId().trim().isEmpty() &&
|
||||
((scene.getContent() != null && !scene.getContent().trim().isEmpty()) ||
|
||||
(scene.getSummary() != null && !scene.getSummary().trim().isEmpty())))
|
||||
.toList();
|
||||
|
||||
if (validScenes.isEmpty()) {
|
||||
log.warn("章节 {} 没有有效的场景内容", chapterId);
|
||||
return "";
|
||||
}
|
||||
|
||||
// 🚀 对场景按sequence排序,然后重新分配顺序号
|
||||
List<Scene> sortedScenes = validScenes.stream()
|
||||
.sorted(Comparator.comparing(Scene::getSequence, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
AtomicInteger sceneNumber = new AtomicInteger(1);
|
||||
List<PromptTemplateModel.NovelOutline.Scene> xmlScenes = sortedScenes.stream()
|
||||
.map(scene -> PromptTemplateModel.NovelOutline.Scene.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(sceneNumber.getAndIncrement()) // 🚀 使用章节内的顺序号
|
||||
.id(scene.getId())
|
||||
.summary(ensureTextIsWrappedWithNewlines(scene.getSummary() != null ?
|
||||
RichTextUtil.deltaJsonToPlainText(scene.getSummary()) : null))
|
||||
.content(ensureTextIsWrappedWithNewlines(scene.getContent() != null ?
|
||||
RichTextUtil.deltaJsonToPlainText(scene.getContent()) : null))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PromptTemplateModel.NovelOutline.Chapter chapter = PromptTemplateModel.NovelOutline.Chapter.builder()
|
||||
.id(chapterId)
|
||||
.number(chapterNumber) // 🚀 使用传入的章节号
|
||||
.scenes(xmlScenes)
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(chapter);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化章节失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化场景
|
||||
*/
|
||||
public String formatScene(Scene scene) {
|
||||
try {
|
||||
// 🚀 检查场景是否有效内容 - 如果既无内容又无摘要,返回空字符串以节省token
|
||||
if (scene == null ||
|
||||
scene.getId() == null || scene.getId().trim().isEmpty() ||
|
||||
((scene.getContent() == null || scene.getContent().trim().isEmpty()) &&
|
||||
(scene.getSummary() == null || scene.getSummary().trim().isEmpty()))) {
|
||||
log.warn("场景无效或无内容,跳过格式化: {}", scene != null ? scene.getId() : "null");
|
||||
return "";
|
||||
}
|
||||
|
||||
PromptTemplateModel.NovelOutline.Scene xmlScene = PromptTemplateModel.NovelOutline.Scene.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(scene.getSequence() != null ? scene.getSequence() : 1) // 🚀 保持原有sequence或使用默认值1
|
||||
.id(scene.getId())
|
||||
.summary(ensureTextIsWrappedWithNewlines(scene.getSummary() != null ?
|
||||
RichTextUtil.deltaJsonToPlainText(scene.getSummary()) : null))
|
||||
.content(ensureTextIsWrappedWithNewlines(scene.getContent() != null ?
|
||||
RichTextUtil.deltaJsonToPlainText(scene.getContent()) : null))
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(xmlScene);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化场景失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化设定项目
|
||||
*/
|
||||
public String formatSetting(NovelSettingItem setting) {
|
||||
try {
|
||||
String attributesStr = "";
|
||||
String tagsStr = "";
|
||||
|
||||
if (setting.getAttributes() != null && !setting.getAttributes().isEmpty()) {
|
||||
attributesStr = setting.getAttributes().entrySet().stream()
|
||||
.map(entry -> entry.getKey() + ": " + entry.getValue())
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
if (setting.getTags() != null && !setting.getTags().isEmpty()) {
|
||||
tagsStr = String.join(", ", setting.getTags());
|
||||
}
|
||||
|
||||
PromptTemplateModel.SelectedContext.Setting xmlSetting =
|
||||
PromptTemplateModel.SelectedContext.Setting.builder()
|
||||
.type(setting.getType())
|
||||
.id(setting.getId())
|
||||
.name(setting.getName())
|
||||
.description(ensureTextIsWrappedWithNewlines(setting.getDescription()))
|
||||
.attributes(attributesStr)
|
||||
.tags(tagsStr)
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(xmlSetting);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化设定失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化设定项目(不包含ID属性)
|
||||
* 用于设定组/设定类型上下文下隐藏UUID
|
||||
*/
|
||||
public String formatSettingWithoutId(NovelSettingItem setting) {
|
||||
try {
|
||||
String attributesStr = "";
|
||||
String tagsStr = "";
|
||||
|
||||
if (setting.getAttributes() != null && !setting.getAttributes().isEmpty()) {
|
||||
attributesStr = setting.getAttributes().entrySet().stream()
|
||||
.map(entry -> entry.getKey() + ": " + entry.getValue())
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
if (setting.getTags() != null && !setting.getTags().isEmpty()) {
|
||||
tagsStr = String.join(", ", setting.getTags());
|
||||
}
|
||||
|
||||
PromptTemplateModel.SelectedContext.Setting xmlSetting =
|
||||
PromptTemplateModel.SelectedContext.Setting.builder()
|
||||
.type(setting.getType())
|
||||
// 不设置ID
|
||||
.name(setting.getName())
|
||||
.description(ensureTextIsWrappedWithNewlines(setting.getDescription()))
|
||||
.attributes(attributesStr)
|
||||
.tags(tagsStr)
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(xmlSetting);
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化设定(隐藏ID)失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化选择的上下文
|
||||
*/
|
||||
public String formatSelectedContext(PromptTemplateModel.SelectedContext context) {
|
||||
try {
|
||||
String result = xmlMapper.writeValueAsString(context);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化选择上下文失败: {}", e.getMessage(), e);
|
||||
return "<selected_context>\n <error>格式化失败</error>\n</selected_context>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化片段
|
||||
*/
|
||||
public String formatSnippet(NovelSnippet snippet) {
|
||||
try {
|
||||
String tagsStr = "";
|
||||
|
||||
if (snippet.getTags() != null && !snippet.getTags().isEmpty()) {
|
||||
tagsStr = String.join(", ", snippet.getTags());
|
||||
}
|
||||
|
||||
PromptTemplateModel.Snippet xmlSnippet = PromptTemplateModel.Snippet.builder()
|
||||
.id(snippet.getId())
|
||||
.title(snippet.getTitle())
|
||||
.notes(ensureTextIsWrappedWithNewlines(snippet.getNotes()))
|
||||
.content(ensureTextIsWrappedWithNewlines(snippet.getContent()))
|
||||
.category(snippet.getCategory())
|
||||
.tags(tagsStr)
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(xmlSnippet);
|
||||
|
||||
// 直接返回结果,不做额外处理
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化片段失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 新增:格式化完整小说文本(包含所有场景的实际内容)
|
||||
*/
|
||||
public String formatFullNovelText(String title, String description, List<Scene> scenes) {
|
||||
try {
|
||||
log.info("开始格式化完整小说文本 - 标题: {}, 原始场景数量: {}", title, scenes != null ? scenes.size() : 0);
|
||||
|
||||
// 过滤有效场景(必须有实际内容) - 🚀 只保留有内容的场景以节省token
|
||||
List<Scene> validScenes = (scenes == null ? java.util.List.<Scene>of() : scenes).stream()
|
||||
.filter(scene -> scene != null &&
|
||||
scene.getId() != null && !scene.getId().trim().isEmpty() &&
|
||||
scene.getChapterId() != null && !scene.getChapterId().trim().isEmpty() &&
|
||||
scene.getContent() != null && !scene.getContent().trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("过滤后有内容的场景数量: {}", validScenes.size());
|
||||
|
||||
if (validScenes.isEmpty()) {
|
||||
log.warn("没有有效的场景内容数据");
|
||||
return "";
|
||||
}
|
||||
|
||||
// 按章节分组,并保持顺序
|
||||
Map<String, List<Scene>> chapterGroups = validScenes.stream()
|
||||
.collect(Collectors.groupingBy(Scene::getChapterId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
log.info("按章节分组后的章节数量: {}", chapterGroups.size());
|
||||
|
||||
// 🚀 使用AtomicInteger来为章节分配顺序号
|
||||
AtomicInteger chapterNumber = new AtomicInteger(1);
|
||||
|
||||
List<PromptTemplateModel.FullNovelText.ChapterContent> chapters = chapterGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String chapterId = entry.getKey();
|
||||
List<Scene> chapterScenes = entry.getValue();
|
||||
|
||||
log.debug("处理章节 {} 的 {} 个场景", chapterId, chapterScenes.size());
|
||||
|
||||
// 🚀 对章节内的场景按sequence排序,然后重新分配顺序号
|
||||
List<Scene> sortedScenes = chapterScenes.stream()
|
||||
.sorted(Comparator.comparing(Scene::getSequence, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
AtomicInteger sceneNumber = new AtomicInteger(1);
|
||||
List<PromptTemplateModel.FullNovelText.SceneContent> xmlScenes = sortedScenes.stream()
|
||||
.map(scene -> {
|
||||
String content = RichTextUtil.deltaJsonToPlainText(scene.getContent());
|
||||
log.debug("场景 {} - 标题: {}, 内容长度: {}",
|
||||
scene.getId(), scene.getTitle(),
|
||||
content != null ? content.length() : 0);
|
||||
|
||||
return PromptTemplateModel.FullNovelText.SceneContent.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(sceneNumber.getAndIncrement()) // 🚀 使用章节内的顺序号
|
||||
.id(scene.getId())
|
||||
.content(content)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int currentChapterNumber = chapterNumber.getAndIncrement();
|
||||
return PromptTemplateModel.FullNovelText.ChapterContent.builder()
|
||||
.id(chapterId)
|
||||
.number(currentChapterNumber) // 🚀 使用章节顺序号,而不是硬编码的1
|
||||
.title("第" + currentChapterNumber + "章") // 🚀 动态生成章节标题
|
||||
.scenes(xmlScenes)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 创建一个默认的Act(如果没有Act概念,可以都放在Act 1中)
|
||||
PromptTemplateModel.FullNovelText.ActContent act = PromptTemplateModel.FullNovelText.ActContent.builder()
|
||||
.number(1)
|
||||
.title("第一幕")
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
|
||||
PromptTemplateModel.FullNovelText fullNovelText = PromptTemplateModel.FullNovelText.builder()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.acts(List.of(act))
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(fullNovelText);
|
||||
log.info("完整小说文本格式化完成,最终XML长度: {}", result.length());
|
||||
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化完整小说文本失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 新增:使用章节顺序映射格式化完整小说文本
|
||||
* - 若映射中存在章节顺序,则优先使用映射中的值;否则回退到自增顺序
|
||||
* - 场景的 number 仍为章节内自增
|
||||
*/
|
||||
public String formatFullNovelTextUsingChapterOrderMap(String title, String description,
|
||||
java.util.List<Scene> scenes,
|
||||
java.util.Map<String, Integer> chapterOrderMap,
|
||||
boolean includeIds) {
|
||||
try {
|
||||
log.info("开始格式化完整小说文本(带章节顺序映射) - 标题: {}, 原始场景数量: {}", title, scenes != null ? scenes.size() : 0);
|
||||
|
||||
java.util.List<Scene> validScenes = (scenes == null ? java.util.List.<Scene>of() : scenes).stream()
|
||||
.filter(scene -> scene != null &&
|
||||
scene.getId() != null && !scene.getId().trim().isEmpty() &&
|
||||
scene.getChapterId() != null && !scene.getChapterId().trim().isEmpty() &&
|
||||
scene.getContent() != null && !scene.getContent().trim().isEmpty())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (validScenes.isEmpty()) {
|
||||
log.warn("没有有效的场景内容数据");
|
||||
return "";
|
||||
}
|
||||
|
||||
java.util.Map<String, java.util.List<Scene>> chapterGroups = validScenes.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(Scene::getChapterId, java.util.LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
|
||||
java.util.concurrent.atomic.AtomicInteger fallbackChapterNumber = new java.util.concurrent.atomic.AtomicInteger(1);
|
||||
|
||||
java.util.List<com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.ChapterContent> chapters = chapterGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String chapterId = entry.getKey();
|
||||
java.util.List<Scene> chapterScenes = entry.getValue();
|
||||
|
||||
java.util.List<Scene> sortedScenes = chapterScenes.stream()
|
||||
.sorted(java.util.Comparator.comparing(Scene::getSequence, java.util.Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
java.util.concurrent.atomic.AtomicInteger sceneNumber = new java.util.concurrent.atomic.AtomicInteger(1);
|
||||
java.util.List<com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.SceneContent> xmlScenes = sortedScenes.stream()
|
||||
.map(scene -> {
|
||||
String content = RichTextUtil.deltaJsonToPlainText(scene.getContent());
|
||||
com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.SceneContent.SceneContentBuilder builder =
|
||||
com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.SceneContent.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(sceneNumber.getAndIncrement())
|
||||
.content(content);
|
||||
if (includeIds) {
|
||||
builder.id(scene.getId());
|
||||
}
|
||||
return builder.build();
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
int mappedOrder = chapterOrderMap != null && chapterOrderMap.containsKey(chapterId)
|
||||
? chapterOrderMap.get(chapterId)
|
||||
: fallbackChapterNumber.getAndIncrement();
|
||||
|
||||
com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.ChapterContent.ChapterContentBuilder chapterBuilder =
|
||||
com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.ChapterContent.builder()
|
||||
.number(mappedOrder)
|
||||
.title("第" + mappedOrder + "章")
|
||||
.scenes(xmlScenes);
|
||||
if (includeIds) {
|
||||
chapterBuilder.id(chapterId);
|
||||
}
|
||||
return chapterBuilder.build();
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.ActContent act = com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.ActContent.builder()
|
||||
.number(1)
|
||||
.title("第一幕")
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
|
||||
com.ainovel.server.common.util.PromptTemplateModel.FullNovelText fullNovelText = com.ainovel.server.common.util.PromptTemplateModel.FullNovelText.builder()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.acts(java.util.List.of(act))
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(fullNovelText);
|
||||
log.info("完整小说文本(带章节顺序映射)格式化完成,最终XML长度: {}", result.length());
|
||||
return result;
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
log.error("格式化完整小说文本(带章节顺序映射)失败: {}", e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 检查字符串是否包含XML内容
|
||||
*/
|
||||
private boolean isXmlContent(String content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 检查是否包含XML标签
|
||||
return content.contains("<") && content.contains(">") &&
|
||||
(content.contains("</") || content.matches(".*<\\w+[^>]*>.*"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 直接构建用户提示词XML,避免context内容被转义
|
||||
*/
|
||||
private String buildUserPromptXmlDirectly(String action, String input, String context,
|
||||
String length, String style, String tone) {
|
||||
StringBuilder xml = new StringBuilder();
|
||||
xml.append("<task>\n");
|
||||
|
||||
if (action != null && !action.isEmpty()) {
|
||||
xml.append(" <action>\n").append(escapeXmlContent(action)).append("\n </action>\n");
|
||||
}
|
||||
|
||||
if (input != null && !input.isEmpty()) {
|
||||
xml.append(" <input>\n").append(escapeXmlContent(input)).append("\n </input>\n");
|
||||
}
|
||||
|
||||
// 🚀 关键:context内容直接插入,不进行转义
|
||||
if (context != null && !context.isEmpty()) {
|
||||
xml.append(" <context>\n").append(context).append("\n </context>\n");
|
||||
}
|
||||
|
||||
// 添加要求信息
|
||||
if ((length != null && !length.isEmpty()) ||
|
||||
(style != null && !style.isEmpty()) ||
|
||||
(tone != null && !tone.isEmpty())) {
|
||||
|
||||
xml.append(" <requirements>\n");
|
||||
|
||||
if (length != null && !length.isEmpty()) {
|
||||
xml.append(" <length>").append(escapeXmlContent(length)).append("</length>\n");
|
||||
}
|
||||
|
||||
if (style != null && !style.isEmpty()) {
|
||||
xml.append(" <style>").append(escapeXmlContent(style)).append("</style>\n");
|
||||
}
|
||||
|
||||
if (tone != null && !tone.isEmpty()) {
|
||||
xml.append(" <tone>").append(escapeXmlContent(tone)).append("</tone>\n");
|
||||
}
|
||||
|
||||
xml.append(" </requirements>\n");
|
||||
}
|
||||
|
||||
xml.append("</task>");
|
||||
return xml.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 直接构建系统提示词XML,避免context内容被转义
|
||||
*/
|
||||
private String buildSystemPromptXmlDirectly(String role, String instructions, String context,
|
||||
String length, String style, Map<String, Object> parameters) {
|
||||
StringBuilder xml = new StringBuilder();
|
||||
xml.append("<system>\n");
|
||||
|
||||
if (role != null && !role.isEmpty()) {
|
||||
xml.append(" <role>\n").append(escapeXmlContent(role)).append("\n </role>\n");
|
||||
}
|
||||
|
||||
if (instructions != null && !instructions.isEmpty()) {
|
||||
xml.append(" <instructions>\n").append(escapeXmlContent(instructions)).append("\n </instructions>\n");
|
||||
}
|
||||
|
||||
// 🚀 关键:context内容直接插入,不进行转义
|
||||
if (context != null && !context.isEmpty()) {
|
||||
xml.append(" <context>\n").append(context).append("\n </context>\n");
|
||||
}
|
||||
|
||||
if (length != null && !length.isEmpty()) {
|
||||
xml.append(" <length>").append(escapeXmlContent(length)).append("</length>\n");
|
||||
}
|
||||
|
||||
if (style != null && !style.isEmpty()) {
|
||||
xml.append(" <style>").append(escapeXmlContent(style)).append("</style>\n");
|
||||
}
|
||||
|
||||
// 添加参数信息
|
||||
if (parameters != null && !parameters.isEmpty()) {
|
||||
boolean hasValidParam = false;
|
||||
StringBuilder paramXml = new StringBuilder();
|
||||
paramXml.append(" <parameters>\n");
|
||||
|
||||
if (parameters.containsKey("temperature")) {
|
||||
Object tempValue = parameters.get("temperature");
|
||||
if (tempValue instanceof Number) {
|
||||
paramXml.append(" <temperature>").append(tempValue).append("</temperature>\n");
|
||||
hasValidParam = true;
|
||||
}
|
||||
}
|
||||
if (parameters.containsKey("maxTokens")) {
|
||||
Object maxTokensValue = parameters.get("maxTokens");
|
||||
if (maxTokensValue instanceof Number) {
|
||||
paramXml.append(" <max_tokens>").append(maxTokensValue).append("</max_tokens>\n");
|
||||
hasValidParam = true;
|
||||
}
|
||||
}
|
||||
if (parameters.containsKey("topP")) {
|
||||
Object topPValue = parameters.get("topP");
|
||||
if (topPValue instanceof Number) {
|
||||
paramXml.append(" <top_p>").append(topPValue).append("</top_p>\n");
|
||||
hasValidParam = true;
|
||||
}
|
||||
}
|
||||
|
||||
paramXml.append(" </parameters>\n");
|
||||
|
||||
// 只有存在有效参数时才添加parameters
|
||||
if (hasValidParam) {
|
||||
xml.append(paramXml);
|
||||
}
|
||||
}
|
||||
|
||||
xml.append("</system>");
|
||||
return xml.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 转义XML内容中的特殊字符(除了context字段)
|
||||
*/
|
||||
private String escapeXmlContent(String content) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
return content.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 新增:格式化Act结构
|
||||
*/
|
||||
public String formatAct(Integer actNumber, String actTitle, String actDescription, List<Scene> scenes) {
|
||||
try {
|
||||
log.info("开始格式化Act {} - 标题: {}, 原始场景数量: {}", actNumber, actTitle, scenes != null ? scenes.size() : 0);
|
||||
|
||||
// 过滤有效场景(必须有实际内容) - 🚀 只保留有内容的场景以节省token
|
||||
List<Scene> validScenes = (scenes == null ? java.util.List.<Scene>of() : scenes).stream()
|
||||
.filter(scene -> scene != null &&
|
||||
scene.getId() != null && !scene.getId().trim().isEmpty() &&
|
||||
scene.getChapterId() != null && !scene.getChapterId().trim().isEmpty() &&
|
||||
scene.getContent() != null && !scene.getContent().trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("Act {} 过滤后有内容的场景数量: {}", actNumber, validScenes.size());
|
||||
|
||||
if (validScenes.isEmpty()) {
|
||||
log.warn("Act {} 没有有效的场景内容数据", actNumber);
|
||||
return "";
|
||||
}
|
||||
|
||||
// 按章节分组,并保持顺序
|
||||
Map<String, List<Scene>> chapterGroups = validScenes.stream()
|
||||
.collect(Collectors.groupingBy(Scene::getChapterId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
// 🚀 使用AtomicInteger来为章节分配顺序号
|
||||
AtomicInteger chapterNumber = new AtomicInteger(1);
|
||||
|
||||
List<PromptTemplateModel.FullNovelText.ChapterContent> chapters = chapterGroups.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String chapterId = entry.getKey();
|
||||
List<Scene> chapterScenes = entry.getValue();
|
||||
|
||||
// 🚀 对章节内的场景按sequence排序,然后重新分配顺序号
|
||||
List<Scene> sortedScenes = chapterScenes.stream()
|
||||
.sorted(Comparator.comparing(Scene::getSequence, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
AtomicInteger sceneNumber = new AtomicInteger(1);
|
||||
List<PromptTemplateModel.FullNovelText.SceneContent> xmlScenes = sortedScenes.stream()
|
||||
.map(scene -> {
|
||||
String content = RichTextUtil.deltaJsonToPlainText(scene.getContent());
|
||||
|
||||
return PromptTemplateModel.FullNovelText.SceneContent.builder()
|
||||
.title(scene.getTitle())
|
||||
.number(sceneNumber.getAndIncrement()) // 🚀 使用章节内的顺序号
|
||||
.id(scene.getId())
|
||||
.content(content)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int currentChapterNumber = chapterNumber.getAndIncrement();
|
||||
return PromptTemplateModel.FullNovelText.ChapterContent.builder()
|
||||
.id(chapterId)
|
||||
.number(currentChapterNumber) // 🚀 使用章节顺序号,而不是硬编码的1
|
||||
.title("第" + currentChapterNumber + "章") // 🚀 动态生成章节标题
|
||||
.scenes(xmlScenes)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
PromptTemplateModel.ActStructure actStructure = PromptTemplateModel.ActStructure.builder()
|
||||
.number(actNumber)
|
||||
.title(actTitle)
|
||||
.description(actDescription)
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
|
||||
String result = xmlMapper.writeValueAsString(actStructure);
|
||||
log.info("Act {} 格式化完成,最终XML长度: {}", actNumber, result.length());
|
||||
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("格式化Act {}失败: {}", actNumber, e.getMessage(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,81 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 反射工具类
|
||||
*/
|
||||
public class ReflectionUtil {
|
||||
|
||||
/**
|
||||
* 获取对象的属性值
|
||||
*
|
||||
* @param obj 对象
|
||||
* @param propertyName 属性名
|
||||
* @param defaultValue 默认值
|
||||
* @return 属性值,如果获取失败则返回默认值
|
||||
*/
|
||||
public static Object getPropertyValue(Object obj, String propertyName, Object defaultValue) {
|
||||
if (obj == null || propertyName == null || propertyName.isEmpty()) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试通过getter方法获取
|
||||
String getterName = "get" + propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1);
|
||||
Method getter = obj.getClass().getMethod(getterName);
|
||||
return getter.invoke(obj);
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
// 尝试通过属性名直接获取
|
||||
Field field = obj.getClass().getDeclaredField(propertyName);
|
||||
field.setAccessible(true);
|
||||
return field.get(obj);
|
||||
} catch (Exception ex) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置对象的属性值
|
||||
*
|
||||
* @param obj 对象
|
||||
* @param propertyName 属性名
|
||||
* @param value 值
|
||||
* @return 是否设置成功
|
||||
*/
|
||||
public static boolean setPropertyValue(Object obj, String propertyName, Object value) {
|
||||
if (obj == null || propertyName == null || propertyName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试通过setter方法设置
|
||||
String setterName = "set" + propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1);
|
||||
Method setter = null;
|
||||
|
||||
// 查找与属性名匹配的setter方法
|
||||
for (Method method : obj.getClass().getMethods()) {
|
||||
if (method.getName().equals(setterName) && method.getParameterCount() == 1) {
|
||||
setter = method;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (setter != null) {
|
||||
setter.invoke(obj, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尝试通过属性名直接设置
|
||||
Field field = obj.getClass().getDeclaredField(propertyName);
|
||||
field.setAccessible(true);
|
||||
field.set(obj, value);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.ainovel.server.common.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.mantoux.delta.Delta;
|
||||
import org.mantoux.delta.OpList;
|
||||
import org.mantoux.delta.Op;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class RichTextUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RichTextUtil.class);
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Extracts plain text from a Quill Delta object.
|
||||
*
|
||||
* @param delta Quill Delta object
|
||||
* @return Plain text string
|
||||
*/
|
||||
public static String deltaToPlainText(Delta delta) {
|
||||
if (delta == null) {
|
||||
return "";
|
||||
}
|
||||
// Use the library's provided method to get plain text
|
||||
return delta.plainText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts plain text from a Quill Delta JSON string.
|
||||
* Supports both standard Delta object format ("ops": [...]) and direct array format ([...]).
|
||||
* Falls back to HTML stripping and then plain text if JSON parsing fails.
|
||||
*
|
||||
* @param deltaJson Quill Delta JSON string, or HTML, or plain text
|
||||
* @return Plain text string
|
||||
*/
|
||||
public static String deltaJsonToPlainText(String deltaJson) {
|
||||
if (deltaJson == null || deltaJson.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String trimmedJson = deltaJson.trim();
|
||||
|
||||
try {
|
||||
// Attempt 1: Parse as standard Delta object {"ops": [...]}
|
||||
if (trimmedJson.startsWith("{") && trimmedJson.endsWith("}") && trimmedJson.contains("\"ops\"")) {
|
||||
try {
|
||||
Delta delta = objectMapper.readValue(trimmedJson, Delta.class);
|
||||
return deltaToPlainText(delta);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Attempt 1: Failed to parse as standard Delta object ({{\"ops\":...}}). Error: {}. Input snippet: {}",
|
||||
e.getMessage(), trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)));
|
||||
// Fall through to try other parsing methods
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 2: Parse as a JSON array of operations [...] using the library's Op and Delta classes
|
||||
if (trimmedJson.startsWith("[") && trimmedJson.endsWith("]")) {
|
||||
try {
|
||||
List<Op> opJavaList = objectMapper.readValue(trimmedJson, new TypeReference<List<Op>>() {});
|
||||
OpList opList = new OpList(opJavaList); // OpList constructor takes Collection<? extends Op>
|
||||
Delta delta = new Delta(opList);
|
||||
return deltaToPlainText(delta);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Attempt 2: Failed to parse JSON array into List<Op>. Error: {}. Input snippet: {}",
|
||||
e.getMessage(), trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)));
|
||||
// Fall through to manual map parsing as a robust fallback for arrays
|
||||
} catch (Exception e) { // Catch other exceptions like from OpList/Delta constructor or runtime issues
|
||||
log.warn("Attempt 2: Failed to construct OpList/Delta from parsed List<Op>. Error: {}. Input snippet: {}",
|
||||
e.getMessage(), trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)));
|
||||
// Fall through to manual map parsing
|
||||
}
|
||||
|
||||
// Attempt 3 (Fallback for array): Parse as List<Map<String, Object>> and extract inserts manually
|
||||
try {
|
||||
List<Map<String, Object>> opsListRaw = objectMapper.readValue(trimmedJson, new TypeReference<List<Map<String, Object>>>() {});
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map<String, Object> opMap : opsListRaw) {
|
||||
if (opMap.containsKey("insert")) {
|
||||
Object insertValue = opMap.get("insert");
|
||||
if (insertValue instanceof String) {
|
||||
sb.append((String) insertValue);
|
||||
} else if (insertValue instanceof Map) {
|
||||
// Delta.plainText() typically adds a newline for embedded objects.
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
// If opsListRaw was empty (trimmedJson was "[]"), sb will be empty, which is correct.
|
||||
return sb.toString();
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Attempt 3 (Fallback): Failed to parse as JSON array of maps. Error: {}. Input snippet: {}",
|
||||
e.getMessage(), trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)));
|
||||
// Fall through to HTML/plain text check if all Delta JSON parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Final Fallbacks: If not a recognized Delta JSON, try as HTML or plain text
|
||||
if (isHtml(trimmedJson)) {
|
||||
log.debug("Input not recognized as Delta JSON, attempting to strip HTML. Input snippet: {}",
|
||||
trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)));
|
||||
return stripHtml(trimmedJson);
|
||||
}
|
||||
|
||||
log.debug("Input is not Delta JSON or HTML, returning as is. Input snippet: {}",
|
||||
trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)));
|
||||
return trimmedJson; // Assume plain text or unprocessable format
|
||||
|
||||
} catch (Exception e) { // Catch any other unexpected exceptions during processing
|
||||
log.error("Unexpected error in deltaJsonToPlainText. Input snippet: {}. Error: {}. Details: {}",
|
||||
trimmedJson.substring(0, Math.min(trimmedJson.length(), 200)), e.getMessage(), e.toString());
|
||||
// Fallback in case of any other error
|
||||
if (isHtml(trimmedJson)) {
|
||||
return stripHtml(trimmedJson);
|
||||
}
|
||||
return trimmedJson; // Final fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic HTML tag stripping.
|
||||
* For complex HTML, consider a dedicated library like JSoup.
|
||||
*
|
||||
* @param html HTML string
|
||||
* @return Text with HTML tags removed
|
||||
*/
|
||||
private static String stripHtml(String html) {
|
||||
if (html == null) return "";
|
||||
String noHtml = html.replaceAll("<[^>]*>", "");
|
||||
// Basic HTML entity decoding
|
||||
noHtml = noHtml.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'");
|
||||
return noHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic check to see if a string might be HTML.
|
||||
*
|
||||
* @param text The string to check
|
||||
* @return true if the string heuristically looks like HTML, false otherwise
|
||||
*/
|
||||
private static boolean isHtml(String text) {
|
||||
if (text == null) return false;
|
||||
String trimmedText = text.trim();
|
||||
// Simple heuristic: starts with <, ends with >, and contains at least one tag-like structure.
|
||||
return trimmedText.startsWith("<") && trimmedText.endsWith(">") && trimmedText.matches(".*<[^>]+>.*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.domain.model.AIPromptPreset;
|
||||
import com.ainovel.server.repository.AIPromptPresetRepository;
|
||||
import com.ainovel.server.repository.EnhancedUserPromptTemplateRepository;
|
||||
import com.ainovel.server.web.dto.request.UniversalAIRequestDto;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* AI提示词预设初始化器
|
||||
* 在应用启动完成后自动初始化系统默认预设
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(2) // 确保在 PromptProviderInitializer 之后执行
|
||||
public class AIPromptPresetInitializer implements ApplicationRunner {
|
||||
|
||||
@Autowired
|
||||
private AIPromptPresetRepository presetRepository;
|
||||
|
||||
@Autowired
|
||||
private EnhancedUserPromptTemplateRepository templateRepository;
|
||||
|
||||
@Autowired
|
||||
private PromptProviderInitializer promptProviderInitializer;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Value("${ainovel.ai.features.setting-tree-generation.init-on-startup:false}")
|
||||
private boolean settingTreeGenerationInitOnStartup;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
log.info("开始初始化系统默认AI预设...");
|
||||
|
||||
try {
|
||||
initializeSystemPresets()
|
||||
.doOnSuccess(unused -> log.info("系统默认AI预设初始化完成"))
|
||||
.doOnError(error -> log.error("初始化系统默认AI预设失败", error))
|
||||
.block(); // 阻塞等待完成,确保初始化完成后才继续
|
||||
} catch (Exception e) {
|
||||
log.error("初始化系统默认AI预设时发生异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化系统预设
|
||||
*/
|
||||
private Mono<Void> initializeSystemPresets() {
|
||||
List<Mono<AIPromptPreset>> presetMonos = new ArrayList<>();
|
||||
|
||||
// 为每个AI功能类型创建系统预设
|
||||
for (AIFeatureType featureType : AIFeatureType.values()) {
|
||||
if (featureType == AIFeatureType.SETTING_TREE_GENERATION && !settingTreeGenerationInitOnStartup) {
|
||||
log.info("⏭️ 跳过 SETTING_TREE_GENERATION 系统预设初始化(开关关闭)");
|
||||
continue;
|
||||
}
|
||||
presetMonos.addAll(createSystemPresetsForFeature(featureType));
|
||||
}
|
||||
|
||||
return Flux.merge(presetMonos).then();
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定功能类型创建系统预设
|
||||
*/
|
||||
private List<Mono<AIPromptPreset>> createSystemPresetsForFeature(AIFeatureType featureType) {
|
||||
List<Mono<AIPromptPreset>> presets = new ArrayList<>();
|
||||
|
||||
if (featureType == AIFeatureType.TEXT_EXPANSION) {
|
||||
presets.add(createTextExpansionSystemPreset());
|
||||
} else if (featureType == AIFeatureType.TEXT_REFACTOR) {
|
||||
presets.add(createTextRefactorSystemPreset());
|
||||
} else if (featureType == AIFeatureType.TEXT_SUMMARY) {
|
||||
presets.add(createTextSummarySystemPreset());
|
||||
} else if (featureType == AIFeatureType.AI_CHAT) {
|
||||
presets.add(createChatSystemPreset());
|
||||
} else if (featureType == AIFeatureType.SCENE_TO_SUMMARY
|
||||
|| featureType == AIFeatureType.SUMMARY_TO_SCENE
|
||||
|| featureType == AIFeatureType.NOVEL_GENERATION
|
||||
|| featureType == AIFeatureType.PROFESSIONAL_FICTION_CONTINUATION) {
|
||||
presets.add(createGenericSystemPreset(featureType));
|
||||
} else {
|
||||
// 为其他功能类型创建通用预设
|
||||
presets.add(createGenericSystemPreset(featureType));
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本扩写系统预设
|
||||
*/
|
||||
private Mono<AIPromptPreset> createTextExpansionSystemPreset() {
|
||||
String presetId = "system-text-expansion-default";
|
||||
|
||||
return presetRepository.existsByPresetIdAndIsSystemTrue(presetId)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("系统预设已存在,跳过创建: {}", presetId);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
UniversalAIRequestDto requestData = UniversalAIRequestDto.builder()
|
||||
.requestType("expansion")
|
||||
.modelConfigId("default-gpt-3.5")
|
||||
.parameters(Map.of(
|
||||
"temperature", 0.7,
|
||||
"max_tokens", 2000
|
||||
))
|
||||
.build();
|
||||
|
||||
// 🚀 修复:计算系统预设哈希
|
||||
String presetHash = calculateSystemPresetHash(presetId, AIFeatureType.TEXT_EXPANSION, requestData);
|
||||
|
||||
AIPromptPreset preset = AIPromptPreset.builder()
|
||||
.presetId(presetId)
|
||||
.userId("system")
|
||||
.presetHash(presetHash) // 🚀 修复:设置计算出的哈希值
|
||||
.presetName("标准文本扩写")
|
||||
.presetDescription("系统默认的文本扩写预设,适用于大部分小说内容扩写场景")
|
||||
.presetTags(Arrays.asList("系统预设", "文本扩写", "小说创作"))
|
||||
.isFavorite(false)
|
||||
.isPublic(true)
|
||||
.useCount(0)
|
||||
.requestData(objectMapper.writeValueAsString(requestData))
|
||||
.systemPrompt("你是一位专业的小说创作助手。请根据提供的内容进行扩写,保持故事的连贯性和角色性格的一致性。")
|
||||
.userPrompt("请扩写以下内容:{input}\n\n上下文信息:{context}\n\n要求:\n1. 保持原有的写作风格\n2. 增加更多的细节描述\n3. 让情节发展更加自然流畅")
|
||||
.aiFeatureType(AIFeatureType.TEXT_EXPANSION.name())
|
||||
.templateId(getSystemTemplateId(AIFeatureType.TEXT_EXPANSION))
|
||||
.promptCustomized(false)
|
||||
.isSystem(true)
|
||||
.showInQuickAccess(true)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("创建系统预设: {}", preset.getPresetName());
|
||||
return presetRepository.save(preset);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("创建文本扩写系统预设失败", e);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本重构系统预设
|
||||
*/
|
||||
private Mono<AIPromptPreset> createTextRefactorSystemPreset() {
|
||||
String presetId = "system-text-refactor-default";
|
||||
|
||||
return presetRepository.existsByPresetIdAndIsSystemTrue(presetId)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("系统预设已存在,跳过创建: {}", presetId);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
UniversalAIRequestDto requestData = UniversalAIRequestDto.builder()
|
||||
.requestType("refactor")
|
||||
.modelConfigId("default-gpt-3.5")
|
||||
.parameters(Map.of(
|
||||
"temperature", 0.6,
|
||||
"max_tokens", 2000
|
||||
))
|
||||
.build();
|
||||
|
||||
// 🚀 修复:计算系统预设哈希
|
||||
String presetHash = calculateSystemPresetHash(presetId, AIFeatureType.TEXT_REFACTOR, requestData);
|
||||
|
||||
AIPromptPreset preset = AIPromptPreset.builder()
|
||||
.presetId(presetId)
|
||||
.userId("system")
|
||||
.presetHash(presetHash) // 🚀 修复:设置计算出的哈希值
|
||||
.presetName("标准文本重构")
|
||||
.presetDescription("系统默认的文本重构预设,用于改善文字表达和故事结构")
|
||||
.presetTags(Arrays.asList("系统预设", "文本重构", "优化"))
|
||||
.isFavorite(false)
|
||||
.isPublic(true)
|
||||
.useCount(0)
|
||||
.requestData(objectMapper.writeValueAsString(requestData))
|
||||
.systemPrompt("你是一位专业的文字编辑。请重构提供的内容,改善文字表达和故事结构,保持原有风格和特色。")
|
||||
.userPrompt("请重构以下内容:{input}\n\n上下文信息:{context}\n\n要求:\n1. 改善文字表达和语言流畅度\n2. 优化故事结构和逻辑\n3. 保持原有的风格特色")
|
||||
.aiFeatureType(AIFeatureType.TEXT_REFACTOR.name())
|
||||
.templateId(getSystemTemplateId(AIFeatureType.TEXT_REFACTOR))
|
||||
.promptCustomized(false)
|
||||
.isSystem(true)
|
||||
.showInQuickAccess(true)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("创建系统预设: {}", preset.getPresetName());
|
||||
return presetRepository.save(preset);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("创建文本重构系统预设失败", e);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本总结系统预设
|
||||
*/
|
||||
private Mono<AIPromptPreset> createTextSummarySystemPreset() {
|
||||
String presetId = "system-text-summary-default";
|
||||
|
||||
return presetRepository.existsByPresetIdAndIsSystemTrue(presetId)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("系统预设已存在,跳过创建: {}", presetId);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
UniversalAIRequestDto requestData = UniversalAIRequestDto.builder()
|
||||
.requestType("summary")
|
||||
.modelConfigId("default-gpt-3.5")
|
||||
.parameters(Map.of(
|
||||
"temperature", 0.3,
|
||||
"max_tokens", 1000
|
||||
))
|
||||
.build();
|
||||
|
||||
// 🚀 修复:计算系统预设哈希
|
||||
String presetHash = calculateSystemPresetHash(presetId, AIFeatureType.TEXT_SUMMARY, requestData);
|
||||
|
||||
AIPromptPreset preset = AIPromptPreset.builder()
|
||||
.presetId(presetId)
|
||||
.userId("system")
|
||||
.presetHash(presetHash) // 🚀 修复:设置计算出的哈希值
|
||||
.presetName("标准文本总结")
|
||||
.presetDescription("系统默认的文本总结预设,用于提取关键情节和重要信息")
|
||||
.presetTags(Arrays.asList("系统预设", "文本总结", "内容概括"))
|
||||
.isFavorite(false)
|
||||
.isPublic(true)
|
||||
.useCount(0)
|
||||
.requestData(objectMapper.writeValueAsString(requestData))
|
||||
.systemPrompt("你是一位专业的文本分析师。请准确总结提供的内容,提取关键情节和重要信息。")
|
||||
.userPrompt("请总结以下内容:{input}\n\n上下文信息:{context}\n\n要求:\n1. 提取关键情节和重要信息\n2. 保持总结的准确性和完整性\n3. 突出重要的故事转折点")
|
||||
.aiFeatureType(AIFeatureType.TEXT_SUMMARY.name())
|
||||
.templateId(getSystemTemplateId(AIFeatureType.TEXT_SUMMARY))
|
||||
.promptCustomized(false)
|
||||
.isSystem(true)
|
||||
.showInQuickAccess(true)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("创建系统预设: {}", preset.getPresetName());
|
||||
return presetRepository.save(preset);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("创建文本总结系统预设失败", e);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建聊天系统预设
|
||||
*/
|
||||
private Mono<AIPromptPreset> createChatSystemPreset() {
|
||||
String presetId = "system-chat-default";
|
||||
|
||||
return presetRepository.existsByPresetIdAndIsSystemTrue(presetId)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("系统预设已存在,跳过创建: {}", presetId);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
UniversalAIRequestDto requestData = UniversalAIRequestDto.builder()
|
||||
.requestType("chat")
|
||||
.modelConfigId("default-gpt-3.5")
|
||||
.parameters(Map.of(
|
||||
"temperature", 0.7,
|
||||
"max_tokens", 2000
|
||||
))
|
||||
.build();
|
||||
|
||||
// 🚀 修复:计算系统预设哈希
|
||||
String presetHash = calculateSystemPresetHash(presetId, AIFeatureType.AI_CHAT, requestData);
|
||||
|
||||
AIPromptPreset preset = AIPromptPreset.builder()
|
||||
.presetId(presetId)
|
||||
.userId("system")
|
||||
.presetHash(presetHash) // 🚀 修复:设置计算出的哈希值
|
||||
.presetName("智能创作助手")
|
||||
.presetDescription("系统默认的AI聊天预设,专业的小说创作助手")
|
||||
.presetTags(Arrays.asList("系统预设", "AI聊天", "创作助手"))
|
||||
.isFavorite(false)
|
||||
.isPublic(true)
|
||||
.useCount(0)
|
||||
.requestData(objectMapper.writeValueAsString(requestData))
|
||||
.systemPrompt("你是一位专业的小说创作助手,具有丰富的文学知识和创作经验。你可以帮助用户进行小说创作的各种任务。")
|
||||
.userPrompt("{prompt}")
|
||||
.aiFeatureType(AIFeatureType.AI_CHAT.name())
|
||||
.templateId(getSystemTemplateId(AIFeatureType.AI_CHAT))
|
||||
.promptCustomized(false)
|
||||
.isSystem(true)
|
||||
.showInQuickAccess(true)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("创建系统预设: {}", preset.getPresetName());
|
||||
return presetRepository.save(preset);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("创建聊天系统预设失败", e);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建场景生成系统预设
|
||||
*/
|
||||
private Mono<AIPromptPreset> createSceneGenerationSystemPreset() {
|
||||
String presetId = "system-scene-generation-default";
|
||||
|
||||
return presetRepository.existsByPresetIdAndIsSystemTrue(presetId)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("系统预设已存在,跳过创建: {}", presetId);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
UniversalAIRequestDto requestData = UniversalAIRequestDto.builder()
|
||||
.requestType("generation")
|
||||
.modelConfigId("default-gpt-4")
|
||||
.parameters(Map.of(
|
||||
"temperature", 0.8,
|
||||
"max_tokens", 3000
|
||||
))
|
||||
.build();
|
||||
|
||||
// 🚀 修复:计算系统预设哈希
|
||||
String presetHash = calculateSystemPresetHash(presetId, AIFeatureType.SCENE_TO_SUMMARY, requestData);
|
||||
|
||||
AIPromptPreset preset = AIPromptPreset.builder()
|
||||
.presetId(presetId)
|
||||
.userId("system")
|
||||
.presetHash(presetHash) // 🚀 修复:设置计算出的哈希值
|
||||
.presetName("智能场景生成")
|
||||
.presetDescription("系统默认的场景生成预设,用于创作新的故事场景")
|
||||
.presetTags(Arrays.asList("系统预设", "场景生成", "内容创作"))
|
||||
.isFavorite(false)
|
||||
.isPublic(true)
|
||||
.useCount(0)
|
||||
.requestData(objectMapper.writeValueAsString(requestData))
|
||||
.systemPrompt("你是一位专业的小说创作者。请根据提供的信息创作引人入胜的故事场景,保持故事的连贯性和吸引力。")
|
||||
.userPrompt("请根据以下信息生成场景:{prompt}\n\n背景设定:{context}\n\n要求:\n1. 创作生动有趣的故事情节\n2. 保持角色性格的一致性\n3. 符合整体故事背景和风格")
|
||||
.aiFeatureType(AIFeatureType.SCENE_TO_SUMMARY.name())
|
||||
.templateId(getSystemTemplateId(AIFeatureType.SCENE_TO_SUMMARY))
|
||||
.promptCustomized(false)
|
||||
.isSystem(true)
|
||||
.showInQuickAccess(true)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("创建系统预设: {}", preset.getPresetName());
|
||||
return presetRepository.save(preset);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("创建场景生成系统预设失败", e);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通用系统预设
|
||||
*/
|
||||
private Mono<AIPromptPreset> createGenericSystemPreset(AIFeatureType featureType) {
|
||||
String presetId = "system-" + featureType.name().toLowerCase().replace("_", "-") + "-default";
|
||||
|
||||
return presetRepository.existsByPresetIdAndIsSystemTrue(presetId)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("系统预设已存在,跳过创建: {}", presetId);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
UniversalAIRequestDto requestData = UniversalAIRequestDto.builder()
|
||||
.requestType("general")
|
||||
.modelConfigId("default-gpt-3.5")
|
||||
.parameters(Map.of(
|
||||
"temperature", 0.7,
|
||||
"max_tokens", 2000
|
||||
))
|
||||
.build();
|
||||
|
||||
// 🚀 修复:计算系统预设哈希
|
||||
String presetHash = calculateSystemPresetHash(presetId, featureType, requestData);
|
||||
|
||||
AIPromptPreset preset = AIPromptPreset.builder()
|
||||
.presetId(presetId)
|
||||
.userId("system")
|
||||
.presetHash(presetHash) // 🚀 修复:设置计算出的哈希值
|
||||
.presetName("默认 " + getFeatureDisplayName(featureType))
|
||||
.presetDescription("系统默认的" + getFeatureDisplayName(featureType) + "预设")
|
||||
.presetTags(Arrays.asList("系统预设", getFeatureDisplayName(featureType)))
|
||||
.isFavorite(false)
|
||||
.isPublic(true)
|
||||
.useCount(0)
|
||||
.requestData(objectMapper.writeValueAsString(requestData))
|
||||
.systemPrompt("你是一位专业的AI助手,可以帮助用户完成各种文本处理任务。")
|
||||
.userPrompt("{prompt}")
|
||||
.aiFeatureType(featureType.name())
|
||||
.templateId(getSystemTemplateId(featureType))
|
||||
.promptCustomized(false)
|
||||
.isSystem(true)
|
||||
.showInQuickAccess(false) // 通用预设默认不显示在快捷访问中
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("创建系统预设: {}", preset.getPresetName());
|
||||
return presetRepository.save(preset);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("创建通用系统预设失败: featureType={}", featureType, e);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取功能类型的显示名称
|
||||
*/
|
||||
private String getFeatureDisplayName(AIFeatureType featureType) {
|
||||
return FEATURE_DISPLAY_NAME_MAP.getOrDefault(featureType, featureType.name());
|
||||
}
|
||||
|
||||
// 使用 EnumMap 避免 enum switch 产生的合成内部类(如 AIPromptPresetInitializer$1)
|
||||
private static final Map<AIFeatureType, String> FEATURE_DISPLAY_NAME_MAP = createFeatureDisplayNameMap();
|
||||
|
||||
private static Map<AIFeatureType, String> createFeatureDisplayNameMap() {
|
||||
Map<AIFeatureType, String> map = new EnumMap<>(AIFeatureType.class);
|
||||
map.put(AIFeatureType.TEXT_EXPANSION, "文本扩写");
|
||||
map.put(AIFeatureType.TEXT_REFACTOR, "文本重构");
|
||||
map.put(AIFeatureType.TEXT_SUMMARY, "文本总结");
|
||||
map.put(AIFeatureType.AI_CHAT, "AI聊天");
|
||||
map.put(AIFeatureType.SCENE_TO_SUMMARY, "场景摘要");
|
||||
map.put(AIFeatureType.SUMMARY_TO_SCENE, "摘要生成场景");
|
||||
map.put(AIFeatureType.NOVEL_GENERATION, "小说生成");
|
||||
map.put(AIFeatureType.PROFESSIONAL_FICTION_CONTINUATION, "专业小说续写");
|
||||
return Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定功能类型的系统模板ID
|
||||
*/
|
||||
private String getSystemTemplateId(AIFeatureType featureType) {
|
||||
String templateId = promptProviderInitializer.getSystemTemplateId(featureType);
|
||||
if (templateId == null) {
|
||||
log.warn("⚠️ 未找到功能类型 {} 的系统模板ID,预设将不关联模板", featureType);
|
||||
} else {
|
||||
log.debug("✅ 获取到功能类型 {} 的系统模板ID: {}", featureType, templateId);
|
||||
}
|
||||
return templateId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 新增:为系统预设计算配置哈希值
|
||||
* 基于预设的关键配置生成唯一哈希,确保不会产生重复键错误
|
||||
*/
|
||||
private String calculateSystemPresetHash(String presetId, AIFeatureType featureType, UniversalAIRequestDto requestData) {
|
||||
try {
|
||||
StringBuilder hashInput = new StringBuilder();
|
||||
|
||||
// 系统预设的唯一标识
|
||||
hashInput.append("system_preset:").append(presetId).append("|");
|
||||
hashInput.append("feature_type:").append(featureType.name()).append("|");
|
||||
hashInput.append("request_type:").append(requestData.getRequestType()).append("|");
|
||||
hashInput.append("model_config:").append(requestData.getModelConfigId()).append("|");
|
||||
|
||||
// 参数信息
|
||||
if (requestData.getParameters() != null) {
|
||||
requestData.getParameters().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(entry -> hashInput.append(entry.getKey()).append(":").append(entry.getValue()).append("|"));
|
||||
}
|
||||
|
||||
// 添加系统标识确保与用户预设区分
|
||||
hashInput.append("is_system:true");
|
||||
|
||||
// 计算SHA-256哈希
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(hashInput.toString().getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 转换为十六进制字符串
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
|
||||
return hexString.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("计算系统预设哈希时发生错误", e);
|
||||
// 如果哈希计算失败,生成一个基于时间和预设ID的后备哈希
|
||||
return "system_fallback_" + presetId + "_" + System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* AI供应商枚举
|
||||
* 定义支持的AI供应商及其基础特征
|
||||
*/
|
||||
@Getter
|
||||
public enum AIProviderEnum {
|
||||
|
||||
OPENAI("openai", "OpenAI", true, false, 8000),
|
||||
ANTHROPIC("anthropic", "Anthropic", true, false, 100000),
|
||||
GEMINI("gemini", "Google Gemini", false, true, 1000000),
|
||||
OPENROUTER("openrouter", "OpenRouter", true, false, 32000),
|
||||
SILICONFLOW("siliconflow", "SiliconFlow", true, false, 32000),
|
||||
TOGETHERAI("togetherai", "TogetherAI", true, false, 32000),
|
||||
DOUBAO("doubao", "Doubao (Bytedance Ark)", true, false, 128000),
|
||||
ZHIPU("zhipu", "Zhipu GLM", true, false, 128000),
|
||||
QWEN("qwen", "Qwen (DashScope)", true, false, 128000),
|
||||
X_AI("x-ai", "xAI", true, false, 128000),
|
||||
GROK("grok", "Grok", true, false, 128000);
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
private final boolean supportsPaidTier;
|
||||
private final boolean hasFreeTierQuota;
|
||||
private final int defaultContextLength;
|
||||
|
||||
AIProviderEnum(String code, String displayName, boolean supportsPaidTier,
|
||||
boolean hasFreeTierQuota, int defaultContextLength) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
this.supportsPaidTier = supportsPaidTier;
|
||||
this.hasFreeTierQuota = hasFreeTierQuota;
|
||||
this.defaultContextLength = defaultContextLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字符串代码获取供应商枚举
|
||||
*/
|
||||
public static AIProviderEnum fromCode(String code) {
|
||||
for (AIProviderEnum provider : values()) {
|
||||
if (provider.code.equalsIgnoreCase(code)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("不支持的AI供应商: " + code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认限流策略
|
||||
*/
|
||||
public RateLimitStrategyEnum getDefaultRateLimitStrategy() {
|
||||
return hasFreeTierQuota ? RateLimitStrategyEnum.CONSERVATIVE : RateLimitStrategyEnum.STANDARD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认重试策略
|
||||
*/
|
||||
public RetryStrategyEnum getDefaultRetryStrategy() {
|
||||
return hasFreeTierQuota ? RetryStrategyEnum.EXPONENTIAL_BACKOFF : RetryStrategyEnum.LINEAR_BACKOFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import com.ainovel.server.service.AIProviderRegistryService;
|
||||
import com.ainovel.server.service.ai.capability.ProviderCapabilityService;
|
||||
|
||||
/**
|
||||
* AI服务配置类
|
||||
* 用于配置AI服务的Bean
|
||||
*/
|
||||
@Configuration
|
||||
public class AIServiceConfig {
|
||||
|
||||
/**
|
||||
* 将ProviderCapabilityService作为AIProviderRegistryService的实现
|
||||
* 使用@Primary确保在有多个实现时,优先使用此实现
|
||||
*
|
||||
* @param providerCapabilityService 提供商能力服务
|
||||
* @return AIProviderRegistryService接口实现
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public AIProviderRegistryService aiProviderRegistryService(ProviderCapabilityService providerCapabilityService) {
|
||||
return providerCapabilityService;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
/**
|
||||
* 异步处理配置
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
||||
|
||||
/**
|
||||
* 配置用于事件监听器的异步执行器
|
||||
* 使用虚拟线程处理异步事件
|
||||
*/
|
||||
@Bean(name = "taskAsyncExecutor")
|
||||
public Executor getAsyncExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
|
||||
// 如果支持虚拟线程(Java 21+),则使用虚拟线程
|
||||
try {
|
||||
ThreadFactory virtualThreadFactory = Thread.ofVirtual().name("event-", 0).factory();
|
||||
executor.setTaskDecorator(runnable -> () -> {
|
||||
Thread thread = Thread.currentThread();
|
||||
String oldName = thread.getName();
|
||||
try {
|
||||
runnable.run();
|
||||
} finally {
|
||||
thread.setName(oldName);
|
||||
}
|
||||
});
|
||||
executor.setThreadFactory(virtualThreadFactory);
|
||||
executor.setCorePoolSize(1); // 使用虚拟线程时,核心线程数可以设置很低
|
||||
executor.setMaxPoolSize(Integer.MAX_VALUE); // 虚拟线程几乎无限制
|
||||
logger.info("已启用虚拟线程处理异步事件");
|
||||
} catch (NoSuchMethodError | UnsupportedOperationException e) {
|
||||
// 如果不支持虚拟线程,则使用普通线程池
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("event-thread-");
|
||||
logger.info("已启用平台线程处理异步事件");
|
||||
}
|
||||
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供一个虚拟线程执行器,用于任务执行过程中的IO密集型操作
|
||||
*/
|
||||
@Bean(name = "virtualThreadExecutor")
|
||||
public ExecutorService virtualThreadExecutor() {
|
||||
try {
|
||||
// 尝试创建虚拟线程执行器
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
logger.info("已创建虚拟线程执行器用于IO密集型操作");
|
||||
return executor;
|
||||
} catch (NoSuchMethodError | UnsupportedOperationException e) {
|
||||
// 如果不支持虚拟线程,则使用固定大小的线程池
|
||||
ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2,
|
||||
Thread.ofPlatform().name("io-thread-", 0).factory());
|
||||
logger.info("不支持虚拟线程,已创建平台线程池用于IO密集型操作");
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
/**
|
||||
* 缓存配置类
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
/**
|
||||
* 配置默认的缓存管理器
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(60, TimeUnit.MINUTES)
|
||||
.initialCapacity(100)
|
||||
.maximumSize(1000));
|
||||
// 启用异步缓存模式,支持响应式编程
|
||||
cacheManager.setAsyncCacheMode(true);
|
||||
return cacheManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置短期缓存管理器,用于需要频繁刷新的数据
|
||||
*/
|
||||
@Bean("shortTermCacheManager")
|
||||
public CacheManager shortTermCacheManager() {
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(5, TimeUnit.MINUTES)
|
||||
.initialCapacity(50)
|
||||
.maximumSize(500));
|
||||
// 启用异步缓存模式,支持响应式编程
|
||||
cacheManager.setAsyncCacheMode(true);
|
||||
return cacheManager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import dev.langchain4j.model.chat.ChatLanguageModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 聊天语言模型配置类
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class ChatLanguageModelConfig {
|
||||
|
||||
@Value("${ai.openai.api-key}")
|
||||
private String openaiApiKey;
|
||||
|
||||
@Value("${ai.openai.chat-model:deepseek/deepseek-v3-base:free}")
|
||||
private String openaiChatModel;
|
||||
|
||||
@Value("${ai.openai.temperature:0.7}")
|
||||
private double temperature;
|
||||
|
||||
@Value("${ai.openai.max-tokens:1024}")
|
||||
private int maxTokens;
|
||||
|
||||
/**
|
||||
* 配置聊天语言模型
|
||||
*
|
||||
* @return 聊天语言模型
|
||||
*/
|
||||
@Bean
|
||||
public ChatLanguageModel chatLanguageModel() {
|
||||
log.info("配置ChatLanguageModel,模型:{}", openaiChatModel);
|
||||
|
||||
ChatLanguageModel chatLanguageModel= OpenAiChatModel.builder()
|
||||
.baseUrl("https://openrouter.ai/api/v1")
|
||||
.apiKey(openaiApiKey)
|
||||
.modelName(openaiChatModel)
|
||||
.temperature(temperature)
|
||||
.maxTokens(maxTokens)
|
||||
.logRequests(true)
|
||||
.logResponses(true)
|
||||
.build();
|
||||
//String message= chatLanguageModel.("1+1=");
|
||||
//log.info(message);
|
||||
return chatLanguageModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistry;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 优雅停机配置,确保应用程序关闭时不丢失消息和任务
|
||||
*/
|
||||
@Configuration
|
||||
public class GracefulShutdownConfiguration implements ApplicationListener<ContextClosedEvent> {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GracefulShutdownConfiguration.class);
|
||||
|
||||
@Autowired
|
||||
private RabbitListenerEndpointRegistry rabbitListenerEndpointRegistry;
|
||||
|
||||
@Value("${task.shutdown.awaitTerminationTimeout:PT30S}")
|
||||
private String shutdownTimeoutString;
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ContextClosedEvent event) {
|
||||
logger.info("收到应用程序关闭事件,开始优雅停机...");
|
||||
|
||||
// 解析超时时间(从ISO-8601 Duration字符串)
|
||||
long timeoutSeconds = 30; // 默认30秒
|
||||
try {
|
||||
timeoutSeconds = java.time.Duration.parse(shutdownTimeoutString).getSeconds();
|
||||
} catch (Exception e) {
|
||||
logger.warn("解析关闭超时时间失败,使用默认值30秒", e);
|
||||
}
|
||||
|
||||
// 停止所有RabbitMQ监听器
|
||||
try {
|
||||
logger.info("停止RabbitMQ监听器...");
|
||||
rabbitListenerEndpointRegistry.stop();
|
||||
|
||||
// 等待所有监听器停止
|
||||
CountDownLatch shutdownLatch = new CountDownLatch(1);
|
||||
new Thread(() -> {
|
||||
try {
|
||||
while (!isAllListenersStopped()) {
|
||||
Thread.sleep(500);
|
||||
}
|
||||
shutdownLatch.countDown();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}, "rabbit-shutdown-monitor").start();
|
||||
|
||||
boolean allStopped = shutdownLatch.await(timeoutSeconds, TimeUnit.SECONDS);
|
||||
if (allStopped) {
|
||||
logger.info("所有RabbitMQ监听器已成功停止");
|
||||
} else {
|
||||
logger.warn("等待RabbitMQ监听器停止超时({}秒),可能还有消息正在处理", timeoutSeconds);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("停止RabbitMQ监听器时发生异常", e);
|
||||
}
|
||||
|
||||
logger.info("优雅停机完成,应用程序即将关闭");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有监听器是否已停止
|
||||
*/
|
||||
private boolean isAllListenersStopped() {
|
||||
return rabbitListenerEndpointRegistry.getListenerContainers().stream()
|
||||
.allMatch(container -> !container.isRunning());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Jackson配置类
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
/**
|
||||
* 配置全局ObjectMapper
|
||||
* @return 配置好的ObjectMapper实例
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// 创建自定义的JavaTimeModule,确保LocalDateTime序列化为字符串
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
|
||||
// 添加自定义的LocalDateTime序列化器,确保始终输出ISO-8601字符串
|
||||
javaTimeModule.addSerializer(LocalDateTime.class,
|
||||
new LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
javaTimeModule.addDeserializer(LocalDateTime.class,
|
||||
new LocalDateTimeDeserializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
|
||||
// 注册Java 8时间模块
|
||||
objectMapper.registerModule(javaTimeModule);
|
||||
|
||||
// 配置日期/时间序列化为ISO-8601格式而不是时间戳
|
||||
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||
|
||||
// 禁用将LocalDateTime写为数组的功能
|
||||
objectMapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
|
||||
objectMapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, false);
|
||||
|
||||
// 忽略未知属性
|
||||
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
// 序列化时忽略null值
|
||||
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
|
||||
// 禁用序列化空bean为空对象
|
||||
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
|
||||
|
||||
return objectMapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* LLM可观测性配置
|
||||
* 配置AOP、异步执行器等
|
||||
*/
|
||||
@Configuration
|
||||
// @EnableAspectJAutoProxy // 可以移除这行,因为不再使用AOP
|
||||
@EnableAsync
|
||||
public class LLMObservabilityConfig {
|
||||
|
||||
/**
|
||||
* LLM追踪专用线程池
|
||||
* 使用虚拟线程提高并发性能
|
||||
*/
|
||||
@Bean("llmTraceExecutor")
|
||||
public Executor llmTraceExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(16);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("llm-trace-");
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(30);
|
||||
|
||||
// 使用虚拟线程(Java 21+)
|
||||
executor.setVirtualThreads(true);
|
||||
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskDecorator;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import reactor.core.publisher.Hooks;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 日志配置,包括MDC跟踪信息和日志格式设置
|
||||
*/
|
||||
@Configuration
|
||||
public class LoggingConfiguration {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LoggingConfiguration.class);
|
||||
|
||||
/**
|
||||
* 设置Reactor上下文传播MDC
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
logger.info("配置Reactor上下文传播MDC");
|
||||
// 启用自动上下文传播 (需要 io.micrometer:context-propagation 依赖)
|
||||
Hooks.enableAutomaticContextPropagation();
|
||||
logger.info("已启用Reactor自动MDC传播");
|
||||
|
||||
// 全局错误 Hook,确保丢弃/运算符错误也能被规范记录
|
||||
Hooks.onErrorDropped(e -> logger.error("Reactor onErrorDropped 错误: {}", e.toString(), e));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* WebFlux请求过滤器,用于设置MDC上下文
|
||||
*/
|
||||
@Bean
|
||||
public WebFilter mdcAndLoggingFilter() {
|
||||
return (exchange, chain) -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
|
||||
// --- MDC 设置 开始 ---
|
||||
String originalTraceId = request.getHeaders().getFirst("X-Trace-ID");
|
||||
final String traceId = (originalTraceId == null)
|
||||
? UUID.randomUUID().toString().replace("-", "")
|
||||
: originalTraceId;
|
||||
MDC.put("traceId", traceId);
|
||||
|
||||
String userId = request.getHeaders().getFirst("X-User-Id");
|
||||
if (userId != null) {
|
||||
MDC.put("userId", userId);
|
||||
}
|
||||
|
||||
final String path = request.getPath().value();
|
||||
MDC.put("path", path);
|
||||
// --- MDC 设置 结束 ---
|
||||
|
||||
// 对健康检查与监控采集等低价值请求不打印日志
|
||||
if (path != null && path.startsWith("/actuator/prometheus")) {
|
||||
return chain.filter(exchange)
|
||||
.doFinally(signalType -> MDC.clear());
|
||||
}
|
||||
|
||||
// --- 请求日志 开始 ---
|
||||
final String finalUserId = userId; // effectively final for lambda
|
||||
logger.info("请求开始: 方法={} URI={} 追踪ID={} 用户ID={}",
|
||||
request.getMethod(),
|
||||
request.getURI(),
|
||||
traceId,
|
||||
finalUserId != null ? finalUserId : "N/A");
|
||||
// --- 请求日志 结束 ---
|
||||
|
||||
// 附加响应日志和MDC清理
|
||||
return chain.filter(exchange)
|
||||
.doOnSuccess(aVoid -> {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
int statusCode = exchange.getResponse().getStatusCode() != null ? exchange.getResponse().getStatusCode().value() : 0;
|
||||
logger.info("请求结束: 状态={} 耗时={}ms 追踪ID={} 路径={}",
|
||||
statusCode, duration, traceId, path);
|
||||
})
|
||||
.doOnError(throwable -> {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
logger.error("请求错误: {} 耗时={}ms 追踪ID={} 路径={}",
|
||||
throwable.getMessage(), duration, traceId, path, throwable);
|
||||
})
|
||||
.doFinally(signalType -> MDC.clear()); // 清理MDC
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务装饰器,用于异步任务间传递MDC
|
||||
*/
|
||||
@Bean
|
||||
public TaskDecorator mdcTaskDecorator() {
|
||||
return task -> {
|
||||
Map<String, String> contextMap = MDC.getCopyOfContextMap();
|
||||
return () -> {
|
||||
try {
|
||||
if (contextMap != null) {
|
||||
MDC.setContextMap(contextMap);
|
||||
}
|
||||
task.run();
|
||||
} finally {
|
||||
MDC.clear();
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求日志过滤器
|
||||
*/
|
||||
/* @Bean
|
||||
@ConditionalOnProperty(name = "logging.request", havingValue = "true")
|
||||
public CommonsRequestLoggingFilter requestLoggingFilter() {
|
||||
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
|
||||
filter.setIncludeQueryString(true);
|
||||
filter.setIncludePayload(true);
|
||||
filter.setMaxPayloadLength(10000);
|
||||
filter.setIncludeHeaders(false);
|
||||
filter.setAfterMessagePrefix("Request data: ");
|
||||
return filter;
|
||||
} */
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.bson.Document;
|
||||
|
||||
/**
|
||||
* MongoDB映射异常监听器
|
||||
* 用于捕获和详细记录映射过程中的异常信息,帮助排查复杂嵌套对象的映射问题
|
||||
*/
|
||||
@Component
|
||||
public class MappingExceptionLogger {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MappingExceptionLogger.class);
|
||||
|
||||
/**
|
||||
* 记录映射异常的详细信息
|
||||
*
|
||||
* @param entity 出问题的实体类
|
||||
* @param document 原始MongoDB文档
|
||||
* @param exception 映射异常
|
||||
*/
|
||||
public void logMappingException(Class<?> entity, Object document, Throwable exception) {
|
||||
logger.error("🚨🚨🚨 MongoDB映射失败详情 🚨🚨🚨");
|
||||
logger.error("═══════════════════════════════════════");
|
||||
|
||||
// 增强的实体类分析
|
||||
Class<?> actualProblemClass = analyzeActualProblemClass(entity, exception);
|
||||
|
||||
logger.error("📋 基本信息:");
|
||||
logger.error(" ├─ 报告实体类: {}", entity.getName());
|
||||
if (!actualProblemClass.equals(entity)) {
|
||||
logger.error(" ├─ 🎯 实际问题类: {}", actualProblemClass.getName());
|
||||
logger.error(" ├─ 🔍 问题类型: {}", getClassType(actualProblemClass));
|
||||
}
|
||||
logger.error(" ├─ 异常类型: {}", exception.getClass().getSimpleName());
|
||||
logger.error(" └─ 异常消息: {}", exception.getMessage());
|
||||
|
||||
logger.error("═══════════════════════════════════════");
|
||||
logger.error("📄 文档信息:");
|
||||
if (document instanceof Document doc) {
|
||||
logger.error(" ├─ 文档字段: {}", doc.keySet());
|
||||
logger.error(" └─ 文档大小: {} 个字段", doc.size());
|
||||
// 不打印完整文档内容,避免日志过长
|
||||
} else {
|
||||
logger.error(" └─ 原始数据类型: {}", document != null ? document.getClass().getSimpleName() : "null");
|
||||
}
|
||||
|
||||
logger.error("═══════════════════════════════════════");
|
||||
logger.error("🔍 堆栈分析:");
|
||||
analyzeStackTrace(exception);
|
||||
|
||||
// 如果是参数名缺失异常,提供更多上下文
|
||||
if (exception.getMessage() != null && exception.getMessage().contains("does not have a name")) {
|
||||
logger.error("═══════════════════════════════════════");
|
||||
logger.error("💡 参数名缺失问题诊断:");
|
||||
logger.error(" ├─ 问题类型: 构造函数参数无法解析");
|
||||
|
||||
// LLMTrace特定的诊断信息
|
||||
if (isLLMTraceRelated(actualProblemClass)) {
|
||||
analyzeLLMTraceSpecificIssues(actualProblemClass);
|
||||
} else {
|
||||
logger.error(" ├─ 可能原因:");
|
||||
logger.error(" │ ├─ 1. 构造函数参数缺少 @JsonProperty 注解");
|
||||
logger.error(" │ ├─ 2. 编译时未启用 -parameters 选项");
|
||||
logger.error(" │ ├─ 3. @NoArgsConstructor 访问级别为 PRIVATE");
|
||||
logger.error(" │ └─ 4. Lombok 生成的构造函数缺少必要注解");
|
||||
logger.error(" └─ 建议修复:");
|
||||
logger.error(" ├─ 检查 {} 类的所有嵌套类", actualProblemClass.getSimpleName());
|
||||
logger.error(" ├─ 确保所有 @NoArgsConstructor 都是 public");
|
||||
logger.error(" └─ 为复杂构造函数添加 @JsonCreator + @JsonProperty");
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("═══════════════════════════════════════");
|
||||
logger.error("📚 完整异常堆栈:");
|
||||
logger.error("", exception);
|
||||
logger.error("🚨🚨🚨 映射异常分析结束 🚨🚨🚨");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析实际出问题的类
|
||||
*/
|
||||
private Class<?> analyzeActualProblemClass(Class<?> reportedEntity, Throwable exception) {
|
||||
// 如果报告的实体类就是Object,说明需要深度分析
|
||||
if (reportedEntity == Object.class) {
|
||||
Class<?> foundClass = searchForLLMTraceInnerClass(exception);
|
||||
if (foundClass != null) {
|
||||
return foundClass;
|
||||
}
|
||||
|
||||
// 尝试从异常消息中提取类信息
|
||||
String message = exception.getMessage();
|
||||
if (message != null && message.contains("Parameter")) {
|
||||
// 尝试从异常堆栈中查找创建实例的相关信息
|
||||
foundClass = searchForClassInStackTrace(exception);
|
||||
if (foundClass != null) {
|
||||
return foundClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reportedEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在异常堆栈中搜索LLMTrace内嵌类
|
||||
*/
|
||||
private Class<?> searchForLLMTraceInnerClass(Throwable exception) {
|
||||
StackTraceElement[] stackTrace = exception.getStackTrace();
|
||||
|
||||
// 常见的LLMTrace内嵌类列表
|
||||
String[] innerClasses = {
|
||||
"Request", "Response", "MessageInfo", "ToolCallInfo",
|
||||
"Parameters", "ToolSpecification", "Metadata",
|
||||
"TokenUsageInfo", "Error", "Performance"
|
||||
};
|
||||
|
||||
boolean isLLMTraceOperation = false;
|
||||
|
||||
for (StackTraceElement element : stackTrace) {
|
||||
String className = element.getClassName();
|
||||
String methodName = element.getMethodName();
|
||||
|
||||
// 检查是否在处理LLMTrace相关的操作
|
||||
if (className.contains("LLMTraceService") ||
|
||||
className.contains("LLMObservability") ||
|
||||
className.contains("LLMTrace")) {
|
||||
isLLMTraceOperation = true;
|
||||
logger.error(" 🎯 [LLMTrace操作检测] 在 {}.{} 中发现LLMTrace相关操作",
|
||||
className.substring(className.lastIndexOf('.') + 1), methodName);
|
||||
|
||||
// 尝试从方法名或上下文推断内嵌类
|
||||
for (String innerClass : innerClasses) {
|
||||
if (methodName.toLowerCase().contains(innerClass.toLowerCase()) ||
|
||||
className.contains("$" + innerClass)) {
|
||||
try {
|
||||
Class<?> innerClazz = Class.forName("com.ainovel.server.domain.model.observability.LLMTrace$" + innerClass);
|
||||
logger.error(" ✅ [内嵌类识别] 找到问题类: {}", innerClazz.getName());
|
||||
return innerClazz;
|
||||
} catch (ClassNotFoundException e) {
|
||||
// 继续查找
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查Spring Data MongoDB的相关操作
|
||||
if (className.contains("MappingMongoConverter") &&
|
||||
methodName.contains("readValue")) {
|
||||
logger.error(" 🔍 [映射上下文] 在 {}.{} 中发现映射操作",
|
||||
className.substring(className.lastIndexOf('.') + 1), methodName);
|
||||
}
|
||||
|
||||
// 检查ReactiveMongoTemplate的find操作
|
||||
if (className.contains("ReactiveMongoTemplate") &&
|
||||
(methodName.contains("find") || methodName.contains("execute"))) {
|
||||
logger.error(" 📊 [MongoDB操作] 在 {}.{} 中执行查询操作",
|
||||
className.substring(className.lastIndexOf('.') + 1), methodName);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果检测到是LLMTrace相关操作,但找不到具体内嵌类,返回LLMTrace主类
|
||||
if (isLLMTraceOperation) {
|
||||
try {
|
||||
Class<?> mainClazz = Class.forName("com.ainovel.server.domain.model.observability.LLMTrace");
|
||||
logger.error(" 📋 [默认识别] 无法确定具体内嵌类,返回LLMTrace主类");
|
||||
return mainClazz;
|
||||
} catch (ClassNotFoundException e) {
|
||||
logger.error(" ❌ [错误] 无法找到LLMTrace主类");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在异常堆栈中搜索类信息
|
||||
*/
|
||||
private Class<?> searchForClassInStackTrace(Throwable exception) {
|
||||
StackTraceElement[] stackTrace = exception.getStackTrace();
|
||||
|
||||
for (StackTraceElement element : stackTrace) {
|
||||
String className = element.getClassName();
|
||||
|
||||
// 查找我们的domain model类
|
||||
if (className.contains("com.ainovel.server.domain.model")) {
|
||||
try {
|
||||
return Class.forName(className);
|
||||
} catch (ClassNotFoundException e) {
|
||||
// 继续搜索
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类类型描述
|
||||
*/
|
||||
private String getClassType(Class<?> clazz) {
|
||||
if (clazz.isEnum()) {
|
||||
return "枚举类";
|
||||
} else if (clazz.isMemberClass()) {
|
||||
return "内嵌类";
|
||||
} else if (clazz.isLocalClass()) {
|
||||
return "局部类";
|
||||
} else if (clazz.isAnonymousClass()) {
|
||||
return "匿名类";
|
||||
} else {
|
||||
return "普通类";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否与LLMTrace相关
|
||||
*/
|
||||
private boolean isLLMTraceRelated(Class<?> clazz) {
|
||||
return clazz.getName().contains("LLMTrace");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析LLMTrace特定的问题
|
||||
*/
|
||||
private void analyzeLLMTraceSpecificIssues(Class<?> problemClass) {
|
||||
logger.error(" ├─ 🎯 LLMTrace映射问题专项分析:");
|
||||
logger.error(" │ ├─ 目标类: {}", problemClass.getSimpleName());
|
||||
|
||||
// 分析具体的内嵌类问题
|
||||
if (problemClass.getName().contains("$")) {
|
||||
String innerClassName = problemClass.getSimpleName();
|
||||
logger.error(" │ ├─ 内嵌类: {}", innerClassName);
|
||||
logger.error(" │ └─ 问题分析:");
|
||||
|
||||
switch (innerClassName) {
|
||||
case "Request":
|
||||
logger.error(" │ ├─ Request类有@JsonCreator构造函数");
|
||||
logger.error(" │ ├─ 检查messages和parameters字段初始化");
|
||||
logger.error(" │ └─ 确认所有@JsonProperty注解正确");
|
||||
break;
|
||||
case "Parameters":
|
||||
logger.error(" │ ├─ Parameters类包含复杂的providerSpecific字段");
|
||||
logger.error(" │ ├─ 检查safeConvertToMap方法调用");
|
||||
logger.error(" │ └─ 确认Map<String, Object>类型转换");
|
||||
break;
|
||||
case "MessageInfo":
|
||||
logger.error(" │ ├─ MessageInfo类有toolCalls集合");
|
||||
logger.error(" │ ├─ 检查List<ToolCallInfo>初始化");
|
||||
logger.error(" │ └─ 确认嵌套对象映射");
|
||||
break;
|
||||
case "ToolSpecification":
|
||||
logger.error(" │ ├─ ToolSpecification包含parameters Map");
|
||||
logger.error(" │ ├─ 检查safeConvertToMap转换");
|
||||
logger.error(" │ └─ 可能是convertToolParameters方法问题");
|
||||
break;
|
||||
default:
|
||||
logger.error(" │ ├─ 通用内嵌类映射问题");
|
||||
logger.error(" │ └─ 检查@JsonCreator和@JsonProperty注解");
|
||||
}
|
||||
} else {
|
||||
logger.error(" │ └─ LLMTrace主类映射问题,检查内嵌类实例化");
|
||||
}
|
||||
|
||||
logger.error(" └─ 🔧 LLMTrace修复建议:");
|
||||
logger.error(" ├─ 1. 检查所有@NoArgsConstructor是否为public");
|
||||
logger.error(" ├─ 2. 确认@JsonCreator构造函数参数都有@JsonProperty");
|
||||
logger.error(" ├─ 3. 检查safeConvertToMap方法的Map转换逻辑");
|
||||
logger.error(" ├─ 4. 验证Builder.Default字段的初始化");
|
||||
logger.error(" └─ 5. 考虑添加@PersistenceCreator注解");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析异常堆栈,找出具体的问题类
|
||||
*/
|
||||
private void analyzeStackTrace(Throwable exception) {
|
||||
StackTraceElement[] stackTrace = exception.getStackTrace();
|
||||
for (int i = 0; i < Math.min(stackTrace.length, 10); i++) {
|
||||
StackTraceElement element = stackTrace[i];
|
||||
String className = element.getClassName();
|
||||
String methodName = element.getMethodName();
|
||||
|
||||
if (className.contains("com.ainovel.server.domain.model")) {
|
||||
logger.error(" ├─ [{}] 问题实体: {}.{}", i, className, methodName);
|
||||
} else if (className.contains("MappingMongoConverter") ||
|
||||
className.contains("BasicPersistentEntity") ||
|
||||
className.contains("PersistentEntityParameterValueProvider")) {
|
||||
logger.error(" ├─ [{}] 映射组件: {}.{}", i, className.substring(className.lastIndexOf('.') + 1), methodName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录实体映射开始信息(调试用)
|
||||
*/
|
||||
public void logMappingStart(Class<?> entity, Object document) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("🔄 开始映射实体: {} <- {}", entity.getSimpleName(),
|
||||
document instanceof Document ? ((Document) document).keySet() : "Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录实体映射成功信息(调试用)
|
||||
*/
|
||||
public void logMappingSuccess(Class<?> entity) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("✅ 映射成功: {}", entity.getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import io.micrometer.core.aop.TimedAspect;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics;
|
||||
import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics;
|
||||
import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics;
|
||||
import io.micrometer.core.instrument.binder.system.ProcessorMetrics;
|
||||
import io.micrometer.core.instrument.binder.system.UptimeMetrics;
|
||||
import io.micrometer.core.instrument.composite.CompositeMeterRegistry;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 指标监控配置,设置Micrometer相关指标
|
||||
*/
|
||||
@Configuration
|
||||
public class MetricsConfiguration {
|
||||
|
||||
/**
|
||||
* 配置JVM内存指标
|
||||
*/
|
||||
@Bean
|
||||
public JvmMemoryMetrics jvmMemoryMetrics() {
|
||||
return new JvmMemoryMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置JVM GC指标
|
||||
*/
|
||||
@Bean
|
||||
public JvmGcMetrics jvmGcMetrics() {
|
||||
return new JvmGcMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置JVM线程指标
|
||||
*/
|
||||
@Bean
|
||||
public JvmThreadMetrics jvmThreadMetrics() {
|
||||
return new JvmThreadMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置处理器指标
|
||||
*/
|
||||
@Bean
|
||||
public ProcessorMetrics processorMetrics() {
|
||||
return new ProcessorMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置运行时间指标
|
||||
*/
|
||||
@Bean
|
||||
public UptimeMetrics uptimeMetrics() {
|
||||
return new UptimeMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置@Timed注解支持
|
||||
*/
|
||||
@Bean
|
||||
public TimedAspect timedAspect(MeterRegistry registry) {
|
||||
return new TimedAspect(registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合指标注册表
|
||||
*/
|
||||
@Bean
|
||||
public CompositeMeterRegistry compositeMeterRegistry() {
|
||||
return new CompositeMeterRegistry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
|
||||
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.mongodb.ReactiveMongoDatabaseFactory;
|
||||
import org.springframework.data.mongodb.ReactiveMongoTransactionManager;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
|
||||
import org.springframework.data.mongodb.core.SimpleReactiveMongoDatabaseFactory;
|
||||
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
|
||||
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
|
||||
import org.springframework.data.mongodb.repository.config.EnableReactiveMongoRepositories;
|
||||
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.mongodb.ConnectionString;
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import com.mongodb.reactivestreams.client.MongoClient;
|
||||
import com.mongodb.reactivestreams.client.MongoClients;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.data.convert.ReadingConverter;
|
||||
import org.springframework.data.convert.WritingConverter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
// import java.util.Map; // 移除通用Map转换后不再需要
|
||||
|
||||
/**
|
||||
* MongoDB配置类
|
||||
* 配置MongoDB连接、响应式支持、日志和统计功能
|
||||
*/
|
||||
@Configuration
|
||||
@EnableReactiveMongoRepositories(basePackages = "com.ainovel.server.repository")
|
||||
@EnableMongoRepositories(basePackages = "com.ainovel.server.repository")
|
||||
public class MongoConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MongoConfig.class);
|
||||
|
||||
@Value("${spring.data.mongodb.uri}")
|
||||
private String mongoUri;
|
||||
|
||||
@Value("${spring.data.mongodb.database}")
|
||||
private String database;
|
||||
|
||||
// 注意:这里不注入全局 ObjectMapper 以避免误用于通用 Map 转换
|
||||
public MongoConfig() {}
|
||||
|
||||
/**
|
||||
* 创建MongoDB事件监听器,用于记录MongoDB操作日志
|
||||
* 注释掉以减少日志输出
|
||||
*/
|
||||
// @Bean
|
||||
// public LoggingEventListener mongoEventListener() {
|
||||
// return new LoggingEventListener();
|
||||
// }
|
||||
|
||||
/**
|
||||
* 创建MongoDB映射调试监听器
|
||||
* 注释掉以减少日志输出
|
||||
*/
|
||||
// @Bean
|
||||
// public AbstractMongoEventListener<Object> mongoMappingDebugListener() {
|
||||
// return new AbstractMongoEventListener<Object>() {
|
||||
// @Override
|
||||
// public void onAfterLoad(AfterLoadEvent<Object> event) {
|
||||
// if (logger.isTraceEnabled()) {
|
||||
// logger.trace("📥 MongoDB加载文档: collection={}, document={}",
|
||||
// event.getCollectionName(), event.getDocument().keySet());
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
/**
|
||||
* 自定义ReactiveMongoTemplate,添加查询统计和日志功能
|
||||
* @param factory MongoDB数据库工厂
|
||||
* @param mappingMongoConverter 自定义的映射转换器(包含点号替换配置)
|
||||
* @return 自定义的ReactiveMongoTemplate
|
||||
*/
|
||||
@Bean
|
||||
public ReactiveMongoTemplate reactiveMongoTemplate(ReactiveMongoDatabaseFactory factory,
|
||||
MappingMongoConverter mappingMongoConverter) {
|
||||
// 使用构造函数直接传入自定义的MappingMongoConverter
|
||||
ReactiveMongoTemplate template = new ReactiveMongoTemplate(factory, mappingMongoConverter);
|
||||
|
||||
// 启用日志记录
|
||||
logger.info("✅ 已配置ReactiveMongoTemplate,使用自定义MappingMongoConverter(支持点号替换)");
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建MongoDB客户端,添加性能监控
|
||||
* @return MongoDB客户端
|
||||
*/
|
||||
@Bean
|
||||
public MongoClient reactiveMongoClient() {
|
||||
ConnectionString connectionString = new ConnectionString(mongoUri);
|
||||
|
||||
MongoClientSettings settings = MongoClientSettings.builder()
|
||||
.applyConnectionString(connectionString)
|
||||
.applicationName("AINovalWriter")
|
||||
.build();
|
||||
|
||||
logger.info("创建MongoDB客户端,连接到: {}", database);
|
||||
return MongoClients.create(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建MongoDB数据库工厂
|
||||
* @param mongoClient MongoDB客户端
|
||||
* @return MongoDB数据库工厂
|
||||
*/
|
||||
@Bean
|
||||
public ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory(MongoClient mongoClient) {
|
||||
return new SimpleReactiveMongoDatabaseFactory(mongoClient, database);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建MongoDB事务管理器
|
||||
* @param dbFactory MongoDB数据库工厂
|
||||
* @return MongoDB事务管理器
|
||||
*/
|
||||
@Bean
|
||||
public ReactiveMongoTransactionManager transactionManager(ReactiveMongoDatabaseFactory dbFactory) {
|
||||
return new ReactiveMongoTransactionManager(dbFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置自定义MongoDB转换器
|
||||
* @return 自定义转换器配置
|
||||
*/
|
||||
@Bean
|
||||
public MongoCustomConversions mongoCustomConversions(SafeMapConverter safeMapConverter) {
|
||||
List<Converter<?, ?>> converters = new ArrayList<>();
|
||||
|
||||
// 日期/时间转换器
|
||||
converters.add(new DateToInstantConverter());
|
||||
converters.add(new InstantToDateConverter());
|
||||
|
||||
// 仅保留安全的Map读取与时间类型转换,避免过于宽泛的 Map<->Object 转换导致的Spring Data WARN
|
||||
|
||||
// 安全的Map转换器 - 处理类型不匹配问题
|
||||
converters.add(safeMapConverter);
|
||||
|
||||
logger.info("MongoDB自定义转换器配置完成,总计 {} 个转换器", converters.size());
|
||||
|
||||
return new MongoCustomConversions(converters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置专门的MongoDB ObjectMapper来处理序列化/反序列化
|
||||
* 确保与JsonCreator注解配合工作,解决复杂嵌套对象映射问题
|
||||
*/
|
||||
@Bean("mongoObjectMapper")
|
||||
public ObjectMapper mongoObjectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
// 注册JavaTime模块
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// 配置反序列化行为
|
||||
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false);
|
||||
mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
|
||||
|
||||
logger.info("MongoDB ObjectMapper配置完成,支持JsonCreator构造函数映射");
|
||||
|
||||
return mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date到Instant的转换器
|
||||
*/
|
||||
@ReadingConverter
|
||||
public static class DateToInstantConverter implements Converter<Date, Instant> {
|
||||
@Override
|
||||
public Instant convert(Date source) {
|
||||
return source == null ? null : source.toInstant();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant到Date的转换器
|
||||
*/
|
||||
@WritingConverter
|
||||
public static class InstantToDateConverter implements Converter<Instant, Date> {
|
||||
@Override
|
||||
public Date convert(Instant source) {
|
||||
return source == null ? null : Date.from(source);
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:通用的 Map<->Object 转换改由业务层的 TaskConversionConfig 控制,
|
||||
// 避免在全局转换器中过于宽泛,导致Spring Data发出非存储类型转换的警告。
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
|
||||
import org.springframework.data.mapping.model.PropertyNameFieldNamingStrategy;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* MongoDB映射配置类
|
||||
* 专门处理复杂嵌套对象的映射问题,特别是LLMTrace类
|
||||
*/
|
||||
@Configuration
|
||||
public class MongoMappingConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MongoMappingConfig.class);
|
||||
|
||||
/**
|
||||
* 自定义MongoDB映射上下文
|
||||
* 解决复杂嵌套对象的构造函数参数名称问题
|
||||
*/
|
||||
@Bean
|
||||
public MongoMappingContext mongoMappingContext() {
|
||||
MongoMappingContext mappingContext = new MongoMappingContext();
|
||||
|
||||
// 设置字段命名策略
|
||||
mappingContext.setFieldNamingStrategy(PropertyNameFieldNamingStrategy.INSTANCE);
|
||||
|
||||
// 启用自动索引创建
|
||||
mappingContext.setAutoIndexCreation(true);
|
||||
|
||||
logger.info("MongoDB映射上下文配置完成,支持复杂嵌套对象映射");
|
||||
|
||||
return mappingContext;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* MongoDB查询计数器切面
|
||||
* 用于统计MongoDB查询数量和执行时间
|
||||
*/
|
||||
@Aspect
|
||||
@Configuration
|
||||
@EnableAspectJAutoProxy
|
||||
public class MongoQueryCounterAspect {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MongoQueryCounterAspect.class);
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final ConcurrentMap<String, AtomicLong> queryCounters = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, Timer> queryTimers = new ConcurrentHashMap<>();
|
||||
|
||||
public MongoQueryCounterAspect(MeterRegistry meterRegistry) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截ReactiveMongoTemplate的所有查询方法
|
||||
* @param joinPoint 切点
|
||||
* @return 查询结果
|
||||
* @throws Throwable 异常
|
||||
*/
|
||||
@Around("execution(* org.springframework.data.mongodb.core.ReactiveMongoTemplate.find*(..)) || " +
|
||||
"execution(* org.springframework.data.mongodb.core.ReactiveMongoTemplate.count*(..)) || " +
|
||||
"execution(* org.springframework.data.mongodb.core.ReactiveMongoTemplate.exists*(..)) || " +
|
||||
"execution(* org.springframework.data.mongodb.core.ReactiveMongoTemplate.get*(..)) || " +
|
||||
"execution(* org.springframework.data.mongodb.core.ReactiveMongoTemplate.update*(..))")
|
||||
public Object countQueries(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
String queryKey = className + "." + methodName;
|
||||
|
||||
// 增加查询计数
|
||||
AtomicLong counter = queryCounters.computeIfAbsent(queryKey, k -> {
|
||||
AtomicLong newCounter = new AtomicLong(0);
|
||||
Counter.builder("mongodb.queries")
|
||||
.tag("method", methodName)
|
||||
.tag("class", className)
|
||||
.register(meterRegistry);
|
||||
return newCounter;
|
||||
});
|
||||
counter.incrementAndGet();
|
||||
|
||||
// 获取或创建计时器
|
||||
Timer timer = queryTimers.computeIfAbsent(queryKey, k ->
|
||||
Timer.builder("mongodb.query.timer")
|
||||
.tag("method", methodName)
|
||||
.tag("class", className)
|
||||
.register(meterRegistry));
|
||||
|
||||
// 记录开始时间
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
// 执行原始方法
|
||||
Object result = joinPoint.proceed();
|
||||
|
||||
// 计算执行时间
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 记录查询信息
|
||||
logger.debug("MongoDB查询: {}, 执行时间: {}ms, 总执行次数: {}",
|
||||
queryKey, executionTime, counter.get());
|
||||
|
||||
// 记录计时器
|
||||
timer.record(() -> {
|
||||
try {
|
||||
Thread.sleep(executionTime);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
});
|
||||
|
||||
// 如果结果是Flux,添加日志记录
|
||||
if (result instanceof Flux) {
|
||||
return ((Flux<?>) result).doOnComplete(() ->
|
||||
logResultCount(queryKey, counter.get()));
|
||||
}
|
||||
// 如果结果是Mono,添加日志记录
|
||||
else if (result instanceof Mono) {
|
||||
return ((Mono<?>) result).doOnSuccess(value ->
|
||||
logResultValue(queryKey, value));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (Throwable e) {
|
||||
logger.error("MongoDB查询出错: {}, 错误: {}", queryKey, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录Flux结果数量
|
||||
* @param queryKey 查询键
|
||||
* @param count 结果数量
|
||||
*/
|
||||
private void logResultCount(String queryKey, long count) {
|
||||
logger.debug("MongoDB查询完成: {}, 结果数量: {}", queryKey, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录Mono结果值
|
||||
* @param queryKey 查询键
|
||||
* @param value 结果值
|
||||
*/
|
||||
private void logResultValue(String queryKey, Object value) {
|
||||
boolean hasResult = value != null;
|
||||
logger.debug("MongoDB查询完成: {}, 是否有结果: {}", queryKey, hasResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
|
||||
import io.micrometer.core.aop.TimedAspect;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
// JVM指标的 Binder 在 MetricsConfiguration 中集中提供,这里不再导入
|
||||
|
||||
/**
|
||||
* 监控配置类
|
||||
* 配置Micrometer和Prometheus指标收集
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAspectJAutoProxy
|
||||
public class MonitoringConfig {
|
||||
|
||||
// 为避免与 MetricsConfiguration 重复注册,同类 JVM 指标 Binder 改由 MetricsConfiguration 提供。
|
||||
// 本配置仅保留 @Timed 切面(若需要),并不再重复绑定 JVM 指标。
|
||||
|
||||
@Bean
|
||||
public TimedAspect timedAspect(MeterRegistry registry) {
|
||||
return new TimedAspect(registry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* 密码编码器配置 将PasswordEncoder配置分离出来,以解决循环依赖问题
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// 兼容历史哈希:
|
||||
// - 支持带前缀的 {bcrypt} 格式
|
||||
// - 支持无前缀的纯 BCrypt 哈希(通过默认匹配编码器降级匹配)
|
||||
DelegatingPasswordEncoder delegating = (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
|
||||
return delegating;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import com.ainovel.server.service.prompt.ContentPlaceholderResolver;
|
||||
import com.ainovel.server.service.prompt.impl.ContextualPlaceholderResolver;
|
||||
|
||||
/**
|
||||
* 占位符解析器配置类
|
||||
* 确保ContextualPlaceholderResolver作为主要的占位符解析器被注入
|
||||
*/
|
||||
@Configuration
|
||||
public class PlaceholderResolverConfig {
|
||||
|
||||
/**
|
||||
* 配置ContextualPlaceholderResolver为主要的占位符解析器
|
||||
* 它会自动委托给ContentProviderPlaceholderResolver处理具体的占位符解析
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
@Qualifier("primaryPlaceholderResolver")
|
||||
public ContentPlaceholderResolver primaryPlaceholderResolver(
|
||||
ContextualPlaceholderResolver contextualResolver) {
|
||||
return contextualResolver;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 定价系统配置
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(PricingConfig.PricingProperties.class)
|
||||
public class PricingConfig {
|
||||
|
||||
/**
|
||||
* 定价系统配置属性
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "pricing")
|
||||
public static class PricingProperties {
|
||||
|
||||
/**
|
||||
* 是否在启动时自动同步定价
|
||||
*/
|
||||
private boolean autoSyncOnStartup = true;
|
||||
|
||||
/**
|
||||
* 定价同步间隔(小时)
|
||||
*/
|
||||
private int syncIntervalHours = 24;
|
||||
|
||||
/**
|
||||
* 是否启用定价缓存
|
||||
*/
|
||||
private boolean enableCache = true;
|
||||
|
||||
/**
|
||||
* 缓存TTL(分钟)
|
||||
*/
|
||||
private int cacheTtlMinutes = 60;
|
||||
|
||||
/**
|
||||
* 默认精度(小数位数)
|
||||
*/
|
||||
private int defaultPrecision = 6;
|
||||
|
||||
/**
|
||||
* 是否启用成本跟踪
|
||||
*/
|
||||
private boolean enableCostTracking = false;
|
||||
|
||||
/**
|
||||
* OpenAI配置
|
||||
*/
|
||||
private OpenAIConfig openai = new OpenAIConfig();
|
||||
|
||||
/**
|
||||
* Anthropic配置
|
||||
*/
|
||||
private AnthropicConfig anthropic = new AnthropicConfig();
|
||||
|
||||
/**
|
||||
* Gemini配置
|
||||
*/
|
||||
private GeminiConfig gemini = new GeminiConfig();
|
||||
|
||||
@Data
|
||||
public static class OpenAIConfig {
|
||||
/**
|
||||
* 是否启用API定价同步
|
||||
*/
|
||||
private boolean enableApiSync = false;
|
||||
|
||||
/**
|
||||
* API密钥(用于获取模型列表和定价)
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* API端点
|
||||
*/
|
||||
private String apiEndpoint = "https://api.openai.com/v1";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AnthropicConfig {
|
||||
/**
|
||||
* 是否启用API定价同步
|
||||
*/
|
||||
private boolean enableApiSync = false;
|
||||
|
||||
/**
|
||||
* API密钥
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* API端点
|
||||
*/
|
||||
private String apiEndpoint = "https://api.anthropic.com/v1";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GeminiConfig {
|
||||
/**
|
||||
* 是否启用API定价同步
|
||||
*/
|
||||
private boolean enableApiSync = false;
|
||||
|
||||
/**
|
||||
* API密钥
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* API端点
|
||||
*/
|
||||
private String apiEndpoint = "https://generativelanguage.googleapis.com/v1";
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PricingProperties pricingProperties() {
|
||||
return new PricingProperties();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.service.prompt.AIFeaturePromptProvider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 提示词提供器初始化器
|
||||
* 在应用启动时自动初始化所有 Provider 的系统模板
|
||||
*
|
||||
* 注意:此初始化器必须在 AIPromptPresetInitializer 之前执行
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(1) // 确保在 AIPromptPresetInitializer 之前执行
|
||||
public class PromptProviderInitializer implements ApplicationRunner {
|
||||
|
||||
@Autowired
|
||||
private List<AIFeaturePromptProvider> promptProviders;
|
||||
|
||||
@Value("${ainovel.ai.features.setting-tree-generation.init-on-startup:false}")
|
||||
private boolean settingTreeGenerationInitOnStartup;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
log.info("🚀 开始初始化所有提示词提供器的系统模板...");
|
||||
log.info("📊 发现 {} 个提示词提供器", promptProviders.size());
|
||||
|
||||
try {
|
||||
Flux.fromIterable(promptProviders)
|
||||
.filter(provider -> {
|
||||
if (provider.getFeatureType() == AIFeatureType.SETTING_TREE_GENERATION && !settingTreeGenerationInitOnStartup) {
|
||||
log.info("⏭️ 跳过 SETTING_TREE_GENERATION 提示词提供器的系统模板初始化(开关关闭)");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.flatMap(provider -> {
|
||||
log.info("🔄 正在初始化提供器: {} ({})",
|
||||
provider.getClass().getSimpleName(),
|
||||
provider.getFeatureType());
|
||||
|
||||
return provider.initializeSystemTemplate()
|
||||
.map(templateId -> {
|
||||
log.info("✅ 提供器初始化成功: {} -> templateId: {}",
|
||||
provider.getFeatureType(), templateId);
|
||||
return templateId;
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("❌ 提供器初始化失败: {}, error: {}",
|
||||
provider.getFeatureType(), error.getMessage(), error);
|
||||
return reactor.core.publisher.Mono.empty();
|
||||
});
|
||||
})
|
||||
.collectList()
|
||||
.doOnSuccess(templateIds -> {
|
||||
log.info("🎉 所有提示词提供器系统模板初始化完成!成功初始化 {} 个模板", templateIds.size());
|
||||
|
||||
// 输出初始化统计
|
||||
promptProviders.forEach(provider -> {
|
||||
String templateId = provider.getSystemTemplateId();
|
||||
if (templateId != null) {
|
||||
log.info("📋 {}: {} -> {}",
|
||||
provider.getFeatureType(),
|
||||
provider.getTemplateIdentifier(),
|
||||
templateId);
|
||||
}
|
||||
});
|
||||
})
|
||||
.doOnError(error -> log.error("💥 提示词提供器系统模板初始化过程中发生异常", error))
|
||||
.block(); // 阻塞等待完成,确保在预设初始化前完成
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("💥 初始化提示词提供器系统模板时发生异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定功能类型的系统模板ID
|
||||
*
|
||||
* @param featureType 功能类型
|
||||
* @return 模板ID,如果未找到则返回null
|
||||
*/
|
||||
public String getSystemTemplateId(com.ainovel.server.domain.model.AIFeatureType featureType) {
|
||||
return promptProviders.stream()
|
||||
.filter(provider -> provider.getFeatureType() == featureType)
|
||||
.findFirst()
|
||||
.map(AIFeaturePromptProvider::getSystemTemplateId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已初始化的系统模板ID映射
|
||||
*
|
||||
* @return 功能类型到模板ID的映射
|
||||
*/
|
||||
public java.util.Map<com.ainovel.server.domain.model.AIFeatureType, String> getAllSystemTemplateIds() {
|
||||
return promptProviders.stream()
|
||||
.filter(provider -> provider.getSystemTemplateId() != null)
|
||||
.collect(java.util.stream.Collectors.toMap(
|
||||
AIFeaturePromptProvider::getFeatureType,
|
||||
AIFeaturePromptProvider::getSystemTemplateId
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 供应商限流配置
|
||||
* 每个AI供应商的详细限流配置
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Slf4j
|
||||
public class ProviderRateLimitConfig {
|
||||
|
||||
private final AIProviderEnum provider;
|
||||
private final RateLimitStrategyEnum rateLimitStrategy;
|
||||
private final RetryStrategyEnum retryStrategy;
|
||||
private final RateLimitDimensionEnum dimension;
|
||||
private final String userId;
|
||||
private final String modelName;
|
||||
private final String taskType;
|
||||
|
||||
// 动态配置 - 可根据运行时状态调整
|
||||
@Builder.Default
|
||||
private final AtomicReference<Double> currentRate = new AtomicReference<>();
|
||||
@Builder.Default
|
||||
private final AtomicReference<Integer> currentBurstCapacity = new AtomicReference<>();
|
||||
|
||||
// 监控指标
|
||||
@Builder.Default
|
||||
private final ConcurrentHashMap<String, Object> metrics = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取当前有效的限流速率
|
||||
*/
|
||||
public double getEffectiveRate() {
|
||||
Double current = currentRate.get();
|
||||
return current != null ? current : rateLimitStrategy.getRatePerSecond();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的突发容量
|
||||
*/
|
||||
public int getEffectiveBurstCapacity() {
|
||||
Integer current = currentBurstCapacity.get();
|
||||
return current != null ? current : rateLimitStrategy.getBurstCapacity();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取限流器键值
|
||||
*/
|
||||
public String getRateLimiterKey() {
|
||||
RateLimitDimensionEnum.RateLimitKeyContext context = RateLimitDimensionEnum.RateLimitKeyContext.of(
|
||||
provider.getCode(), userId, modelName, taskType);
|
||||
return dimension.generateKey(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取RabbitMQ重试队列名称
|
||||
*/
|
||||
public String getRetryQueueName() {
|
||||
return String.format("ai.retry.%s.dlx", provider.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态调整限流参数
|
||||
*/
|
||||
public void adjustRateLimit(double errorRate, int consecutiveErrors) {
|
||||
if (rateLimitStrategy == RateLimitStrategyEnum.ADAPTIVE) {
|
||||
double adjustmentFactor = calculateAdjustmentFactor(errorRate, consecutiveErrors);
|
||||
double baseRate = rateLimitStrategy.getRatePerSecond();
|
||||
double newRate = baseRate * adjustmentFactor;
|
||||
|
||||
// 限制调整范围
|
||||
newRate = Math.max(0.1, Math.min(newRate, baseRate * 2));
|
||||
|
||||
currentRate.set(newRate);
|
||||
|
||||
log.info("动态调整限流参数: provider={}, errorRate={}, newRate={}",
|
||||
provider.getCode(), errorRate, newRate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算调整因子
|
||||
*/
|
||||
private double calculateAdjustmentFactor(double errorRate, int consecutiveErrors) {
|
||||
// 基于错误率的调整
|
||||
double errorFactor = 1.0;
|
||||
if (errorRate > 0.3) {
|
||||
errorFactor = 0.3; // 高错误率,大幅降低
|
||||
} else if (errorRate > 0.1) {
|
||||
errorFactor = 0.6; // 中等错误率,适度降低
|
||||
} else if (errorRate < 0.01) {
|
||||
errorFactor = 1.5; // 低错误率,适度提高
|
||||
}
|
||||
|
||||
// 基于连续错误的调整
|
||||
double consecutiveFactor = Math.max(0.2, 1.0 - consecutiveErrors * 0.1);
|
||||
|
||||
return errorFactor * consecutiveFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新监控指标
|
||||
*/
|
||||
public void updateMetrics(String metricName, Object value) {
|
||||
metrics.put(metricName, value);
|
||||
metrics.put("lastUpdated", System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取监控指标
|
||||
*/
|
||||
public Object getMetric(String metricName) {
|
||||
return metrics.get(metricName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置
|
||||
*/
|
||||
public void resetToDefault() {
|
||||
currentRate.set(null);
|
||||
currentBurstCapacity.set(null);
|
||||
metrics.clear();
|
||||
log.info("重置供应商配置为默认值: provider={}", provider.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认配置
|
||||
*/
|
||||
public static ProviderRateLimitConfig createDefault(AIProviderEnum provider, String userId, String modelName) {
|
||||
return ProviderRateLimitConfig.builder()
|
||||
.provider(provider)
|
||||
.rateLimitStrategy(provider.getDefaultRateLimitStrategy())
|
||||
.retryStrategy(provider.getDefaultRetryStrategy())
|
||||
.dimension(RateLimitDimensionEnum.USER_PROVIDER_MODEL) // 默认用户+供应商+模型维度
|
||||
.userId(userId)
|
||||
.modelName(modelName)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Gemini特定配置 (针对免费层限制)
|
||||
*/
|
||||
public static ProviderRateLimitConfig createGeminiConfig(String userId, String modelName) {
|
||||
return ProviderRateLimitConfig.builder()
|
||||
.provider(AIProviderEnum.GEMINI)
|
||||
.rateLimitStrategy(RateLimitStrategyEnum.CONSERVATIVE) // 保守策略应对200次/天限制
|
||||
.retryStrategy(RetryStrategyEnum.EXPONENTIAL_BACKOFF) // 4倍指数退避
|
||||
.dimension(RateLimitDimensionEnum.GLOBAL) // Gemini使用全局维度限流(因为免费层共享配额)
|
||||
.userId(userId)
|
||||
.modelName(modelName)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务级配置
|
||||
*/
|
||||
public static ProviderRateLimitConfig createTaskConfig(AIProviderEnum provider, String userId, String modelName, String taskType) {
|
||||
return ProviderRateLimitConfig.builder()
|
||||
.provider(provider)
|
||||
.rateLimitStrategy(provider.getDefaultRateLimitStrategy())
|
||||
.retryStrategy(provider.getDefaultRetryStrategy())
|
||||
.dimension(RateLimitDimensionEnum.HYBRID) // 使用混合维度
|
||||
.userId(userId)
|
||||
.modelName(modelName)
|
||||
.taskType(taskType)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* AI模型提供商服务配置类
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@Getter
|
||||
public class ProviderServiceConfig {
|
||||
|
||||
@Value("${ai.use-langchain4j:true}")
|
||||
private boolean useLangChain4j;
|
||||
|
||||
@Value("${ai.enable-provider-auto-detection:false}")
|
||||
private boolean enableProviderAutoDetection;
|
||||
|
||||
@Value("${ai.default-provider:openai}")
|
||||
private String defaultProvider;
|
||||
|
||||
@Value("${ai.default-model:gpt-3.5-turbo}")
|
||||
private String defaultModel;
|
||||
|
||||
@Value("${ai.connect-timeout:30}")
|
||||
private int connectTimeoutSeconds;
|
||||
|
||||
@Value("${ai.read-timeout:60}")
|
||||
private int readTimeoutSeconds;
|
||||
|
||||
/**
|
||||
* 获取代理配置
|
||||
*
|
||||
* @return 代理配置
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public ProxyConfig proxyConfig(
|
||||
@Value("${proxy.enabled:false}") boolean proxyEnabled,
|
||||
@Value("${proxy.host:}") String proxyHost,
|
||||
@Value("${proxy.port:0}") int proxyPort,
|
||||
@Value("${proxy.username:}") String proxyUsername,
|
||||
@Value("${proxy.password:}") String proxyPassword,
|
||||
@Value("${proxy.applySystemProperties:true}") boolean applySystemProperties,
|
||||
@Value("${proxy.applyProxySelector:false}") boolean applyProxySelector,
|
||||
@Value("${proxy.type:http}") String proxyType,
|
||||
@Value("${proxy.trustAllCerts:false}") boolean trustAllCerts) {
|
||||
|
||||
ProxyConfig config = ProxyConfig.builder()
|
||||
.enabled(proxyEnabled)
|
||||
.host(proxyHost)
|
||||
.port(proxyPort)
|
||||
.username(proxyUsername)
|
||||
.password(proxyPassword)
|
||||
.build();
|
||||
// 使用 setter 避免个别构建方法名冲突(如 type/trustAllCerts )
|
||||
config.setApplySystemProperties(applySystemProperties);
|
||||
config.setApplyProxySelector(applyProxySelector);
|
||||
config.setType(proxyType);
|
||||
config.setTrustAllCerts(trustAllCerts);
|
||||
|
||||
log.info("代理配置: enabled={}, host={}, port={}, type={}, applySysProps={}, applySelector={}",
|
||||
proxyEnabled, proxyHost, proxyPort, proxyType, applySystemProperties, applyProxySelector);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import com.ainovel.server.service.ai.AIModelProvider;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 代理配置类
|
||||
*/
|
||||
@Slf4j
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProxyConfig {
|
||||
|
||||
/**
|
||||
* 是否启用代理
|
||||
*/
|
||||
@Value("${proxy.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
/**
|
||||
* 代理主机
|
||||
*/
|
||||
@Value("${proxy.host:localhost}")
|
||||
private String host;
|
||||
|
||||
/**
|
||||
* 代理端口
|
||||
*/
|
||||
@Value("${proxy.port:6888}")
|
||||
private int port;
|
||||
|
||||
/**
|
||||
* 代理用户名(如需认证)
|
||||
*/
|
||||
@Value("${proxy.username:}")
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 代理密码(如需认证)
|
||||
*/
|
||||
@Value("${proxy.password:}")
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 是否通过 System.setProperty 应用 http/https 代理属性(仅影响当前JVM)
|
||||
*/
|
||||
@Value("${proxy.applySystemProperties:true}")
|
||||
private boolean applySystemProperties;
|
||||
|
||||
/**
|
||||
* 是否设置全局 ProxySelector(Java 11+ HttpClient 使用)。默认关闭以避免全局副作用。
|
||||
*/
|
||||
@Value("${proxy.applyProxySelector:false}")
|
||||
private boolean applyProxySelector;
|
||||
|
||||
/**
|
||||
* 代理类型:http 或 socks
|
||||
*/
|
||||
@Value("${proxy.type:http}")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 是否信任所有证书(仅限排障时临时开启,生产默认为 false)
|
||||
*/
|
||||
@Value("${proxy.trustAllCerts:false}")
|
||||
private boolean trustAllCerts;
|
||||
|
||||
/**
|
||||
* 获取完整的代理地址
|
||||
*
|
||||
* @return 代理地址,格式为 host:port
|
||||
*/
|
||||
public String getProxyAddress() {
|
||||
return host + ":" + port;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理配置是否有效
|
||||
*
|
||||
* @return 是否有效
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return enabled && host != null && !host.isEmpty() && port > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对多个AI模型提供商应用代理配置
|
||||
*
|
||||
* @param providers AI模型提供商列表
|
||||
*/
|
||||
public void applyToProviders(List<AIModelProvider> providers) {
|
||||
if (enabled && isValid()) {
|
||||
log.info("正在为AI模型提供商配置HTTP代理: {}:{}", host, port);
|
||||
|
||||
for (AIModelProvider provider : providers) {
|
||||
try {
|
||||
provider.setProxy(host, port);
|
||||
log.info("已为 {} 模型提供商配置代理", provider.getProviderName());
|
||||
} catch (Exception e) {
|
||||
log.error("为 {} 模型提供商配置代理时出错: {}",
|
||||
provider.getProviderName(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.amqp.core.AcknowledgeMode;
|
||||
import org.springframework.amqp.core.Binding;
|
||||
import org.springframework.amqp.core.BindingBuilder;
|
||||
import org.springframework.amqp.core.DirectExchange;
|
||||
import org.springframework.amqp.core.Exchange;
|
||||
import org.springframework.amqp.core.ExchangeBuilder;
|
||||
import org.springframework.amqp.core.FanoutExchange;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.QueueBuilder;
|
||||
import org.springframework.amqp.core.TopicExchange;
|
||||
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
|
||||
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
|
||||
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* RabbitMQ配置类
|
||||
*/
|
||||
@Configuration
|
||||
@EnableRabbit
|
||||
@ConditionalOnProperty(name = "spring.rabbitmq.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class RabbitMQConfig {
|
||||
private static final Logger logger = LoggerFactory.getLogger(RabbitMQConfig.class);
|
||||
|
||||
// 交换机名称
|
||||
public static final String TASKS_EXCHANGE = "tasks.exchange";
|
||||
public static final String TASKS_RETRY_EXCHANGE = "tasks.retry.exchange";
|
||||
public static final String TASKS_REQUEUE_EXCHANGE = "tasks.requeue.exchange";
|
||||
public static final String TASKS_DLX_EXCHANGE = "tasks.dlx.exchange";
|
||||
public static final String TASKS_EVENTS_EXCHANGE = "tasks.events.exchange";
|
||||
|
||||
// 队列名称
|
||||
public static final String TASKS_QUEUE = "tasks.queue";
|
||||
public static final String TASKS_DLQ_QUEUE = "tasks.dlq.queue";
|
||||
public static final String TASKS_EVENTS_QUEUE = "tasks.events.queue";
|
||||
|
||||
// 等待队列(用于延迟重试)
|
||||
public static final String TASKS_WAIT_15S_QUEUE = "tasks.wait_15s.queue";
|
||||
public static final String TASKS_WAIT_1M_QUEUE = "tasks.wait_1m.queue";
|
||||
public static final String TASKS_WAIT_5M_QUEUE = "tasks.wait_5m.queue";
|
||||
public static final String TASKS_WAIT_30M_QUEUE = "tasks.wait_30m.queue";
|
||||
|
||||
// 路由键前缀
|
||||
public static final String TASK_TYPE_PREFIX = "task.";
|
||||
|
||||
@Value("${spring.rabbitmq.host:localhost}")
|
||||
private String host;
|
||||
|
||||
@Value("${spring.rabbitmq.port:5672}")
|
||||
private int port;
|
||||
|
||||
@Value("${spring.rabbitmq.username:guest}")
|
||||
private String username;
|
||||
|
||||
@Value("${spring.rabbitmq.password:guest}")
|
||||
private String password;
|
||||
|
||||
@Value("${spring.rabbitmq.virtual-host:/}")
|
||||
private String virtualHost;
|
||||
|
||||
@Value("${spring.rabbitmq.listener.simple.prefetch:1}")
|
||||
private int prefetchCount;
|
||||
|
||||
@Value("${spring.rabbitmq.listener.simple.concurrency:5}")
|
||||
private int concurrentConsumers;
|
||||
|
||||
@Value("${spring.rabbitmq.listener.simple.max-concurrency:10}")
|
||||
private int maxConcurrentConsumers;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("taskObjectMapper")
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 配置连接工厂
|
||||
*/
|
||||
@Bean
|
||||
public ConnectionFactory connectionFactory() {
|
||||
CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
|
||||
connectionFactory.setHost(host);
|
||||
connectionFactory.setPort(port);
|
||||
connectionFactory.setUsername(username);
|
||||
connectionFactory.setPassword(password);
|
||||
connectionFactory.setVirtualHost(virtualHost);
|
||||
|
||||
// 启用发布确认
|
||||
connectionFactory.setPublisherConfirmType(CachingConnectionFactory.ConfirmType.CORRELATED);
|
||||
connectionFactory.setPublisherReturns(true);
|
||||
|
||||
return connectionFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置RabbitAdmin,用于管理交换机、队列等资源
|
||||
*/
|
||||
@Bean
|
||||
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
|
||||
RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory);
|
||||
rabbitAdmin.setAutoStartup(true);
|
||||
return rabbitAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置RabbitTemplate
|
||||
*/
|
||||
@Bean
|
||||
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
|
||||
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
|
||||
|
||||
// 设置消息转换器
|
||||
rabbitTemplate.setMessageConverter(jackson2JsonMessageConverter());
|
||||
|
||||
// 启用强制消息
|
||||
rabbitTemplate.setMandatory(true);
|
||||
|
||||
// 设置消息确认回调
|
||||
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
|
||||
if (!ack) {
|
||||
logger.error("消息发送失败: {} - {}", correlationData, cause);
|
||||
}
|
||||
});
|
||||
|
||||
// 设置消息返回回调
|
||||
rabbitTemplate.setReturnsCallback(returned -> {
|
||||
logger.error("消息路由失败: {}, 交换机: {}, 路由键: {}, 原因: {}",
|
||||
returned.getMessage(), returned.getExchange(),
|
||||
returned.getRoutingKey(), returned.getReplyText());
|
||||
});
|
||||
|
||||
return rabbitTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置消息转换器
|
||||
*/
|
||||
@Bean
|
||||
public Jackson2JsonMessageConverter jackson2JsonMessageConverter() {
|
||||
return new Jackson2JsonMessageConverter(objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置监听容器工厂
|
||||
*/
|
||||
@Bean
|
||||
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
|
||||
ConnectionFactory connectionFactory) {
|
||||
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
|
||||
factory.setConnectionFactory(connectionFactory);
|
||||
factory.setMessageConverter(jackson2JsonMessageConverter());
|
||||
|
||||
// 配置手动确认模式
|
||||
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
|
||||
|
||||
// 配置并发消费者数
|
||||
factory.setConcurrentConsumers(concurrentConsumers);
|
||||
factory.setMaxConcurrentConsumers(maxConcurrentConsumers);
|
||||
|
||||
// 配置预取数量
|
||||
factory.setPrefetchCount(prefetchCount);
|
||||
|
||||
// 使用虚拟线程
|
||||
factory.setTaskExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
// 交换机定义
|
||||
|
||||
/**
|
||||
* 任务主交换机(主题交换机)
|
||||
* 注意:改回TopicExchange以支持通配符,简化任务路由配置
|
||||
*/
|
||||
@Bean
|
||||
public DirectExchange tasksExchange() {
|
||||
return new DirectExchange(TASKS_EXCHANGE, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重试交换机(扇形)
|
||||
*/
|
||||
@Bean
|
||||
public FanoutExchange tasksRetryExchange() {
|
||||
return new FanoutExchange(TASKS_RETRY_EXCHANGE, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重新入队交换机(直连)
|
||||
*/
|
||||
@Bean
|
||||
public DirectExchange tasksRequeueExchange() {
|
||||
return new DirectExchange(TASKS_REQUEUE_EXCHANGE, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务死信交换机(扇形)
|
||||
*/
|
||||
@Bean
|
||||
public FanoutExchange tasksDlxExchange() {
|
||||
return new FanoutExchange(TASKS_DLX_EXCHANGE, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务事件交换机(主题)
|
||||
*/
|
||||
@Bean
|
||||
public TopicExchange tasksEventsExchange() {
|
||||
return new TopicExchange(TASKS_EVENTS_EXCHANGE, true, false);
|
||||
}
|
||||
|
||||
// 队列定义
|
||||
|
||||
/**
|
||||
* 任务主队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksQueue() {
|
||||
return QueueBuilder.durable(TASKS_QUEUE)
|
||||
.withArgument("x-dead-letter-exchange", TASKS_RETRY_EXCHANGE)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务死信队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksDlqQueue() {
|
||||
return QueueBuilder.durable(TASKS_DLQ_QUEUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务事件队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksEventsQueue() {
|
||||
return QueueBuilder.durable(TASKS_EVENTS_QUEUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务15秒延迟队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksWait15sQueue() {
|
||||
return QueueBuilder.durable(TASKS_WAIT_15S_QUEUE)
|
||||
.withArgument("x-dead-letter-exchange", TASKS_REQUEUE_EXCHANGE)
|
||||
.withArgument("x-message-ttl", 15000) // 15秒
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务1分钟延迟队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksWait1mQueue() {
|
||||
return QueueBuilder.durable(TASKS_WAIT_1M_QUEUE)
|
||||
.withArgument("x-dead-letter-exchange", TASKS_REQUEUE_EXCHANGE)
|
||||
.withArgument("x-message-ttl", 60000) // 1分钟
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务5分钟延迟队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksWait5mQueue() {
|
||||
return QueueBuilder.durable(TASKS_WAIT_5M_QUEUE)
|
||||
.withArgument("x-dead-letter-exchange", TASKS_REQUEUE_EXCHANGE)
|
||||
.withArgument("x-message-ttl", 300000) // 5分钟
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务30分钟延迟队列
|
||||
*/
|
||||
@Bean
|
||||
public Queue tasksWait30mQueue() {
|
||||
return QueueBuilder.durable(TASKS_WAIT_30M_QUEUE)
|
||||
.withArgument("x-dead-letter-exchange", TASKS_REQUEUE_EXCHANGE)
|
||||
.withArgument("x-message-ttl", 1800000) // 30分钟
|
||||
.build();
|
||||
}
|
||||
|
||||
// 绑定定义
|
||||
|
||||
/**
|
||||
* 任务重试交换机 -> 等待队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksRetryToWait15sBinding() {
|
||||
return BindingBuilder.bind(tasksWait15sQueue()).to(tasksRetryExchange());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重试交换机 -> 等待队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksRetryToWait1mBinding() {
|
||||
return BindingBuilder.bind(tasksWait1mQueue()).to(tasksRetryExchange());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重试交换机 -> 等待队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksRetryToWait5mBinding() {
|
||||
return BindingBuilder.bind(tasksWait5mQueue()).to(tasksRetryExchange());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重试交换机 -> 等待队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksRetryToWait30mBinding() {
|
||||
return BindingBuilder.bind(tasksWait30mQueue()).to(tasksRetryExchange());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重新入队交换机 -> 任务主队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksRequeueToTasksBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksRequeueExchange())
|
||||
.with("#"); // 匹配所有路由键
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务死信交换机 -> 死信队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksDlxToDlqBinding() {
|
||||
return BindingBuilder.bind(tasksDlqQueue()).to(tasksDlxExchange());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务事件交换机 -> 事件队列绑定
|
||||
*/
|
||||
@Bean
|
||||
public Binding tasksEventsToQueueBinding() {
|
||||
return BindingBuilder.bind(tasksEventsQueue())
|
||||
.to(tasksEventsExchange())
|
||||
.with("task.event.#"); // 使用通配符匹配所有task.event开头的路由键
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 通用任务绑定 - 捕获所有任务类型
|
||||
// * 使用通配符将所有task.前缀的消息路由到任务队列
|
||||
// * 这是推荐的绑定方式,可以自动处理新的任务类型。
|
||||
// */
|
||||
// @Bean
|
||||
// public Binding allTasksBinding() {
|
||||
// return BindingBuilder.bind(tasksQueue())
|
||||
// .to(tasksExchange()) // 确保绑定到TopicExchange
|
||||
// .with(TASK_TYPE_PREFIX + "#"); // 匹配所有task.前缀的路由键
|
||||
// }
|
||||
|
||||
/**
|
||||
* 创建任务生成摘要类型的绑定
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding generateSummaryBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "GENERATE_SUMMARY");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务生成场景类型的绑定
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding generateSceneBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "GENERATE_SCENE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量生成摘要任务类型的绑定
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding batchGenerateSummaryBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "BATCH_GENERATE_SUMMARY");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量生成场景任务类型的绑定
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding batchGenerateSceneBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "BATCH_GENERATE_SCENE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建续写内容任务类型的绑定
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding continueWritingContentBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "CONTINUE_WRITING_CONTENT");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建生成下一章摘要任务类型的绑定
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding generateNextSummariesOnlyBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "GENERATE_NEXT_SUMMARIES_ONLY");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Binding generateSingleChapterOnlyBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "GENERATE_SINGLE_CHAPTER");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建生成单个摘要任务类型的绑定 (子任务)
|
||||
* (冗余,已被 allTasksBinding 覆盖)
|
||||
*/
|
||||
@Bean
|
||||
public Binding generateSingleSummaryBinding() {
|
||||
return BindingBuilder.bind(tasksQueue())
|
||||
.to(tasksExchange())
|
||||
.with(TASK_TYPE_PREFIX + "GENERATE_SINGLE_SUMMARY"); // 添加这个子任务的绑定(虽然冗余)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.ainovel.server.service.EmbeddingService;
|
||||
import com.ainovel.server.service.rag.LangChain4jEmbeddingModel;
|
||||
|
||||
import dev.langchain4j.data.document.DocumentSplitter;
|
||||
import dev.langchain4j.data.document.splitter.DocumentSplitters;
|
||||
import dev.langchain4j.data.segment.TextSegment;
|
||||
import dev.langchain4j.model.embedding.EmbeddingModel;
|
||||
import dev.langchain4j.rag.content.retriever.ContentRetriever;
|
||||
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
|
||||
import dev.langchain4j.store.embedding.EmbeddingStore;
|
||||
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* RAG(检索增强生成)配置类
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class RagConfig {
|
||||
|
||||
@Value("${rag.document-splitter.chunk-size:1000}")
|
||||
private int chunkSize;
|
||||
|
||||
@Value("${rag.document-splitter.chunk-overlap:200}")
|
||||
private int chunkOverlap;
|
||||
|
||||
@Value("${rag.retriever.max-results:5}")
|
||||
private int maxResults;
|
||||
|
||||
@Value("${rag.retriever.min-score:0.6}")
|
||||
private double minScore;
|
||||
|
||||
/**
|
||||
* 配置文档拆分器
|
||||
*
|
||||
* @return 文档拆分器
|
||||
*/
|
||||
@Bean
|
||||
public DocumentSplitter documentSplitter() {
|
||||
log.info("配置DocumentSplitter,块大小:{},重叠大小:{}", chunkSize, chunkOverlap);
|
||||
return DocumentSplitters.recursive(chunkSize, chunkOverlap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置LangChain4j嵌入模型适配器
|
||||
*
|
||||
* @param embeddingService 嵌入服务
|
||||
* @return 嵌入模型
|
||||
*/
|
||||
@Bean
|
||||
public EmbeddingModel embeddingModel(EmbeddingService embeddingService) {
|
||||
log.info("配置EmbeddingModel适配器");
|
||||
return new LangChain4jEmbeddingModel(embeddingService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置嵌入存储摄取器
|
||||
*
|
||||
* @param documentSplitter 文档拆分器
|
||||
* @param embeddingModel 嵌入模型
|
||||
* @param embeddingStore 嵌入存储
|
||||
* @return 嵌入存储摄取器
|
||||
*/
|
||||
@Bean
|
||||
public EmbeddingStoreIngestor embeddingStoreIngestor(
|
||||
DocumentSplitter documentSplitter,
|
||||
EmbeddingModel embeddingModel,
|
||||
EmbeddingStore<TextSegment> embeddingStore) {
|
||||
log.info("配置EmbeddingStoreIngestor");
|
||||
return EmbeddingStoreIngestor.builder()
|
||||
.documentSplitter(documentSplitter)
|
||||
.embeddingModel(embeddingModel)
|
||||
.embeddingStore(embeddingStore)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置内容检索器
|
||||
*
|
||||
* @param embeddingStore 嵌入存储
|
||||
* @param embeddingModel 嵌入模型
|
||||
* @return 内容检索器
|
||||
*/
|
||||
@Bean
|
||||
public ContentRetriever contentRetriever(
|
||||
EmbeddingStore<TextSegment> embeddingStore,
|
||||
EmbeddingModel embeddingModel) {
|
||||
log.info("配置ContentRetriever,最大结果数:{},最小分数:{}", maxResults, minScore);
|
||||
|
||||
return EmbeddingStoreContentRetriever.builder()
|
||||
.embeddingStore(embeddingStore)
|
||||
.embeddingModel(embeddingModel)
|
||||
.maxResults(maxResults)
|
||||
.minScore(minScore)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 限流配置管理器
|
||||
* 统一管理不同维度和策略的限流配置
|
||||
*
|
||||
* 配置层次:
|
||||
* 1. 全局默认配置
|
||||
* 2. 供应商特定配置
|
||||
* 3. 任务类型特定配置
|
||||
* 4. 动态运行时配置
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "task.ratelimiter")
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitConfigurationManager {
|
||||
|
||||
// 维度配置映射
|
||||
private Map<String, RateLimitDimensionEnum> dimensions = new HashMap<>();
|
||||
|
||||
// 默认配置
|
||||
private DefaultConfig defaultConfig = new DefaultConfig();
|
||||
|
||||
// 供应商配置
|
||||
private Map<String, ProviderConfig> providers = new HashMap<>();
|
||||
|
||||
// 任务类型配置
|
||||
private Map<String, TaskConfig> tasks = new HashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("初始化限流配置管理器");
|
||||
|
||||
// 设置默认维度配置
|
||||
if (dimensions.isEmpty()) {
|
||||
dimensions.put("default", RateLimitDimensionEnum.USER_PROVIDER_MODEL);
|
||||
dimensions.put("gemini", RateLimitDimensionEnum.GLOBAL);
|
||||
dimensions.put("sensitive_tasks", RateLimitDimensionEnum.HYBRID);
|
||||
dimensions.put("high_performance", RateLimitDimensionEnum.PROVIDER_MODEL);
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
validateConfiguration();
|
||||
|
||||
log.info("限流配置管理器初始化完成: 维度={}, 供应商={}, 任务={}",
|
||||
dimensions.size(), providers.size(), tasks.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建供应商特定的限流配置
|
||||
*/
|
||||
public ProviderRateLimitConfig createProviderConfig(AIProviderEnum provider, String userId,
|
||||
String modelName, String taskType) {
|
||||
// 1. 获取供应商特定配置
|
||||
ProviderConfig providerConfig = providers.get(provider.getCode().toLowerCase());
|
||||
if (providerConfig == null) {
|
||||
log.debug("未找到供应商{}的特定配置,使用默认配置", provider.getCode());
|
||||
providerConfig = createDefaultProviderConfig();
|
||||
}
|
||||
|
||||
// 2. 获取任务类型配置
|
||||
TaskConfig taskConfig = tasks.get(taskType);
|
||||
|
||||
// 3. 合并配置优先级:任务配置 > 供应商配置 > 默认配置
|
||||
ProviderRateLimitConfig config = ProviderRateLimitConfig.builder()
|
||||
.provider(provider)
|
||||
.rateLimitStrategy(determineStrategy(providerConfig, taskConfig))
|
||||
.retryStrategy(determineRetryStrategy(providerConfig, taskConfig))
|
||||
.dimension(determineDimension(provider, taskType, providerConfig, taskConfig))
|
||||
.userId(userId)
|
||||
.modelName(modelName)
|
||||
.taskType(taskType)
|
||||
.build();
|
||||
|
||||
// 动态设置运行时参数
|
||||
config.getCurrentRate().set(determineRate(providerConfig, taskConfig));
|
||||
config.getCurrentBurstCapacity().set(determineBurstCapacity(providerConfig, taskConfig));
|
||||
|
||||
// 设置监控指标
|
||||
config.updateMetrics("maxRetryAttempts", determineMaxRetryAttempts(providerConfig, taskConfig));
|
||||
config.updateMetrics("timeoutMillis", determineTimeoutMillis(providerConfig, taskConfig));
|
||||
// 注入日限额和安全缓冲配置,供限流策略动态读取
|
||||
if (providerConfig.getDailyLimit() != null) {
|
||||
config.updateMetrics("dailyLimit", providerConfig.getDailyLimit());
|
||||
}
|
||||
if (providerConfig.getSafetyBuffer() != null) {
|
||||
config.updateMetrics("safetyBuffer", providerConfig.getSafetyBuffer());
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定限流维度
|
||||
*/
|
||||
private RateLimitDimensionEnum determineDimension(AIProviderEnum provider, String taskType,
|
||||
ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
// 任务配置优先
|
||||
if (taskConfig != null && taskConfig.getDimension() != null) {
|
||||
return taskConfig.getDimension();
|
||||
}
|
||||
|
||||
// 供应商配置次之
|
||||
if (providerConfig.getDimension() != null) {
|
||||
return providerConfig.getDimension();
|
||||
}
|
||||
|
||||
// 特殊规则:Gemini使用全局维度
|
||||
if (provider == AIProviderEnum.GEMINI) {
|
||||
return RateLimitDimensionEnum.GLOBAL;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
return dimensions.getOrDefault("default", RateLimitDimensionEnum.USER_PROVIDER_MODEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定限流策略
|
||||
*/
|
||||
private RateLimitStrategyEnum determineStrategy(ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
if (taskConfig != null && taskConfig.getStrategy() != null) {
|
||||
return taskConfig.getStrategy();
|
||||
}
|
||||
|
||||
if (providerConfig.getStrategy() != null) {
|
||||
return providerConfig.getStrategy();
|
||||
}
|
||||
|
||||
return RateLimitStrategyEnum.STANDARD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定重试策略
|
||||
*/
|
||||
private RetryStrategyEnum determineRetryStrategy(ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
if (taskConfig != null && taskConfig.getRetryStrategy() != null) {
|
||||
return taskConfig.getRetryStrategy();
|
||||
}
|
||||
|
||||
if (providerConfig.getRetryStrategy() != null) {
|
||||
return providerConfig.getRetryStrategy();
|
||||
}
|
||||
|
||||
return RetryStrategyEnum.LINEAR_BACKOFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定速率限制
|
||||
*/
|
||||
private double determineRate(ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
if (taskConfig != null && taskConfig.getRate() != null) {
|
||||
return taskConfig.getRate();
|
||||
}
|
||||
|
||||
if (providerConfig.getRate() != null) {
|
||||
return providerConfig.getRate();
|
||||
}
|
||||
|
||||
return defaultConfig.getRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定突发容量
|
||||
*/
|
||||
private int determineBurstCapacity(ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
if (taskConfig != null && taskConfig.getBurstCapacity() != null) {
|
||||
return taskConfig.getBurstCapacity();
|
||||
}
|
||||
|
||||
if (providerConfig.getBurstCapacity() != null) {
|
||||
return providerConfig.getBurstCapacity();
|
||||
}
|
||||
|
||||
return defaultConfig.getBurstCapacity();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定最大重试次数
|
||||
*/
|
||||
private int determineMaxRetryAttempts(ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
if (taskConfig != null && taskConfig.getMaxRetryAttempts() != null) {
|
||||
return taskConfig.getMaxRetryAttempts();
|
||||
}
|
||||
|
||||
if (providerConfig.getMaxRetryAttempts() != null) {
|
||||
return providerConfig.getMaxRetryAttempts();
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定超时时间
|
||||
*/
|
||||
private long determineTimeoutMillis(ProviderConfig providerConfig, TaskConfig taskConfig) {
|
||||
if (taskConfig != null && taskConfig.getDefaultTimeoutMillis() != null) {
|
||||
return taskConfig.getDefaultTimeoutMillis();
|
||||
}
|
||||
|
||||
if (providerConfig.getDefaultTimeoutMillis() != null) {
|
||||
return providerConfig.getDefaultTimeoutMillis();
|
||||
}
|
||||
|
||||
return defaultConfig.getDefaultTimeoutMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认供应商配置
|
||||
*/
|
||||
private ProviderConfig createDefaultProviderConfig() {
|
||||
ProviderConfig config = new ProviderConfig();
|
||||
config.setStrategy(RateLimitStrategyEnum.STANDARD);
|
||||
config.setDimension(RateLimitDimensionEnum.USER_PROVIDER_MODEL);
|
||||
config.setRate(defaultConfig.getRate());
|
||||
config.setBurstCapacity(defaultConfig.getBurstCapacity());
|
||||
config.setRetryStrategy(RetryStrategyEnum.LINEAR_BACKOFF);
|
||||
config.setMaxRetryAttempts(3);
|
||||
config.setDefaultTimeoutMillis(defaultConfig.getDefaultTimeoutMillis());
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置
|
||||
*/
|
||||
private void validateConfiguration() {
|
||||
// 验证维度配置
|
||||
for (Map.Entry<String, RateLimitDimensionEnum> entry : dimensions.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
log.warn("维度配置{}的值为null,将使用默认值", entry.getKey());
|
||||
entry.setValue(RateLimitDimensionEnum.USER_PROVIDER_MODEL);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证供应商配置
|
||||
for (Map.Entry<String, ProviderConfig> entry : providers.entrySet()) {
|
||||
ProviderConfig config = entry.getValue();
|
||||
if (config.getRate() != null && config.getRate() <= 0) {
|
||||
log.warn("供应商{}的速率配置无效: {}", entry.getKey(), config.getRate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置摘要
|
||||
*/
|
||||
public Map<String, Object> getConfigurationSummary() {
|
||||
Map<String, Object> summary = new HashMap<>();
|
||||
summary.put("dimensions", dimensions.size());
|
||||
summary.put("providers", providers.size());
|
||||
summary.put("tasks", tasks.size());
|
||||
summary.put("defaultRate", defaultConfig.getRate());
|
||||
summary.put("defaultBurstCapacity", defaultConfig.getBurstCapacity());
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
|
||||
public Map<String, RateLimitDimensionEnum> getDimensions() {
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
public void setDimensions(Map<String, RateLimitDimensionEnum> dimensions) {
|
||||
this.dimensions = dimensions;
|
||||
}
|
||||
|
||||
public DefaultConfig getDefaultConfig() {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
public void setDefaultConfig(DefaultConfig defaultConfig) {
|
||||
this.defaultConfig = defaultConfig;
|
||||
}
|
||||
|
||||
public Map<String, ProviderConfig> getProviders() {
|
||||
return providers;
|
||||
}
|
||||
|
||||
public void setProviders(Map<String, ProviderConfig> providers) {
|
||||
this.providers = providers;
|
||||
}
|
||||
|
||||
public Map<String, TaskConfig> getTasks() {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public void setTasks(Map<String, TaskConfig> tasks) {
|
||||
this.tasks = tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class DefaultConfig {
|
||||
private double rate = 10.0;
|
||||
private int burstCapacity = 20;
|
||||
private long defaultTimeoutMillis = 5000;
|
||||
private RateLimitDimensionEnum dimension = RateLimitDimensionEnum.USER_PROVIDER_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商配置
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class ProviderConfig {
|
||||
private RateLimitStrategyEnum strategy;
|
||||
private RateLimitDimensionEnum dimension;
|
||||
private Double rate;
|
||||
private Integer burstCapacity;
|
||||
private RetryStrategyEnum retryStrategy;
|
||||
private Integer maxRetryAttempts;
|
||||
private Long defaultTimeoutMillis;
|
||||
private Integer dailyLimit;
|
||||
private Integer safetyBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务配置
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class TaskConfig {
|
||||
private RateLimitStrategyEnum strategy;
|
||||
private RateLimitDimensionEnum dimension;
|
||||
private Double rate;
|
||||
private Integer burstCapacity;
|
||||
private RetryStrategyEnum retryStrategy;
|
||||
private Integer maxRetryAttempts;
|
||||
private Long defaultTimeoutMillis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 限流维度枚举
|
||||
* 定义不同粒度的限流控制维度
|
||||
*/
|
||||
@Getter
|
||||
public enum RateLimitDimensionEnum {
|
||||
|
||||
/**
|
||||
* 全局维度 - 按供应商限流
|
||||
* 适用场景:API密钥级别的限流控制
|
||||
* 键格式:provider:{providerCode}
|
||||
*/
|
||||
GLOBAL("provider:{providerCode}", "全局供应商限流"),
|
||||
|
||||
/**
|
||||
* 用户维度 - 按用户+供应商限流
|
||||
* 适用场景:用户级别的配额控制
|
||||
* 键格式:user:{userId}:provider:{providerCode}
|
||||
*/
|
||||
USER_PROVIDER("user:{userId}:provider:{providerCode}", "用户供应商限流"),
|
||||
|
||||
/**
|
||||
* 模型维度 - 按供应商+模型限流
|
||||
* 适用场景:特定模型的限流控制(如GPT-4限制更严格)
|
||||
* 键格式:provider:{providerCode}:model:{modelName}
|
||||
*/
|
||||
PROVIDER_MODEL("provider:{providerCode}:model:{modelName}", "供应商模型限流"),
|
||||
|
||||
/**
|
||||
* 用户模型维度 - 按用户+供应商+模型限流
|
||||
* 适用场景:细粒度的用户级模型限流
|
||||
* 键格式:user:{userId}:provider:{providerCode}:model:{modelName}
|
||||
*/
|
||||
USER_PROVIDER_MODEL("user:{userId}:provider:{providerCode}:model:{modelName}", "用户供应商模型限流"),
|
||||
|
||||
/**
|
||||
* 任务类型维度 - 按任务类型+供应商限流
|
||||
* 适用场景:不同任务类型的差异化限流
|
||||
* 键格式:task:{taskType}:provider:{providerCode}
|
||||
*/
|
||||
TASK_PROVIDER("task:{taskType}:provider:{providerCode}", "任务供应商限流"),
|
||||
|
||||
/**
|
||||
* 混合维度 - 按用户+任务类型+供应商+模型限流
|
||||
* 适用场景:最细粒度的限流控制
|
||||
* 键格式:user:{userId}:task:{taskType}:provider:{providerCode}:model:{modelName}
|
||||
*/
|
||||
HYBRID("user:{userId}:task:{taskType}:provider:{providerCode}:model:{modelName}", "混合维度限流");
|
||||
|
||||
private final String keyTemplate;
|
||||
private final String description;
|
||||
|
||||
RateLimitDimensionEnum(String keyTemplate, String description) {
|
||||
this.keyTemplate = keyTemplate;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成限流键
|
||||
*/
|
||||
public String generateKey(RateLimitKeyContext context) {
|
||||
String key = keyTemplate;
|
||||
|
||||
// 替换占位符
|
||||
if (context.getProviderCode() != null) {
|
||||
key = key.replace("{providerCode}", context.getProviderCode());
|
||||
}
|
||||
if (context.getUserId() != null) {
|
||||
key = key.replace("{userId}", context.getUserId());
|
||||
}
|
||||
if (context.getModelName() != null) {
|
||||
key = key.replace("{modelName}", context.getModelName());
|
||||
}
|
||||
if (context.getTaskType() != null) {
|
||||
key = key.replace("{taskType}", context.getTaskType());
|
||||
}
|
||||
|
||||
// 移除未替换的占位符段
|
||||
key = removeUnreplacedSegments(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除未替换的占位符段
|
||||
*/
|
||||
private String removeUnreplacedSegments(String key) {
|
||||
// 移除包含大括号的段
|
||||
String[] segments = key.split(":");
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (String segment : segments) {
|
||||
if (!segment.contains("{") && !segment.contains("}")) {
|
||||
if (result.length() > 0) {
|
||||
result.append(":");
|
||||
}
|
||||
result.append(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含用户维度
|
||||
*/
|
||||
public boolean hasUserDimension() {
|
||||
return keyTemplate.contains("{userId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含模型维度
|
||||
*/
|
||||
public boolean hasModelDimension() {
|
||||
return keyTemplate.contains("{modelName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含任务维度
|
||||
*/
|
||||
public boolean hasTaskDimension() {
|
||||
return keyTemplate.contains("{taskType}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 限流键上下文
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
@lombok.AllArgsConstructor
|
||||
@lombok.NoArgsConstructor
|
||||
public static class RateLimitKeyContext {
|
||||
private String providerCode;
|
||||
private String userId;
|
||||
private String modelName;
|
||||
private String taskType;
|
||||
|
||||
public static RateLimitKeyContext of(String providerCode, String userId, String modelName) {
|
||||
return RateLimitKeyContext.builder()
|
||||
.providerCode(providerCode)
|
||||
.userId(userId)
|
||||
.modelName(modelName)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static RateLimitKeyContext of(String providerCode, String userId, String modelName, String taskType) {
|
||||
return RateLimitKeyContext.builder()
|
||||
.providerCode(providerCode)
|
||||
.userId(userId)
|
||||
.modelName(modelName)
|
||||
.taskType(taskType)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 限流策略枚举
|
||||
* 定义不同的限流策略及其参数配置
|
||||
*/
|
||||
@Getter
|
||||
public enum RateLimitStrategyEnum {
|
||||
|
||||
/**
|
||||
* 保守策略 - 用于免费API或配额限制严格的服务
|
||||
*/
|
||||
CONSERVATIVE(
|
||||
0.2, // 每秒0.2个请求
|
||||
1, // 突发容量1
|
||||
30000, // 30秒超时
|
||||
4.0, // 4倍重试间隔增长
|
||||
5 // 最大重试次数
|
||||
),
|
||||
|
||||
/**
|
||||
* 标准策略 - 用于付费API的一般场景
|
||||
*/
|
||||
STANDARD(
|
||||
2.0, // 每秒2个请求
|
||||
5, // 突发容量5
|
||||
10000, // 10秒超时
|
||||
2.0, // 2倍重试间隔增长
|
||||
3 // 最大重试次数
|
||||
),
|
||||
|
||||
/**
|
||||
* 激进策略 - 用于高配额付费API
|
||||
*/
|
||||
AGGRESSIVE(
|
||||
10.0, // 每秒10个请求
|
||||
20, // 突发容量20
|
||||
5000, // 5秒超时
|
||||
1.5, // 1.5倍重试间隔增长
|
||||
2 // 最大重试次数
|
||||
),
|
||||
|
||||
/**
|
||||
* 自适应策略 - 根据历史错误率动态调整
|
||||
*/
|
||||
ADAPTIVE(
|
||||
1.0, // 初始每秒1个请求
|
||||
3, // 突发容量3
|
||||
15000, // 15秒超时
|
||||
3.0, // 3倍重试间隔增长
|
||||
4 // 最大重试次数
|
||||
);
|
||||
|
||||
private final double ratePerSecond;
|
||||
private final int burstCapacity;
|
||||
private final long timeoutMillis;
|
||||
private final double retryBackoffMultiplier;
|
||||
private final int maxRetryAttempts;
|
||||
|
||||
RateLimitStrategyEnum(double ratePerSecond, int burstCapacity, long timeoutMillis,
|
||||
double retryBackoffMultiplier, int maxRetryAttempts) {
|
||||
this.ratePerSecond = ratePerSecond;
|
||||
this.burstCapacity = burstCapacity;
|
||||
this.timeoutMillis = timeoutMillis;
|
||||
this.retryBackoffMultiplier = retryBackoffMultiplier;
|
||||
this.maxRetryAttempts = maxRetryAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误率动态调整策略
|
||||
*/
|
||||
public RateLimitStrategyEnum adjustForErrorRate(double errorRate) {
|
||||
if (this == ADAPTIVE) {
|
||||
if (errorRate > 0.3) {
|
||||
return CONSERVATIVE;
|
||||
} else if (errorRate > 0.1) {
|
||||
return STANDARD;
|
||||
} else {
|
||||
return AGGRESSIVE;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.mongodb.ReactiveMongoDatabaseFactory;
|
||||
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
|
||||
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
|
||||
import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver;
|
||||
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
|
||||
|
||||
@Configuration
|
||||
public class ReactiveMongoConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ReactiveMongoConfig.class);
|
||||
private static final String DOT_REPLACEMENT = "#DOT#";
|
||||
|
||||
@Bean
|
||||
@Primary // 确保这个Bean优先级最高
|
||||
public MappingMongoConverter mappingMongoConverter(ReactiveMongoDatabaseFactory factory,
|
||||
MongoMappingContext context,
|
||||
MongoCustomConversions conversions) {
|
||||
logger.info("🔧 创建 MappingMongoConverter Bean...");
|
||||
|
||||
NoOpDbRefResolver dbRefResolver = NoOpDbRefResolver.INSTANCE;
|
||||
MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, context);
|
||||
converter.setCustomConversions(conversions);
|
||||
converter.setCodecRegistryProvider(factory);
|
||||
|
||||
// 强制设置点号替换,解决 "ai.daily.calls"、"import.daily.limit" 等带点号的Map key问题
|
||||
converter.setMapKeyDotReplacement(DOT_REPLACEMENT);
|
||||
|
||||
logger.info("✅ MongoDB MappingMongoConverter 配置完成:");
|
||||
logger.info(" - 点号替换字符: '{}'", DOT_REPLACEMENT);
|
||||
logger.info(" - Bean优先级: @Primary");
|
||||
logger.info(" - 解决Map key包含点号的问题: ai.daily.calls, import.daily.limit 等");
|
||||
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 重试策略枚举
|
||||
* 定义不同的重试策略及其参数配置
|
||||
*/
|
||||
@Getter
|
||||
public enum RetryStrategyEnum {
|
||||
|
||||
/**
|
||||
* 指数退避 - 用于配额限制敏感的服务
|
||||
*/
|
||||
EXPONENTIAL_BACKOFF(
|
||||
1000, // 初始延迟1秒
|
||||
4.0, // 4倍增长因子 (按用户要求)
|
||||
120000, // 最大延迟2分钟
|
||||
true, // 启用抖动
|
||||
true, // 使用RabbitMQ延迟队列
|
||||
5 // 最大重试次数
|
||||
),
|
||||
|
||||
/**
|
||||
* 线性退避 - 用于一般重试场景
|
||||
*/
|
||||
LINEAR_BACKOFF(
|
||||
2000, // 初始延迟2秒
|
||||
2.0, // 2倍增长因子
|
||||
30000, // 最大延迟30秒
|
||||
true, // 启用抖动
|
||||
false, // 不使用RabbitMQ延迟队列
|
||||
3 // 最大重试次数
|
||||
),
|
||||
|
||||
/**
|
||||
* 固定间隔 - 用于网络错误等临时问题
|
||||
*/
|
||||
FIXED_INTERVAL(
|
||||
5000, // 固定5秒延迟
|
||||
1.0, // 不增长
|
||||
5000, // 最大延迟也是5秒
|
||||
false, // 不启用抖动
|
||||
false, // 不使用RabbitMQ延迟队列
|
||||
2 // 最大重试次数
|
||||
),
|
||||
|
||||
/**
|
||||
* 智能退避 - 根据错误类型动态调整
|
||||
*/
|
||||
INTELLIGENT_BACKOFF(
|
||||
3000, // 初始延迟3秒
|
||||
3.0, // 3倍增长因子
|
||||
60000, // 最大延迟1分钟
|
||||
true, // 启用抖动
|
||||
true, // 使用RabbitMQ延迟队列
|
||||
4 // 最大重试次数
|
||||
);
|
||||
|
||||
private final long initialDelayMillis;
|
||||
private final double backoffMultiplier;
|
||||
private final long maxDelayMillis;
|
||||
private final boolean enableJitter;
|
||||
private final boolean useRabbitMQDelay;
|
||||
private final int maxRetryAttempts;
|
||||
|
||||
RetryStrategyEnum(long initialDelayMillis, double backoffMultiplier, long maxDelayMillis,
|
||||
boolean enableJitter, boolean useRabbitMQDelay, int maxRetryAttempts) {
|
||||
this.initialDelayMillis = initialDelayMillis;
|
||||
this.backoffMultiplier = backoffMultiplier;
|
||||
this.maxDelayMillis = maxDelayMillis;
|
||||
this.enableJitter = enableJitter;
|
||||
this.useRabbitMQDelay = useRabbitMQDelay;
|
||||
this.maxRetryAttempts = maxRetryAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算下次重试延迟时间
|
||||
*/
|
||||
public long calculateDelay(int attemptNumber) {
|
||||
long delay;
|
||||
|
||||
switch (this) {
|
||||
case EXPONENTIAL_BACKOFF:
|
||||
case INTELLIGENT_BACKOFF:
|
||||
delay = (long) (initialDelayMillis * Math.pow(backoffMultiplier, attemptNumber - 1));
|
||||
break;
|
||||
case LINEAR_BACKOFF:
|
||||
delay = initialDelayMillis * attemptNumber;
|
||||
break;
|
||||
case FIXED_INTERVAL:
|
||||
default:
|
||||
delay = initialDelayMillis;
|
||||
break;
|
||||
}
|
||||
|
||||
// 限制最大延迟
|
||||
delay = Math.min(delay, maxDelayMillis);
|
||||
|
||||
// 添加抖动避免惊群效应
|
||||
if (enableJitter) {
|
||||
double jitter = Math.random() * 0.1; // 10%抖动
|
||||
delay = (long) (delay * (1 + jitter));
|
||||
}
|
||||
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型调整重试策略
|
||||
*/
|
||||
public RetryStrategyEnum adjustForErrorType(String errorType) {
|
||||
if (this == INTELLIGENT_BACKOFF) {
|
||||
if (errorType.contains("429") || errorType.contains("quota")) {
|
||||
return EXPONENTIAL_BACKOFF;
|
||||
} else if (errorType.contains("500") || errorType.contains("502")) {
|
||||
return LINEAR_BACKOFF;
|
||||
} else if (errorType.contains("timeout")) {
|
||||
return FIXED_INTERVAL;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.data.convert.ReadingConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 安全的Map转换器,处理可能的类型不匹配问题
|
||||
* 特别是当数据库中存储的是JSON字符串,但需要映射为Map<String, Object>时
|
||||
*/
|
||||
@Component
|
||||
@ReadingConverter
|
||||
public class SafeMapConverter implements Converter<Object, Map<String, Object>> {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SafeMapConverter.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SafeMapConverter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> convert(Object source) {
|
||||
if (source == null) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
// 如果已经是Map,直接返回
|
||||
if (source instanceof Map) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> result = (Map<String, Object>) source;
|
||||
return result;
|
||||
} catch (ClassCastException e) {
|
||||
logger.warn("Map类型转换失败,尝试重新构建: {}", e.getMessage());
|
||||
// 如果类型转换失败,尝试重新构建
|
||||
try {
|
||||
Map<?, ?> rawMap = (Map<?, ?>) source;
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
rawMap.forEach((key, value) -> {
|
||||
String stringKey = key != null ? key.toString() : null;
|
||||
result.put(stringKey, value);
|
||||
});
|
||||
return result;
|
||||
} catch (Exception ex) {
|
||||
logger.error("Map重构失败: {}", ex.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是字符串,尝试解析为JSON
|
||||
if (source instanceof String) {
|
||||
String jsonString = (String) source;
|
||||
if (jsonString.trim().isEmpty()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析为Map
|
||||
TypeReference<Map<String, Object>> typeRef = new TypeReference<Map<String, Object>>() {};
|
||||
return objectMapper.readValue(jsonString, typeRef);
|
||||
} catch (Exception e) {
|
||||
logger.warn("无法将字符串解析为Map,返回包含原字符串的Map: {}", e.getMessage());
|
||||
// 如果解析失败,将字符串作为值存储
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("value", jsonString);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 对于其他类型,尝试使用ObjectMapper转换
|
||||
try {
|
||||
TypeReference<Map<String, Object>> typeRef = new TypeReference<Map<String, Object>>() {};
|
||||
return objectMapper.convertValue(source, typeRef);
|
||||
} catch (Exception e) {
|
||||
logger.warn("无法转换对象为Map: {} -> {}", source.getClass().getSimpleName(), e.getMessage());
|
||||
// 如果所有转换都失败,返回一个包含原始值的Map
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("originalValue", source);
|
||||
result.put("originalType", source.getClass().getSimpleName());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulingConfig {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
|
||||
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import com.ainovel.server.security.JwtAuthenticationManager;
|
||||
import com.ainovel.server.security.JwtServerAuthenticationConverter;
|
||||
|
||||
/**
|
||||
* 安全配置类 配置JWT认证和授权规则
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@Profile("!test")
|
||||
public class SecurityConfig {
|
||||
|
||||
private final ReactiveAuthenticationManager authenticationManager;
|
||||
private final ServerAuthenticationConverter authenticationConverter;
|
||||
|
||||
@Autowired
|
||||
public SecurityConfig(JwtAuthenticationManager authenticationManager,
|
||||
JwtServerAuthenticationConverter authenticationConverter) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.authenticationConverter = authenticationConverter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
||||
// 创建JWT认证过滤器
|
||||
AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(authenticationManager);
|
||||
authenticationWebFilter.setServerAuthenticationConverter(authenticationConverter);
|
||||
// 只对需要认证的路径进行认证检查
|
||||
authenticationWebFilter.setRequiresAuthenticationMatcher(
|
||||
ServerWebExchangeMatchers.pathMatchers("/api/v1/novels/**", "/api/v1/scenes/**",
|
||||
"/api/v1/users/**", "/api/v1/ai/**", "/api/v1/chats/**", "/api/v1/user-ai-configs/**",
|
||||
"/api/v1/ai-chat/**", "/api/v1/api/users/**", "/api/v1/api/tasks/**",
|
||||
"/api/v1/api/models/**", "/api/v1/security-test/**", "/api/v1/mongo-test/**",
|
||||
"/api/v1/novel-snippets/**", "/api/v1/ai-chat-history/**", "/api/v1/api/user-editor-settings/**",
|
||||
"/api/v1/prompts/**", "/api/v1/prompt-aggregation/**", "/api/v1/prompt-templates/**", "/api/v1/content-provider/**",
|
||||
"/api/v1/admin/**","/api/v1/public-models/**","/api/v1/credits/**","/api/v1/preset-aggregation/**", "/api/v1/presets/**",
|
||||
"/api/v1/setting-histories/**","/api/v1/setting-generation/**","/api/v1/test/setting-generation/**","/api/v1/compose/**","/api/v1/tool-orchestration/**",
|
||||
"/api/v1/analytics/**", "/api/v1/payments/**")
|
||||
);
|
||||
|
||||
// 添加认证失败处理器
|
||||
authenticationWebFilter.setAuthenticationFailureHandler(
|
||||
(exchange, ex) -> {
|
||||
exchange.getExchange().getResponse().setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
return exchange.getExchange().getResponse().setComplete();
|
||||
}
|
||||
);
|
||||
|
||||
return http
|
||||
.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.cors(corsSpec -> corsSpec.configurationSource(corsConfigurationSource()))
|
||||
.authorizeExchange(exchanges -> exchanges
|
||||
// 公开端点
|
||||
.pathMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
// 静态资源和根路径
|
||||
.pathMatchers("/", "/index.html", "/favicon.ico", "/manifest.json").permitAll()
|
||||
.pathMatchers("/assets/**", "/icons/**", "/canvaskit/**", "/*.js", "/*.css").permitAll()
|
||||
// 放开 Actuator 指标端点给 Prometheus 抓取
|
||||
.pathMatchers(HttpMethod.GET, "/actuator/prometheus").permitAll()
|
||||
.pathMatchers(HttpMethod.GET, "/actuator/health").permitAll()
|
||||
.pathMatchers("/api/v1/auth/**").permitAll()
|
||||
.pathMatchers("/api/v1/auth/login").permitAll()
|
||||
.pathMatchers("/api/v1/auth/login/phone").permitAll()
|
||||
.pathMatchers("/api/v1/auth/login/email").permitAll()
|
||||
.pathMatchers("/api/v1/auth/register").permitAll()
|
||||
.pathMatchers("/api/v1/auth/register/quick").permitAll()
|
||||
.pathMatchers("/api/v1/auth/verification-code").permitAll()
|
||||
.pathMatchers("/api/v1/auth/captcha").permitAll()
|
||||
.pathMatchers("/api/v1/admin/auth/**").permitAll() // 管理员登录端点
|
||||
.pathMatchers("/api/v1/users/register").permitAll()
|
||||
// 订阅与点数包:公开获取
|
||||
.pathMatchers("/api/v1/subscription-plans/**").permitAll()
|
||||
.pathMatchers("/api/v1/credit-packs/**").permitAll()
|
||||
// 设定生成:放开 GET /strategies 供游客拉取公共策略
|
||||
.pathMatchers(HttpMethod.GET, "/api/v1/setting-generation/strategies").permitAll()
|
||||
// 需要认证的端点
|
||||
.pathMatchers("/api/v1/setting-generation/**").authenticated()
|
||||
.pathMatchers("/api/v1/test/setting-generation/**").authenticated()
|
||||
.pathMatchers("/api/v1/setting-histories/**").authenticated()
|
||||
.pathMatchers("/api/v1/novels/**").authenticated()
|
||||
.pathMatchers("/api/v1/preset-aggregation/**").authenticated()
|
||||
.pathMatchers("/api/v1/presets/**").authenticated()
|
||||
.pathMatchers("/api/v1/scenes/**").authenticated()
|
||||
.pathMatchers("/api/v1/users/**").authenticated()
|
||||
.pathMatchers("/api/v1/ai/**").authenticated()
|
||||
.pathMatchers("/api/v1/chats/**").authenticated()
|
||||
.pathMatchers("/api/v1/user-ai-configs/**").authenticated()
|
||||
.pathMatchers("/api/v1/ai-chat/**").authenticated()
|
||||
.pathMatchers("/api/v1/api/users/**").authenticated()
|
||||
.pathMatchers("/api/v1/api/tasks/**").authenticated()
|
||||
.pathMatchers("/api/v1/api/models/**").authenticated()
|
||||
.pathMatchers("/api/v1/security-test/**").authenticated()
|
||||
.pathMatchers("/api/v1/mongo-test/**").authenticated()
|
||||
.pathMatchers("/api/v1/novel-snippets/**").authenticated()
|
||||
.pathMatchers("/api/v1/ai-chat-history/**").authenticated()
|
||||
.pathMatchers("/api/v1/api/user-editor-settings/**").authenticated()
|
||||
.pathMatchers("/api/v1/public-models/**").authenticated()
|
||||
.pathMatchers("/api/v1/credits/**").authenticated()
|
||||
.pathMatchers("/api/v1/compose/**").authenticated()
|
||||
.pathMatchers("/api/v1/tool-orchestration/**").authenticated()
|
||||
// 数据分析接口:需要用户认证
|
||||
.pathMatchers("/api/v1/analytics/**").authenticated()
|
||||
// 新增的提示词相关API端点
|
||||
.pathMatchers("/api/v1/prompts/**").authenticated()
|
||||
.pathMatchers("/api/v1/prompt-aggregation/**").authenticated()
|
||||
.pathMatchers("/api/v1/prompt-templates/**").authenticated()
|
||||
.pathMatchers("/api/v1/content-provider/**").authenticated()
|
||||
// 管理员API端点
|
||||
.pathMatchers("/api/v1/admin/**").authenticated()
|
||||
// 支付:回调放行,其余需要认证
|
||||
.pathMatchers("/api/v1/payments/notify/**").permitAll()
|
||||
.pathMatchers("/api/v1/payments/**").authenticated()
|
||||
// 其他所有请求需要认证
|
||||
.anyExchange().authenticated()
|
||||
)
|
||||
// 显式设置全局认证管理器,避免默认过滤器无provider导致500
|
||||
.authenticationManager(authenticationManager)
|
||||
// 兜底:未认证时统一返回401
|
||||
.exceptionHandling(spec -> spec.authenticationEntryPoint((swe, e) -> {
|
||||
swe.getResponse().setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
return swe.getResponse().setComplete();
|
||||
}))
|
||||
// 使用addFilterAt替代addFilter,并指定正确的过滤器顺序
|
||||
.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
// 使用无状态会话
|
||||
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(Arrays.asList("*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
|
||||
configuration.setExposedHeaders(Arrays.asList("Authorization"));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.ainovel.server.service.setting.generation.SettingGenerationStrategy;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 设定生成配置
|
||||
* 配置策略Bean和相关组件
|
||||
*/
|
||||
@Configuration
|
||||
public class SettingGenerationConfig {
|
||||
|
||||
/**
|
||||
* 注册所有策略为Map
|
||||
* key为策略名称,value为策略实现
|
||||
*/
|
||||
@Bean
|
||||
public Map<String, SettingGenerationStrategy> settingGenerationStrategies(
|
||||
List<SettingGenerationStrategy> strategies) {
|
||||
return strategies.stream()
|
||||
.collect(Collectors.toMap(
|
||||
strategy -> strategy.getStrategyName().toLowerCase().replace(" ", "-"),
|
||||
Function.identity()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class SpringContextHolder implements ApplicationContextAware {
|
||||
|
||||
private static ApplicationContext CONTEXT;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
CONTEXT = applicationContext;
|
||||
}
|
||||
|
||||
public static <T> T getBean(Class<T> type) {
|
||||
return CONTEXT.getBean(type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 存储相关配置
|
||||
*/
|
||||
@Configuration
|
||||
public class StorageConfig {
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties(prefix = "ainovel.storage")
|
||||
public StorageProperties storageProperties() {
|
||||
return new StorageProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储配置属性
|
||||
*/
|
||||
@Data
|
||||
public static class StorageProperties {
|
||||
|
||||
/**
|
||||
* 默认存储提供者
|
||||
*/
|
||||
private String defaultProvider = "alioss";
|
||||
|
||||
/**
|
||||
* 封面存储路径
|
||||
*/
|
||||
private String coversPath = "covers";
|
||||
|
||||
/**
|
||||
* 启动时是否测试存储连接
|
||||
*/
|
||||
private boolean testOnStartup = false;
|
||||
|
||||
/**
|
||||
* 阿里云OSS配置
|
||||
*/
|
||||
private AliOssProperties aliyun = new AliOssProperties();
|
||||
|
||||
/**
|
||||
* 其他存储提供者可以在这里添加
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* 阿里云OSS配置属性
|
||||
*/
|
||||
@Data
|
||||
public static class AliOssProperties {
|
||||
|
||||
/**
|
||||
* 终端节点
|
||||
*/
|
||||
private String endpoint;
|
||||
|
||||
/**
|
||||
* 访问密钥ID
|
||||
*/
|
||||
private String accessKeyId;
|
||||
|
||||
/**
|
||||
* 访问密钥密钥
|
||||
*/
|
||||
private String accessKeySecret;
|
||||
|
||||
/**
|
||||
* 存储桶名称
|
||||
*/
|
||||
private String bucketName;
|
||||
|
||||
/**
|
||||
* 自定义基础URL(可选)
|
||||
*/
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 地域信息,如cn-hangzhou(可选,如果不提供将从endpoint中提取)
|
||||
*/
|
||||
private String region;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.ainovel.server.config.StorageConfig.StorageProperties;
|
||||
import com.ainovel.server.service.provider.AliOSSStorageProvider;
|
||||
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
|
||||
import com.aliyun.oss.common.comm.SignVersion;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 存储服务启动测试 在应用启动时进行OSS存储服务连接测试
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class StorageStartupTester implements ApplicationRunner {
|
||||
|
||||
private final StorageProperties storageProperties;
|
||||
private final AliOSSStorageProvider ossStorageProvider;
|
||||
private final Environment environment;
|
||||
|
||||
@Autowired
|
||||
public StorageStartupTester(StorageProperties storageProperties,
|
||||
AliOSSStorageProvider ossStorageProvider,
|
||||
Environment environment) {
|
||||
this.storageProperties = storageProperties;
|
||||
this.ossStorageProvider = ossStorageProvider;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
// 检查是否启用测试
|
||||
if (!storageProperties.isTestOnStartup()) {
|
||||
log.info("阿里云OSS连接测试已禁用,跳过测试");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("开始测试阿里云OSS连接...");
|
||||
|
||||
try {
|
||||
|
||||
// 创建测试文件名
|
||||
String testFileName = "oss-test-" + UUID.randomUUID().toString() + ".txt";
|
||||
String testKey = String.format("%s/tests/%s", storageProperties.getCoversPath(), testFileName);
|
||||
String testContent = "这是一个测试文件,创建于 " + System.currentTimeMillis();
|
||||
|
||||
// 获取OSS配置
|
||||
com.ainovel.server.config.StorageConfig.AliOssProperties ossProps = storageProperties.getAliyun();
|
||||
String endpoint = ossProps.getEndpoint();
|
||||
String accessKeyId = ossProps.getAccessKeyId();
|
||||
String accessKeySecret = ossProps.getAccessKeySecret();
|
||||
String bucketName = ossProps.getBucketName();
|
||||
String region = ossProps.getRegion();
|
||||
|
||||
if (region == null || region.isEmpty()) {
|
||||
region = extractRegionFromEndpoint(endpoint);
|
||||
log.info("从endpoint提取region: {}", region);
|
||||
}
|
||||
|
||||
log.info("测试OSS连接: endpoint={}, bucket={}, region={}, testKey={}",
|
||||
endpoint, bucketName, region, testKey);
|
||||
|
||||
// 测试上传
|
||||
OSS ossClient = null;
|
||||
try {
|
||||
// 创建客户端
|
||||
ClientBuilderConfiguration conf = new ClientBuilderConfiguration();
|
||||
conf.setSignatureVersion(SignVersion.V4);
|
||||
|
||||
if (region != null && !region.isEmpty()) {
|
||||
ossClient = OSSClientBuilder.create()
|
||||
.endpoint(endpoint)
|
||||
.credentialsProvider(new DefaultCredentialProvider(accessKeyId, accessKeySecret))
|
||||
.clientConfiguration(conf)
|
||||
.region(region)
|
||||
.build();
|
||||
} else {
|
||||
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret, conf);
|
||||
}
|
||||
|
||||
// 上传测试文件
|
||||
ossClient.putObject(bucketName, testKey,
|
||||
new ByteArrayInputStream(testContent.getBytes()));
|
||||
log.info("测试文件上传成功: {}", testKey);
|
||||
|
||||
// 检查文件是否存在
|
||||
boolean exists = ossClient.doesObjectExist(bucketName, testKey);
|
||||
log.info("测试文件存在检查: {}", exists ? "成功" : "失败");
|
||||
|
||||
// 删除测试文件
|
||||
ossClient.deleteObject(bucketName, testKey);
|
||||
log.info("测试文件删除成功");
|
||||
|
||||
log.info("阿里云OSS连接测试成功完成!存储服务配置正常。");
|
||||
} finally {
|
||||
if (ossClient != null) {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("阿里云OSS连接测试失败", e);
|
||||
|
||||
// 如果在生产环境中测试失败,可能需要发出警告
|
||||
if (isProductionEnvironment()) {
|
||||
log.error("警告:生产环境中OSS存储服务测试失败,这可能会影响应用程序的正常运行!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从endpoint提取region信息
|
||||
*/
|
||||
private String extractRegionFromEndpoint(String endpoint) {
|
||||
try {
|
||||
// 移除协议部分
|
||||
String noProtocol = endpoint.replaceAll("^https?://", "");
|
||||
// 查找第一个点的位置
|
||||
int dotIndex = noProtocol.indexOf('.');
|
||||
if (dotIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 提取 oss-cn-hangzhou 部分
|
||||
String prefix = noProtocol.substring(0, dotIndex);
|
||||
// 如果以 oss- 开头,去掉 oss- 前缀
|
||||
if (prefix.startsWith("oss-")) {
|
||||
return prefix.substring(4);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("从endpoint提取region时出错: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是生产环境
|
||||
*/
|
||||
private boolean isProductionEnvironment() {
|
||||
String[] activeProfiles = environment.getActiveProfiles();
|
||||
for (String profile : activeProfiles) {
|
||||
if (profile.equalsIgnoreCase("prod") || profile.equalsIgnoreCase("production")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.ainovel.server.task.dto.continuecontent.ContinueWritingContentParameters;
|
||||
import com.ainovel.server.task.dto.continuecontent.GenerateSingleChapterParameters;
|
||||
import com.ainovel.server.task.dto.scenegeneration.GenerateSceneParameters;
|
||||
import com.ainovel.server.task.dto.scenegeneration.GenerateSceneResult;
|
||||
import com.ainovel.server.task.dto.summarygeneration.GenerateSummaryParameters;
|
||||
import com.ainovel.server.task.dto.summarygeneration.GenerateSummaryResult;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.convert.TypeDescriptor;
|
||||
import org.springframework.core.convert.converter.GenericConverter;
|
||||
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 任务转换配置类
|
||||
* 提供后台任务系统参数、进度、结果对象的响应式序列化/反序列化支持
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class TaskConversionConfig {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// 任务类型到参数类型的映射
|
||||
private final Map<String, Class<?>> parameterTypeMap = new ConcurrentHashMap<>();
|
||||
|
||||
// 任务类型到结果类型的映射
|
||||
private final Map<String, Class<?>> resultTypeMap = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
public TaskConversionConfig(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
|
||||
// 初始化类型映射
|
||||
initializeTypeMapping();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化任务类型到参数类型和结果类型的映射
|
||||
*/
|
||||
private void initializeTypeMapping() {
|
||||
// 摘要生成任务
|
||||
parameterTypeMap.put("GENERATE_SUMMARY", GenerateSummaryParameters.class);
|
||||
resultTypeMap.put("GENERATE_SUMMARY", GenerateSummaryResult.class);
|
||||
|
||||
// 场景生成任务
|
||||
parameterTypeMap.put("GENERATE_SCENE", GenerateSceneParameters.class);
|
||||
resultTypeMap.put("GENERATE_SCENE", GenerateSceneResult.class);
|
||||
|
||||
// 添加任务类型到参数类的映射
|
||||
parameterTypeMap.put("CONTINUE_WRITING_CONTENT", ContinueWritingContentParameters.class);
|
||||
parameterTypeMap.put("GENERATE_SINGLE_CHAPTER", GenerateSingleChapterParameters.class); // 添加这一行
|
||||
|
||||
// 批量摘要生成任务
|
||||
// 需要定义 BatchGenerateSummaryParameters 和 BatchGenerateSummaryResult 类
|
||||
// parameterTypeMap.put("BATCH_GENERATE_SUMMARY", BatchGenerateSummaryParameters.class);
|
||||
// resultTypeMap.put("BATCH_GENERATE_SUMMARY", BatchGenerateSummaryResult.class);
|
||||
|
||||
// 批量场景生成任务
|
||||
// 需要定义 BatchGenerateSceneParameters 和 BatchGenerateSceneResult 类
|
||||
// parameterTypeMap.put("BATCH_GENERATE_SCENE", BatchGenerateSceneParameters.class);
|
||||
// resultTypeMap.put("BATCH_GENERATE_SCENE", BatchGenerateSceneResult.class);
|
||||
|
||||
// 续写内容任务
|
||||
// parameterTypeMap.put("CONTINUE_WRITING_CONTENT", ContinueWritingContentParameters.class);
|
||||
// resultTypeMap.put("CONTINUE_WRITING_CONTENT", ContinueWritingContentResult.class);
|
||||
|
||||
// 仅续写摘要任务
|
||||
// parameterTypeMap.put("GENERATE_NEXT_SUMMARIES_ONLY", GenerateNextSummariesOnlyParameters.class);
|
||||
// resultTypeMap.put("GENERATE_NEXT_SUMMARIES_ONLY", GenerateNextSummariesOnlyResult.class);
|
||||
|
||||
// 生成章节内容任务
|
||||
// parameterTypeMap.put("GENERATE_CHAPTER_CONTENT", GenerateChapterContentParameters.class);
|
||||
// resultTypeMap.put("GENERATE_CHAPTER_CONTENT", GenerateChapterContentResult.class);
|
||||
|
||||
// 确保添加了所有实际使用的任务类型的映射
|
||||
log.info("TaskConversionConfig 初始化完成,已注册参数类型映射: {}", parameterTypeMap.keySet());
|
||||
log.info("TaskConversionConfig 初始化完成,已注册结果类型映射: {}", resultTypeMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据任务类型和原始数据对象,反序列化为指定类型的参数对象
|
||||
* @param taskType 任务类型
|
||||
* @param source 原始数据(通常是Map)
|
||||
* @return 反序列化后的参数对象的Mono
|
||||
*/
|
||||
public Mono<Object> convertParametersToType(String taskType, Object source) {
|
||||
if (source == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
Class<?> targetType = parameterTypeMap.get(taskType);
|
||||
if (targetType == null) {
|
||||
log.warn("未找到任务类型 {} 的参数类型映射", taskType);
|
||||
return Mono.justOrEmpty(source);
|
||||
}
|
||||
|
||||
return Mono.fromCallable(() -> {
|
||||
try {
|
||||
if (source instanceof Map) {
|
||||
return objectMapper.convertValue(source, targetType);
|
||||
} else if (targetType.isInstance(source)) {
|
||||
return source;
|
||||
} else {
|
||||
return objectMapper.readValue(objectMapper.writeValueAsString(source), targetType);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("反序列化任务参数失败, taskType={}", taskType, e);
|
||||
return source; // 如果转换失败,返回原始对象
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据任务类型和原始数据对象,反序列化为指定类型的结果对象
|
||||
* @param taskType 任务类型
|
||||
* @param source 原始数据(通常是Map)
|
||||
* @return 反序列化后的结果对象的Mono
|
||||
*/
|
||||
public Mono<Object> convertResultToType(String taskType, Object source) {
|
||||
if (source == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
Class<?> targetType = resultTypeMap.get(taskType);
|
||||
if (targetType == null) {
|
||||
log.warn("未找到任务类型 {} 的结果类型映射", taskType);
|
||||
return Mono.justOrEmpty(source);
|
||||
}
|
||||
|
||||
return Mono.fromCallable(() -> {
|
||||
try {
|
||||
if (source instanceof Map) {
|
||||
return objectMapper.convertValue(source, targetType);
|
||||
} else if (targetType.isInstance(source)) {
|
||||
return source;
|
||||
} else {
|
||||
return objectMapper.readValue(objectMapper.writeValueAsString(source), targetType);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("反序列化任务结果失败, taskType={}", taskType, e);
|
||||
return source; // 如果转换失败,返回原始对象
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意对象序列化为MongoDB可存储的格式,通常是Map
|
||||
* @param source 源对象
|
||||
* @return 序列化后的对象的Mono
|
||||
*/
|
||||
public Mono<Object> convertToStorageFormat(Object source) {
|
||||
if (source == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return Mono.fromCallable(() -> {
|
||||
try {
|
||||
if (source instanceof Map) {
|
||||
return source;
|
||||
} else {
|
||||
return objectMapper.convertValue(source, Map.class);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("序列化对象为存储格式失败", e);
|
||||
// 如果无法转换为Map,尝试使用toString()
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("value", source.toString());
|
||||
result.put("_conversion_error", "true");
|
||||
return result;
|
||||
}
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定的任务类型注册参数类型
|
||||
* @param taskType 任务类型
|
||||
* @param parameterClass 参数类型的Class
|
||||
*/
|
||||
public void registerParameterType(String taskType, Class<?> parameterClass) {
|
||||
parameterTypeMap.put(taskType, parameterClass);
|
||||
log.info("已注册任务类型 {} 的参数类型: {}", taskType, parameterClass.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定的任务类型注册结果类型
|
||||
* @param taskType 任务类型
|
||||
* @param resultClass 结果类型的Class
|
||||
*/
|
||||
public void registerResultType(String taskType, Class<?> resultClass) {
|
||||
resultTypeMap.put(taskType, resultClass);
|
||||
log.info("已注册任务类型 {} 的结果类型: {}", taskType, resultClass.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台任务系统专用的Jackson配置类
|
||||
* 提供专为BackgroundTask的parameters、progress、result对象设计的序列化/反序列化支持
|
||||
*/
|
||||
@Configuration
|
||||
public class TaskJacksonConfig {
|
||||
|
||||
/**
|
||||
* 创建后台任务系统专用的ObjectMapper
|
||||
* @param objectMapper 从全局JacksonConfig中获取的基础配置
|
||||
* @return 后台任务系统专用的ObjectMapper
|
||||
*/
|
||||
@Bean(name = "taskObjectMapper")
|
||||
public ObjectMapper taskObjectMapper(@Autowired ObjectMapper objectMapper) {
|
||||
// 基于全局ObjectMapper创建专用实例
|
||||
ObjectMapper taskMapper = objectMapper.copy();
|
||||
|
||||
// 注册自定义模块
|
||||
SimpleModule taskModule = new SimpleModule("TaskModule");
|
||||
|
||||
// 为特定类型添加序列化器
|
||||
configureSerializers(taskModule);
|
||||
|
||||
// 为特定类型添加反序列化器
|
||||
configureDeserializers(taskModule);
|
||||
|
||||
// 注册模块
|
||||
taskMapper.registerModule(taskModule);
|
||||
|
||||
// 允许未知属性
|
||||
taskMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
// 确保保留空集合
|
||||
taskMapper.configure(SerializationFeature.WRITE_EMPTY_JSON_ARRAYS, true);
|
||||
|
||||
// 确保Map中的null值也被序列化,以便在更新进度时能够显式地设置null
|
||||
taskMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
|
||||
|
||||
return taskMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置序列化器
|
||||
* @param module 要配置的SimpleModule
|
||||
*/
|
||||
private void configureSerializers(SimpleModule module) {
|
||||
// 例如,为Instant类型添加自定义的序列化器
|
||||
// module.addSerializer(Instant.class, new CustomInstantSerializer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置反序列化器
|
||||
* @param module 要配置的SimpleModule
|
||||
*/
|
||||
private void configureDeserializers(SimpleModule module) {
|
||||
// 例如,为Instant类型添加自定义的反序列化器
|
||||
// module.addDeserializer(Instant.class, new CustomInstantDeserializer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 可用于在Map类型和特定对象类型间进行转换的帮助方法
|
||||
* @param map 源Map
|
||||
* @param targetClass 目标类型
|
||||
* @param objectMapper ObjectMapper实例
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
public static <T> T convertMapToObject(Map<String, Object> map, Class<T> targetClass, ObjectMapper objectMapper) {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.convertValue(map, targetClass);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("无法将Map转换为" + targetClass.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象转换为Map的帮助方法
|
||||
* @param object 源对象
|
||||
* @param objectMapper ObjectMapper实例
|
||||
* @return 转换后的Map
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> convertObjectToMap(Object object, ObjectMapper objectMapper) {
|
||||
if (object == null) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
if (object instanceof Map) {
|
||||
return (Map<String, Object>) object;
|
||||
}
|
||||
try {
|
||||
return objectMapper.convertValue(object, Map.class);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("无法将对象转换为Map", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||
|
||||
/**
|
||||
* 测试环境专用安全配置
|
||||
* 仅在测试环境(test或performance-test配置文件激活时)生效
|
||||
* 禁用JWT验证和CSRF保护,方便测试
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@Profile({ "test", "performance-test" })
|
||||
public class TestSecurityConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TestSecurityConfig.class);
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain testSecurityFilterChain(ServerHttpSecurity http) {
|
||||
logger.info("使用测试环境安全配置,所有请求将被允许通过,无需认证");
|
||||
|
||||
return http
|
||||
.csrf(ServerHttpSecurity.CsrfSpec::disable) // 禁用CSRF保护
|
||||
.cors(corsSpec -> corsSpec.configurationSource(corsConfigurationSource()))
|
||||
.authorizeExchange(exchanges -> {
|
||||
logger.debug("配置测试环境安全规则:允许所有请求");
|
||||
exchanges
|
||||
.pathMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.pathMatchers("/api/v1/**").permitAll() // 允许所有API请求
|
||||
.pathMatchers("/**").permitAll() // 允许所有请求通过,不需要认证
|
||||
.anyExchange().permitAll(); // 确保所有请求都允许通过
|
||||
})
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(Arrays.asList("*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
|
||||
configuration.setExposedHeaders(Arrays.asList("Authorization"));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import com.ainovel.server.service.ai.tools.fallback.ToolFallbackParser;
|
||||
import com.ainovel.server.service.ai.tools.fallback.ToolFallbackRegistry;
|
||||
import com.ainovel.server.service.ai.tools.fallback.impl.DefaultToolFallbackRegistry;
|
||||
import com.ainovel.server.service.ai.tools.fallback.impl.CreateComposeOutlinesJsonFallbackParser;
|
||||
import com.ainovel.server.service.ai.tools.fallback.impl.TextToSettingsJsonFallbackParser;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class ToolFallbackConfig {
|
||||
|
||||
@Bean
|
||||
public ToolFallbackParser textToSettingsJsonFallbackParser() {
|
||||
return new TextToSettingsJsonFallbackParser();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ToolFallbackParser createComposeOutlinesJsonFallbackParser() {
|
||||
return new CreateComposeOutlinesJsonFallbackParser();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ToolFallbackRegistry toolFallbackRegistry(List<ToolFallbackParser> parsers) {
|
||||
return new DefaultToolFallbackRegistry(parsers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import com.ainovel.server.service.vectorstore.ChromaVectorStore;
|
||||
import com.ainovel.server.service.vectorstore.VectorStore;
|
||||
|
||||
import dev.langchain4j.data.segment.TextSegment;
|
||||
import dev.langchain4j.store.embedding.EmbeddingStore;
|
||||
import dev.langchain4j.store.embedding.chroma.ChromaEmbeddingStore;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 向量存储配置类
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "vectorstore.chroma.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class VectorStoreConfig {
|
||||
|
||||
/**
|
||||
* 创建Chroma向量存储
|
||||
* @param chromaUrl Chroma服务URL
|
||||
* @param collectionName 集合名称
|
||||
* @param useRandomCollection 是否使用随机集合名
|
||||
* @param reuseCollection 是否重用已存在的集合
|
||||
* @return 向量存储实例
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public VectorStore chromaVectorStore(
|
||||
@Value("${vectorstore.chroma.url:http://localhost:18000}") String chromaUrl,
|
||||
@Value("${vectorstore.chroma.collection:ainovel}") String collectionNamePrefix,
|
||||
@Value("${vectorstore.chroma.use-random-collection:true}") boolean useRandomCollection,
|
||||
@Value("${vectorstore.chroma.reuse-collection:false}") boolean reuseCollection,
|
||||
@Value("${vectorstore.chroma.max-retries:3}") int maxRetries,
|
||||
@Value("${vectorstore.chroma.retry-delay-ms:1000}") int retryDelayMs,
|
||||
@Value("${vectorstore.chroma.log-requests:false}") boolean logRequests,
|
||||
@Value("${vectorstore.chroma.log-responses:false}") boolean logResponses) {
|
||||
|
||||
String collectionName = useRandomCollection
|
||||
? collectionNamePrefix + "_" + UUID.randomUUID().toString().substring(0, 8)
|
||||
: collectionNamePrefix;
|
||||
|
||||
log.info("配置Chroma向量存储,URL: {}, 集合: {}, 重用集合: {}", chromaUrl, collectionName, reuseCollection);
|
||||
return new ChromaVectorStore(chromaUrl, collectionName, maxRetries, retryDelayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建LangChain4j的Chroma嵌入存储
|
||||
* @param chromaUrl Chroma服务URL
|
||||
* @param collectionName 集合名称
|
||||
* @param useRandomCollection 是否使用随机集合名
|
||||
* @param timeout 超时设置
|
||||
* @param logRequests 是否记录请求日志
|
||||
* @param logResponses 是否记录响应日志
|
||||
* @return 嵌入存储实例
|
||||
*/
|
||||
@Bean
|
||||
public EmbeddingStore<TextSegment> chromaEmbeddingStore(
|
||||
@Value("${vectorstore.chroma.url:http://localhost:18000}") String chromaUrl,
|
||||
@Value("${vectorstore.chroma.collection:ainovel}") String collectionNamePrefix,
|
||||
@Value("true") boolean useRandomCollection,
|
||||
@Value("${vectorstore.chroma.timeout-seconds:5}") int timeoutSeconds,
|
||||
@Value("${vectorstore.chroma.log-requests:false}") boolean logRequests,
|
||||
@Value("${vectorstore.chroma.log-responses:false}") boolean logResponses) {
|
||||
|
||||
String collectionName = useRandomCollection
|
||||
? collectionNamePrefix + "_" + UUID.randomUUID().toString().substring(0, 8)
|
||||
: collectionNamePrefix;
|
||||
|
||||
log.info("配置LangChain4j Chroma嵌入存储,URL: {}, 集合: {}, 超时: {}秒",
|
||||
chromaUrl, collectionName, timeoutSeconds);
|
||||
|
||||
return ChromaEmbeddingStore.builder()
|
||||
.baseUrl(chromaUrl)
|
||||
.collectionName(collectionName)
|
||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.logRequests(logRequests)
|
||||
.logResponses(logResponses)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.ainovel.server.service.vectorstore.VectorStore;
|
||||
import dev.langchain4j.data.segment.TextSegment;
|
||||
import dev.langchain4j.store.embedding.EmbeddingStore;
|
||||
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fallback configuration when Chroma is disabled.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "vectorstore.chroma.enabled", havingValue = "false")
|
||||
public class VectorStoreFallbackConfig {
|
||||
|
||||
// Provide a no-op VectorStore to satisfy business services depending on our interface
|
||||
@Bean
|
||||
public VectorStore noopVectorStore() {
|
||||
return new VectorStore() {
|
||||
@Override
|
||||
public Mono<String> storeVector(String content, float[] vector, Map<String, Object> metadata) {
|
||||
return Mono.error(new UnsupportedOperationException("VectorStore disabled by configuration"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<String>> storeVectorsBatch(List<VectorData> vectorDataList) {
|
||||
return Mono.error(new UnsupportedOperationException("VectorStore disabled by configuration"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> storeKnowledgeChunk(com.ainovel.server.domain.model.KnowledgeChunk chunk) {
|
||||
return Mono.error(new UnsupportedOperationException("VectorStore disabled by configuration"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<com.ainovel.server.service.vectorstore.SearchResult> search(float[] queryVector, int limit) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<com.ainovel.server.service.vectorstore.SearchResult> search(float[] queryVector, Map<String, Object> filter, int limit) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<com.ainovel.server.service.vectorstore.SearchResult> searchByNovelId(float[] queryVector, String novelId, int limit) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteByNovelId(String novelId) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteBySourceId(String novelId, String sourceType, String sourceId) {
|
||||
return Mono.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Provide a minimal EmbeddingStore so RagConfig can still build ContentRetriever without Chroma
|
||||
@Bean
|
||||
public EmbeddingStore<TextSegment> fallbackEmbeddingStore() {
|
||||
return new InMemoryEmbeddingStore<>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.config.EnableWebFlux;
|
||||
|
||||
import reactor.netty.resources.LoopResources;
|
||||
|
||||
/**
|
||||
* 虚拟线程配置类
|
||||
* 配置Spring WebFlux使用JDK 23虚拟线程
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebFlux
|
||||
public class VirtualThreadConfig {
|
||||
|
||||
/**
|
||||
* 配置虚拟线程执行器
|
||||
* 使用JDK 23的虚拟线程特性
|
||||
*/
|
||||
@Bean
|
||||
public Executor taskExecutor() {
|
||||
return Executors.newVirtualThreadPerTaskExecutor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置Reactor Netty资源
|
||||
* 优化WebFlux的底层资源使用
|
||||
*/
|
||||
@Bean
|
||||
public LoopResources loopResources() {
|
||||
return LoopResources.create("reactor-http", 1,
|
||||
Runtime.getRuntime().availableProcessors() * 2, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ainovel.server.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.web.reactive.config.EnableWebFlux;
|
||||
import org.springframework.web.reactive.config.ResourceHandlerRegistry;
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||
import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer;
|
||||
|
||||
import com.ainovel.server.security.CurrentUserMethodArgumentResolver ;
|
||||
|
||||
/**
|
||||
* WebFlux配置 用于配置参数解析器、跨域、静态资源等
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebFlux
|
||||
public class WebConfig implements WebFluxConfigurer {
|
||||
|
||||
private final CurrentUserMethodArgumentResolver currentUserResolver;
|
||||
|
||||
@Autowired
|
||||
public WebConfig(CurrentUserMethodArgumentResolver currentUserResolver) {
|
||||
this.currentUserResolver = currentUserResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
|
||||
configurer.addCustomResolver(currentUserResolver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// 静态资源配置:映射所有静态文件到 /app/web/ 目录
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("file:/app/web/")
|
||||
.setCacheControl(CacheControl.noCache())
|
||||
.resourceChain(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.domain.model.AIPromptPreset;
|
||||
import com.ainovel.server.domain.model.EnhancedUserPromptTemplate;
|
||||
import com.ainovel.server.repository.AIPromptPresetRepository;
|
||||
import com.ainovel.server.repository.EnhancedUserPromptTemplateRepository;
|
||||
import com.ainovel.server.web.dto.request.CreatePresetRequestDto;
|
||||
import com.ainovel.server.web.dto.request.UpdatePresetInfoRequest;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
* AI提示词预设管理控制器
|
||||
* 提供预设的CRUD操作和管理功能
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/presets")
|
||||
@Tag(name = "预设管理", description = "AI提示词预设的管理接口")
|
||||
public class AIPromptPresetController {
|
||||
|
||||
@Autowired
|
||||
private AIPromptPresetRepository presetRepository;
|
||||
|
||||
@Autowired
|
||||
private EnhancedUserPromptTemplateRepository templateRepository;
|
||||
|
||||
@Autowired
|
||||
private com.ainovel.server.service.AIPresetService aiPresetService;
|
||||
|
||||
/**
|
||||
* 创建新的用户预设(新逻辑:直接存储原始请求数据)
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建预设", description = "创建新的用户预设,直接存储原始请求数据")
|
||||
public Mono<ApiResponse<AIPromptPreset>> createPreset(
|
||||
@RequestBody CreatePresetRequestDto request,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("创建预设: userId={}, presetName={}", userId, request.getPresetName());
|
||||
|
||||
// 🚀 使用新的AIPresetService创建预设
|
||||
return aiPresetService.createPreset(
|
||||
request.getRequest(),
|
||||
request.getPresetName(),
|
||||
request.getPresetDescription(),
|
||||
request.getPresetTags()
|
||||
)
|
||||
.map(savedPreset -> {
|
||||
log.info("预设创建成功: userId={}, presetId={}, presetName={}",
|
||||
userId, savedPreset.getPresetId(), savedPreset.getPresetName());
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("创建预设失败: userId={}, error={}", userId, error.getMessage());
|
||||
// 直接抛出异常,让全局异常处理器处理
|
||||
return new RuntimeException("创建预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取预设列表(按功能分组)
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取预设列表", description = "获取指定功能下的预设列表,包含用户预设和系统预设")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getPresetList(
|
||||
@RequestParam String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取预设列表: userId={}, featureType={}, novelId={}", userId, featureType, novelId);
|
||||
|
||||
return presetRepository.findUserAndSystemPresetsByFeatureType(userId, featureType)
|
||||
.collectList()
|
||||
.map(presets -> {
|
||||
log.info("返回预设列表: userId={}, featureType={}, 预设数={}", userId, featureType, presets.size());
|
||||
return ApiResponse.success(presets);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取预设列表失败: userId={}, featureType={}, error={}", userId, featureType, error.getMessage());
|
||||
return new RuntimeException("获取预设列表失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取快捷访问预设列表
|
||||
*/
|
||||
@GetMapping("/quick-access")
|
||||
@Operation(summary = "获取快捷访问预设", description = "获取所有标记为快捷访问的预设,按功能分组")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getQuickAccessPresets(
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取快捷访问预设: userId={}, featureType={}, novelId={}", userId, featureType, novelId);
|
||||
|
||||
Mono<List<AIPromptPreset>> presetsMono;
|
||||
if (featureType != null) {
|
||||
presetsMono = presetRepository.findQuickAccessPresetsByUserAndFeatureType(userId, featureType)
|
||||
.collectList();
|
||||
} else {
|
||||
presetsMono = presetRepository.findByUserIdAndShowInQuickAccessTrue(userId)
|
||||
.concatWith(presetRepository.findByIsSystemTrueAndShowInQuickAccessTrue())
|
||||
.distinct()
|
||||
.collectList();
|
||||
}
|
||||
|
||||
return presetsMono
|
||||
.map(presets -> {
|
||||
log.info("返回快捷访问预设: userId={}, featureType={}, 预设数={}", userId, featureType, presets.size());
|
||||
return ApiResponse.success(presets);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取快捷访问预设失败: userId={}, error={}", userId, error.getMessage());
|
||||
return new RuntimeException("获取快捷访问预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 覆盖更新预设(完整对象)
|
||||
*/
|
||||
@PutMapping("/{presetId}")
|
||||
@Operation(summary = "覆盖更新预设", description = "提交完整的 AIPromptPreset JSON,后端用新数据覆盖旧预设")
|
||||
public Mono<ApiResponse<AIPromptPreset>> overwritePreset(
|
||||
@PathVariable String presetId,
|
||||
@RequestBody AIPromptPreset newPreset,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("覆盖更新预设: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return aiPresetService.overwritePreset(presetId, newPreset)
|
||||
.map(savedPreset -> {
|
||||
log.info("预设覆盖更新成功: userId={}, presetId={}", userId, presetId);
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("覆盖更新预设失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("覆盖更新预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预设基本信息(兼容旧接口)
|
||||
*/
|
||||
@PutMapping("/{presetId}/info")
|
||||
@Operation(summary = "更新预设基本信息", description = "更新预设的名称、描述和标签")
|
||||
public Mono<ApiResponse<AIPromptPreset>> updatePresetInfo(
|
||||
@PathVariable String presetId,
|
||||
@RequestBody UpdatePresetInfoRequest request,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("更新预设基本信息: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return aiPresetService.updatePresetInfo(
|
||||
presetId,
|
||||
request.getPresetName(),
|
||||
request.getPresetDescription(),
|
||||
request.getPresetTags()
|
||||
)
|
||||
.map(savedPreset -> {
|
||||
log.info("预设基本信息更新成功: userId={}, presetId={}", userId, presetId);
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("更新预设基本信息失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("更新预设基本信息失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户预设
|
||||
*/
|
||||
@DeleteMapping("/{presetId}")
|
||||
@Operation(summary = "删除预设", description = "删除用户自己的预设")
|
||||
public Mono<ApiResponse<String>> deletePreset(
|
||||
@PathVariable String presetId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("删除预设: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return aiPresetService.deletePreset(presetId)
|
||||
.thenReturn("预设删除成功")
|
||||
.map(result -> {
|
||||
log.info("预设删除成功: userId={}, presetId={}", userId, presetId);
|
||||
return ApiResponse.success(result);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("删除预设失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("删除预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制预设(可以复制系统预设或自己的预设)
|
||||
*/
|
||||
@PostMapping("/{presetId}/duplicate")
|
||||
@Operation(summary = "复制预设", description = "复制预设,无论是系统预设还是自己的预设")
|
||||
public Mono<ApiResponse<AIPromptPreset>> duplicatePreset(
|
||||
@PathVariable String presetId,
|
||||
@RequestBody(required = false) Map<String, String> request,
|
||||
@RequestParam(required = false, defaultValue = "") String newName,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
// 支持两种方式:请求体中的newPresetName或查询参数中的newName
|
||||
String presetName = null;
|
||||
if (request != null && request.containsKey("newPresetName")) {
|
||||
presetName = request.get("newPresetName");
|
||||
} else if (!newName.isEmpty()) {
|
||||
presetName = newName;
|
||||
}
|
||||
|
||||
log.info("复制预设: userId={}, presetId={}, newName={}", userId, presetId, presetName);
|
||||
|
||||
return aiPresetService.duplicatePreset(presetId, presetName)
|
||||
.map(savedPreset -> {
|
||||
log.info("预设复制成功: userId={}, originalPresetId={}, newPresetId={}",
|
||||
userId, presetId, savedPreset.getPresetId());
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("复制预设失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("复制预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预设提示词
|
||||
*/
|
||||
@PutMapping("/{presetId}/prompts")
|
||||
@Operation(summary = "更新预设提示词", description = "更新预设的自定义提示词")
|
||||
public Mono<ApiResponse<AIPromptPreset>> updatePresetPrompts(
|
||||
@PathVariable String presetId,
|
||||
@RequestBody Map<String, String> request,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("更新预设提示词: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
String customSystemPrompt = request.get("customSystemPrompt");
|
||||
String customUserPrompt = request.get("customUserPrompt");
|
||||
|
||||
return aiPresetService.updatePresetPrompts(presetId, customSystemPrompt, customUserPrompt)
|
||||
.map(savedPreset -> {
|
||||
log.info("预设提示词更新成功: userId={}, presetId={}", userId, presetId);
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("更新预设提示词失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("更新预设提示词失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
*/
|
||||
@PostMapping("/{presetId}/favorite")
|
||||
@Operation(summary = "切换收藏状态", description = "切换预设的收藏状态")
|
||||
public Mono<ApiResponse<AIPromptPreset>> toggleFavorite(
|
||||
@PathVariable String presetId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("切换预设收藏状态: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return aiPresetService.toggleFavorite(presetId)
|
||||
.map(savedPreset -> {
|
||||
log.info("预设收藏状态切换成功: userId={}, presetId={}, isFavorite={}",
|
||||
userId, presetId, savedPreset.getIsFavorite());
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("切换预设收藏状态失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("切换预设收藏状态失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录预设使用
|
||||
*/
|
||||
@PostMapping("/{presetId}/usage")
|
||||
@Operation(summary = "记录预设使用", description = "记录预设的使用情况,更新使用次数和最后使用时间")
|
||||
public Mono<ApiResponse<String>> recordPresetUsage(
|
||||
@PathVariable String presetId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("记录预设使用: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return aiPresetService.recordUsage(presetId)
|
||||
.thenReturn("预设使用记录成功")
|
||||
.map(result -> {
|
||||
log.info("预设使用记录成功: userId={}, presetId={}", userId, presetId);
|
||||
return ApiResponse.success(result);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("记录预设使用失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("记录预设使用失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/取消快捷访问
|
||||
*/
|
||||
@PostMapping("/{presetId}/quick-access")
|
||||
@Operation(summary = "切换快捷访问", description = "切换预设的快捷访问状态")
|
||||
public Mono<ApiResponse<AIPromptPreset>> toggleQuickAccess(
|
||||
@PathVariable String presetId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("切换快捷访问: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return aiPresetService.toggleQuickAccess(presetId)
|
||||
.map(savedPreset -> {
|
||||
log.info("快捷访问状态切换成功: userId={}, presetId={}, showInQuickAccess={}",
|
||||
userId, presetId, savedPreset.getShowInQuickAccess());
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("切换快捷访问失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("切换快捷访问失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预设详情
|
||||
*/
|
||||
@GetMapping("/detail/{presetId}")
|
||||
@Operation(summary = "获取预设详情", description = "获取指定预设的详细信息")
|
||||
public Mono<ApiResponse<AIPromptPreset>> getPresetDetail(
|
||||
@PathVariable String presetId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取预设详情: userId={}, presetId={}", userId, presetId);
|
||||
|
||||
return presetRepository.findByPresetId(presetId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("预设不存在")))
|
||||
.map(preset -> {
|
||||
log.info("返回预设详情: userId={}, presetId={}, presetName={}",
|
||||
userId, presetId, preset.getPresetName());
|
||||
return ApiResponse.success(preset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取预设详情失败: userId={}, presetId={}, error={}", userId, presetId, error.getMessage());
|
||||
return new RuntimeException("获取预设详情失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改预设关联的模板ID
|
||||
*/
|
||||
@PutMapping("/{presetId}/template")
|
||||
@Operation(summary = "修改预设模板关联", description = "修改预设关联的EnhancedUserPromptTemplate模板ID")
|
||||
public Mono<ApiResponse<AIPromptPreset>> updatePresetTemplate(
|
||||
@PathVariable String presetId,
|
||||
@RequestParam String templateId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("修改预设模板关联: userId={}, presetId={}, templateId={}", userId, presetId, templateId);
|
||||
|
||||
return presetRepository.findByPresetId(presetId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("预设不存在")))
|
||||
.flatMap(preset -> {
|
||||
// 仅允许修改自己的用户预设
|
||||
if (!userId.equals(preset.getUserId()) || preset.getIsSystem()) {
|
||||
return Mono.error(new RuntimeException("无权修改此预设的模板关联"));
|
||||
}
|
||||
// 交由服务层做功能类型与范围校验
|
||||
return aiPresetService.updatePresetTemplate(presetId, templateId);
|
||||
})
|
||||
.map(savedPreset -> {
|
||||
log.info("预设模板关联修改成功: userId={}, presetId={}, templateId={}",
|
||||
userId, presetId, templateId);
|
||||
return ApiResponse.success(savedPreset);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("修改预设模板关联失败: userId={}, presetId={}, templateId={}, error={}",
|
||||
userId, presetId, templateId, error.getMessage());
|
||||
return new RuntimeException("修改预设模板关联失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的模板列表(用于关联预设)
|
||||
*/
|
||||
@GetMapping("/templates/available")
|
||||
@Operation(summary = "获取可用模板", description = "获取用户可用的EnhancedUserPromptTemplate列表,用于关联预设")
|
||||
public Mono<ApiResponse<List<EnhancedUserPromptTemplate>>> getAvailableTemplates(
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取可用模板列表: userId={}, featureType={}", userId, featureType);
|
||||
|
||||
Mono<List<EnhancedUserPromptTemplate>> templatesMono;
|
||||
|
||||
if (featureType != null) {
|
||||
try {
|
||||
AIFeatureType feature = AIFeatureType.valueOf(featureType);
|
||||
// 获取用户的模板 + 公开的模板
|
||||
templatesMono = templateRepository.findByUserIdAndFeatureType(userId, feature)
|
||||
.concatWith(templateRepository.findPublicTemplatesByFeatureType(feature))
|
||||
.distinct() // 去重
|
||||
.collectList();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Mono.error(new RuntimeException("无效的功能类型: " + featureType));
|
||||
}
|
||||
} else {
|
||||
// 获取用户的所有模板 + 所有公开模板
|
||||
templatesMono = templateRepository.findByUserId(userId)
|
||||
.concatWith(templateRepository.findByIsPublicTrue())
|
||||
.distinct() // 去重
|
||||
.collectList();
|
||||
}
|
||||
|
||||
return templatesMono
|
||||
.map(templates -> {
|
||||
log.info("返回可用模板列表: userId={}, featureType={}, 模板数={}",
|
||||
userId, featureType, templates.size());
|
||||
return ApiResponse.success(templates);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取可用模板列表失败: userId={}, featureType={}, error={}",
|
||||
userId, featureType, error.getMessage());
|
||||
return new RuntimeException("获取可用模板列表失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模板ID获取模板详情
|
||||
*/
|
||||
@GetMapping("/templates/{templateId}")
|
||||
@Operation(summary = "获取模板详情", description = "获取指定模板的详细信息")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> getTemplateDetail(
|
||||
@PathVariable String templateId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取模板详情: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return templateRepository.findById(templateId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("模板不存在")))
|
||||
.map(template -> {
|
||||
log.info("返回模板详情: userId={}, templateId={}, templateName={}",
|
||||
userId, templateId, template.getName());
|
||||
return ApiResponse.success(template);
|
||||
})
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取模板详情失败: userId={}, templateId={}, error={}",
|
||||
userId, templateId, error.getMessage());
|
||||
return new RuntimeException("获取模板详情失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏预设列表
|
||||
*/
|
||||
@GetMapping("/favorites")
|
||||
@Operation(summary = "获取收藏预设", description = "获取用户收藏的预设列表,可按功能类型和小说ID过滤")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getFavoritePresets(
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取收藏预设: userId={}, featureType={}, novelId={}", userId, featureType, novelId);
|
||||
|
||||
return aiPresetService.getFavoritePresets(userId, featureType, novelId)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取收藏预设失败: userId={}, error={}", userId, error.getMessage());
|
||||
return new RuntimeException("获取收藏预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近使用预设列表
|
||||
*/
|
||||
@GetMapping("/recent")
|
||||
@Operation(summary = "获取最近使用预设", description = "按使用时间倒序返回最近使用的预设")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getRecentPresets(
|
||||
@RequestParam(defaultValue = "10") int limit,
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取最近使用预设: userId={}, limit={}, featureType={}, novelId={}", userId, limit, featureType, novelId);
|
||||
|
||||
return aiPresetService.getRecentPresets(userId, limit, featureType, novelId)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取最近使用预设失败: userId={}, error={}", userId, error.getMessage());
|
||||
return new RuntimeException("获取最近使用预设失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取功能预设列表(收藏、最近使用、推荐)
|
||||
*/
|
||||
@GetMapping("/feature-list")
|
||||
@Operation(summary = "获取功能预设列表", description = "获取收藏、最近使用和推荐的预设列表")
|
||||
public Mono<ApiResponse<com.ainovel.server.dto.response.PresetListResponse>> getFeaturePresetList(
|
||||
@RequestParam String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("获取功能预设列表: userId={}, featureType={}, novelId={}", userId, featureType, novelId);
|
||||
|
||||
return aiPresetService.getFeaturePresetList(userId, featureType, novelId)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> {
|
||||
log.error("获取功能预设列表失败: userId={}, featureType={}, error={}", userId, featureType, error.getMessage());
|
||||
return new RuntimeException("获取功能预设列表失败: " + error.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统预设列表(可按功能类型过滤)
|
||||
*/
|
||||
@GetMapping("/system")
|
||||
@Operation(summary = "获取系统预设", description = "获取所有系统预设,可按功能类型过滤")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getSystemPresets(
|
||||
@RequestParam(required = false) String featureType) {
|
||||
|
||||
return aiPresetService.getSystemPresets(featureType)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> new RuntimeException("获取系统预设失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取预设
|
||||
*/
|
||||
@PostMapping("/batch")
|
||||
@Operation(summary = "批量获取预设", description = "根据预设ID列表批量获取预设")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getPresetsBatch(@RequestBody Map<String, Object> body,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
Object ids = body != null ? body.get("presetIds") : null;
|
||||
if (!(ids instanceof List)) {
|
||||
return Mono.just(ApiResponse.error("请求体缺少presetIds数组"));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> presetIds = (List<String>) ids;
|
||||
return aiPresetService.getPresetsBatch(presetIds)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> new RuntimeException("批量获取预设失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按功能类型获取当前用户的预设
|
||||
*/
|
||||
@GetMapping("/feature/{featureType}")
|
||||
@Operation(summary = "按功能类型获取预设", description = "按功能类型获取当前用户的预设")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> getUserPresetsByFeatureType(
|
||||
@PathVariable String featureType,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
return aiPresetService.getUserPresetsByFeatureType(userId, featureType)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> new RuntimeException("按功能类型获取预设失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的预设,按功能类型分组
|
||||
*/
|
||||
@GetMapping("/grouped")
|
||||
@Operation(summary = "分组获取预设", description = "按功能类型分组获取用户预设")
|
||||
public Mono<ApiResponse<Map<String, List<AIPromptPreset>>>> getGroupedUserPresets(
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestHeader(value = "X-User-Id", required = false) String headerUserId) {
|
||||
|
||||
String targetUserId = (userId != null && !userId.isEmpty()) ? userId : headerUserId;
|
||||
if (targetUserId == null || targetUserId.isEmpty()) {
|
||||
return Mono.just(ApiResponse.error("缺少用户标识"));
|
||||
}
|
||||
|
||||
return aiPresetService.getUserPresetsGrouped(targetUserId)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> new RuntimeException("分组获取预设失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设搜索
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "搜索预设", description = "按关键词/标签/功能类型搜索当前用户的预设")
|
||||
public Mono<ApiResponse<List<AIPromptPreset>>> searchPresets(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String tags,
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
List<String> tagList = null;
|
||||
if (tags != null && !tags.isEmpty()) {
|
||||
String cleaned = tags.replace("[", "").replace("]", "");
|
||||
tagList = List.of(cleaned.split(","))
|
||||
.stream()
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (novelId != null && !novelId.isEmpty()) {
|
||||
return aiPresetService.searchUserPresetsByNovelId(userId, keyword, tagList, featureType, novelId)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> new RuntimeException("搜索预设失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
return aiPresetService.searchUserPresets(userId, keyword, tagList, featureType)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorMap(error -> new RuntimeException("搜索预设失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设统计信息
|
||||
*/
|
||||
@GetMapping("/statistics")
|
||||
@Operation(summary = "获取预设统计信息", description = "返回总数/收藏/最近使用/按功能类型分布/热门标签")
|
||||
public Mono<ApiResponse<Map<String, Object>>> getPresetStatistics(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
var since = java.time.LocalDateTime.now().minusDays(30);
|
||||
|
||||
Mono<Long> totalMono = presetRepository.countByUserId(userId);
|
||||
Mono<Long> favMono = presetRepository.countByUserIdAndIsFavoriteTrue(userId);
|
||||
Mono<Long> recentMono = presetRepository.findRecentlyUsedPresets(userId, since).count();
|
||||
|
||||
Mono<Map<String, Long>> byFeatureMono = presetRepository.findByUserId(userId)
|
||||
.collectList()
|
||||
.map(list -> {
|
||||
java.util.Map<String, Long> map = new java.util.HashMap<>();
|
||||
for (var p : list) {
|
||||
String ft = p.getAiFeatureType() != null ? p.getAiFeatureType() : "UNKNOWN";
|
||||
map.put(ft, map.getOrDefault(ft, 0L) + 1L);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
Mono<List<String>> popularTagsMono = presetRepository.findByUserId(userId)
|
||||
.collectList()
|
||||
.map(list -> {
|
||||
java.util.Map<String, Integer> tagCount = new java.util.HashMap<>();
|
||||
for (var p : list) {
|
||||
if (p.getPresetTags() != null) {
|
||||
for (var t : p.getPresetTags()) {
|
||||
if (t != null && !t.isEmpty()) {
|
||||
tagCount.put(t, tagCount.getOrDefault(t, 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tagCount.entrySet().stream()
|
||||
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
|
||||
.limit(10)
|
||||
.map(java.util.Map.Entry::getKey)
|
||||
.toList();
|
||||
});
|
||||
|
||||
return Mono.zip(totalMono, favMono, recentMono, byFeatureMono, popularTagsMono)
|
||||
.map(tuple -> {
|
||||
Map<String, Object> res = new java.util.HashMap<>();
|
||||
res.put("totalPresets", tuple.getT1());
|
||||
res.put("favoritePresets", tuple.getT2());
|
||||
res.put("recentlyUsedPresets", tuple.getT3());
|
||||
res.put("presetsByFeatureType", tuple.getT4());
|
||||
res.put("popularTags", tuple.getT5());
|
||||
return ApiResponse.success(res);
|
||||
})
|
||||
.onErrorMap(error -> new RuntimeException("获取预设统计信息失败: " + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能类型预设管理聚合(轻量)
|
||||
*/
|
||||
@GetMapping("/management/{featureType}")
|
||||
@Operation(summary = "功能预设管理聚合", description = "返回该功能下用户/系统/快捷/收藏及简单统计")
|
||||
public Mono<ApiResponse<Map<String, Object>>> getFeatureTypePresetManagement(
|
||||
@PathVariable String featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
Mono<List<AIPromptPreset>> userPresetsMono = (novelId != null && !novelId.isEmpty())
|
||||
? aiPresetService.getUserPresetsByFeatureTypeAndNovelId(userId, featureType, novelId).collectList()
|
||||
: aiPresetService.getUserPresetsByFeatureType(userId, featureType).collectList();
|
||||
|
||||
Mono<List<AIPromptPreset>> systemPresetsMono = aiPresetService.getSystemPresets(featureType).collectList();
|
||||
Mono<List<AIPromptPreset>> quickAccessMono = aiPresetService.getQuickAccessPresets(userId, featureType).collectList();
|
||||
Mono<List<AIPromptPreset>> favoritesMono = aiPresetService.getFavoritePresets(userId, featureType, novelId).collectList();
|
||||
|
||||
return Mono.zip(userPresetsMono, systemPresetsMono, quickAccessMono, favoritesMono)
|
||||
.map(tuple -> {
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("featureType", featureType);
|
||||
data.put("userPresets", tuple.getT1());
|
||||
data.put("systemPresets", tuple.getT2());
|
||||
data.put("quickAccessPresets", tuple.getT3());
|
||||
data.put("favoritePresets", tuple.getT4());
|
||||
data.put("total", tuple.getT1().size() + tuple.getT2().size());
|
||||
return ApiResponse.success(data);
|
||||
})
|
||||
.onErrorMap(error -> new RuntimeException("获取功能预设管理信息失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.User;
|
||||
import com.ainovel.server.service.JwtService;
|
||||
import com.ainovel.server.service.RoleService;
|
||||
import com.ainovel.server.service.UserService;
|
||||
import com.ainovel.server.web.dto.AdminAuthRequest;
|
||||
import com.ainovel.server.web.dto.AdminAuthResponse;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员认证控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/auth")
|
||||
public class AdminAuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final RoleService roleService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
|
||||
@Autowired
|
||||
public AdminAuthController(UserService userService, RoleService roleService,
|
||||
PasswordEncoder passwordEncoder, JwtService jwtService) {
|
||||
this.userService = userService;
|
||||
this.roleService = roleService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<ApiResponse<AdminAuthResponse>>> login(@RequestBody AdminAuthRequest request) {
|
||||
return userService.findUserByUsername(request.getUsername())
|
||||
.filter(user -> passwordEncoder.matches(request.getPassword(), user.getPassword()))
|
||||
.filter(user -> hasAdminRole(user))
|
||||
.flatMap(user -> {
|
||||
// 获取用户的所有权限 - 使用用户的角色ID列表
|
||||
return roleService.getUserPermissions(user.getRoleIds())
|
||||
.map(permissions -> {
|
||||
// 生成包含角色和权限的JWT令牌
|
||||
String token = jwtService.generateTokenWithRolesAndPermissions(
|
||||
user, user.getRoles(), permissions);
|
||||
String refreshToken = jwtService.generateRefreshToken(user);
|
||||
|
||||
AdminAuthResponse response = new AdminAuthResponse(
|
||||
token,
|
||||
refreshToken,
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getRoles(),
|
||||
permissions
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
});
|
||||
})
|
||||
.defaultIfEmpty(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.error("用户名或密码错误,或无管理员权限")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有管理员角色
|
||||
*/
|
||||
private boolean hasAdminRole(User user) {
|
||||
if (user.getRoles() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有管理员相关角色
|
||||
return user.getRoles().stream()
|
||||
.anyMatch(role -> role.toLowerCase().contains("admin") ||
|
||||
role.toLowerCase().contains("super"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.service.AdminDashboardService;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员仪表板控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/dashboard")
|
||||
@PreAuthorize("hasAuthority('ADMIN_VIEW_DASHBOARD')")
|
||||
public class AdminDashboardController {
|
||||
|
||||
private final AdminDashboardService adminDashboardService;
|
||||
|
||||
@Autowired
|
||||
public AdminDashboardController(AdminDashboardService adminDashboardService) {
|
||||
this.adminDashboardService = adminDashboardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仪表板统计数据
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
public Mono<ResponseEntity<ApiResponse<DashboardStats>>> getDashboardStats() {
|
||||
return adminDashboardService.getDashboardStats()
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表板统计数据DTO
|
||||
*/
|
||||
public static class DashboardStats {
|
||||
private int totalUsers;
|
||||
private int activeUsers;
|
||||
private int totalNovels;
|
||||
private int aiRequestsToday;
|
||||
private double creditsConsumed;
|
||||
private java.util.List<ChartData> userGrowthData;
|
||||
private java.util.List<ChartData> requestsData;
|
||||
private java.util.List<ActivityItem> recentActivities;
|
||||
|
||||
public DashboardStats() {}
|
||||
|
||||
public DashboardStats(int totalUsers, int activeUsers, int totalNovels,
|
||||
int aiRequestsToday, double creditsConsumed,
|
||||
java.util.List<ChartData> userGrowthData,
|
||||
java.util.List<ChartData> requestsData,
|
||||
java.util.List<ActivityItem> recentActivities) {
|
||||
this.totalUsers = totalUsers;
|
||||
this.activeUsers = activeUsers;
|
||||
this.totalNovels = totalNovels;
|
||||
this.aiRequestsToday = aiRequestsToday;
|
||||
this.creditsConsumed = creditsConsumed;
|
||||
this.userGrowthData = userGrowthData;
|
||||
this.requestsData = requestsData;
|
||||
this.recentActivities = recentActivities;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
public int getTotalUsers() { return totalUsers; }
|
||||
public void setTotalUsers(int totalUsers) { this.totalUsers = totalUsers; }
|
||||
|
||||
public int getActiveUsers() { return activeUsers; }
|
||||
public void setActiveUsers(int activeUsers) { this.activeUsers = activeUsers; }
|
||||
|
||||
public int getTotalNovels() { return totalNovels; }
|
||||
public void setTotalNovels(int totalNovels) { this.totalNovels = totalNovels; }
|
||||
|
||||
public int getAiRequestsToday() { return aiRequestsToday; }
|
||||
public void setAiRequestsToday(int aiRequestsToday) { this.aiRequestsToday = aiRequestsToday; }
|
||||
|
||||
public double getCreditsConsumed() { return creditsConsumed; }
|
||||
public void setCreditsConsumed(double creditsConsumed) { this.creditsConsumed = creditsConsumed; }
|
||||
|
||||
public java.util.List<ChartData> getUserGrowthData() { return userGrowthData; }
|
||||
public void setUserGrowthData(java.util.List<ChartData> userGrowthData) { this.userGrowthData = userGrowthData; }
|
||||
|
||||
public java.util.List<ChartData> getRequestsData() { return requestsData; }
|
||||
public void setRequestsData(java.util.List<ChartData> requestsData) { this.requestsData = requestsData; }
|
||||
|
||||
public java.util.List<ActivityItem> getRecentActivities() { return recentActivities; }
|
||||
public void setRecentActivities(java.util.List<ActivityItem> recentActivities) { this.recentActivities = recentActivities; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表数据DTO
|
||||
*/
|
||||
public static class ChartData {
|
||||
private String label;
|
||||
private double value;
|
||||
private java.time.LocalDateTime date;
|
||||
|
||||
public ChartData() {}
|
||||
|
||||
public ChartData(String label, double value, java.time.LocalDateTime date) {
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public String getLabel() { return label; }
|
||||
public void setLabel(String label) { this.label = label; }
|
||||
|
||||
public double getValue() { return value; }
|
||||
public void setValue(double value) { this.value = value; }
|
||||
|
||||
public java.time.LocalDateTime getDate() { return date; }
|
||||
public void setDate(java.time.LocalDateTime date) { this.date = date; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动项DTO
|
||||
*/
|
||||
public static class ActivityItem {
|
||||
private String id;
|
||||
private String userId;
|
||||
private String userName;
|
||||
private String action;
|
||||
private String description;
|
||||
private java.time.LocalDateTime timestamp;
|
||||
private String metadata;
|
||||
|
||||
public ActivityItem() {}
|
||||
|
||||
public ActivityItem(String id, String userId, String userName, String action,
|
||||
String description, java.time.LocalDateTime timestamp, String metadata) {
|
||||
this.id = id;
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
this.action = action;
|
||||
this.description = description;
|
||||
this.timestamp = timestamp;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
|
||||
public String getUserName() { return userName; }
|
||||
public void setUserName(String userName) { this.userName = userName; }
|
||||
|
||||
public String getAction() { return action; }
|
||||
public void setAction(String action) { this.action = action; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public java.time.LocalDateTime getTimestamp() { return timestamp; }
|
||||
public void setTimestamp(java.time.LocalDateTime timestamp) { this.timestamp = timestamp; }
|
||||
|
||||
public String getMetadata() { return metadata; }
|
||||
public void setMetadata(String metadata) { this.metadata = metadata; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.common.response.PagedResponse;
|
||||
import com.ainovel.server.common.response.CursorPageResponse;
|
||||
|
||||
import com.ainovel.server.common.security.CurrentUser;
|
||||
import com.ainovel.server.domain.model.observability.LLMTrace;
|
||||
import com.ainovel.server.service.ai.observability.LLMTraceService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理员LLM可观测性控制器
|
||||
* 用于查看和管理大模型调用日志,便于运维和观察
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/llm-observability")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "管理员LLM可观测性", description = "大模型调用日志查看和分析")
|
||||
public class AdminLLMObservabilityController {
|
||||
|
||||
@Autowired
|
||||
private LLMTraceService llmTraceService;
|
||||
|
||||
// ==================== 日志查询 ====================
|
||||
|
||||
/**
|
||||
* 获取所有LLM调用日志
|
||||
*/
|
||||
@GetMapping("/traces")
|
||||
@Operation(summary = "获取LLM调用日志", description = "分页获取系统中所有的LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<PagedResponse<LLMTrace>>>> getAllTraces(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@RequestParam(defaultValue = "timestamp") String sortBy,
|
||||
@RequestParam(defaultValue = "desc") String sortDir) {
|
||||
log.info("管理员获取LLM调用日志: page={}, size={}, sortBy={}, sortDir={}", page, size, sortBy, sortDir);
|
||||
|
||||
return llmTraceService.findAllTracesPageable(page, size)
|
||||
.map(pagedResponse -> ResponseEntity.ok(ApiResponse.success(pagedResponse)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页:按时间倒序滚动查询
|
||||
*/
|
||||
@GetMapping("/traces/cursor")
|
||||
@Operation(summary = "游标分页获取LLM调用日志", description = "基于createdAt/_id倒序的游标分页,适合无限滚动")
|
||||
public Mono<ResponseEntity<ApiResponse<CursorPageResponse<LLMTrace>>>> getTracesByCursor(
|
||||
@RequestParam(required = false) String cursor,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String provider,
|
||||
@RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestParam(required = false) Boolean hasError,
|
||||
@RequestParam(required = false) String businessType,
|
||||
@RequestParam(required = false) String correlationId,
|
||||
@RequestParam(required = false) String traceId,
|
||||
@RequestParam(required = false) LLMTrace.CallType type,
|
||||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime
|
||||
) {
|
||||
log.info("管理员(游标)获取LLM调用日志: cursor={}, limit={}", cursor, limit);
|
||||
return llmTraceService.findTracesByCursor(cursor, limit, userId, provider, model, sessionId, hasError,
|
||||
businessType, correlationId, traceId, type, tag, startTime, endTime)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("游标方式获取LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID获取LLM调用日志
|
||||
*/
|
||||
@GetMapping("/traces/user/{userId}")
|
||||
@Operation(summary = "获取用户LLM调用日志", description = "获取指定用户的所有LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<PagedResponse<LLMTrace>>>> getTracesByUserId(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
log.info("管理员获取用户LLM调用日志: userId={}, page={}, size={}", userId, page, size);
|
||||
|
||||
return llmTraceService.findTracesByUserIdPageable(userId, page, size)
|
||||
.map(pagedResponse -> ResponseEntity.ok(ApiResponse.success(pagedResponse)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取用户LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据提供商获取LLM调用日志
|
||||
*/
|
||||
@GetMapping("/traces/provider/{provider}")
|
||||
@Operation(summary = "获取提供商LLM调用日志", description = "获取指定提供商的所有LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<PagedResponse<LLMTrace>>>> getTracesByProvider(
|
||||
@PathVariable String provider,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
log.info("管理员获取提供商LLM调用日志: provider={}, page={}, size={}", provider, page, size);
|
||||
|
||||
return llmTraceService.findTracesByProviderPageable(provider, page, size)
|
||||
.map(pagedResponse -> ResponseEntity.ok(ApiResponse.success(pagedResponse)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取提供商LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型名称获取LLM调用日志
|
||||
*/
|
||||
@GetMapping("/traces/model/{modelName}")
|
||||
@Operation(summary = "获取模型LLM调用日志", description = "获取指定模型的所有LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<PagedResponse<LLMTrace>>>> getTracesByModel(
|
||||
@PathVariable String modelName,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
log.info("管理员获取模型LLM调用日志: modelName={}, page={}, size={}", modelName, page, size);
|
||||
|
||||
return llmTraceService.findTracesByModelPageable(modelName, page, size)
|
||||
.map(pagedResponse -> ResponseEntity.ok(ApiResponse.success(pagedResponse)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取模型LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间范围获取LLM调用日志
|
||||
*/
|
||||
@GetMapping("/traces/timerange")
|
||||
@Operation(summary = "按时间范围获取LLM调用日志", description = "获取指定时间范围内的LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<PagedResponse<LLMTrace>>>> getTracesByTimeRange(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
log.info("管理员按时间范围获取LLM调用日志: startTime={}, endTime={}, page={}, size={}",
|
||||
startTime, endTime, page, size);
|
||||
|
||||
return llmTraceService.findTracesByTimeRangePageable(startTime, endTime, page, size)
|
||||
.map(pagedResponse -> ResponseEntity.ok(ApiResponse.success(pagedResponse)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("按时间范围获取LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索LLM调用日志
|
||||
*/
|
||||
@GetMapping("/traces/search")
|
||||
@Operation(summary = "搜索LLM调用日志", description = "根据多个条件搜索LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<PagedResponse<LLMTrace>>>> searchTraces(
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String provider,
|
||||
@RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestParam(required = false) Boolean hasError,
|
||||
@RequestParam(required = false) String businessType,
|
||||
@RequestParam(required = false) String correlationId,
|
||||
@RequestParam(required = false) String traceId,
|
||||
@RequestParam(required = false) LLMTrace.CallType type,
|
||||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
log.info("管理员搜索LLM调用日志: userId={}, provider={}, model={}, sessionId={}, hasError={}, businessType={}, correlationId={}, traceId={}, type={}, tag={}, page={}, size={}",
|
||||
userId, provider, model, sessionId, hasError, businessType, correlationId, traceId, type, tag, page, size);
|
||||
|
||||
return llmTraceService.searchTracesPageable(
|
||||
userId, provider, model, sessionId, hasError, businessType, correlationId, traceId, type, tag, startTime, endTime, page, size)
|
||||
.map(pagedResponse -> ResponseEntity.ok(ApiResponse.success(pagedResponse)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("搜索LLM调用日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出LLM调用日志(应用过滤条件)
|
||||
*/
|
||||
@PostMapping("/export2")
|
||||
@Operation(summary = "导出LLM调用日志(带过滤)", description = "导出指定条件的LLM调用日志,应用与search相同的过滤")
|
||||
public Mono<ResponseEntity<ApiResponse<List<LLMTrace>>>> exportTracesAdvanced(
|
||||
@RequestBody(required = false) Map<String, Object> filterCriteria,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 导出LLM调用日志(高级)", adminId);
|
||||
|
||||
String userId = asString(filterCriteria, "userId");
|
||||
String provider = asString(filterCriteria, "provider");
|
||||
String model = asString(filterCriteria, "model");
|
||||
String sessionId = asString(filterCriteria, "sessionId");
|
||||
Boolean hasError = asBoolean(filterCriteria, "hasError");
|
||||
String businessType = asString(filterCriteria, "businessType");
|
||||
String correlationId = asString(filterCriteria, "correlationId");
|
||||
String traceId = asString(filterCriteria, "traceId");
|
||||
LLMTrace.CallType type = asCallType(filterCriteria, "type");
|
||||
String tag = asString(filterCriteria, "tag");
|
||||
LocalDateTime startTime = asDateTime(filterCriteria, "startTime");
|
||||
LocalDateTime endTime = asDateTime(filterCriteria, "endTime");
|
||||
|
||||
return llmTraceService.filterAll(userId, provider, model, sessionId, hasError, businessType,
|
||||
correlationId, traceId, type, tag, startTime, endTime)
|
||||
.map(traces -> ResponseEntity.ok(ApiResponse.success(traces)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("导出日志失败")));
|
||||
}
|
||||
|
||||
private static String asString(Map<String, Object> m, String k) {
|
||||
if (m == null) return null;
|
||||
Object v = m.get(k);
|
||||
return v == null ? null : v.toString();
|
||||
}
|
||||
private static Boolean asBoolean(Map<String, Object> m, String k) {
|
||||
if (m == null) return null;
|
||||
Object v = m.get(k);
|
||||
if (v == null) return null;
|
||||
if (v instanceof Boolean) return (Boolean) v;
|
||||
return Boolean.parseBoolean(v.toString());
|
||||
}
|
||||
private static LocalDateTime asDateTime(Map<String, Object> m, String k) {
|
||||
if (m == null) return null;
|
||||
Object v = m.get(k);
|
||||
if (v == null) return null;
|
||||
try { return LocalDateTime.parse(v.toString()); } catch (Exception e) { return null; }
|
||||
}
|
||||
private static LLMTrace.CallType asCallType(Map<String, Object> m, String k) {
|
||||
if (m == null) return null;
|
||||
Object v = m.get(k);
|
||||
if (v == null) return null;
|
||||
try { return LLMTrace.CallType.valueOf(v.toString()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个LLM调用日志详情
|
||||
*/
|
||||
@GetMapping("/traces/{traceId}")
|
||||
@Operation(summary = "获取LLM调用日志详情", description = "获取指定ID的LLM调用日志详细信息")
|
||||
public Mono<ResponseEntity<ApiResponse<LLMTrace>>> getTraceById(@PathVariable String traceId) {
|
||||
log.info("管理员获取LLM调用日志详情: {}", traceId);
|
||||
|
||||
return llmTraceService.findTraceById(traceId)
|
||||
.map(trace -> ResponseEntity.ok(ApiResponse.success(trace)))
|
||||
.defaultIfEmpty(ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("日志不存在")));
|
||||
}
|
||||
|
||||
// ==================== 统计分析 ====================
|
||||
|
||||
/**
|
||||
* 获取LLM调用统计信息
|
||||
*/
|
||||
@GetMapping("/statistics/overview")
|
||||
@Operation(summary = "获取LLM调用统计概览", description = "获取系统LLM调用的统计概览信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getOverviewStatistics(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取LLM调用统计概览: startTime={}, endTime={}", startTime, endTime);
|
||||
|
||||
return llmTraceService.getOverviewStatistics(startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取统计信息失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提供商统计信息
|
||||
*/
|
||||
@GetMapping("/statistics/providers")
|
||||
@Operation(summary = "获取提供商统计信息", description = "获取各提供商的调用统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getProviderStatistics(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取提供商统计信息: startTime={}, endTime={}", startTime, endTime);
|
||||
|
||||
return llmTraceService.getProviderStatistics(startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取提供商统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型统计信息
|
||||
*/
|
||||
@GetMapping("/statistics/models")
|
||||
@Operation(summary = "获取模型统计信息", description = "获取各模型的调用统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getModelStatistics(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取模型统计信息: startTime={}, endTime={}", startTime, endTime);
|
||||
|
||||
return llmTraceService.getModelStatistics(startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取模型统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
*/
|
||||
@GetMapping("/statistics/users")
|
||||
@Operation(summary = "获取用户统计信息", description = "获取用户LLM调用统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getUserStatistics(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取用户统计信息: startTime={}, endTime={}", startTime, endTime);
|
||||
|
||||
return llmTraceService.getUserStatistics(startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取用户统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误统计信息
|
||||
*/
|
||||
@GetMapping("/statistics/errors")
|
||||
@Operation(summary = "获取错误统计信息", description = "获取LLM调用错误的统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getErrorStatistics(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取错误统计信息: startTime={}, endTime={}", startTime, endTime);
|
||||
|
||||
return llmTraceService.getErrorStatistics(startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取错误统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能统计信息
|
||||
*/
|
||||
@GetMapping("/statistics/performance")
|
||||
@Operation(summary = "获取性能统计信息", description = "获取LLM调用性能统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getPerformanceStatistics(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取性能统计信息: startTime={}, endTime={}", startTime, endTime);
|
||||
|
||||
return llmTraceService.getPerformanceStatistics(startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取性能统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取趋势数据(按时间分桶)
|
||||
*/
|
||||
@GetMapping("/statistics/trends")
|
||||
@Operation(summary = "获取趋势数据", description = "按时间分桶返回指定指标的趋势数据")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getTrends(
|
||||
@RequestParam(required = false) String metric,
|
||||
@RequestParam(required = false) String groupBy,
|
||||
@RequestParam(required = false) String businessType,
|
||||
@RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String provider,
|
||||
@RequestParam(defaultValue = "hour") String interval,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
log.info("获取趋势数据 metric={}, groupBy={}, businessType={}, model={}, provider={}, interval={}, startTime={}, endTime={}",
|
||||
metric, groupBy, businessType, model, provider, interval, startTime, endTime);
|
||||
|
||||
return llmTraceService.getTrends(metric, groupBy, businessType, model, provider, interval, startTime, endTime)
|
||||
.map(data -> ResponseEntity.ok(ApiResponse.success(data)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取趋势数据失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户的功能维度统计(按业务功能聚合调用次数与Token)
|
||||
*/
|
||||
@GetMapping("/statistics/users/{userId}/features")
|
||||
@Operation(summary = "获取用户功能维度统计", description = "按业务功能聚合调用次数与Token")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getUserFeatureStatistics(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
return llmTraceService.getUserFeatureStatistics(userId, startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取用户功能维度统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户日维度Token消耗
|
||||
*/
|
||||
@GetMapping("/statistics/users/{userId}/daily-tokens")
|
||||
@Operation(summary = "获取用户日维度Token消耗", description = "按天统计Token消耗")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Integer>>>> getUserDailyTokens(
|
||||
@PathVariable String userId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
|
||||
return llmTraceService.getUserDailyTokens(userId, startTime, endTime)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取用户日维度Token统计失败")));
|
||||
}
|
||||
|
||||
// ==================== 导出功能 ====================
|
||||
|
||||
/**
|
||||
* 导出LLM调用日志
|
||||
*/
|
||||
@PostMapping("/export")
|
||||
@Operation(summary = "导出LLM调用日志", description = "导出指定条件的LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<List<LLMTrace>>>> exportTraces(
|
||||
@RequestBody(required = false) Map<String, Object> filterCriteria,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 导出LLM调用日志", adminId);
|
||||
|
||||
return llmTraceService.exportTraces(filterCriteria)
|
||||
.map(traces -> ResponseEntity.ok(ApiResponse.success(traces)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("导出日志失败")));
|
||||
}
|
||||
|
||||
// ==================== 系统管理 ====================
|
||||
|
||||
/**
|
||||
* 清理旧日志
|
||||
*/
|
||||
@DeleteMapping("/cleanup")
|
||||
@Operation(summary = "清理旧日志", description = "清理指定时间之前的LLM调用日志")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> cleanupOldTraces(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime beforeTime,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 清理{}之前的LLM调用日志", adminId, beforeTime);
|
||||
|
||||
return llmTraceService.cleanupOldTraces(beforeTime)
|
||||
.map(result -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("deletedCount", result);
|
||||
response.put("beforeTime", beforeTime);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
})
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("清理日志失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统健康状态
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
@Operation(summary = "获取系统健康状态", description = "获取LLM可观测性系统的健康状态")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getSystemHealth() {
|
||||
log.info("获取LLM可观测性系统健康状态");
|
||||
|
||||
return llmTraceService.getSystemHealth()
|
||||
.map(health -> ResponseEntity.ok(ApiResponse.success(health)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取系统健康状态失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库状态
|
||||
*/
|
||||
@GetMapping("/database/status")
|
||||
@Operation(summary = "获取数据库状态", description = "获取LLM日志数据库的状态信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getDatabaseStatus() {
|
||||
log.info("获取LLM日志数据库状态");
|
||||
|
||||
return llmTraceService.getDatabaseStatus()
|
||||
.map(status -> ResponseEntity.ok(ApiResponse.success(status)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取数据库状态失败")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.domain.model.PublicModelConfig;
|
||||
import com.ainovel.server.dto.PublicModelConfigDetailsDTO;
|
||||
import com.ainovel.server.dto.PublicModelConfigRequestDTO;
|
||||
import com.ainovel.server.dto.PublicModelConfigResponseDTO;
|
||||
import com.ainovel.server.dto.PublicModelConfigWithKeysDTO;
|
||||
import com.ainovel.server.service.PublicModelConfigService;
|
||||
|
||||
import org.jasypt.encryption.StringEncryptor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员模型配置管理控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/model-configs")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_MODELS') or hasRole('SUPER_ADMIN')")
|
||||
public class AdminModelConfigController {
|
||||
|
||||
private final PublicModelConfigService publicModelConfigService;
|
||||
private final StringEncryptor encryptor;
|
||||
|
||||
@Autowired
|
||||
public AdminModelConfigController(PublicModelConfigService publicModelConfigService, StringEncryptor encryptor) {
|
||||
this.publicModelConfigService = publicModelConfigService;
|
||||
this.encryptor = encryptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有公共模型配置的详细信息
|
||||
* 包含定价信息和使用统计
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<ApiResponse<List<PublicModelConfigDetailsDTO>>>> getAllConfigs() {
|
||||
return publicModelConfigService.findAllWithDetails()
|
||||
.collectList()
|
||||
.map(configs -> ResponseEntity.ok(ApiResponse.success(configs)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取公共模型配置列表失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简单的公共模型配置列表(不包含详细信息)
|
||||
*/
|
||||
@GetMapping("/simple")
|
||||
public Mono<ResponseEntity<ApiResponse<List<PublicModelConfigResponseDTO>>>> getSimpleConfigs() {
|
||||
return publicModelConfigService.findAll()
|
||||
.map(this::convertToResponseDTO)
|
||||
.collectList()
|
||||
.map(configs -> ResponseEntity.ok(ApiResponse.success(configs)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取简单公共模型配置列表失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取模型配置
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigResponseDTO>>> getConfigById(@PathVariable String id) {
|
||||
return publicModelConfigService.findById(id)
|
||||
.map(config -> ResponseEntity.ok(ApiResponse.success(convertToResponseDTO(config))))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取模型配置详细信息(包含API Keys)
|
||||
* 仅供管理员使用
|
||||
*/
|
||||
@GetMapping("/{id}/with-keys")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigWithKeysDTO>>> getConfigWithKeysById(@PathVariable String id) {
|
||||
return publicModelConfigService.findById(id)
|
||||
.map(config -> ResponseEntity.ok(ApiResponse.success(convertToWithKeysDTO(config))))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新模型配置
|
||||
*/
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigResponseDTO>>> createConfig(
|
||||
@RequestBody PublicModelConfigRequestDTO requestDTO,
|
||||
@RequestParam(value = "validate", required = false, defaultValue = "false") boolean validate) {
|
||||
PublicModelConfig config = convertToEntity(requestDTO);
|
||||
|
||||
return publicModelConfigService.createConfig(config)
|
||||
.flatMap(savedConfig -> {
|
||||
if (validate) {
|
||||
log.info("创建配置后立即验证API Key: {}", savedConfig.getId());
|
||||
return publicModelConfigService.validateConfig(savedConfig.getId());
|
||||
} else {
|
||||
return Mono.just(savedConfig);
|
||||
}
|
||||
})
|
||||
.map(finalConfig -> ResponseEntity.ok(ApiResponse.success(convertToResponseDTO(finalConfig))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("创建公共模型配置失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型配置
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigResponseDTO>>> updateConfig(
|
||||
@PathVariable String id,
|
||||
@RequestBody PublicModelConfigRequestDTO requestDTO,
|
||||
@RequestParam(value = "validate", required = false, defaultValue = "false") boolean validate) {
|
||||
PublicModelConfig config = convertToEntity(requestDTO);
|
||||
|
||||
return publicModelConfigService.updateConfig(id, config)
|
||||
.flatMap(updatedConfig -> {
|
||||
if (validate) {
|
||||
log.info("更新配置后立即验证API Key: {}", id);
|
||||
return publicModelConfigService.validateConfig(id);
|
||||
} else {
|
||||
return Mono.just(updatedConfig);
|
||||
}
|
||||
})
|
||||
.map(finalConfig -> ResponseEntity.ok(ApiResponse.success(convertToResponseDTO(finalConfig))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("更新公共模型配置失败: {}", id, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型配置
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<Void>>> deleteConfig(@PathVariable String id) {
|
||||
return publicModelConfigService.deleteConfig(id)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.<Void>success())))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.<Void>error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用模型配置
|
||||
*/
|
||||
@PatchMapping("/{id}/status")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfig>>> toggleConfigStatus(
|
||||
@PathVariable String id,
|
||||
@RequestBody StatusRequest request) {
|
||||
return publicModelConfigService.toggleStatus(id, request.isEnabled())
|
||||
.map(updatedConfig -> ResponseEntity.ok(ApiResponse.success(updatedConfig)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为模型配置添加支持的功能
|
||||
*/
|
||||
@PostMapping("/{id}/features")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfig>>> addFeatureToConfig(
|
||||
@PathVariable String id,
|
||||
@RequestBody FeatureRequest request) {
|
||||
return publicModelConfigService.addEnabledFeature(id, request.getFeatureType())
|
||||
.map(updatedConfig -> ResponseEntity.ok(ApiResponse.success(updatedConfig)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型配置移除支持的功能
|
||||
*/
|
||||
@DeleteMapping("/{id}/features/{featureType}")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfig>>> removeFeatureFromConfig(
|
||||
@PathVariable String id,
|
||||
@PathVariable AIFeatureType featureType) {
|
||||
return publicModelConfigService.removeEnabledFeature(id, featureType)
|
||||
.map(updatedConfig -> ResponseEntity.ok(ApiResponse.success(updatedConfig)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新模型配置的积分汇率乘数
|
||||
*/
|
||||
@PatchMapping("/credit-rate")
|
||||
public Mono<ResponseEntity<ApiResponse<List<PublicModelConfigResponseDTO>>>> updateCreditRates(
|
||||
@RequestBody List<CreditRateUpdate> updates) {
|
||||
return publicModelConfigService.batchUpdateCreditRates(updates)
|
||||
.map(this::convertToResponseDTO)
|
||||
.collectList()
|
||||
.map(updatedConfigs -> ResponseEntity.ok(ApiResponse.success(updatedConfigs)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("批量更新积分汇率失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证指定配置的所有API Key
|
||||
*/
|
||||
@PostMapping("/{id}/validate")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigResponseDTO>>> validateConfig(@PathVariable String id) {
|
||||
return publicModelConfigService.validateConfig(id)
|
||||
.map(config -> ResponseEntity.ok(ApiResponse.success(convertToResponseDTO(config))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("验证公共模型配置失败: {}", id, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为配置添加API Key
|
||||
*/
|
||||
@PostMapping("/{id}/api-keys")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigResponseDTO>>> addApiKey(
|
||||
@PathVariable String id,
|
||||
@RequestBody ApiKeyRequest request) {
|
||||
return publicModelConfigService.addApiKey(id, request.getApiKey(), request.getNote())
|
||||
.map(config -> ResponseEntity.ok(ApiResponse.success(convertToResponseDTO(config))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("添加API Key失败: {}", id, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置中移除API Key
|
||||
*/
|
||||
@DeleteMapping("/{id}/api-keys")
|
||||
public Mono<ResponseEntity<ApiResponse<PublicModelConfigResponseDTO>>> removeApiKey(
|
||||
@PathVariable String id,
|
||||
@RequestBody ApiKeyRequest request) {
|
||||
return publicModelConfigService.removeApiKey(id, request.getApiKey())
|
||||
.map(config -> ResponseEntity.ok(ApiResponse.success(convertToResponseDTO(config))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("移除API Key失败: {}", id, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态请求DTO
|
||||
*/
|
||||
public static class StatusRequest {
|
||||
private boolean enabled;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能请求DTO
|
||||
*/
|
||||
public static class FeatureRequest {
|
||||
private AIFeatureType featureType;
|
||||
|
||||
public AIFeatureType getFeatureType() {
|
||||
return featureType;
|
||||
}
|
||||
|
||||
public void setFeatureType(AIFeatureType featureType) {
|
||||
this.featureType = featureType;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分汇率更新DTO
|
||||
*/
|
||||
public static class CreditRateUpdate {
|
||||
private String configId;
|
||||
private Double creditRateMultiplier;
|
||||
|
||||
public String getConfigId() {
|
||||
return configId;
|
||||
}
|
||||
|
||||
public void setConfigId(String configId) {
|
||||
this.configId = configId;
|
||||
}
|
||||
|
||||
public Double getCreditRateMultiplier() {
|
||||
return creditRateMultiplier;
|
||||
}
|
||||
|
||||
public void setCreditRateMultiplier(Double creditRateMultiplier) {
|
||||
this.creditRateMultiplier = creditRateMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key请求DTO
|
||||
*/
|
||||
public static class ApiKeyRequest {
|
||||
private String apiKey;
|
||||
private String note;
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
public void setNote(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换实体为响应DTO
|
||||
*/
|
||||
private PublicModelConfigResponseDTO convertToResponseDTO(PublicModelConfig config) {
|
||||
List<PublicModelConfigResponseDTO.ApiKeyStatusDTO> apiKeyStatuses = config.getApiKeys() != null
|
||||
? config.getApiKeys().stream()
|
||||
.map(entry -> PublicModelConfigResponseDTO.ApiKeyStatusDTO.builder()
|
||||
.isValid(entry.getIsValid())
|
||||
.validationError(entry.getValidationError())
|
||||
.lastValidatedAt(entry.getLastValidatedAt())
|
||||
.note(entry.getNote())
|
||||
.build())
|
||||
.collect(Collectors.toList())
|
||||
: List.of();
|
||||
|
||||
return PublicModelConfigResponseDTO.builder()
|
||||
.id(config.getId())
|
||||
.provider(config.getProvider())
|
||||
.modelId(config.getModelId())
|
||||
.displayName(config.getDisplayName())
|
||||
.enabled(config.getEnabled())
|
||||
.apiEndpoint(config.getApiEndpoint())
|
||||
.isValidated(config.getIsValidated())
|
||||
.apiKeyPoolStatus(config.getApiKeyPoolStatus())
|
||||
.apiKeyStatuses(apiKeyStatuses)
|
||||
.enabledForFeatures(config.getEnabledForFeatures())
|
||||
.creditRateMultiplier(config.getCreditRateMultiplier())
|
||||
.maxConcurrentRequests(config.getMaxConcurrentRequests())
|
||||
.dailyRequestLimit(config.getDailyRequestLimit())
|
||||
.hourlyRequestLimit(config.getHourlyRequestLimit())
|
||||
.priority(config.getPriority())
|
||||
.description(config.getDescription())
|
||||
.tags(config.getTags())
|
||||
.createdAt(config.getCreatedAt())
|
||||
.updatedAt(config.getUpdatedAt())
|
||||
.createdBy(config.getCreatedBy())
|
||||
.updatedBy(config.getUpdatedBy())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换请求DTO为实体
|
||||
*/
|
||||
private PublicModelConfig convertToEntity(PublicModelConfigRequestDTO requestDTO) {
|
||||
PublicModelConfig config = PublicModelConfig.builder()
|
||||
.provider(requestDTO.getProvider())
|
||||
.modelId(requestDTO.getModelId())
|
||||
.displayName(requestDTO.getDisplayName())
|
||||
.enabled(requestDTO.getEnabled())
|
||||
.apiEndpoint(requestDTO.getApiEndpoint())
|
||||
.enabledForFeatures(requestDTO.getEnabledForFeatures())
|
||||
.creditRateMultiplier(requestDTO.getCreditRateMultiplier())
|
||||
.maxConcurrentRequests(requestDTO.getMaxConcurrentRequests())
|
||||
.dailyRequestLimit(requestDTO.getDailyRequestLimit())
|
||||
.hourlyRequestLimit(requestDTO.getHourlyRequestLimit())
|
||||
.priority(requestDTO.getPriority())
|
||||
.description(requestDTO.getDescription())
|
||||
.tags(requestDTO.getTags())
|
||||
.build();
|
||||
|
||||
// 转换API Key
|
||||
if (requestDTO.getApiKeys() != null) {
|
||||
for (PublicModelConfigRequestDTO.ApiKeyRequestDTO apiKeyDTO : requestDTO.getApiKeys()) {
|
||||
config.addApiKey(apiKeyDTO.getApiKey(), apiKeyDTO.getNote());
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换实体为包含API Keys的响应DTO
|
||||
*/
|
||||
private PublicModelConfigWithKeysDTO convertToWithKeysDTO(PublicModelConfig config) {
|
||||
List<PublicModelConfigWithKeysDTO.ApiKeyWithStatusDTO> apiKeyStatuses = config.getApiKeys() != null
|
||||
? config.getApiKeys().stream()
|
||||
.map(entry -> {
|
||||
String decryptedApiKey = null;
|
||||
try {
|
||||
// 解密API Key用于管理界面显示
|
||||
decryptedApiKey = encryptor.decrypt(entry.getApiKey());
|
||||
} catch (Exception e) {
|
||||
log.warn("解密API Key失败,返回加密值: configId={}, error={}", config.getId(), e.getMessage());
|
||||
// 如果解密失败,仍然返回原始值(可能是明文或有问题的加密值)
|
||||
decryptedApiKey = entry.getApiKey();
|
||||
}
|
||||
|
||||
return PublicModelConfigWithKeysDTO.ApiKeyWithStatusDTO.builder()
|
||||
.apiKey(decryptedApiKey)
|
||||
.isValid(entry.getIsValid())
|
||||
.validationError(entry.getValidationError())
|
||||
.lastValidatedAt(entry.getLastValidatedAt())
|
||||
.note(entry.getNote())
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
: List.of();
|
||||
|
||||
return PublicModelConfigWithKeysDTO.builder()
|
||||
.id(config.getId())
|
||||
.provider(config.getProvider())
|
||||
.modelId(config.getModelId())
|
||||
.displayName(config.getDisplayName())
|
||||
.enabled(config.getEnabled())
|
||||
.apiEndpoint(config.getApiEndpoint())
|
||||
.isValidated(config.getIsValidated())
|
||||
.apiKeyPoolStatus(config.getApiKeyPoolStatus())
|
||||
.apiKeyStatuses(apiKeyStatuses)
|
||||
.enabledForFeatures(config.getEnabledForFeatures())
|
||||
.creditRateMultiplier(config.getCreditRateMultiplier())
|
||||
.maxConcurrentRequests(config.getMaxConcurrentRequests())
|
||||
.dailyRequestLimit(config.getDailyRequestLimit())
|
||||
.hourlyRequestLimit(config.getHourlyRequestLimit())
|
||||
.priority(config.getPriority())
|
||||
.description(config.getDescription())
|
||||
.tags(config.getTags())
|
||||
.createdAt(config.getCreatedAt())
|
||||
.updatedAt(config.getUpdatedAt())
|
||||
.createdBy(config.getCreatedBy())
|
||||
.updatedBy(config.getUpdatedBy())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.domain.model.AIPromptPreset;
|
||||
import com.ainovel.server.service.AdminPromptPresetService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理员系统预设管理控制器
|
||||
* 提供系统级AI预设的完整管理功能
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/prompt-presets")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_PRESETS') or hasRole('SUPER_ADMIN')")
|
||||
@Tag(name = "管理员预设管理", description = "系统级AI预设的管理接口")
|
||||
public class AdminPromptPresetController {
|
||||
|
||||
@Autowired
|
||||
private AdminPromptPresetService adminPresetService;
|
||||
|
||||
/**
|
||||
* 获取所有系统预设
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取所有系统预设", description = "获取系统中所有的官方预设")
|
||||
public Mono<ResponseEntity<ApiResponse<List<AIPromptPreset>>>> getAllSystemPresets(
|
||||
@RequestParam(required = false) String featureType) {
|
||||
|
||||
log.info("获取系统预设列表,功能类型: {}", featureType);
|
||||
|
||||
Mono<List<AIPromptPreset>> presetsMono;
|
||||
if (featureType != null && !featureType.isEmpty()) {
|
||||
try {
|
||||
AIFeatureType feature = AIFeatureType.valueOf(featureType.toUpperCase());
|
||||
presetsMono = adminPresetService.findSystemPresetsByFeatureType(feature).collectList();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("无效的功能类型: " + featureType)));
|
||||
}
|
||||
} else {
|
||||
presetsMono = adminPresetService.findAllSystemPresets().collectList();
|
||||
}
|
||||
|
||||
return presetsMono
|
||||
.map(presets -> {
|
||||
log.info("返回 {} 个系统预设", presets.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(presets));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取系统预设失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("获取系统预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建系统预设
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建系统预设", description = "创建新的系统级预设")
|
||||
public Mono<ResponseEntity<ApiResponse<AIPromptPreset>>> createSystemPreset(
|
||||
@RequestBody AIPromptPreset preset,
|
||||
Authentication authentication) {
|
||||
|
||||
String adminId = authentication.getName();
|
||||
log.info("管理员 {} 创建系统预设: {}", adminId, preset.getPresetName());
|
||||
|
||||
return adminPresetService.createSystemPreset(preset, adminId)
|
||||
.map(savedPreset -> {
|
||||
log.info("系统预设创建成功: {} (ID: {})", savedPreset.getPresetName(), savedPreset.getPresetId());
|
||||
return ResponseEntity.ok(ApiResponse.success(savedPreset));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("创建系统预设失败: {}", preset.getPresetName(), e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("创建系统预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新系统预设
|
||||
*/
|
||||
@PutMapping("/{presetId}")
|
||||
@Operation(summary = "更新系统预设", description = "更新指定的系统预设")
|
||||
public Mono<ResponseEntity<ApiResponse<AIPromptPreset>>> updateSystemPreset(
|
||||
@PathVariable String presetId,
|
||||
@RequestBody AIPromptPreset preset,
|
||||
Authentication authentication) {
|
||||
|
||||
String adminId = authentication.getName();
|
||||
log.info("管理员 {} 更新系统预设: {}", adminId, presetId);
|
||||
|
||||
return adminPresetService.updateSystemPreset(presetId, preset, adminId)
|
||||
.map(updatedPreset -> {
|
||||
log.info("系统预设更新成功: {}", presetId);
|
||||
return ResponseEntity.ok(ApiResponse.success(updatedPreset));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("更新系统预设失败: {}", presetId, e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("更新系统预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除系统预设
|
||||
*/
|
||||
@DeleteMapping("/{presetId}")
|
||||
@Operation(summary = "删除系统预设", description = "删除指定的系统预设")
|
||||
public Mono<ResponseEntity<ApiResponse<String>>> deleteSystemPreset(@PathVariable String presetId) {
|
||||
|
||||
log.info("删除系统预设: {}", presetId);
|
||||
|
||||
return adminPresetService.deleteSystemPreset(presetId)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.success("系统预设删除成功"))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("删除系统预设失败: {}", presetId, e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("删除系统预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换系统预设的快捷访问状态
|
||||
*/
|
||||
@PostMapping("/{presetId}/toggle-quick-access")
|
||||
@Operation(summary = "切换快捷访问", description = "切换系统预设的快捷访问状态")
|
||||
public Mono<ResponseEntity<ApiResponse<AIPromptPreset>>> toggleQuickAccess(@PathVariable String presetId) {
|
||||
|
||||
log.info("切换系统预设快捷访问状态: {}", presetId);
|
||||
|
||||
return adminPresetService.toggleSystemPresetQuickAccess(presetId)
|
||||
.map(updatedPreset -> {
|
||||
log.info("系统预设快捷访问状态已更新: {} -> {}", presetId, updatedPreset.getShowInQuickAccess());
|
||||
return ResponseEntity.ok(ApiResponse.success(updatedPreset));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("切换快捷访问状态失败: {}", presetId, e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("切换快捷访问状态失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新系统预设可见性
|
||||
*/
|
||||
@PatchMapping("/batch-visibility")
|
||||
@Operation(summary = "批量更新可见性", description = "批量设置系统预设的快捷访问状态")
|
||||
public Mono<ResponseEntity<ApiResponse<List<AIPromptPreset>>>> batchUpdateVisibility(
|
||||
@RequestBody BatchVisibilityRequest request) {
|
||||
|
||||
log.info("批量更新 {} 个系统预设的可见性为: {}", request.getPresetIds().size(), request.isShowInQuickAccess());
|
||||
|
||||
return adminPresetService.batchUpdateVisibility(request.getPresetIds(), request.isShowInQuickAccess())
|
||||
.map(updatedPresets -> {
|
||||
log.info("批量更新完成,影响 {} 个预设", updatedPresets.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(updatedPresets));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("批量更新可见性失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("批量更新可见性失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统预设统计信息
|
||||
*/
|
||||
@GetMapping("/statistics")
|
||||
@Operation(summary = "获取统计信息", description = "获取系统预设的整体统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getStatistics() {
|
||||
|
||||
log.info("获取系统预设统计信息");
|
||||
|
||||
return adminPresetService.getSystemPresetsStatistics()
|
||||
.map(stats -> {
|
||||
log.info("返回系统预设统计信息");
|
||||
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取统计信息失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("获取统计信息失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预设详情和使用统计
|
||||
*/
|
||||
@GetMapping("/{presetId}/details")
|
||||
@Operation(summary = "获取预设详情", description = "获取预设的详细信息和使用统计")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getPresetDetails(@PathVariable String presetId) {
|
||||
|
||||
log.info("获取系统预设详情: {}", presetId);
|
||||
|
||||
return adminPresetService.getPresetDetailsWithStats(presetId)
|
||||
.map(details -> {
|
||||
log.info("返回预设详情: {}", presetId);
|
||||
return ResponseEntity.ok(ApiResponse.success(details));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取预设详情失败: {}", presetId, e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("获取预设详情失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出系统预设
|
||||
*/
|
||||
@PostMapping("/export")
|
||||
@Operation(summary = "导出系统预设", description = "导出指定的系统预设,如果不指定则导出全部")
|
||||
public Mono<ResponseEntity<ApiResponse<List<AIPromptPreset>>>> exportPresets(
|
||||
@RequestBody(required = false) ExportRequest request) {
|
||||
|
||||
List<String> presetIds = request != null ? request.getPresetIds() : List.of();
|
||||
log.info("导出系统预设,指定ID数量: {}", presetIds.size());
|
||||
|
||||
return adminPresetService.exportSystemPresets(presetIds)
|
||||
.map(presets -> {
|
||||
log.info("成功导出 {} 个系统预设", presets.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(presets));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("导出系统预设失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("导出系统预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入系统预设
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入系统预设", description = "导入系统预设数据")
|
||||
public Mono<ResponseEntity<ApiResponse<List<AIPromptPreset>>>> importPresets(
|
||||
@RequestBody List<AIPromptPreset> presets,
|
||||
Authentication authentication) {
|
||||
|
||||
String adminId = authentication.getName();
|
||||
log.info("管理员 {} 导入 {} 个系统预设", adminId, presets.size());
|
||||
|
||||
return adminPresetService.importSystemPresets(presets, adminId)
|
||||
.map(savedPresets -> {
|
||||
log.info("成功导入 {} 个系统预设", savedPresets.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(savedPresets));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("导入系统预设失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("导入系统预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户预设提升为系统预设
|
||||
*/
|
||||
@PostMapping("/promote/{userPresetId}")
|
||||
@Operation(summary = "提升为系统预设", description = "将用户预设提升为系统预设")
|
||||
public Mono<ResponseEntity<ApiResponse<AIPromptPreset>>> promoteUserPreset(
|
||||
@PathVariable String userPresetId,
|
||||
Authentication authentication) {
|
||||
|
||||
String adminId = authentication.getName();
|
||||
log.info("管理员 {} 将用户预设 {} 提升为系统预设", adminId, userPresetId);
|
||||
|
||||
return adminPresetService.promoteUserPresetToSystem(userPresetId, adminId)
|
||||
.map(systemPreset -> {
|
||||
log.info("用户预设已成功提升为系统预设: {}", systemPreset.getPresetId());
|
||||
return ResponseEntity.ok(ApiResponse.success(systemPreset));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("提升用户预设失败: {}", userPresetId, e);
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("提升用户预设失败: " + e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量可见性更新请求
|
||||
*/
|
||||
public static class BatchVisibilityRequest {
|
||||
private List<String> presetIds;
|
||||
private boolean showInQuickAccess;
|
||||
|
||||
public List<String> getPresetIds() {
|
||||
return presetIds;
|
||||
}
|
||||
|
||||
public void setPresetIds(List<String> presetIds) {
|
||||
this.presetIds = presetIds;
|
||||
}
|
||||
|
||||
public boolean isShowInQuickAccess() {
|
||||
return showInQuickAccess;
|
||||
}
|
||||
|
||||
public void setShowInQuickAccess(boolean showInQuickAccess) {
|
||||
this.showInQuickAccess = showInQuickAccess;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出请求
|
||||
*/
|
||||
public static class ExportRequest {
|
||||
private List<String> presetIds;
|
||||
|
||||
public List<String> getPresetIds() {
|
||||
return presetIds;
|
||||
}
|
||||
|
||||
public void setPresetIds(List<String> presetIds) {
|
||||
this.presetIds = presetIds;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.common.security.CurrentUser;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.domain.model.EnhancedUserPromptTemplate;
|
||||
|
||||
import com.ainovel.server.service.AdminPromptTemplateService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理员提示词模板管理控制器
|
||||
* 基于 EnhancedUserPromptTemplate 的统一管理
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/prompt-templates")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "管理员模板管理", description = "基于增强用户提示词模板的统一管理")
|
||||
public class AdminPromptTemplateController {
|
||||
|
||||
@Autowired
|
||||
private AdminPromptTemplateService adminTemplateService;
|
||||
|
||||
// ==================== 公共模板查询 ====================
|
||||
|
||||
/**
|
||||
* 获取所有公共模板
|
||||
*/
|
||||
@GetMapping("/public")
|
||||
@Operation(summary = "获取所有公共模板", description = "获取系统中所有的公共提示词模板")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> getAllPublicTemplates(
|
||||
@RequestParam(required = false) String featureType) {
|
||||
log.info("管理员获取公共模板,功能类型过滤: {}", featureType);
|
||||
|
||||
Flux<EnhancedUserPromptTemplate> templates = featureType != null && !featureType.isEmpty()
|
||||
? adminTemplateService.findPublicTemplatesByFeatureType(AIFeatureType.valueOf(featureType))
|
||||
: adminTemplateService.findAllPublicTemplates();
|
||||
|
||||
return ResponseEntity.ok(templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待审核模板
|
||||
*/
|
||||
@GetMapping("/pending")
|
||||
@Operation(summary = "获取待审核模板", description = "获取用户提交的待审核模板列表")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> getPendingTemplates() {
|
||||
log.info("管理员获取待审核模板");
|
||||
return ResponseEntity.ok(adminTemplateService.findPendingTemplates());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已验证模板
|
||||
*/
|
||||
@GetMapping("/verified")
|
||||
@Operation(summary = "获取已验证模板", description = "获取官方认证的模板列表")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> getVerifiedTemplates() {
|
||||
log.info("管理员获取已验证模板");
|
||||
return ResponseEntity.ok(adminTemplateService.findVerifiedTemplates());
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索公共模板
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "搜索公共模板", description = "根据关键词、功能类型等条件搜索公共模板")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> searchPublicTemplates(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(required = false) Boolean verified,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
log.info("管理员搜索公共模板: 关键词={}, 功能类型={}, 验证状态={}", keyword, featureType, verified);
|
||||
|
||||
AIFeatureType feature = featureType != null && !featureType.isEmpty()
|
||||
? AIFeatureType.valueOf(featureType) : null;
|
||||
|
||||
Flux<EnhancedUserPromptTemplate> results = adminTemplateService.searchPublicTemplates(
|
||||
keyword, feature, verified, page, size);
|
||||
|
||||
return ResponseEntity.ok(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门公共模板
|
||||
*/
|
||||
@GetMapping("/popular")
|
||||
@Operation(summary = "获取热门模板", description = "获取使用量和评分最高的公共模板")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> getPopularTemplates(
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
log.info("管理员获取热门模板: 功能类型={}, 限制={}", featureType, limit);
|
||||
|
||||
AIFeatureType feature = featureType != null && !featureType.isEmpty()
|
||||
? AIFeatureType.valueOf(featureType) : null;
|
||||
|
||||
Flux<EnhancedUserPromptTemplate> templates = adminTemplateService.getPopularPublicTemplates(feature, limit);
|
||||
return ResponseEntity.ok(templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新公共模板
|
||||
*/
|
||||
@GetMapping("/latest")
|
||||
@Operation(summary = "获取最新模板", description = "获取最近创建的公共模板")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> getLatestTemplates(
|
||||
@RequestParam(required = false) String featureType,
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
log.info("管理员获取最新模板: 功能类型={}, 限制={}", featureType, limit);
|
||||
|
||||
AIFeatureType feature = featureType != null && !featureType.isEmpty()
|
||||
? AIFeatureType.valueOf(featureType) : null;
|
||||
|
||||
Flux<EnhancedUserPromptTemplate> templates = adminTemplateService.getLatestPublicTemplates(feature, limit);
|
||||
return ResponseEntity.ok(templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户模板(包括私有和公共)
|
||||
*/
|
||||
@GetMapping("/all-user")
|
||||
@Operation(summary = "获取所有用户模板", description = "分页获取系统中所有用户的模板(包括私有和公共)")
|
||||
public ResponseEntity<Flux<EnhancedUserPromptTemplate>> getAllUserTemplates(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String search) {
|
||||
log.info("管理员获取所有用户模板: page={}, size={}, search={}", page, size, search);
|
||||
|
||||
Flux<EnhancedUserPromptTemplate> templates = adminTemplateService.findAllUserTemplates(page, size, search);
|
||||
return ResponseEntity.ok(templates);
|
||||
}
|
||||
|
||||
// ==================== 模板创建与更新 ====================
|
||||
|
||||
/**
|
||||
* 创建官方模板
|
||||
*/
|
||||
@PostMapping("/official")
|
||||
@Operation(summary = "创建官方模板", description = "创建新的官方认证提示词模板")
|
||||
public Mono<ResponseEntity<ApiResponse<EnhancedUserPromptTemplate>>> createOfficialTemplate(
|
||||
@Valid @RequestBody EnhancedUserPromptTemplate template,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 创建官方模板: {}", adminId, template.getName());
|
||||
|
||||
return adminTemplateService.createOfficialTemplate(template, adminId)
|
||||
.map(savedTemplate -> ResponseEntity.ok(ApiResponse.success(savedTemplate)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("创建官方模板失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新公共模板
|
||||
*/
|
||||
@PutMapping("/{templateId}")
|
||||
@Operation(summary = "更新公共模板", description = "更新指定的公共模板信息")
|
||||
public Mono<ResponseEntity<ApiResponse<EnhancedUserPromptTemplate>>> updatePublicTemplate(
|
||||
@PathVariable String templateId,
|
||||
@Valid @RequestBody EnhancedUserPromptTemplate template,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 更新公共模板: {}", adminId, templateId);
|
||||
|
||||
return adminTemplateService.updatePublicTemplate(templateId, template, adminId)
|
||||
.map(updatedTemplate -> ResponseEntity.ok(ApiResponse.success(updatedTemplate)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("更新公共模板失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除公共模板
|
||||
*/
|
||||
@DeleteMapping("/{templateId}")
|
||||
@Operation(summary = "删除公共模板", description = "删除指定的公共模板")
|
||||
public Mono<ResponseEntity<ApiResponse<String>>> deletePublicTemplate(
|
||||
@PathVariable String templateId,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 删除公共模板: {}", adminId, templateId);
|
||||
|
||||
return adminTemplateService.deletePublicTemplate(templateId, adminId)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.success("模板删除成功"))))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("删除模板失败")));
|
||||
}
|
||||
|
||||
// ==================== 审核与发布管理 ====================
|
||||
|
||||
/**
|
||||
* 审核用户模板
|
||||
*/
|
||||
@PostMapping("/{templateId}/review")
|
||||
@Operation(summary = "审核用户模板", description = "审核用户提交的模板,决定是否通过并公开")
|
||||
public Mono<ResponseEntity<ApiResponse<EnhancedUserPromptTemplate>>> reviewTemplate(
|
||||
@PathVariable String templateId,
|
||||
@RequestParam boolean approved,
|
||||
@RequestParam(required = false) String reviewComment,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 审核模板 {}: {}", adminId, templateId, approved ? "通过" : "拒绝");
|
||||
|
||||
return adminTemplateService.reviewUserTemplate(templateId, approved, adminId, reviewComment)
|
||||
.map(reviewedTemplate -> ResponseEntity.ok(ApiResponse.success(reviewedTemplate)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("审核模板失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布模板
|
||||
*/
|
||||
@PostMapping("/{templateId}/publish")
|
||||
@Operation(summary = "发布模板", description = "将模板设置为公开状态")
|
||||
public Mono<ResponseEntity<ApiResponse<EnhancedUserPromptTemplate>>> publishTemplate(
|
||||
@PathVariable String templateId,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 发布模板: {}", adminId, templateId);
|
||||
|
||||
return adminTemplateService.publishTemplate(templateId, adminId)
|
||||
.map(publishedTemplate -> ResponseEntity.ok(ApiResponse.success(publishedTemplate)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("发布模板失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消发布模板
|
||||
*/
|
||||
@PostMapping("/{templateId}/unpublish")
|
||||
@Operation(summary = "取消发布模板", description = "将模板设置为私有状态")
|
||||
public Mono<ResponseEntity<ApiResponse<EnhancedUserPromptTemplate>>> unpublishTemplate(
|
||||
@PathVariable String templateId,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 取消发布模板: {}", adminId, templateId);
|
||||
|
||||
return adminTemplateService.unpublishTemplate(templateId, adminId)
|
||||
.map(unpublishedTemplate -> ResponseEntity.ok(ApiResponse.success(unpublishedTemplate)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("取消发布模板失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置验证状态
|
||||
*/
|
||||
@PostMapping("/{templateId}/verify")
|
||||
@Operation(summary = "设置验证状态", description = "设置模板的官方认证状态")
|
||||
public Mono<ResponseEntity<ApiResponse<EnhancedUserPromptTemplate>>> setVerified(
|
||||
@PathVariable String templateId,
|
||||
@RequestParam boolean verified,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 设置模板 {} 验证状态: {}", adminId, templateId, verified);
|
||||
|
||||
return adminTemplateService.setVerified(templateId, verified, adminId)
|
||||
.map(verifiedTemplate -> ResponseEntity.ok(ApiResponse.success(verifiedTemplate)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("设置验证状态失败")));
|
||||
}
|
||||
|
||||
// ==================== 批量操作 ====================
|
||||
|
||||
/**
|
||||
* 批量审核模板
|
||||
*/
|
||||
@PostMapping("/batch/review")
|
||||
@Operation(summary = "批量审核模板", description = "批量审核多个用户提交的模板")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> batchReview(
|
||||
@RequestBody List<String> templateIds,
|
||||
@RequestParam boolean approved,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 批量审核 {} 个模板: {}", adminId, templateIds.size(), approved ? "通过" : "拒绝");
|
||||
|
||||
return adminTemplateService.batchReviewTemplates(templateIds, approved, adminId)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("批量审核失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置验证状态
|
||||
*/
|
||||
@PostMapping("/batch/verify")
|
||||
@Operation(summary = "批量设置验证状态", description = "批量设置多个模板的官方认证状态")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> batchSetVerified(
|
||||
@RequestBody List<String> templateIds,
|
||||
@RequestParam boolean verified,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 批量设置 {} 个模板验证状态: {}", adminId, templateIds.size(), verified);
|
||||
|
||||
return adminTemplateService.batchSetVerified(templateIds, verified, adminId)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("批量设置验证状态失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发布/取消发布
|
||||
*/
|
||||
@PostMapping("/batch/publish")
|
||||
@Operation(summary = "批量发布操作", description = "批量发布或取消发布多个模板")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> batchPublish(
|
||||
@RequestBody List<String> templateIds,
|
||||
@RequestParam boolean publish,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 批量{}发布 {} 个模板", adminId, publish ? "" : "取消", templateIds.size());
|
||||
|
||||
return adminTemplateService.batchPublishTemplates(templateIds, publish, adminId)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("批量发布操作失败")));
|
||||
}
|
||||
|
||||
// ==================== 统计与分析 ====================
|
||||
|
||||
/**
|
||||
* 获取模板使用统计
|
||||
*/
|
||||
@GetMapping("/{templateId}/statistics")
|
||||
@Operation(summary = "获取模板统计", description = "获取指定模板的详细使用统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getTemplateStatistics(
|
||||
@PathVariable String templateId) {
|
||||
log.info("获取模板 {} 的使用统计", templateId);
|
||||
|
||||
return adminTemplateService.getTemplateUsageStatistics(templateId)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取模板统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公共模板统计
|
||||
*/
|
||||
@GetMapping("/statistics/public")
|
||||
@Operation(summary = "获取公共模板统计", description = "获取所有公共模板的统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getPublicTemplatesStatistics() {
|
||||
log.info("获取公共模板统计信息");
|
||||
|
||||
return adminTemplateService.getPublicTemplatesStatistics()
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取公共模板统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户模板统计
|
||||
*/
|
||||
@GetMapping("/statistics/user")
|
||||
@Operation(summary = "获取用户模板统计", description = "获取指定用户或所有用户的模板统计")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getUserTemplatesStatistics(
|
||||
@RequestParam(required = false) String userId) {
|
||||
log.info("获取用户模板统计信息: {}", userId);
|
||||
|
||||
return adminTemplateService.getUserTemplatesStatistics(userId)
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取用户模板统计失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统模板统计
|
||||
*/
|
||||
@GetMapping("/statistics/system")
|
||||
@Operation(summary = "获取系统模板统计", description = "获取整个系统的模板统计信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getSystemTemplatesStatistics() {
|
||||
log.info("获取系统模板统计信息");
|
||||
|
||||
return adminTemplateService.getSystemTemplatesStatistics()
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("获取系统模板统计失败")));
|
||||
}
|
||||
|
||||
// ==================== 导入导出 ====================
|
||||
|
||||
/**
|
||||
* 导出公共模板
|
||||
*/
|
||||
@PostMapping("/export")
|
||||
@Operation(summary = "导出公共模板", description = "导出指定的公共模板,如果不指定则导出全部")
|
||||
public Mono<ResponseEntity<ApiResponse<List<EnhancedUserPromptTemplate>>>> exportTemplates(
|
||||
@RequestBody(required = false) List<String> templateIds,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 导出模板", adminId);
|
||||
|
||||
List<String> ids = templateIds != null ? templateIds : List.of();
|
||||
|
||||
return adminTemplateService.exportPublicTemplates(ids, adminId)
|
||||
.map(templates -> ResponseEntity.ok(ApiResponse.success(templates)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("导出模板失败")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入公共模板
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入公共模板", description = "导入公共模板数据,自动设置为官方认证")
|
||||
public Mono<ResponseEntity<ApiResponse<List<EnhancedUserPromptTemplate>>>> importTemplates(
|
||||
@RequestBody List<EnhancedUserPromptTemplate> templates,
|
||||
@CurrentUser String adminId) {
|
||||
log.info("管理员 {} 导入 {} 个模板", adminId, templates.size());
|
||||
|
||||
return adminTemplateService.importPublicTemplates(templates, adminId)
|
||||
.map(importedTemplates -> ResponseEntity.ok(ApiResponse.success(importedTemplates)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("导入模板失败")));
|
||||
}
|
||||
|
||||
// ==================== 模板详情 ====================
|
||||
|
||||
/**
|
||||
* 获取模板详情
|
||||
*/
|
||||
@GetMapping("/{templateId}")
|
||||
@Operation(summary = "获取模板详情", description = "获取指定模板的完整信息")
|
||||
public Mono<ResponseEntity<ApiResponse<Map<String, Object>>>> getTemplateDetails(
|
||||
@PathVariable String templateId) {
|
||||
log.info("获取模板详情: {}", templateId);
|
||||
|
||||
return adminTemplateService.getTemplateUsageStatistics(templateId)
|
||||
.map(details -> ResponseEntity.ok(ApiResponse.success(details)))
|
||||
.onErrorReturn(ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("模板不存在")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.ModelInfo;
|
||||
import com.ainovel.server.service.AIService;
|
||||
import com.ainovel.server.service.ai.pricing.PricingDataSyncService;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员提供商和模型信息控制器
|
||||
* 用于获取可用的AI提供商和模型信息,以及同步定价数据
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/providers")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_MODELS') or hasRole('SUPER_ADMIN')")
|
||||
public class AdminProviderController {
|
||||
|
||||
private final AIService aiService;
|
||||
private final PricingDataSyncService pricingDataSyncService;
|
||||
|
||||
@Autowired
|
||||
public AdminProviderController(AIService aiService, PricingDataSyncService pricingDataSyncService) {
|
||||
this.aiService = aiService;
|
||||
this.pricingDataSyncService = pricingDataSyncService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的提供商
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<ApiResponse<List<String>>>> getAvailableProviders() {
|
||||
return aiService.getAvailableProviders()
|
||||
.collectList()
|
||||
.map(providers -> ResponseEntity.ok(ApiResponse.success(providers)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取可用提供商失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定提供商的模型信息
|
||||
*/
|
||||
@GetMapping("/{provider}/models")
|
||||
public Mono<ResponseEntity<ApiResponse<List<ModelInfo>>>> getModelsForProvider(@PathVariable String provider) {
|
||||
return aiService.getModelInfosForProvider(provider)
|
||||
.collectList()
|
||||
.map(models -> ResponseEntity.ok(ApiResponse.success(models)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("获取提供商模型信息失败: {}", provider, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用API Key获取指定提供商的模型信息
|
||||
*/
|
||||
@PostMapping("/{provider}/models")
|
||||
public Mono<ResponseEntity<ApiResponse<List<ModelInfo>>>> getModelsWithApiKey(
|
||||
@PathVariable String provider,
|
||||
@RequestBody ApiKeyRequest request) {
|
||||
return aiService.getModelInfosForProviderWithApiKey(provider, request.getApiKey(), request.getApiEndpoint())
|
||||
.collectList()
|
||||
.map(models -> ResponseEntity.ok(ApiResponse.success(models)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("使用API Key获取提供商模型信息失败: {}", provider, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步所有提供商的定价数据
|
||||
*/
|
||||
@PostMapping("/sync-pricing")
|
||||
public Mono<ResponseEntity<ApiResponse<String>>> syncAllProvidersPricing() {
|
||||
return pricingDataSyncService.syncAllProvidersPricing()
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.success("定价数据同步已启动"))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("同步定价数据失败", e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步指定提供商的定价数据
|
||||
*/
|
||||
@PostMapping("/{provider}/sync-pricing")
|
||||
public Mono<ResponseEntity<ApiResponse<String>>> syncProviderPricing(@PathVariable String provider) {
|
||||
return pricingDataSyncService.syncProviderPricing(provider)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.success("提供商 " + provider + " 的定价数据同步已启动"))))
|
||||
.onErrorResume(e -> {
|
||||
log.error("同步提供商定价数据失败: {}", provider, e);
|
||||
return Mono.just(ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key请求DTO
|
||||
*/
|
||||
public static class ApiKeyRequest {
|
||||
private String apiKey;
|
||||
private String apiEndpoint;
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getApiEndpoint() {
|
||||
return apiEndpoint;
|
||||
}
|
||||
|
||||
public void setApiEndpoint(String apiEndpoint) {
|
||||
this.apiEndpoint = apiEndpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.Role;
|
||||
import com.ainovel.server.service.RoleService;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员角色管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/roles")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_ROLES') or hasRole('SUPER_ADMIN')")
|
||||
public class AdminRoleController {
|
||||
|
||||
private final RoleService roleService;
|
||||
|
||||
@Autowired
|
||||
public AdminRoleController(RoleService roleService) {
|
||||
this.roleService = roleService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有角色列表
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<ApiResponse<List<Role>>>> getAllRoles() {
|
||||
return roleService.findAll()
|
||||
.collectList()
|
||||
.map(roles -> ResponseEntity.ok(ApiResponse.success(roles)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取角色
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<Role>>> getRoleById(@PathVariable String id) {
|
||||
return roleService.findById(id)
|
||||
.map(role -> ResponseEntity.ok(ApiResponse.success(role)))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新角色
|
||||
*/
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<ApiResponse<Role>>> createRole(@RequestBody Role role) {
|
||||
return roleService.createRole(role)
|
||||
.map(savedRole -> ResponseEntity.ok(ApiResponse.success(savedRole)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<Role>>> updateRole(@PathVariable String id, @RequestBody Role role) {
|
||||
return roleService.updateRole(id, role)
|
||||
.map(updatedRole -> ResponseEntity.ok(ApiResponse.success(updatedRole)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<Void>>> deleteRole(@PathVariable String id) {
|
||||
return roleService.deleteRole(id)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.<Void>success())))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.<Void>error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为角色添加权限
|
||||
*/
|
||||
@PostMapping("/{id}/permissions")
|
||||
public Mono<ResponseEntity<ApiResponse<Role>>> addPermissionToRole(
|
||||
@PathVariable String id,
|
||||
@RequestBody PermissionRequest request) {
|
||||
return roleService.addPermissionToRole(id, request.getPermission())
|
||||
.map(updatedRole -> ResponseEntity.ok(ApiResponse.success(updatedRole)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从角色移除权限
|
||||
*/
|
||||
@DeleteMapping("/{id}/permissions/{permission}")
|
||||
public Mono<ResponseEntity<ApiResponse<Role>>> removePermissionFromRole(
|
||||
@PathVariable String id,
|
||||
@PathVariable String permission) {
|
||||
return roleService.removePermissionFromRole(id, permission)
|
||||
.map(updatedRole -> ResponseEntity.ok(ApiResponse.success(updatedRole)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限请求DTO
|
||||
*/
|
||||
public static class PermissionRequest {
|
||||
private String permission;
|
||||
|
||||
public String getPermission() {
|
||||
return permission;
|
||||
}
|
||||
|
||||
public void setPermission(String permission) {
|
||||
this.permission = permission;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.SubscriptionPlan;
|
||||
import com.ainovel.server.service.SubscriptionPlanService;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员订阅计划管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/subscription-plans")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_SUBSCRIPTIONS') or hasRole('SUPER_ADMIN')")
|
||||
public class AdminSubscriptionController {
|
||||
|
||||
private final SubscriptionPlanService subscriptionPlanService;
|
||||
|
||||
@Autowired
|
||||
public AdminSubscriptionController(SubscriptionPlanService subscriptionPlanService) {
|
||||
this.subscriptionPlanService = subscriptionPlanService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有订阅计划
|
||||
*
|
||||
* 注意:前端期望在 data 字段中拿到 List<SubscriptionPlan>,
|
||||
* 因此前端管理端不要直接返回 Flux,而是 collectList 后再包装。
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<ApiResponse<java.util.List<SubscriptionPlan>>>> getAllPlans() {
|
||||
return subscriptionPlanService.findAll()
|
||||
.collectList()
|
||||
.map(list -> ResponseEntity.ok(ApiResponse.success(list)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取订阅计划
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<SubscriptionPlan>>> getPlanById(@PathVariable String id) {
|
||||
return subscriptionPlanService.findById(id)
|
||||
.map(plan -> ResponseEntity.ok(ApiResponse.success(plan)))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新订阅计划
|
||||
*/
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<ApiResponse<SubscriptionPlan>>> createPlan(@RequestBody SubscriptionPlan plan) {
|
||||
return subscriptionPlanService.createPlan(plan)
|
||||
.map(savedPlan -> ResponseEntity.ok(ApiResponse.success(savedPlan)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新订阅计划
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<SubscriptionPlan>>> updatePlan(@PathVariable String id, @RequestBody SubscriptionPlan plan) {
|
||||
return subscriptionPlanService.updatePlan(id, plan)
|
||||
.map(updatedPlan -> ResponseEntity.ok(ApiResponse.success(updatedPlan)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除订阅计划
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<Void>>> deletePlan(@PathVariable String id) {
|
||||
return subscriptionPlanService.deletePlan(id)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.<Void>success())))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.<Void>error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用订阅计划
|
||||
*/
|
||||
@PatchMapping("/{id}/status")
|
||||
public Mono<ResponseEntity<ApiResponse<SubscriptionPlan>>> togglePlanStatus(
|
||||
@PathVariable String id,
|
||||
@RequestBody StatusRequest request) {
|
||||
return subscriptionPlanService.togglePlanStatus(id, request.isActive())
|
||||
.map(updatedPlan -> ResponseEntity.ok(ApiResponse.success(updatedPlan)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态请求DTO
|
||||
*/
|
||||
public static class StatusRequest {
|
||||
private boolean active;
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.SystemConfig;
|
||||
import com.ainovel.server.service.SystemConfigService;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 管理员系统配置管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/system-configs")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_CONFIGS') or hasRole('SUPER_ADMIN')")
|
||||
public class AdminSystemConfigController {
|
||||
|
||||
private final SystemConfigService systemConfigService;
|
||||
|
||||
@Autowired
|
||||
public AdminSystemConfigController(SystemConfigService systemConfigService) {
|
||||
this.systemConfigService = systemConfigService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有系统配置
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<ApiResponse<List<SystemConfig>>>> getAllConfigs() {
|
||||
return systemConfigService.findAll()
|
||||
.collectList()
|
||||
.map(configs -> ResponseEntity.ok(ApiResponse.success(configs)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置分组获取配置
|
||||
*/
|
||||
@GetMapping("/group/{group}")
|
||||
public Mono<ResponseEntity<ApiResponse<List<SystemConfig>>>> getConfigsByGroup(@PathVariable String group) {
|
||||
return systemConfigService.findByGroup(group)
|
||||
.collectList()
|
||||
.map(configs -> ResponseEntity.ok(ApiResponse.success(configs)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有非只读配置
|
||||
*/
|
||||
@GetMapping("/editable")
|
||||
public Mono<ResponseEntity<ApiResponse<List<SystemConfig>>>> getEditableConfigs() {
|
||||
return systemConfigService.findAllNonReadOnly()
|
||||
.collectList()
|
||||
.map(configs -> ResponseEntity.ok(ApiResponse.success(configs)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置键获取配置
|
||||
*/
|
||||
@GetMapping("/{configKey}")
|
||||
public Mono<ResponseEntity<ApiResponse<SystemConfig>>> getConfigByKey(@PathVariable String configKey) {
|
||||
return systemConfigService.getConfig(configKey)
|
||||
.map(config -> ResponseEntity.ok(ApiResponse.success(config)))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新系统配置
|
||||
*/
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<ApiResponse<SystemConfig>>> createConfig(@RequestBody SystemConfig config) {
|
||||
return systemConfigService.createConfig(config)
|
||||
.map(savedConfig -> ResponseEntity.ok(ApiResponse.success(savedConfig)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新系统配置
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<SystemConfig>>> updateConfig(@PathVariable String id, @RequestBody SystemConfig config) {
|
||||
return systemConfigService.updateConfig(id, config)
|
||||
.map(updatedConfig -> ResponseEntity.ok(ApiResponse.success(updatedConfig)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除系统配置
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<Void>>> deleteConfig(@PathVariable String id) {
|
||||
return systemConfigService.deleteConfig(id)
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.<Void>success())))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.<Void>error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置值
|
||||
*/
|
||||
@PatchMapping("/{configKey}/value")
|
||||
public Mono<ResponseEntity<ApiResponse<Boolean>>> setConfigValue(
|
||||
@PathVariable String configKey,
|
||||
@RequestBody ValueRequest request) {
|
||||
return systemConfigService.setConfigValue(configKey, request.getValue())
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置配置值
|
||||
*/
|
||||
@PatchMapping("/batch")
|
||||
public Mono<ResponseEntity<ApiResponse<Boolean>>> setConfigValues(@RequestBody Map<String, String> configs) {
|
||||
return systemConfigService.setConfigValues(configs)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认配置
|
||||
*/
|
||||
@PostMapping("/initialize")
|
||||
public Mono<ResponseEntity<ApiResponse<Boolean>>> initializeDefaultConfigs() {
|
||||
return systemConfigService.initializeDefaultConfigs()
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置值
|
||||
*/
|
||||
@PostMapping("/{configKey}/validate")
|
||||
public Mono<ResponseEntity<ApiResponse<Boolean>>> validateConfigValue(
|
||||
@PathVariable String configKey,
|
||||
@RequestBody ValueRequest request) {
|
||||
return systemConfigService.validateConfigValue(configKey, request.getValue())
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 值请求DTO
|
||||
*/
|
||||
public static class ValueRequest {
|
||||
private String value;
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.User;
|
||||
import com.ainovel.server.service.AdminUserService;
|
||||
import com.ainovel.server.service.CreditService;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员用户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/users")
|
||||
@PreAuthorize("hasAuthority('ADMIN_MANAGE_USERS')")
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminUserService adminUserService;
|
||||
private final CreditService creditService;
|
||||
|
||||
@Autowired
|
||||
public AdminUserController(AdminUserService adminUserService, CreditService creditService) {
|
||||
this.adminUserService = adminUserService;
|
||||
this.creditService = creditService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表(分页)
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<ApiResponse<List<User>>>> getUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String search) {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
|
||||
Flux<User> usersFlux;
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
usersFlux = adminUserService.searchUsers(search, pageable);
|
||||
} else {
|
||||
usersFlux = adminUserService.findAllUsers(pageable);
|
||||
}
|
||||
|
||||
// 将Flux转换为List后返回,确保前端能正确解析
|
||||
return usersFlux.collectList()
|
||||
.map(users -> ResponseEntity.ok(ApiResponse.success(users)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取用户详情
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<User>>> getUserById(@PathVariable String id) {
|
||||
return adminUserService.findUserById(id)
|
||||
.map(user -> ResponseEntity.ok(ApiResponse.success(user)))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public Mono<ResponseEntity<ApiResponse<User>>> updateUser(@PathVariable String id, @RequestBody UserUpdateRequest request) {
|
||||
return adminUserService.updateUser(id, request)
|
||||
.map(updatedUser -> ResponseEntity.ok(ApiResponse.success(updatedUser)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用/启用用户账户
|
||||
*/
|
||||
@PatchMapping("/{id}/status")
|
||||
public Mono<ResponseEntity<ApiResponse<User>>> toggleUserStatus(
|
||||
@PathVariable String id,
|
||||
@RequestBody UserStatusRequest request) {
|
||||
return adminUserService.updateUserStatus(id, request.getStatus())
|
||||
.map(updatedUser -> ResponseEntity.ok(ApiResponse.success(updatedUser)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户分配角色
|
||||
*/
|
||||
@PostMapping("/{id}/roles")
|
||||
public Mono<ResponseEntity<ApiResponse<User>>> assignRoleToUser(
|
||||
@PathVariable String id,
|
||||
@RequestBody RoleAssignmentRequest request) {
|
||||
return adminUserService.assignRoleToUser(id, request.getRoleId())
|
||||
.map(updatedUser -> ResponseEntity.ok(ApiResponse.success(updatedUser)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户角色
|
||||
*/
|
||||
@DeleteMapping("/{id}/roles/{roleId}")
|
||||
public Mono<ResponseEntity<ApiResponse<User>>> removeRoleFromUser(
|
||||
@PathVariable String id,
|
||||
@PathVariable String roleId) {
|
||||
return adminUserService.removeRoleFromUser(id, roleId)
|
||||
.map(updatedUser -> ResponseEntity.ok(ApiResponse.success(updatedUser)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户添加积分
|
||||
*/
|
||||
@PostMapping("/{id}/credits")
|
||||
public Mono<ResponseEntity<ApiResponse<Long>>> addCreditsToUser(
|
||||
@PathVariable String id,
|
||||
@RequestBody CreditOperationRequest request) {
|
||||
return creditService.addCredits(id, request.getAmount(), request.getReason())
|
||||
.then(creditService.getUserCredits(id))
|
||||
.map(credits -> ResponseEntity.ok(ApiResponse.success(credits)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减用户积分
|
||||
*/
|
||||
@DeleteMapping("/{id}/credits")
|
||||
public Mono<ResponseEntity<ApiResponse<Long>>> deductCreditsFromUser(
|
||||
@PathVariable String id,
|
||||
@RequestBody CreditOperationRequest request) {
|
||||
return creditService.deductCredits(id, request.getAmount())
|
||||
.then(creditService.getUserCredits(id))
|
||||
.map(credits -> ResponseEntity.ok(ApiResponse.success(credits)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
*/
|
||||
@GetMapping("/statistics")
|
||||
public Mono<ResponseEntity<ApiResponse<UserStatistics>>> getUserStatistics() {
|
||||
return adminUserService.getUserStatistics()
|
||||
.map(stats -> ResponseEntity.ok(ApiResponse.success(stats)))
|
||||
.onErrorResume(e -> Mono.just(
|
||||
ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户更新请求DTO
|
||||
*/
|
||||
public static class UserUpdateRequest {
|
||||
private String email;
|
||||
private String displayName;
|
||||
private User.AccountStatus accountStatus;
|
||||
|
||||
// Getters and setters
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
|
||||
public User.AccountStatus getAccountStatus() { return accountStatus; }
|
||||
public void setAccountStatus(User.AccountStatus accountStatus) { this.accountStatus = accountStatus; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态请求DTO
|
||||
*/
|
||||
public static class UserStatusRequest {
|
||||
private User.AccountStatus status;
|
||||
|
||||
public User.AccountStatus getStatus() { return status; }
|
||||
public void setStatus(User.AccountStatus status) { this.status = status; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色分配请求DTO
|
||||
*/
|
||||
public static class RoleAssignmentRequest {
|
||||
private String roleId;
|
||||
|
||||
public String getRoleId() { return roleId; }
|
||||
public void setRoleId(String roleId) { this.roleId = roleId; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分操作请求DTO
|
||||
*/
|
||||
public static class CreditOperationRequest {
|
||||
private long amount;
|
||||
private String reason;
|
||||
|
||||
public long getAmount() { return amount; }
|
||||
public void setAmount(long amount) { this.amount = amount; }
|
||||
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户统计信息DTO
|
||||
*/
|
||||
public static class UserStatistics {
|
||||
private long totalUsers;
|
||||
private long activeUsers;
|
||||
private long suspendedUsers;
|
||||
private long newUsersToday;
|
||||
private long newUsersThisWeek;
|
||||
private long newUsersThisMonth;
|
||||
|
||||
// Getters and setters
|
||||
public long getTotalUsers() { return totalUsers; }
|
||||
public void setTotalUsers(long totalUsers) { this.totalUsers = totalUsers; }
|
||||
|
||||
public long getActiveUsers() { return activeUsers; }
|
||||
public void setActiveUsers(long activeUsers) { this.activeUsers = activeUsers; }
|
||||
|
||||
public long getSuspendedUsers() { return suspendedUsers; }
|
||||
public void setSuspendedUsers(long suspendedUsers) { this.suspendedUsers = suspendedUsers; }
|
||||
|
||||
public long getNewUsersToday() { return newUsersToday; }
|
||||
public void setNewUsersToday(long newUsersToday) { this.newUsersToday = newUsersToday; }
|
||||
|
||||
public long getNewUsersThisWeek() { return newUsersThisWeek; }
|
||||
public void setNewUsersThisWeek(long newUsersThisWeek) { this.newUsersThisWeek = newUsersThisWeek; }
|
||||
|
||||
public long getNewUsersThisMonth() { return newUsersThisMonth; }
|
||||
public void setNewUsersThisMonth(long newUsersThisMonth) { this.newUsersThisMonth = newUsersThisMonth; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.service.impl.content.ContentProviderFactory;
|
||||
import com.ainovel.server.service.prompt.PlaceholderDescriptionService;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 内容提供器状态API控制器
|
||||
* 提供内容提供器实现状态和占位符可用性查询
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/content-provider")
|
||||
@Tag(name = "内容提供器", description = "内容提供器实现状态和占位符管理")
|
||||
public class ContentProviderController {
|
||||
|
||||
@Autowired
|
||||
private ContentProviderFactory contentProviderFactory;
|
||||
|
||||
@Autowired
|
||||
private PlaceholderDescriptionService placeholderDescriptionService;
|
||||
|
||||
/**
|
||||
* 获取所有已实现的内容提供器类型
|
||||
*/
|
||||
@GetMapping("/available-types")
|
||||
@Operation(summary = "获取可用的内容提供器类型", description = "返回所有已注册和实现的内容提供器类型")
|
||||
public ApiResponse<Set<String>> getAvailableContentProviderTypes() {
|
||||
Set<String> availableTypes = contentProviderFactory.getAvailableTypes();
|
||||
log.info("返回可用内容提供器类型: {}", availableTypes);
|
||||
return ApiResponse.success(availableTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定内容提供器是否已实现
|
||||
*/
|
||||
@GetMapping("/check-providers")
|
||||
@Operation(summary = "批量检查内容提供器状态", description = "检查指定的内容提供器类型是否已实现")
|
||||
public ApiResponse<Map<String, Boolean>> checkContentProviders(@RequestParam Set<String> types) {
|
||||
Map<String, Boolean> providerStatus = contentProviderFactory.checkProviders(types);
|
||||
log.info("内容提供器状态检查: 请求={}, 结果={}", types, providerStatus);
|
||||
return ApiResponse.success(providerStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的占位符
|
||||
*/
|
||||
@GetMapping("/available-placeholders")
|
||||
@Operation(summary = "获取可用占位符", description = "返回所有实际可用的占位符(已过滤掉未实现的内容提供器)")
|
||||
public ApiResponse<Set<String>> getAvailablePlaceholders() {
|
||||
Set<String> availablePlaceholders = placeholderDescriptionService.getAvailablePlaceholders();
|
||||
log.info("返回可用占位符数量: {}", availablePlaceholders.size());
|
||||
return ApiResponse.success(availablePlaceholders);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取占位符描述映射
|
||||
*/
|
||||
@GetMapping("/placeholder-descriptions")
|
||||
@Operation(summary = "获取占位符描述", description = "获取指定占位符的详细描述信息")
|
||||
public ApiResponse<Map<String, String>> getPlaceholderDescriptions(@RequestParam Set<String> placeholders) {
|
||||
Map<String, String> descriptions = placeholderDescriptionService.getPlaceholderDescriptions(placeholders);
|
||||
log.info("返回占位符描述: 请求={}, 描述数量={}", placeholders.size(), descriptions.size());
|
||||
return ApiResponse.success(descriptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤占位符,只返回可用的
|
||||
*/
|
||||
@GetMapping("/filter-placeholders")
|
||||
@Operation(summary = "过滤可用占位符", description = "从请求的占位符中过滤出实际可用的占位符")
|
||||
public ApiResponse<FilterResult> filterAvailablePlaceholders(@RequestParam Set<String> requestedPlaceholders) {
|
||||
Set<String> availablePlaceholders = placeholderDescriptionService.filterAvailablePlaceholders(requestedPlaceholders);
|
||||
Set<String> unavailablePlaceholders = new java.util.HashSet<>(requestedPlaceholders);
|
||||
unavailablePlaceholders.removeAll(availablePlaceholders);
|
||||
|
||||
FilterResult result = new FilterResult(
|
||||
requestedPlaceholders,
|
||||
availablePlaceholders,
|
||||
unavailablePlaceholders,
|
||||
placeholderDescriptionService.getPlaceholderDescriptions(availablePlaceholders)
|
||||
);
|
||||
|
||||
log.info("占位符过滤结果: 请求={}, 可用={}, 不可用={}",
|
||||
requestedPlaceholders.size(), availablePlaceholders.size(), unavailablePlaceholders.size());
|
||||
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的内容提供器和占位符状态报告
|
||||
*/
|
||||
@GetMapping("/status-report")
|
||||
@Operation(summary = "获取状态报告", description = "获取内容提供器和占位符的完整状态报告")
|
||||
public ApiResponse<StatusReport> getStatusReport() {
|
||||
Set<String> availableProviders = contentProviderFactory.getAvailableTypes();
|
||||
Set<String> availablePlaceholders = placeholderDescriptionService.getAvailablePlaceholders();
|
||||
Map<String, String> placeholderDescriptions = placeholderDescriptionService.getPlaceholderDescriptions(availablePlaceholders);
|
||||
|
||||
StatusReport report = new StatusReport(
|
||||
availableProviders,
|
||||
availablePlaceholders,
|
||||
placeholderDescriptions,
|
||||
availableProviders.size(),
|
||||
availablePlaceholders.size()
|
||||
);
|
||||
|
||||
log.info("生成状态报告: 提供器数={}, 占位符数={}",
|
||||
availableProviders.size(), availablePlaceholders.size());
|
||||
|
||||
return ApiResponse.success(report);
|
||||
}
|
||||
|
||||
// ==================== 数据传输对象 ====================
|
||||
|
||||
/**
|
||||
* 过滤结果
|
||||
*/
|
||||
public static class FilterResult {
|
||||
private final Set<String> requestedPlaceholders;
|
||||
private final Set<String> availablePlaceholders;
|
||||
private final Set<String> unavailablePlaceholders;
|
||||
private final Map<String, String> descriptions;
|
||||
|
||||
public FilterResult(Set<String> requestedPlaceholders, Set<String> availablePlaceholders,
|
||||
Set<String> unavailablePlaceholders, Map<String, String> descriptions) {
|
||||
this.requestedPlaceholders = requestedPlaceholders;
|
||||
this.availablePlaceholders = availablePlaceholders;
|
||||
this.unavailablePlaceholders = unavailablePlaceholders;
|
||||
this.descriptions = descriptions;
|
||||
}
|
||||
|
||||
public Set<String> getRequestedPlaceholders() { return requestedPlaceholders; }
|
||||
public Set<String> getAvailablePlaceholders() { return availablePlaceholders; }
|
||||
public Set<String> getUnavailablePlaceholders() { return unavailablePlaceholders; }
|
||||
public Map<String, String> getDescriptions() { return descriptions; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态报告
|
||||
*/
|
||||
public static class StatusReport {
|
||||
private final Set<String> availableProviders;
|
||||
private final Set<String> availablePlaceholders;
|
||||
private final Map<String, String> placeholderDescriptions;
|
||||
private final int providerCount;
|
||||
private final int placeholderCount;
|
||||
|
||||
public StatusReport(Set<String> availableProviders, Set<String> availablePlaceholders,
|
||||
Map<String, String> placeholderDescriptions, int providerCount, int placeholderCount) {
|
||||
this.availableProviders = availableProviders;
|
||||
this.availablePlaceholders = availablePlaceholders;
|
||||
this.placeholderDescriptions = placeholderDescriptions;
|
||||
this.providerCount = providerCount;
|
||||
this.placeholderCount = placeholderCount;
|
||||
}
|
||||
|
||||
public Set<String> getAvailableProviders() { return availableProviders; }
|
||||
public Set<String> getAvailablePlaceholders() { return availablePlaceholders; }
|
||||
public Map<String, String> getPlaceholderDescriptions() { return placeholderDescriptions; }
|
||||
public int getProviderCount() { return providerCount; }
|
||||
public int getPlaceholderCount() { return placeholderCount; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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 jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.domain.model.EnhancedUserPromptTemplate;
|
||||
import com.ainovel.server.dto.CreatePromptTemplateRequest;
|
||||
import com.ainovel.server.dto.PublishTemplateRequest;
|
||||
import com.ainovel.server.dto.UpdatePromptTemplateRequest;
|
||||
import com.ainovel.server.service.EnhancedUserPromptService;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 增强用户提示词管理控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/prompt-templates")
|
||||
@Tag(name = "用户提示词模板管理", description = "提供用户自定义提示词模板的创建、更新、删除、分享等功能")
|
||||
public class EnhancedUserPromptController {
|
||||
|
||||
@Autowired
|
||||
private EnhancedUserPromptService promptService;
|
||||
|
||||
/**
|
||||
* 获取当前用户ID的辅助方法
|
||||
*/
|
||||
private String getCurrentUserId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getPrincipal() == null) {
|
||||
throw new IllegalArgumentException("用户未认证");
|
||||
}
|
||||
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (!(principal instanceof com.ainovel.server.domain.model.User)) {
|
||||
throw new IllegalArgumentException("无效的用户认证信息");
|
||||
}
|
||||
|
||||
return ((com.ainovel.server.domain.model.User) principal).getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户提示词模板
|
||||
*/
|
||||
@Operation(summary = "创建提示词模板", description = "用户创建新的自定义提示词模板")
|
||||
@PostMapping
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> createPromptTemplate(
|
||||
@Valid @RequestBody CreatePromptTemplateRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("创建用户提示词模板请求: userId={}, name={}", userId, request.getName());
|
||||
|
||||
return promptService.createPromptTemplate(
|
||||
userId,
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
request.getFeatureType(),
|
||||
request.getSystemPrompt(),
|
||||
request.getUserPrompt(),
|
||||
request.getTags(),
|
||||
request.getCategories()
|
||||
)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("创建用户提示词模板失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("创建失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户提示词模板
|
||||
*/
|
||||
@PutMapping("/{templateId}")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> updatePromptTemplate(
|
||||
@PathVariable String templateId,
|
||||
@Valid @RequestBody UpdatePromptTemplateRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("更新用户提示词模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.updatePromptTemplate(
|
||||
userId,
|
||||
templateId,
|
||||
request.getName(),
|
||||
request.getDescription(),
|
||||
request.getSystemPrompt(),
|
||||
request.getUserPrompt(),
|
||||
request.getTags(),
|
||||
request.getCategories()
|
||||
)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("更新用户提示词模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("更新失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户提示词模板
|
||||
*/
|
||||
@DeleteMapping("/{templateId}")
|
||||
public Mono<ApiResponse<Void>> deletePromptTemplate(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("删除用户提示词模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.deletePromptTemplate(userId, templateId)
|
||||
.then(Mono.just(ApiResponse.<Void>success()))
|
||||
.onErrorResume(error -> {
|
||||
log.error("删除用户提示词模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("删除失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户提示词模板详情
|
||||
*/
|
||||
@GetMapping("/{templateId}")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> getPromptTemplate(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
|
||||
return promptService.getPromptTemplateById(userId, templateId)
|
||||
.map(ApiResponse::success)
|
||||
.switchIfEmpty(Mono.just(ApiResponse.error("模板不存在")))
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户提示词模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有提示词模板
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ApiResponse<List<EnhancedUserPromptTemplate>>> getUserPromptTemplates(
|
||||
@RequestParam(required = false) AIFeatureType featureType,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.debug("获取用户提示词模板列表: userId={}, featureType={}", userId, featureType);
|
||||
|
||||
Flux<EnhancedUserPromptTemplate> templates = featureType != null
|
||||
? promptService.getUserPromptTemplatesByFeatureType(userId, featureType)
|
||||
: promptService.getUserPromptTemplates(userId);
|
||||
|
||||
return templates.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户提示词模板列表失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收藏的模板
|
||||
*/
|
||||
@GetMapping("/favorites")
|
||||
public Mono<ApiResponse<List<EnhancedUserPromptTemplate>>> getUserFavoriteTemplates(
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.debug("获取用户收藏模板: userId={}", userId);
|
||||
|
||||
return promptService.getUserFavoriteTemplates(userId)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户收藏模板失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近使用的模板
|
||||
*/
|
||||
@GetMapping("/recent")
|
||||
public Mono<ApiResponse<List<EnhancedUserPromptTemplate>>> getRecentlyUsedTemplates(
|
||||
@RequestParam(defaultValue = "10") @Min(1) @Max(50) int limit,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.debug("获取最近使用模板: userId={}, limit={}", userId, limit);
|
||||
|
||||
return promptService.getRecentlyUsedTemplates(userId, limit)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取最近使用模板失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布模板为公开
|
||||
*/
|
||||
@PostMapping("/{templateId}/publish")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> publishTemplate(
|
||||
@PathVariable String templateId,
|
||||
@Valid @RequestBody PublishTemplateRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("发布模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.publishTemplate(userId, templateId, request.getShareCode())
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("发布模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("发布失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过分享码获取模板
|
||||
*/
|
||||
@GetMapping("/share/{shareCode}")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> getTemplateByShareCode(
|
||||
@PathVariable String shareCode) {
|
||||
|
||||
log.debug("通过分享码获取模板: shareCode={}", shareCode);
|
||||
|
||||
return promptService.getTemplateByShareCode(shareCode)
|
||||
.map(ApiResponse::success)
|
||||
.switchIfEmpty(Mono.just(ApiResponse.error("分享码无效或模板不存在")))
|
||||
.onErrorResume(error -> {
|
||||
log.error("通过分享码获取模板失败: shareCode={}, error={}", shareCode, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制公开模板
|
||||
*/
|
||||
@PostMapping("/{templateId}/copy")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> copyPublicTemplate(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("复制公开模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.copyPublicTemplate(userId, templateId)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("复制公开模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("复制失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开模板列表
|
||||
*/
|
||||
@Operation(summary = "获取公开模板列表", description = "分页获取指定功能类型的公开提示词模板")
|
||||
@GetMapping("/public")
|
||||
public Mono<ApiResponse<List<EnhancedUserPromptTemplate>>> getPublicTemplates(
|
||||
@Parameter(description = "功能类型", required = true) @RequestParam AIFeatureType featureType,
|
||||
@Parameter(description = "页码,从0开始") @RequestParam(defaultValue = "0") @Min(0) int page,
|
||||
@Parameter(description = "每页大小,1-100之间") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
|
||||
|
||||
log.debug("获取公开模板列表: featureType={}, page={}, size={}", featureType, page, size);
|
||||
|
||||
return promptService.getPublicTemplates(featureType, page, size)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取公开模板列表失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏模板
|
||||
*/
|
||||
@PostMapping("/{templateId}/favorite")
|
||||
public Mono<ApiResponse<Void>> favoriteTemplate(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("收藏模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.favoriteTemplate(userId, templateId)
|
||||
.then(Mono.just(ApiResponse.<Void>success()))
|
||||
.onErrorResume(error -> {
|
||||
log.error("收藏模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("收藏失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收藏模板
|
||||
*/
|
||||
@DeleteMapping("/{templateId}/favorite")
|
||||
public Mono<ApiResponse<Void>> unfavoriteTemplate(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("取消收藏模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.unfavoriteTemplate(userId, templateId)
|
||||
.then(Mono.just(ApiResponse.<Void>success()))
|
||||
.onErrorResume(error -> {
|
||||
log.error("取消收藏模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("取消收藏失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 评分模板
|
||||
*/
|
||||
@Operation(summary = "评分模板", description = "用户对公开模板进行评分(1-5星)")
|
||||
@PostMapping("/{templateId}/rate")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> rateTemplate(
|
||||
@Parameter(description = "模板ID") @PathVariable String templateId,
|
||||
@Parameter(description = "评分,1-5星") @RequestParam @Min(1) @Max(5) int rating,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("评分模板请求: userId={}, templateId={}, rating={}", userId, templateId, rating);
|
||||
|
||||
return promptService.rateTemplate(userId, templateId, rating)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("评分模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("评分失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录模板使用
|
||||
*/
|
||||
@PostMapping("/{templateId}/usage")
|
||||
public Mono<ApiResponse<Void>> recordTemplateUsage(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
|
||||
return promptService.recordTemplateUsage(userId, templateId)
|
||||
.then(Mono.just(ApiResponse.<Void>success()))
|
||||
.onErrorResume(error -> {
|
||||
log.debug("记录模板使用失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.<Void>success()); // 记录失败不影响主要功能
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有标签
|
||||
*/
|
||||
@GetMapping("/tags")
|
||||
public Mono<ApiResponse<List<String>>> getUserTags(Authentication authentication) {
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.debug("获取用户标签: userId={}", userId);
|
||||
|
||||
return promptService.getUserTags(userId)
|
||||
.collectList()
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户标签失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认模板
|
||||
*/
|
||||
@PostMapping("/{templateId}/set-default")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> setDefaultTemplate(
|
||||
@PathVariable String templateId,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.info("设置默认模板请求: userId={}, templateId={}", userId, templateId);
|
||||
|
||||
return promptService.setDefaultTemplate(userId, templateId)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("设置默认模板失败: templateId={}, error={}", templateId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("设置失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认模板
|
||||
*/
|
||||
@GetMapping("/default")
|
||||
public Mono<ApiResponse<EnhancedUserPromptTemplate>> getDefaultTemplate(
|
||||
@RequestParam AIFeatureType featureType,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = getCurrentUserId(authentication);
|
||||
log.debug("获取默认模板请求: userId={}, featureType={}", userId, featureType);
|
||||
|
||||
return promptService.getDefaultTemplate(userId, featureType)
|
||||
.map(ApiResponse::success)
|
||||
.switchIfEmpty(Mono.just(ApiResponse.error("未找到默认模板")))
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取默认模板失败: userId={}, featureType={}, error={}", userId, featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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 com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.ModelPricing;
|
||||
import com.ainovel.server.repository.ModelPricingRepository;
|
||||
import com.ainovel.server.service.ai.pricing.PricingDataSyncService;
|
||||
import com.ainovel.server.service.ai.pricing.TokenPricingCalculator;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 模型定价管理控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pricing")
|
||||
@Tag(name = "ModelPricing", description = "模型定价管理API")
|
||||
public class ModelPricingController {
|
||||
|
||||
@Autowired
|
||||
private ModelPricingRepository modelPricingRepository;
|
||||
|
||||
@Autowired
|
||||
private PricingDataSyncService pricingDataSyncService;
|
||||
|
||||
@Autowired
|
||||
private List<TokenPricingCalculator> pricingCalculators;
|
||||
|
||||
/**
|
||||
* 获取所有模型定价信息
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取所有模型定价信息")
|
||||
public Mono<ResponseEntity<ApiResponse<List<ModelPricing>>>> getAllPricing() {
|
||||
return modelPricingRepository.findByActiveTrue()
|
||||
.collectList()
|
||||
.map(pricingList -> ResponseEntity.ok(ApiResponse.success(pricingList)))
|
||||
.doOnSuccess(response -> log.info("Retrieved {} pricing records",
|
||||
response.getBody().getData().size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据提供商获取定价信息
|
||||
*/
|
||||
@GetMapping("/provider/{provider}")
|
||||
@Operation(summary = "根据提供商获取定价信息")
|
||||
public Mono<ResponseEntity<ApiResponse<List<ModelPricing>>>> getPricingByProvider(
|
||||
@Parameter(description = "提供商名称") @PathVariable String provider) {
|
||||
return modelPricingRepository.findByProviderAndActiveTrue(provider)
|
||||
.collectList()
|
||||
.map(pricingList -> ResponseEntity.ok(ApiResponse.success(pricingList)))
|
||||
.doOnSuccess(response -> log.info("Retrieved {} pricing records for provider {}",
|
||||
response.getBody().getData().size(), provider));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定模型的定价信息
|
||||
*/
|
||||
@GetMapping("/provider/{provider}/model/{modelId}")
|
||||
@Operation(summary = "获取特定模型的定价信息")
|
||||
public Mono<ResponseEntity<ApiResponse<ModelPricing>>> getModelPricing(
|
||||
@Parameter(description = "提供商名称") @PathVariable String provider,
|
||||
@Parameter(description = "模型ID") @PathVariable String modelId) {
|
||||
return modelPricingRepository.findByProviderAndModelIdAndActiveTrue(provider, modelId)
|
||||
.map(pricing -> ResponseEntity.ok(ApiResponse.success(pricing)))
|
||||
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算Token成本
|
||||
*/
|
||||
@PostMapping("/calculate")
|
||||
@Operation(summary = "计算Token成本")
|
||||
public Mono<ResponseEntity<ApiResponse<CostCalculationResult>>> calculateCost(
|
||||
@RequestBody CostCalculationRequest request) {
|
||||
|
||||
// 查找对应的计算器
|
||||
TokenPricingCalculator calculator = pricingCalculators.stream()
|
||||
.filter(calc -> calc.getProviderName().equals(request.getProvider()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (calculator == null) {
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("不支持的提供商: " + request.getProvider())));
|
||||
}
|
||||
|
||||
return calculator.calculateInputCost(request.getModelId(), request.getInputTokens())
|
||||
.zipWith(calculator.calculateOutputCost(request.getModelId(), request.getOutputTokens()))
|
||||
.zipWith(calculator.calculateTotalCost(request.getModelId(),
|
||||
request.getInputTokens(), request.getOutputTokens()))
|
||||
.map(tuple -> {
|
||||
BigDecimal inputCost = tuple.getT1().getT1();
|
||||
BigDecimal outputCost = tuple.getT1().getT2();
|
||||
BigDecimal totalCost = tuple.getT2();
|
||||
|
||||
CostCalculationResult result = new CostCalculationResult();
|
||||
result.setProvider(request.getProvider());
|
||||
result.setModelId(request.getModelId());
|
||||
result.setInputTokens(request.getInputTokens());
|
||||
result.setOutputTokens(request.getOutputTokens());
|
||||
result.setInputCost(inputCost);
|
||||
result.setOutputCost(outputCost);
|
||||
result.setTotalCost(totalCost);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步提供商定价信息
|
||||
*/
|
||||
@PostMapping("/sync/{provider}")
|
||||
@Operation(summary = "同步提供商定价信息")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Mono<ResponseEntity<ApiResponse<PricingDataSyncService.PricingSyncResult>>> syncProviderPricing(
|
||||
@Parameter(description = "提供商名称") @PathVariable String provider) {
|
||||
return pricingDataSyncService.syncProviderPricing(provider)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.doOnSuccess(response -> log.info("Sync completed for provider {}: {}",
|
||||
provider, response.getBody().getData()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步所有提供商定价信息
|
||||
*/
|
||||
@PostMapping("/sync-all")
|
||||
@Operation(summary = "同步所有提供商定价信息")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Mono<ResponseEntity<ApiResponse<List<PricingDataSyncService.PricingSyncResult>>>> syncAllPricing() {
|
||||
return pricingDataSyncService.syncAllProvidersPricing()
|
||||
.collectList()
|
||||
.map(results -> ResponseEntity.ok(ApiResponse.success(results)))
|
||||
.doOnSuccess(response -> log.info("Sync completed for all providers: {} results",
|
||||
response.getBody().getData().size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新模型定价
|
||||
*/
|
||||
@PutMapping
|
||||
@Operation(summary = "创建或更新模型定价")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Mono<ResponseEntity<ApiResponse<ModelPricing>>> upsertPricing(
|
||||
@RequestBody ModelPricing pricing) {
|
||||
return pricingDataSyncService.updateModelPricing(pricing)
|
||||
.map(savedPricing -> ResponseEntity.ok(ApiResponse.success(savedPricing)))
|
||||
.doOnSuccess(response -> log.info("Updated pricing for {}:{}",
|
||||
pricing.getProvider(), pricing.getModelId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新模型定价
|
||||
*/
|
||||
@PutMapping("/batch")
|
||||
@Operation(summary = "批量更新模型定价")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Mono<ResponseEntity<ApiResponse<PricingDataSyncService.PricingSyncResult>>> batchUpdatePricing(
|
||||
@RequestBody List<ModelPricing> pricingList) {
|
||||
return pricingDataSyncService.batchUpdatePricing(pricingList)
|
||||
.map(result -> ResponseEntity.ok(ApiResponse.success(result)))
|
||||
.doOnSuccess(response -> log.info("Batch update completed: {}",
|
||||
response.getBody().getData()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型定价(软删除)
|
||||
*/
|
||||
@DeleteMapping("/provider/{provider}/model/{modelId}")
|
||||
@Operation(summary = "删除模型定价")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Mono<ResponseEntity<ApiResponse<Void>>> deletePricing(
|
||||
@Parameter(description = "提供商名称") @PathVariable String provider,
|
||||
@Parameter(description = "模型ID") @PathVariable String modelId) {
|
||||
return modelPricingRepository.findByProviderAndModelIdAndActiveTrue(provider, modelId)
|
||||
.flatMap(pricing -> {
|
||||
pricing.setActive(false);
|
||||
pricing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
return modelPricingRepository.save(pricing);
|
||||
})
|
||||
.then(Mono.just(ResponseEntity.ok(ApiResponse.<Void>success())))
|
||||
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build()))
|
||||
.doOnSuccess(response -> log.info("Deleted pricing for {}:{}", provider, modelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索模型定价
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "搜索模型定价")
|
||||
public Flux<ModelPricing> searchPricing(
|
||||
@Parameter(description = "最小价格") @RequestParam(required = false) Double minPrice,
|
||||
@Parameter(description = "最大价格") @RequestParam(required = false) Double maxPrice,
|
||||
@Parameter(description = "最小Token数") @RequestParam(required = false) Integer minTokens,
|
||||
@Parameter(description = "最大Token数") @RequestParam(required = false) Integer maxTokens,
|
||||
@Parameter(description = "提供商") @RequestParam(required = false) String provider) {
|
||||
|
||||
Flux<ModelPricing> query = modelPricingRepository.findByActiveTrue();
|
||||
|
||||
if (provider != null && !provider.trim().isEmpty()) {
|
||||
query = modelPricingRepository.findByProviderAndActiveTrue(provider);
|
||||
}
|
||||
|
||||
if (minPrice != null && maxPrice != null) {
|
||||
query = modelPricingRepository.findByPriceRange(minPrice, maxPrice);
|
||||
}
|
||||
|
||||
if (minTokens != null && maxTokens != null) {
|
||||
query = modelPricingRepository.findByTokenRange(minTokens, maxTokens);
|
||||
}
|
||||
|
||||
return query.doOnNext(pricing -> log.debug("Found pricing: {}:{}",
|
||||
pricing.getProvider(), pricing.getModelId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的提供商列表
|
||||
*/
|
||||
@GetMapping("/providers")
|
||||
@Operation(summary = "获取支持的提供商列表")
|
||||
public Flux<String> getSupportedProviders() {
|
||||
return pricingDataSyncService.getSupportedProviders()
|
||||
.doOnNext(provider -> log.debug("Supported provider: {}", provider));
|
||||
}
|
||||
|
||||
/**
|
||||
* 成本计算请求
|
||||
*/
|
||||
@Data
|
||||
public static class CostCalculationRequest {
|
||||
private String provider;
|
||||
private String modelId;
|
||||
private int inputTokens;
|
||||
private int outputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成本计算结果
|
||||
*/
|
||||
@Data
|
||||
public static class CostCalculationResult {
|
||||
private String provider;
|
||||
private String modelId;
|
||||
private int inputTokens;
|
||||
private int outputTokens;
|
||||
private BigDecimal inputCost;
|
||||
private BigDecimal outputCost;
|
||||
private BigDecimal totalCost;
|
||||
|
||||
public String getFormattedTotalCost() {
|
||||
return String.format("$%.6f", totalCost);
|
||||
}
|
||||
|
||||
public String getFormattedInputCost() {
|
||||
return String.format("$%.6f", inputCost);
|
||||
}
|
||||
|
||||
public String getFormattedOutputCost() {
|
||||
return String.format("$%.6f", outputCost);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import com.ainovel.server.domain.model.NovelSettingGenerationHistory;
|
||||
import com.ainovel.server.domain.model.NovelSettingItemHistory;
|
||||
import com.ainovel.server.domain.model.setting.generation.SettingGenerationSession;
|
||||
import com.ainovel.server.security.CurrentUser;
|
||||
import com.ainovel.server.service.setting.NovelSettingHistoryService;
|
||||
import com.ainovel.server.service.setting.generation.ISettingGenerationService;
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设定生成历史记录控制器
|
||||
*
|
||||
* 设定历史记录管理说明:
|
||||
* 1. 设定历史记录与小说无关,与用户有关 - 历史记录是按用户维度管理的
|
||||
* 2. 小说与历史记录的关系:
|
||||
* - 当用户进入小说设定生成页面时,如果没有历史记录,会创建一个历史记录,收集当前小说的设定作为快照
|
||||
* - 用户从小说列表页面发起提示词生成设定请求,生成完后会自动生成一个历史记录
|
||||
* 3. 历史记录相当于小说设定的快照,供用户修改和版本管理
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/setting-histories")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "设定生成历史记录管理", description = "管理用户的设定生成历史记录")
|
||||
public class NovelSettingHistoryController {
|
||||
|
||||
private final NovelSettingHistoryService historyService;
|
||||
private final ISettingGenerationService settingGenerationService;
|
||||
|
||||
/**
|
||||
* 获取用户的设定生成历史记录列表
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "获取历史记录列表", description = "获取当前用户的所有设定生成历史记录 (仅返回概要信息,减少数据量)")
|
||||
public Flux<Map<String, Object>> getHistories(
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "0") int page,
|
||||
@Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size,
|
||||
@Parameter(description = "小说ID过滤(可选)") @RequestParam(required = false) String novelId,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("获取用户 {} 的历史记录列表,小说过滤: {}", currentUser.getId(), novelId);
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return historyService.getUserHistories(currentUser.getId(), novelId, pageable)
|
||||
.map(history -> {
|
||||
// 只返回概要信息,避免大字段导致的超时与带宽浪费
|
||||
Map<String, Object> summary = new java.util.HashMap<>();
|
||||
summary.put("sessionId", history.getHistoryId()); // 兼容前端现有解析逻辑
|
||||
summary.put("historyId", history.getHistoryId());
|
||||
summary.put("userId", history.getUserId());
|
||||
summary.put("novelId", history.getNovelId());
|
||||
summary.put("initialPrompt", history.getInitialPrompt());
|
||||
summary.put("strategy", history.getStrategy());
|
||||
summary.put("modelConfigId", history.getModelConfigId());
|
||||
summary.put("status", history.getStatus() != null ? history.getStatus().name() : null);
|
||||
summary.put("settingsCount", history.getSettingsCount());
|
||||
summary.put("title", history.getTitle());
|
||||
summary.put("description", history.getDescription());
|
||||
if (history.getCreatedAt() != null) {
|
||||
summary.put("createdAt", history.getCreatedAt().toString());
|
||||
}
|
||||
if (history.getUpdatedAt() != null) {
|
||||
summary.put("updatedAt", history.getUpdatedAt().toString());
|
||||
}
|
||||
summary.put("metadata", history.getMetadata());
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史记录详情
|
||||
*/
|
||||
@GetMapping("/{historyId}")
|
||||
@Operation(summary = "获取历史记录详情", description = "获取指定历史记录的详细信息")
|
||||
public Mono<ApiResponse<Map<String, Object>>> getHistory(
|
||||
@Parameter(description = "历史记录ID") @PathVariable String historyId,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("获取历史记录详情: {} by user: {}", historyId, currentUser.getId());
|
||||
|
||||
return historyService.getHistoryWithSettings(historyId)
|
||||
.map(historyWithSettings -> {
|
||||
// 构建返回给前端的数据结构
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("history", historyWithSettings.history());
|
||||
response.put("rootNodes", historyWithSettings.rootNodes());
|
||||
|
||||
return ApiResponse.<Map<String, Object>>success(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取历史记录详情失败", error);
|
||||
return Mono.just(ApiResponse.<Map<String, Object>>error("HISTORY_NOT_FOUND", error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从历史记录创建新的编辑会话
|
||||
*/
|
||||
@PostMapping("/{historyId}/edit")
|
||||
@Operation(summary = "编辑历史记录", description = "基于历史记录创建新的编辑会话")
|
||||
public Mono<ApiResponse<SessionCreatedResponse>> createEditSession(
|
||||
@Parameter(description = "历史记录ID") @PathVariable String historyId,
|
||||
@Valid @RequestBody CreateEditSessionRequest request,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("从历史记录 {} 创建编辑会话 by user: {}", historyId, currentUser.getId());
|
||||
|
||||
return settingGenerationService.startSessionFromHistory(
|
||||
historyId,
|
||||
request.getEditReason(),
|
||||
request.getModelConfigId()
|
||||
).map(session -> {
|
||||
SessionCreatedResponse response = new SessionCreatedResponse();
|
||||
response.setSessionId(session.getSessionId());
|
||||
response.setMessage("编辑会话创建成功");
|
||||
return ApiResponse.<SessionCreatedResponse>success(response);
|
||||
}).onErrorResume(error -> {
|
||||
log.error("创建编辑会话失败", error);
|
||||
return Mono.just(ApiResponse.<SessionCreatedResponse>error("SESSION_CREATE_FAILED", error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制历史记录
|
||||
*/
|
||||
@PostMapping("/{historyId}/copy")
|
||||
@Operation(summary = "复制历史记录", description = "复制现有历史记录创建新的历史记录")
|
||||
public Mono<ApiResponse<NovelSettingGenerationHistory>> copyHistory(
|
||||
@Parameter(description = "历史记录ID") @PathVariable String historyId,
|
||||
@Valid @RequestBody CopyHistoryRequest request,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("复制历史记录 {} by user: {}", historyId, currentUser.getId());
|
||||
|
||||
return historyService.copyHistory(historyId, request.getCopyReason(), currentUser.getId())
|
||||
.map(history -> ApiResponse.<NovelSettingGenerationHistory>success(history))
|
||||
.onErrorResume(error -> {
|
||||
log.error("复制历史记录失败", error);
|
||||
return Mono.just(ApiResponse.<NovelSettingGenerationHistory>error("COPY_FAILED", error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复历史记录到小说设定中
|
||||
*/
|
||||
@PostMapping("/{historyId}/restore")
|
||||
@Operation(summary = "恢复历史记录", description = "将历史记录中的设定恢复到指定小说设定中")
|
||||
public Mono<ApiResponse<RestoreResponse>> restoreHistory(
|
||||
@Parameter(description = "历史记录ID") @PathVariable String historyId,
|
||||
@Valid @RequestBody RestoreHistoryRequest request,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("恢复历史记录 {} to novel {} by user: {}", historyId, request.getNovelId(), currentUser.getId());
|
||||
|
||||
return historyService.restoreHistoryToNovel(historyId, request.getNovelId(), currentUser.getId())
|
||||
.map(settingIds -> {
|
||||
RestoreResponse response = new RestoreResponse();
|
||||
response.setSuccess(true);
|
||||
response.setMessage("历史记录恢复成功");
|
||||
response.setRestoredSettingIds(settingIds);
|
||||
return ApiResponse.<RestoreResponse>success(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("恢复历史记录失败", error);
|
||||
return Mono.just(ApiResponse.<RestoreResponse>error("RESTORE_FAILED", error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除历史记录
|
||||
*/
|
||||
@DeleteMapping("/{historyId}")
|
||||
@Operation(summary = "删除历史记录", description = "删除指定的历史记录")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> deleteHistory(
|
||||
@Parameter(description = "历史记录ID") @PathVariable String historyId,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("删除历史记录 {} by user: {}", historyId, currentUser.getId());
|
||||
|
||||
return historyService.deleteHistory(historyId, currentUser.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点历史记录
|
||||
*/
|
||||
@GetMapping("/{historyId}/nodes/{nodeId}/history")
|
||||
@Operation(summary = "获取节点历史记录", description = "获取指定设定节点的变更历史")
|
||||
public Flux<NovelSettingItemHistory> getNodeHistory(
|
||||
@Parameter(description = "历史记录ID") @PathVariable String historyId,
|
||||
@Parameter(description = "节点ID") @PathVariable String nodeId,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "0") int page,
|
||||
@Parameter(description = "每页大小") @RequestParam(defaultValue = "10") int size,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("获取节点 {} 的历史记录 by user: {}", nodeId, currentUser.getId());
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return historyService.getNodeHistories(nodeId, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计历史记录数量
|
||||
*/
|
||||
@GetMapping("/count")
|
||||
@Operation(summary = "统计历史记录数量", description = "统计用户的历史记录数量")
|
||||
public Mono<ApiResponse<Long>> countHistories(
|
||||
@Parameter(description = "小说ID过滤(可选)") @RequestParam(required = false) String novelId,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
return historyService.countUserHistories(currentUser.getId(), novelId)
|
||||
.map(count -> ApiResponse.<Long>success(count));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除历史记录
|
||||
*/
|
||||
@DeleteMapping("/batch")
|
||||
@Operation(summary = "批量删除历史记录", description = "批量删除指定的历史记录")
|
||||
public Mono<ApiResponse<BatchDeleteResponse>> batchDeleteHistories(
|
||||
@Valid @RequestBody BatchDeleteRequest request,
|
||||
@AuthenticationPrincipal CurrentUser currentUser) {
|
||||
|
||||
log.info("批量删除历史记录 {} by user: {}", request.getHistoryIds(), currentUser.getId());
|
||||
|
||||
return historyService.batchDeleteHistories(request.getHistoryIds(), currentUser.getId())
|
||||
.map(deletedCount -> {
|
||||
BatchDeleteResponse response = new BatchDeleteResponse();
|
||||
response.setDeletedCount(deletedCount);
|
||||
response.setMessage("成功删除 " + deletedCount + " 条历史记录");
|
||||
return ApiResponse.<BatchDeleteResponse>success(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("批量删除历史记录失败", error);
|
||||
return Mono.just(ApiResponse.<BatchDeleteResponse>error("BATCH_DELETE_FAILED", error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== DTO 类 ====================
|
||||
|
||||
/**
|
||||
* 创建编辑会话请求
|
||||
*/
|
||||
@Data
|
||||
public static class CreateEditSessionRequest {
|
||||
/**
|
||||
* 编辑原因/说明
|
||||
*/
|
||||
private String editReason;
|
||||
|
||||
/**
|
||||
* 模型配置ID
|
||||
*/
|
||||
@NotBlank(message = "模型配置ID不能为空")
|
||||
private String modelConfigId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制历史记录请求
|
||||
*/
|
||||
@Data
|
||||
public static class CopyHistoryRequest {
|
||||
/**
|
||||
* 复制原因
|
||||
*/
|
||||
@NotBlank(message = "复制原因不能为空")
|
||||
private String copyReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复历史记录请求
|
||||
*/
|
||||
@Data
|
||||
public static class RestoreHistoryRequest {
|
||||
/**
|
||||
* 目标小说ID
|
||||
*/
|
||||
@NotBlank(message = "小说ID不能为空")
|
||||
private String novelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除请求
|
||||
*/
|
||||
@Data
|
||||
public static class BatchDeleteRequest {
|
||||
/**
|
||||
* 历史记录ID列表
|
||||
*/
|
||||
@NotEmpty(message = "历史记录ID列表不能为空")
|
||||
private List<String> historyIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话创建响应
|
||||
*/
|
||||
@Data
|
||||
public static class SessionCreatedResponse {
|
||||
private String sessionId;
|
||||
private String message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复响应
|
||||
*/
|
||||
@Data
|
||||
public static class RestoreResponse {
|
||||
private Boolean success;
|
||||
private String message;
|
||||
private List<String> restoredSettingIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除响应
|
||||
*/
|
||||
@Data
|
||||
public static class BatchDeleteResponse {
|
||||
private Integer deletedCount;
|
||||
private String message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ainovel.server.domain.dto.ApiKeyTestRequest;
|
||||
import com.ainovel.server.domain.model.ModelListingCapability;
|
||||
import com.ainovel.server.service.ai.capability.ProviderCapabilityService;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 提供商能力控制器
|
||||
* 提供获取AI提供商能力和测试API密钥的REST接口
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/providers")
|
||||
@Slf4j
|
||||
public class ProviderCapabilityController {
|
||||
|
||||
private final ProviderCapabilityService capabilityService;
|
||||
|
||||
@Autowired
|
||||
public ProviderCapabilityController(ProviderCapabilityService capabilityService) {
|
||||
this.capabilityService = capabilityService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提供商的模型列表能力
|
||||
*
|
||||
* @param provider 提供商名称
|
||||
* @return 模型列表能力
|
||||
*/
|
||||
@GetMapping("/{provider}/capability")
|
||||
public Mono<ResponseEntity<ModelListingCapability>> getProviderCapability(@PathVariable String provider) {
|
||||
log.info("获取提供商能力: {}", provider);
|
||||
|
||||
return capabilityService.getProviderCapability(provider)
|
||||
.map(capability -> ResponseEntity.ok(capability))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试API密钥是否有效
|
||||
*
|
||||
* @param provider 提供商名称
|
||||
* @param request 包含API密钥和端点的请求
|
||||
* @return 测试结果
|
||||
*/
|
||||
@PostMapping("/{provider}/test-api-key")
|
||||
public Mono<ResponseEntity<Boolean>> testApiKey(
|
||||
@PathVariable String provider,
|
||||
@RequestBody ApiKeyTestRequest request) {
|
||||
|
||||
log.info("测试API密钥: provider={}", provider);
|
||||
|
||||
return capabilityService.testApiKey(provider, request.getApiKey(), request.getApiEndpoint())
|
||||
.map(result -> ResponseEntity.ok(result))
|
||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提供商的默认API端点
|
||||
*
|
||||
* @param provider 提供商名称
|
||||
* @return 默认API端点
|
||||
*/
|
||||
@GetMapping("/{provider}/default-endpoint")
|
||||
public ResponseEntity<String> getDefaultApiEndpoint(@PathVariable String provider) {
|
||||
String endpoint = capabilityService.getDefaultApiEndpoint(provider);
|
||||
|
||||
if (endpoint != null) {
|
||||
return ResponseEntity.ok(endpoint);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.dto.PresetPackage;
|
||||
import com.ainovel.server.service.UnifiedPresetAggregationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 统一预设聚合API控制器
|
||||
* 为前端提供一站式的预设获取和缓存接口
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/preset-aggregation")
|
||||
@Tag(name = "预设聚合", description = "统一的前端预设聚合接口")
|
||||
public class UnifiedPresetAggregationController {
|
||||
|
||||
@Autowired
|
||||
private UnifiedPresetAggregationService aggregationService;
|
||||
|
||||
/**
|
||||
* 获取功能的完整预设包
|
||||
* 包含系统预设、用户预设、快捷访问预设等全部信息
|
||||
*/
|
||||
@GetMapping("/package/{featureType}")
|
||||
@Operation(summary = "获取完整预设包", description = "一次性获取功能的所有预设信息,便于前端缓存")
|
||||
public Mono<ApiResponse<PresetPackage>> getCompletePresetPackage(
|
||||
@PathVariable AIFeatureType featureType,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求完整预设包: featureType={}, userId={}, novelId={}",
|
||||
featureType, userId, novelId);
|
||||
|
||||
return aggregationService.getCompletePresetPackage(featureType, userId, novelId)
|
||||
.map(presetPackage -> {
|
||||
log.info("返回预设包: featureType={}, 系统预设数={}, 用户预设数={}, 快捷访问数={}",
|
||||
featureType,
|
||||
presetPackage.getSystemPresets().size(),
|
||||
presetPackage.getUserPresets().size(),
|
||||
presetPackage.getQuickAccessPresets().size());
|
||||
|
||||
return ApiResponse.success(presetPackage);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取预设包失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取预设包失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的预设概览
|
||||
* 跨功能统计信息,用于用户Dashboard
|
||||
*/
|
||||
@GetMapping("/overview")
|
||||
@Operation(summary = "获取用户预设概览", description = "获取用户的跨功能预设统计信息")
|
||||
public Mono<ApiResponse<UnifiedPresetAggregationService.UserPresetOverview>> getUserPresetOverview(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求用户预设概览: userId={}", userId);
|
||||
|
||||
return aggregationService.getUserPresetOverview(userId)
|
||||
.map(overview -> {
|
||||
log.info("返回用户概览: userId={}, 总预设数={}, 功能数={}, 快捷访问数={}",
|
||||
userId,
|
||||
overview.getTotalPresetCount(),
|
||||
overview.getPresetCountsByFeature().size(),
|
||||
overview.getQuickAccessPresetCount());
|
||||
|
||||
return ApiResponse.success(overview);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户概览失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取用户概览失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个功能的预设包
|
||||
* 用于前端初始化时一次性获取所有需要的数据
|
||||
*/
|
||||
@GetMapping("/packages/batch")
|
||||
@Operation(summary = "批量获取预设包", description = "一次性获取多个功能的预设包,减少网络请求")
|
||||
public Mono<ApiResponse<Map<AIFeatureType, PresetPackage>>> getBatchPresetPackages(
|
||||
@RequestParam(required = false) AIFeatureType[] featureTypes,
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
AIFeatureType[] targetTypes = featureTypes != null ? featureTypes : AIFeatureType.values();
|
||||
|
||||
log.info("🚀 前端请求批量预设包: userId={}, 功能数={}, novelId={}",
|
||||
userId, targetTypes.length, novelId);
|
||||
|
||||
return aggregationService.getBatchPresetPackages(Arrays.asList(targetTypes), userId, novelId)
|
||||
.map(packagesMap -> {
|
||||
log.info("✅ 返回批量预设包: userId={}, 成功获取功能数={}", userId, packagesMap.size());
|
||||
|
||||
// 统计所有功能包的系统预设总数
|
||||
int totalSystemCount = packagesMap.values().stream()
|
||||
.mapToInt(pkg -> pkg.getSystemPresets().size())
|
||||
.sum();
|
||||
|
||||
// 统计所有功能包的快捷访问预设总数
|
||||
int totalQuickAccessCount = packagesMap.values().stream()
|
||||
.mapToInt(pkg -> pkg.getQuickAccessPresets().size())
|
||||
.sum();
|
||||
|
||||
log.info("📈 总体统计: 系统预设总数={}, 快捷访问预设总数={}", totalSystemCount, totalQuickAccessCount);
|
||||
|
||||
return ApiResponse.success(packagesMap);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("❌ 批量获取预设包失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("批量获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热用户缓存
|
||||
* 系统启动或用户登录时调用,提升后续响应速度
|
||||
*/
|
||||
@PostMapping("/cache/warmup")
|
||||
@Operation(summary = "预热预设缓存", description = "预热用户的预设缓存,提升后续访问速度")
|
||||
public Mono<ApiResponse<UnifiedPresetAggregationService.CacheWarmupResult>> warmupCache(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求缓存预热: userId={}", userId);
|
||||
|
||||
return aggregationService.warmupCache(userId)
|
||||
.map(result -> {
|
||||
log.info("缓存预热完成: userId={}, 成功={}, 耗时={}ms, 预热功能数={}",
|
||||
userId, result.isSuccess(), result.getDuration(), result.getWarmedFeatures());
|
||||
|
||||
return ApiResponse.success(result);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("缓存预热失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("缓存预热失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统缓存统计
|
||||
* 用于系统监控和性能分析
|
||||
*/
|
||||
@GetMapping("/cache/stats")
|
||||
@Operation(summary = "获取缓存统计", description = "获取聚合服务的缓存统计信息")
|
||||
public Mono<ApiResponse<UnifiedPresetAggregationService.AggregationCacheStats>> getCacheStats() {
|
||||
|
||||
log.info("前端请求缓存统计");
|
||||
|
||||
return aggregationService.getCacheStats()
|
||||
.map(stats -> {
|
||||
log.info("返回缓存统计: 缓存大小={}, 总请求数={}, 命中率={}%",
|
||||
stats.getTotalCacheSize(), stats.getTotalRequests(), stats.getHitRate());
|
||||
|
||||
return ApiResponse.success(stats);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取缓存统计失败: error={}", error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取缓存统计失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除预设聚合缓存
|
||||
* 用于调试和强制刷新缓存
|
||||
*/
|
||||
@PostMapping("/cache/clear")
|
||||
@Operation(summary = "清除聚合缓存", description = "清除所有预设聚合缓存,强制重新加载数据")
|
||||
public Mono<ApiResponse<String>> clearCache(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求清除聚合缓存: userId={}", userId);
|
||||
|
||||
return aggregationService.clearAllCaches()
|
||||
.map(result -> {
|
||||
log.info("缓存清除完成: userId={}, result={}", userId, result);
|
||||
return ApiResponse.success(result);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("清除缓存失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("清除缓存失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 获取用户的所有预设聚合数据
|
||||
* 一次性返回用户的所有预设相关数据,避免多次API调用
|
||||
*/
|
||||
@GetMapping("/all-data")
|
||||
@Operation(summary = "获取所有预设聚合数据", description = "一次性获取用户的所有预设相关数据,用于前端缓存")
|
||||
public Mono<ApiResponse<UnifiedPresetAggregationService.AllUserPresetData>> getAllUserPresetData(
|
||||
@RequestParam(required = false) String novelId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("🚀 前端请求所有预设聚合数据: userId={}, novelId={}", userId, novelId);
|
||||
|
||||
return aggregationService.getAllUserPresetData(userId, novelId)
|
||||
.map(allData -> {
|
||||
log.info("✅ 返回完整预设聚合数据: userId={}, 耗时={}ms", userId, allData.getCacheDuration());
|
||||
log.info("📊 数据概览: 概览统计={}, 功能包数={}, 系统预设{}个, 用户预设分组{}个",
|
||||
allData.getOverview() != null ? "已包含" : "未包含",
|
||||
allData.getPackagesByFeatureType().size(),
|
||||
allData.getSystemPresets().size(),
|
||||
allData.getUserPresetsByFeatureType().size());
|
||||
|
||||
return ApiResponse.success(allData);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("❌ 获取所有预设聚合数据失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取聚合数据失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查接口
|
||||
* 检查聚合服务是否正常工作
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
@Operation(summary = "聚合服务健康检查", description = "检查预设聚合服务的健康状态")
|
||||
public Mono<ApiResponse<Map<String, Object>>> healthCheck() {
|
||||
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> health = Map.of(
|
||||
"status", "UP",
|
||||
"timestamp", System.currentTimeMillis(),
|
||||
"service", "UnifiedPresetAggregationService",
|
||||
"version", "1.0"
|
||||
);
|
||||
|
||||
log.info("预设聚合服务健康检查: status=UP");
|
||||
return ApiResponse.success(health);
|
||||
})
|
||||
.onErrorReturn(ApiResponse.error("聚合服务不可用"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.service.UnifiedPromptAggregationService;
|
||||
import com.ainovel.server.service.prompt.impl.VirtualThreadPlaceholderResolver;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 统一提示词聚合API控制器
|
||||
* 为前端提供一站式的提示词获取和缓存接口
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/prompt-aggregation")
|
||||
@Tag(name = "提示词聚合", description = "统一的前端提示词聚合接口")
|
||||
public class UnifiedPromptAggregationController {
|
||||
|
||||
@Autowired
|
||||
private UnifiedPromptAggregationService aggregationService;
|
||||
|
||||
@Autowired
|
||||
private VirtualThreadPlaceholderResolver virtualThreadResolver;
|
||||
|
||||
/**
|
||||
* 获取功能的完整提示词包
|
||||
* 包含系统默认、用户自定义、公开模板、最近使用等全部信息
|
||||
*/
|
||||
@GetMapping("/package/{featureType}")
|
||||
@Operation(summary = "获取完整提示词包", description = "一次性获取功能的所有提示词信息,便于前端缓存")
|
||||
public Mono<ApiResponse<UnifiedPromptAggregationService.PromptPackage>> getCompletePromptPackage(
|
||||
@PathVariable AIFeatureType featureType,
|
||||
@RequestParam(defaultValue = "true") boolean includePublic,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求完整提示词包: featureType={}, userId={}, includePublic={}",
|
||||
featureType, userId, includePublic);
|
||||
|
||||
return aggregationService.getCompletePromptPackage(featureType, userId, includePublic)
|
||||
.map(promptPackage -> {
|
||||
log.info("返回提示词包: featureType={}, 用户模板数={}, 公开模板数={}, 占位符数={}",
|
||||
featureType,
|
||||
promptPackage.getUserPrompts().size(),
|
||||
promptPackage.getPublicPrompts().size(),
|
||||
promptPackage.getSupportedPlaceholders().size());
|
||||
|
||||
return ApiResponse.success(promptPackage);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取提示词包失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取提示词包失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的提示词概览
|
||||
* 跨功能统计信息,用于用户Dashboard
|
||||
*/
|
||||
@GetMapping("/overview")
|
||||
@Operation(summary = "获取用户提示词概览", description = "获取用户的跨功能提示词统计信息")
|
||||
public Mono<ApiResponse<UnifiedPromptAggregationService.UserPromptOverview>> getUserPromptOverview(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求用户提示词概览: userId={}", userId);
|
||||
|
||||
return aggregationService.getUserPromptOverview(userId)
|
||||
.map(overview -> {
|
||||
log.info("返回用户概览: userId={}, 总使用次数={}, 功能数={}, 收藏数={}",
|
||||
userId,
|
||||
overview.getTotalUsageCount(),
|
||||
overview.getPromptCountsByFeature().size(),
|
||||
overview.getFavoritePrompts().size());
|
||||
|
||||
return ApiResponse.success(overview);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户概览失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取用户概览失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个功能的提示词包
|
||||
* 用于前端初始化时一次性获取所有需要的数据
|
||||
*/
|
||||
@GetMapping("/packages/batch")
|
||||
@Operation(summary = "批量获取提示词包", description = "一次性获取多个功能的提示词包,减少网络请求")
|
||||
public Mono<ApiResponse<Map<AIFeatureType, UnifiedPromptAggregationService.PromptPackage>>> getBatchPromptPackages(
|
||||
@RequestParam(required = false) AIFeatureType[] featureTypes,
|
||||
@RequestParam(defaultValue = "true") boolean includePublic,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
AIFeatureType[] targetTypes = featureTypes != null ? featureTypes : AIFeatureType.values();
|
||||
|
||||
log.info("🚀 前端请求批量提示词包: userId={}, 功能数={}, includePublic={}",
|
||||
userId, targetTypes.length, includePublic);
|
||||
|
||||
return reactor.core.publisher.Flux.fromArray(targetTypes)
|
||||
.flatMap(featureType ->
|
||||
aggregationService.getCompletePromptPackage(featureType, userId, includePublic)
|
||||
.map(pkg -> {
|
||||
// 详细记录每个功能包的信息
|
||||
log.info("📦 功能包详情: featureType={}, 用户模板数={}, 公开模板数={}",
|
||||
featureType, pkg.getUserPrompts().size(), pkg.getPublicPrompts().size());
|
||||
|
||||
// 记录用户模板中的默认模板信息
|
||||
long defaultCount = pkg.getUserPrompts().stream()
|
||||
.filter(p -> p.isDefault())
|
||||
.count();
|
||||
log.info("🌟 功能包默认模板: featureType={}, 默认模板数={}", featureType, defaultCount);
|
||||
|
||||
if (defaultCount > 0) {
|
||||
pkg.getUserPrompts().stream()
|
||||
.filter(p -> p.isDefault())
|
||||
.forEach(p -> log.info(" ⭐ 默认模板: id={}, name={}", p.getId(), p.getName()));
|
||||
}
|
||||
|
||||
return Map.entry(featureType, pkg);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.warn("功能包获取失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.empty(); // 跳过失败的功能
|
||||
})
|
||||
)
|
||||
.collectMap(Map.Entry::getKey, Map.Entry::getValue)
|
||||
.map(packagesMap -> {
|
||||
log.info("✅ 返回批量提示词包: userId={}, 成功获取功能数={}", userId, packagesMap.size());
|
||||
|
||||
// 统计所有功能包的默认模板总数
|
||||
int totalDefaultCount = packagesMap.values().stream()
|
||||
.mapToInt(pkg -> (int) pkg.getUserPrompts().stream()
|
||||
.filter(p -> p.isDefault())
|
||||
.count())
|
||||
.sum();
|
||||
log.info("📈 总体统计: 所有功能包默认模板总数={}", totalDefaultCount);
|
||||
|
||||
return ApiResponse.success(packagesMap);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("❌ 批量获取提示词包失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("批量获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热用户缓存
|
||||
* 系统启动或用户登录时调用,提升后续响应速度
|
||||
*/
|
||||
@PostMapping("/cache/warmup")
|
||||
@Operation(summary = "预热提示词缓存", description = "预热用户的提示词缓存,提升后续访问速度")
|
||||
public Mono<ApiResponse<UnifiedPromptAggregationService.CacheWarmupResult>> warmupCache(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求缓存预热: userId={}", userId);
|
||||
|
||||
return aggregationService.warmupCache(userId)
|
||||
.map(result -> {
|
||||
log.info("缓存预热完成: userId={}, 成功={}, 耗时={}ms, 预热功能数={}",
|
||||
userId, result.isSuccess(), result.getDuration(), result.getWarmedFeatures());
|
||||
|
||||
return ApiResponse.success(result);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("缓存预热失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("缓存预热失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统缓存统计
|
||||
* 用于系统监控和性能分析
|
||||
*/
|
||||
@GetMapping("/cache/stats")
|
||||
@Operation(summary = "获取缓存统计", description = "获取聚合服务的缓存统计信息")
|
||||
public Mono<ApiResponse<UnifiedPromptAggregationService.AggregationCacheStats>> getCacheStats() {
|
||||
|
||||
log.info("前端请求缓存统计");
|
||||
|
||||
return aggregationService.getCacheStats()
|
||||
.map(stats -> {
|
||||
log.info("返回缓存统计: 缓存大小={}, 缓存键数量={}",
|
||||
stats.getTotalCacheSize(), stats.getCacheHitCounts().size());
|
||||
|
||||
return ApiResponse.success(stats);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取缓存统计失败: error={}", error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取缓存统计失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取虚拟线程性能统计
|
||||
* 用于监控占位符解析性能
|
||||
*/
|
||||
@GetMapping("/performance/placeholder")
|
||||
@Operation(summary = "获取占位符性能统计", description = "获取虚拟线程占位符解析的性能统计")
|
||||
public Mono<ApiResponse<VirtualThreadPlaceholderResolver.PlaceholderPerformanceStats>> getPlaceholderPerformanceStats() {
|
||||
|
||||
log.info("前端请求占位符性能统计");
|
||||
|
||||
return virtualThreadResolver.getPerformanceStats()
|
||||
.map(stats -> {
|
||||
log.info("返回占位符性能统计: 总解析次数={}, 并行解析次数={}, 平均耗时={}ms",
|
||||
stats.getTotalResolveCount(), stats.getParallelResolveCount(), stats.getAverageResolveTime());
|
||||
|
||||
return ApiResponse.success(stats);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取占位符性能统计失败: error={}", error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取性能统计失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除提示词聚合缓存
|
||||
* 用于调试和强制刷新缓存
|
||||
*/
|
||||
@PostMapping("/cache/clear")
|
||||
@Operation(summary = "清除聚合缓存", description = "清除所有提示词聚合缓存,强制重新加载数据")
|
||||
public Mono<ApiResponse<String>> clearCache(
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
|
||||
log.info("前端请求清除聚合缓存: userId={}", userId);
|
||||
|
||||
return aggregationService.clearAllCaches()
|
||||
.map(result -> {
|
||||
log.info("缓存清除完成: userId={}, result={}", userId, result);
|
||||
return ApiResponse.success(result);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
log.error("清除缓存失败: userId={}, error={}", userId, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("清除缓存失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查接口
|
||||
* 检查聚合服务是否正常工作
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
@Operation(summary = "聚合服务健康检查", description = "检查提示词聚合服务的健康状态")
|
||||
public Mono<ApiResponse<Map<String, Object>>> healthCheck() {
|
||||
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> health = Map.of(
|
||||
"status", "UP",
|
||||
"timestamp", System.currentTimeMillis(),
|
||||
"service", "UnifiedPromptAggregationService",
|
||||
"version", "1.0"
|
||||
);
|
||||
|
||||
log.info("聚合服务健康检查: status=UP");
|
||||
return ApiResponse.success(health);
|
||||
})
|
||||
.onErrorReturn(ApiResponse.error("聚合服务不可用"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.ainovel.server.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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 com.ainovel.server.common.response.ApiResponse;
|
||||
import com.ainovel.server.domain.model.AIFeatureType;
|
||||
import com.ainovel.server.dto.RenderPromptRequest;
|
||||
import com.ainovel.server.service.UnifiedPromptService;
|
||||
import com.ainovel.server.service.prompt.AIFeaturePromptProvider;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 统一提示词系统控制器
|
||||
* 整合所有提示词相关功能的API入口
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/prompts")
|
||||
public class UnifiedPromptController {
|
||||
|
||||
@Autowired
|
||||
private UnifiedPromptService promptService;
|
||||
|
||||
/**
|
||||
* 获取系统提示词
|
||||
*/
|
||||
@PostMapping("/{featureType}/system")
|
||||
public Mono<ApiResponse<String>> getSystemPrompt(
|
||||
@PathVariable AIFeatureType featureType,
|
||||
@RequestBody Map<String, Object> parameters,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = authentication.getName();
|
||||
log.debug("获取系统提示词: userId={}, featureType={}", userId, featureType);
|
||||
|
||||
return promptService.getSystemPrompt(featureType, userId, parameters)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取系统提示词失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户提示词
|
||||
*/
|
||||
@PostMapping("/{featureType}/user")
|
||||
public Mono<ApiResponse<String>> getUserPrompt(
|
||||
@PathVariable AIFeatureType featureType,
|
||||
@RequestParam(required = false) String templateId,
|
||||
@RequestBody Map<String, Object> parameters,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = authentication.getName();
|
||||
log.debug("获取用户提示词: userId={}, featureType={}, templateId={}", userId, featureType, templateId);
|
||||
|
||||
return promptService.getUserPrompt(featureType, userId, templateId, parameters)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取用户提示词失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的提示词对话
|
||||
*/
|
||||
@PostMapping("/{featureType}/conversation")
|
||||
public Mono<ApiResponse<UnifiedPromptService.PromptConversation>> getPromptConversation(
|
||||
@PathVariable AIFeatureType featureType,
|
||||
@RequestParam(required = false) String templateId,
|
||||
@RequestBody Map<String, Object> parameters,
|
||||
Authentication authentication) {
|
||||
|
||||
String userId = authentication.getName();
|
||||
log.debug("获取完整提示词对话: userId={}, featureType={}, templateId={}", userId, featureType, templateId);
|
||||
|
||||
return promptService.getCompletePromptConversation(featureType, userId, templateId, parameters)
|
||||
.map(ApiResponse::success)
|
||||
.onErrorResume(error -> {
|
||||
log.error("获取完整提示词对话失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取功能类型支持的占位符
|
||||
*/
|
||||
@GetMapping("/{featureType}/placeholders")
|
||||
public Mono<ApiResponse<Set<String>>> getSupportedPlaceholders(@PathVariable AIFeatureType featureType) {
|
||||
log.debug("获取支持的占位符: featureType={}", featureType);
|
||||
|
||||
try {
|
||||
Set<String> placeholders = promptService.getSupportedPlaceholders(featureType);
|
||||
return Mono.just(ApiResponse.success(placeholders));
|
||||
} catch (Exception error) {
|
||||
log.error("获取支持的占位符失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证提示词内容中的占位符
|
||||
*/
|
||||
@PostMapping("/{featureType}/validate")
|
||||
public Mono<ApiResponse<AIFeaturePromptProvider.ValidationResult>> validatePrompt(
|
||||
@PathVariable AIFeatureType featureType,
|
||||
@RequestBody RenderPromptRequest request) {
|
||||
|
||||
log.debug("验证提示词占位符: featureType={}", featureType);
|
||||
|
||||
try {
|
||||
AIFeaturePromptProvider.ValidationResult result = promptService.validatePlaceholders(featureType, request.getContent());
|
||||
return Mono.just(ApiResponse.success(result));
|
||||
} catch (Exception error) {
|
||||
log.error("验证提示词占位符失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("验证失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的功能类型
|
||||
*/
|
||||
@GetMapping("/feature-types")
|
||||
public Mono<ApiResponse<Set<AIFeatureType>>> getSupportedFeatureTypes() {
|
||||
log.debug("获取支持的功能类型");
|
||||
|
||||
try {
|
||||
Set<AIFeatureType> featureTypes = promptService.getSupportedFeatureTypes();
|
||||
return Mono.just(ApiResponse.success(featureTypes));
|
||||
} catch (Exception error) {
|
||||
log.error("获取支持的功能类型失败: error={}", error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查功能类型是否支持
|
||||
*/
|
||||
@GetMapping("/{featureType}/supported")
|
||||
public Mono<ApiResponse<Boolean>> isFeatureTypeSupported(@PathVariable AIFeatureType featureType) {
|
||||
log.debug("检查功能类型支持: featureType={}", featureType);
|
||||
|
||||
try {
|
||||
boolean supported = promptService.hasPromptProvider(featureType);
|
||||
return Mono.just(ApiResponse.success(supported));
|
||||
} catch (Exception error) {
|
||||
log.error("检查功能类型支持失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("检查失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词提供器的默认系统提示词
|
||||
*/
|
||||
@GetMapping("/{featureType}/default/system")
|
||||
public Mono<ApiResponse<String>> getDefaultSystemPrompt(@PathVariable AIFeatureType featureType) {
|
||||
log.debug("获取默认系统提示词: featureType={}", featureType);
|
||||
|
||||
try {
|
||||
AIFeaturePromptProvider provider = promptService.getPromptProvider(featureType);
|
||||
if (provider != null) {
|
||||
String defaultPrompt = provider.getDefaultSystemPrompt();
|
||||
return Mono.just(ApiResponse.success(defaultPrompt));
|
||||
} else {
|
||||
return Mono.just(ApiResponse.error("不支持的功能类型: " + featureType));
|
||||
}
|
||||
} catch (Exception error) {
|
||||
log.error("获取默认系统提示词失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词提供器的默认用户提示词
|
||||
*/
|
||||
@GetMapping("/{featureType}/default/user")
|
||||
public Mono<ApiResponse<String>> getDefaultUserPrompt(@PathVariable AIFeatureType featureType) {
|
||||
log.debug("获取默认用户提示词: featureType={}", featureType);
|
||||
|
||||
try {
|
||||
AIFeaturePromptProvider provider = promptService.getPromptProvider(featureType);
|
||||
if (provider != null) {
|
||||
String defaultPrompt = provider.getDefaultUserPrompt();
|
||||
return Mono.just(ApiResponse.success(defaultPrompt));
|
||||
} else {
|
||||
return Mono.just(ApiResponse.error("不支持的功能类型: " + featureType));
|
||||
}
|
||||
} catch (Exception error) {
|
||||
log.error("获取默认用户提示词失败: featureType={}, error={}", featureType, error.getMessage());
|
||||
return Mono.just(ApiResponse.error("获取失败: " + error.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ainovel.server.domain.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API密钥测试请求DTO
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiKeyTestRequest {
|
||||
|
||||
/**
|
||||
* API密钥
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* API端点(可选)
|
||||
*/
|
||||
private String apiEndpoint;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ainovel.server.domain.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 解析后的小说数据模型
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ParsedNovelData {
|
||||
|
||||
/**
|
||||
* 小说标题
|
||||
*/
|
||||
private String novelTitle;
|
||||
|
||||
/**
|
||||
* 解析后的场景列表
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<ParsedSceneData> scenes = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 添加场景
|
||||
*
|
||||
* @param scene 解析后的场景
|
||||
* @return this 对象,用于链式调用
|
||||
*/
|
||||
public ParsedNovelData addScene(ParsedSceneData scene) {
|
||||
if (scenes == null) {
|
||||
scenes = new ArrayList<>();
|
||||
}
|
||||
scenes.add(scene);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ainovel.server.domain.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 解析后的场景数据模型
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ParsedSceneData {
|
||||
|
||||
/**
|
||||
* 场景标题(即章节标题)
|
||||
*/
|
||||
private String sceneTitle;
|
||||
|
||||
/**
|
||||
* 场景内容
|
||||
*/
|
||||
private String sceneContent;
|
||||
|
||||
/**
|
||||
* 场景顺序
|
||||
*/
|
||||
private int order;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ainovel.server.domain.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndex;
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndexes;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* AI聊天消息领域模型
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "ai_chat_messages")
|
||||
@CompoundIndexes({
|
||||
@CompoundIndex(name = "session_message_idx", def = "{'sessionId': 1, 'createdAt': 1}"),
|
||||
@CompoundIndex(name = "user_message_idx", def = "{'userId': 1, 'createdAt': 1}")
|
||||
})
|
||||
public class AIChatMessage {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Indexed
|
||||
private String sessionId;
|
||||
|
||||
@Indexed
|
||||
private String userId;
|
||||
|
||||
// 消息角色:user/assistant/system
|
||||
private String role;
|
||||
|
||||
// 消息内容
|
||||
private String content;
|
||||
|
||||
// 关联的小说ID(可选)
|
||||
private String novelId;
|
||||
|
||||
// 关联的场景ID(可选)
|
||||
private String sceneId;
|
||||
|
||||
// 使用的AI模型
|
||||
private String modelName;
|
||||
|
||||
// 消息元数据
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
// 消息状态(SENT, DELIVERED, READ等)
|
||||
private String status;
|
||||
|
||||
// 消息类型(TEXT, IMAGE, COMMAND等)
|
||||
private String messageType;
|
||||
|
||||
// 父消息ID(用于消息线程)
|
||||
private String parentMessageId;
|
||||
|
||||
// 消息token数
|
||||
private Integer tokenCount;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ainovel.server.domain.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndex;
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndexes;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* AI聊天会话领域模型
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "ai_chat_sessions")
|
||||
@CompoundIndexes({
|
||||
@CompoundIndex(name = "user_session_idx", def = "{'userId': 1, 'sessionId': 1}"),
|
||||
@CompoundIndex(name = "user_novel_idx", def = "{'userId': 1, 'novelId': 1}")
|
||||
})
|
||||
public class AIChatSession {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Indexed
|
||||
private String sessionId;
|
||||
|
||||
@Indexed
|
||||
private String userId;
|
||||
|
||||
// 关联的小说ID(可选)
|
||||
private String novelId;
|
||||
|
||||
// 会话标题(自动生成或用户指定)
|
||||
private String title;
|
||||
|
||||
// 会话元数据
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
// 使用的AI模型配置
|
||||
private String selectedModelConfigId;
|
||||
|
||||
// 🚀 新增:当前活动的提示词预设ID
|
||||
private String activePromptPresetId;
|
||||
|
||||
// 会话状态(ACTIVE, ARCHIVED等)
|
||||
private String status;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// 最后一条消息的时间
|
||||
private LocalDateTime lastMessageAt;
|
||||
|
||||
// 消息总数
|
||||
private int messageCount;
|
||||
|
||||
// 聊天记忆配置
|
||||
private ChatMemoryConfig memoryConfig;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ainovel.server.domain.model;
|
||||
|
||||
/**
|
||||
* AI功能类型枚举 用于定义不同AI功能的类型标识
|
||||
*/
|
||||
public enum AIFeatureType {
|
||||
/**
|
||||
* 场景生成摘要
|
||||
*/
|
||||
SCENE_TO_SUMMARY,
|
||||
/**
|
||||
* 摘要生成场景
|
||||
*/
|
||||
SUMMARY_TO_SCENE,
|
||||
|
||||
/**
|
||||
* 文本扩写功能
|
||||
*/
|
||||
TEXT_EXPANSION,
|
||||
|
||||
/**
|
||||
* 文本重构功能
|
||||
*/
|
||||
TEXT_REFACTOR,
|
||||
|
||||
/**
|
||||
* 文本缩写功能
|
||||
*/
|
||||
TEXT_SUMMARY,
|
||||
|
||||
/**
|
||||
* AI聊天对话功能
|
||||
*/
|
||||
AI_CHAT,
|
||||
|
||||
/**
|
||||
* 小说内容生成功能
|
||||
*/
|
||||
NOVEL_GENERATION,
|
||||
|
||||
/**
|
||||
* 专业续写小说功能
|
||||
*/
|
||||
PROFESSIONAL_FICTION_CONTINUATION,
|
||||
|
||||
/**
|
||||
* 场景节拍生成功能
|
||||
*/
|
||||
SCENE_BEAT_GENERATION,
|
||||
|
||||
/**
|
||||
* AI设定树生成功能
|
||||
*/
|
||||
SETTING_TREE_GENERATION
|
||||
|
||||
,
|
||||
/**
|
||||
* 小说编排(大纲/章节/组合)
|
||||
*/
|
||||
NOVEL_COMPOSE
|
||||
|
||||
// 未来可扩展其他功能点,如角色生成、大纲优化等
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.ainovel.server.domain.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* AI交互领域模型
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "ai_interactions")
|
||||
public class AIInteraction {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private String userId;
|
||||
|
||||
private String novelId;
|
||||
|
||||
/**
|
||||
* 对话消息
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Message {
|
||||
private String role; // user, assistant
|
||||
private String content;
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
/**
|
||||
* 相关上下文
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Context {
|
||||
@Builder.Default
|
||||
private List<String> sceneIds = new ArrayList<>();
|
||||
@Builder.Default
|
||||
private List<String> characterIds = new ArrayList<>();
|
||||
private Double retrievalScore;
|
||||
}
|
||||
|
||||
private Context context;
|
||||
}
|
||||
|
||||
@Builder.Default
|
||||
private List<Message> conversation = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 生成内容
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Generation {
|
||||
private String prompt;
|
||||
private String result;
|
||||
private String model;
|
||||
private Map<String, Object> parameters;
|
||||
|
||||
/**
|
||||
* Token使用情况
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class TokenUsage {
|
||||
private Integer prompt;
|
||||
private Integer completion;
|
||||
private Integer total;
|
||||
}
|
||||
|
||||
private TokenUsage tokenUsage;
|
||||
private Double cost;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@Builder.Default
|
||||
private List<Generation> generations = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ainovel.server.domain.model;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndex;
|
||||
import org.springframework.data.mongodb.core.index.CompoundIndexes;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI提示词预设实体
|
||||
* 用于存储用户创建的AI配置预设
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "ai_prompt_presets")
|
||||
@CompoundIndexes({
|
||||
@CompoundIndex(name = "user_feature_idx", def = "{'userId': 1, 'aiFeatureType': 1}"),
|
||||
@CompoundIndex(name = "user_name_idx", def = "{'userId': 1, 'presetName': 1}"),
|
||||
@CompoundIndex(name = "system_feature_idx", def = "{'isSystem': 1, 'aiFeatureType': 1}"),
|
||||
@CompoundIndex(name = "quick_access_idx", def = "{'showInQuickAccess': 1, 'aiFeatureType': 1}"),
|
||||
@CompoundIndex(name = "user_quick_access_idx", def = "{'userId': 1, 'showInQuickAccess': 1, 'aiFeatureType': 1}")
|
||||
})
|
||||
public class AIPromptPreset {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Field("preset_id")
|
||||
@Indexed(unique = true)
|
||||
private String presetId; // UUID,唯一业务ID
|
||||
|
||||
@Field("user_id")
|
||||
@Indexed
|
||||
private String userId; // 用户ID
|
||||
|
||||
@Field("novel_id")
|
||||
@Indexed
|
||||
private String novelId; // 小说ID(可选,为null表示全局预设)
|
||||
|
||||
// 🚀 新增:用户定义的预设信息
|
||||
@Field("preset_name")
|
||||
private String presetName; // 用户自定义预设名称
|
||||
|
||||
@Field("preset_description")
|
||||
private String presetDescription; // 预设描述
|
||||
|
||||
@Field("preset_tags")
|
||||
private List<String> presetTags; // 标签列表,便于分类管理
|
||||
|
||||
@Field("is_favorite")
|
||||
@Builder.Default
|
||||
private Boolean isFavorite = false; // 是否收藏
|
||||
|
||||
@Field("is_public")
|
||||
@Builder.Default
|
||||
private Boolean isPublic = false; // 是否公开(未来可分享给其他用户)
|
||||
|
||||
@Field("use_count")
|
||||
@Builder.Default
|
||||
private Integer useCount = 0; // 使用次数统计
|
||||
|
||||
@Field("preset_hash")
|
||||
private String presetHash; // 配置内容的哈希值 (SHA-256)
|
||||
|
||||
@Field("request_data")
|
||||
private String requestData; // 存储完整的 UniversalAIRequestDto JSON
|
||||
|
||||
/**
|
||||
* 【快照字段】根据配置和模板生成的系统提示词最终版本。
|
||||
* 此字段存储的是填充了动态数据(如上下文、选中文本等)后的提示词快照,主要用于预览和历史追溯。
|
||||
* 在实际AI请求中,应优先通过模板ID重新生成以确保上下文的实时性。
|
||||
*/
|
||||
@Field("system_prompt")
|
||||
private String systemPrompt;
|
||||
|
||||
/**
|
||||
* 【快照字段】根据配置和模板生成的用户提示词最终版本。
|
||||
* 此字段存储的是填充了动态数据(如上下文、选中文本等)后的提示词快照,主要用于预览和历史追溯。
|
||||
* 在实际AI请求中,应优先通过模板ID重新生成以确保上下文的实时性。
|
||||
*/
|
||||
@Field("user_prompt")
|
||||
private String userPrompt;
|
||||
|
||||
@Field("ai_feature_type")
|
||||
private String aiFeatureType; // 功能类型 (e.g., 'CHAT')
|
||||
|
||||
// 🚀 新增:提示词自定义配置
|
||||
@Field("custom_system_prompt")
|
||||
private String customSystemPrompt; // 用户自定义的系统提示词
|
||||
|
||||
@Field("custom_user_prompt")
|
||||
private String customUserPrompt; // 用户自定义的用户提示词
|
||||
|
||||
@Field("prompt_customized")
|
||||
@Builder.Default
|
||||
private Boolean promptCustomized = false; // 是否自定义了提示词
|
||||
|
||||
// 🚀 新增:模板关联字段
|
||||
@Field("template_id")
|
||||
private String templateId; // 关联的EnhancedUserPromptTemplate模板ID
|
||||
|
||||
// 🚀 新增:系统预设和快捷访问字段
|
||||
@Field("is_system")
|
||||
@Builder.Default
|
||||
private Boolean isSystem = false; // 是否为系统预设
|
||||
|
||||
@Field("show_in_quick_access")
|
||||
@Builder.Default
|
||||
private Boolean showInQuickAccess = false; // 是否在快捷访问列表中显示
|
||||
|
||||
@Field("created_at")
|
||||
private LocalDateTime createdAt; // 创建时间
|
||||
|
||||
@Field("updated_at")
|
||||
private LocalDateTime updatedAt; // 更新时间
|
||||
|
||||
@Field("last_used_at")
|
||||
private LocalDateTime lastUsedAt; // 最后使用时间
|
||||
|
||||
/**
|
||||
* 获取生效的系统提示词
|
||||
*/
|
||||
public String getEffectiveSystemPrompt() {
|
||||
return (promptCustomized && customSystemPrompt != null && !customSystemPrompt.isEmpty())
|
||||
? customSystemPrompt : systemPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取生效的用户提示词
|
||||
*/
|
||||
public String getEffectiveUserPrompt() {
|
||||
return (promptCustomized && customUserPrompt != null && !customUserPrompt.isEmpty())
|
||||
? customUserPrompt : userPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加使用次数
|
||||
*/
|
||||
public void incrementUseCount() {
|
||||
this.useCount = (this.useCount == null ? 0 : this.useCount) + 1;
|
||||
this.lastUsedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user