From 085408e84f61dcb294df157b22b5619318d3f009 Mon Sep 17 00:00:00 2001
From: dlwlrma <965810157@qq.com>
Date: Tue, 29 Sep 2026 11:41:11 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E9=99=90=E5=88=B6=E7=9F=A5=E8=AF=86?=
=?UTF-8?q?=E5=BA=93=E4=B8=8A=E4=BC=A0=E5=B9=B6=E5=8F=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 每实例默认最多四个同步上传,超限返回繁忙提示,正常或异常退出后释放名额
- 保留请求频率限制和原有上传链路,补充并发、异常释放与配置回归测试
- 最终后端 372 项通过、45 项原有禁用,前端及浏览器各 22 项通过;复验中的 TTS 超时记录保留
---
.../config/KnowledgeBaseUploadProperties.java | 18 ++
.../file/KnowledgeBaseUploadLimiter.java | 36 +++
.../service/KnowledgeBaseUploadService.java | 7 +
app/src/main/resources/application.yml | 4 +
.../KnowledgeBaseUploadPropertiesTest.java | 98 ++++++
.../file/KnowledgeBaseUploadLimiterTest.java | 35 +++
.../KnowledgeBaseUploadConcurrencyTest.java | 284 ++++++++++++++++++
.../KnowledgeBaseUploadServiceTest.java | 5 +-
8 files changed, 486 insertions(+), 1 deletion(-)
create mode 100644 app/src/main/java/interview/guide/common/config/KnowledgeBaseUploadProperties.java
create mode 100644 app/src/main/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiter.java
create mode 100644 app/src/test/java/interview/guide/common/config/KnowledgeBaseUploadPropertiesTest.java
create mode 100644 app/src/test/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiterTest.java
create mode 100644 app/src/test/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadConcurrencyTest.java
diff --git a/app/src/main/java/interview/guide/common/config/KnowledgeBaseUploadProperties.java b/app/src/main/java/interview/guide/common/config/KnowledgeBaseUploadProperties.java
new file mode 100644
index 00000000..2059dd04
--- /dev/null
+++ b/app/src/main/java/interview/guide/common/config/KnowledgeBaseUploadProperties.java
@@ -0,0 +1,18 @@
+package interview.guide.common.config;
+
+import jakarta.validation.constraints.Min;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+/** 知识库同步上传链路的单实例并发配置,修改后需重启应用。 */
+@Data
+@Component
+@Validated
+@ConfigurationProperties(prefix = "app.knowledge-base-upload")
+public class KnowledgeBaseUploadProperties {
+
+ @Min(1)
+ private int maxConcurrent = 4;
+}
diff --git a/app/src/main/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiter.java b/app/src/main/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiter.java
new file mode 100644
index 00000000..57365d47
--- /dev/null
+++ b/app/src/main/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiter.java
@@ -0,0 +1,36 @@
+package interview.guide.infrastructure.file;
+
+import interview.guide.common.config.KnowledgeBaseUploadProperties;
+import interview.guide.common.exception.BusinessException;
+import interview.guide.common.exception.ErrorCode;
+import java.util.concurrent.Semaphore;
+import java.util.function.Supplier;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+
+/**
+ * 限制一个应用实例中正在执行的知识库上传,不排队等待。
+ *
+ *
不替代请求频率限制,也不限制 multipart 接收或后台向量化任务。
+ */
+@Component
+public class KnowledgeBaseUploadLimiter {
+
+ private final Semaphore permits;
+
+ public KnowledgeBaseUploadLimiter(KnowledgeBaseUploadProperties properties) {
+ Assert.isTrue(properties.getMaxConcurrent() > 0, "知识库上传并发上限必须大于 0");
+ permits = new Semaphore(properties.getMaxConcurrent());
+ }
+
+ public T execute(Supplier upload) {
+ if (!permits.tryAcquire()) {
+ throw new BusinessException(ErrorCode.RATE_LIMIT_EXCEEDED, "知识库上传繁忙,请稍后重试");
+ }
+ try {
+ return upload.get();
+ } finally {
+ permits.release();
+ }
+ }
+}
diff --git a/app/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadService.java b/app/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadService.java
index d99b8152..f77071e9 100644
--- a/app/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadService.java
+++ b/app/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadService.java
@@ -5,6 +5,7 @@
import interview.guide.infrastructure.file.FileHashService;
import interview.guide.infrastructure.file.FileStorageService;
import interview.guide.infrastructure.file.FileValidationService;
+import interview.guide.infrastructure.file.KnowledgeBaseUploadLimiter;
import interview.guide.modules.knowledgebase.listener.VectorizeStreamProducer;
import interview.guide.modules.knowledgebase.model.KnowledgeBaseEntity;
import interview.guide.modules.knowledgebase.model.VectorStatus;
@@ -34,6 +35,7 @@ public class KnowledgeBaseUploadService {
private final FileValidationService fileValidationService;
private final FileHashService fileHashService;
private final VectorizeStreamProducer vectorizeStreamProducer;
+ private final KnowledgeBaseUploadLimiter uploadLimiter;
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
@@ -46,6 +48,11 @@ public class KnowledgeBaseUploadService {
* @return 上传结果和存储信息(包含duplicate字段,表示是否为重复上传)
*/
public Map uploadKnowledgeBase(MultipartFile file, String name, String category) {
+ return uploadLimiter.execute(() -> doUploadKnowledgeBase(file, name, category));
+ }
+
+ private Map doUploadKnowledgeBase(
+ MultipartFile file, String name, String category) {
// 1. 验证文件
fileValidationService.validateFile(file, MAX_FILE_SIZE, "知识库");
diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml
index 5b4bc56b..67e947fd 100644
--- a/app/src/main/resources/application.yml
+++ b/app/src/main/resources/application.yml
@@ -206,6 +206,10 @@ app:
- application/vnd.openxmlformats-officedocument.wordprocessingml.document
- text/plain
+ # 单个应用实例中同步处理的知识库上传数,不替代请求频率限制
+ knowledge-base-upload:
+ max-concurrent: ${APP_KNOWLEDGE_BASE_UPLOAD_MAX_CONCURRENT:4}
+
# RustFS (S3兼容) 存储配置
storage:
endpoint: ${APP_STORAGE_ENDPOINT:http://localhost:9000}
diff --git a/app/src/test/java/interview/guide/common/config/KnowledgeBaseUploadPropertiesTest.java b/app/src/test/java/interview/guide/common/config/KnowledgeBaseUploadPropertiesTest.java
new file mode 100644
index 00000000..ff9cba3f
--- /dev/null
+++ b/app/src/test/java/interview/guide/common/config/KnowledgeBaseUploadPropertiesTest.java
@@ -0,0 +1,98 @@
+package interview.guide.common.config;
+
+import interview.guide.common.exception.BusinessException;
+import interview.guide.infrastructure.file.KnowledgeBaseUploadLimiter;
+import java.io.IOException;
+import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.env.YamlPropertySourceLoader;
+import org.springframework.boot.context.properties.bind.validation.BindValidationException;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.env.MapPropertySource;
+import org.springframework.core.io.ClassPathResource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("上传并发配置绑定与校验")
+class KnowledgeBaseUploadPropertiesTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(UploadConfiguration.class);
+
+ @Test
+ @DisplayName("未设置配置时应用使用默认四个上传名额")
+ void shouldUseDefaultLimit() {
+ contextRunner.run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context.getBean(KnowledgeBaseUploadProperties.class).getMaxConcurrent())
+ .isEqualTo(4);
+ assertThat(context.getBean(KnowledgeBaseUploadLimiter.class).execute(() -> "uploaded"))
+ .isEqualTo("uploaded");
+ });
+ }
+
+ @Test
+ @DisplayName("自定义并发配置实际作用于共享上传名额")
+ void shouldBindConfiguredLimitToLimiter() {
+ contextRunner.withPropertyValues("app.knowledge-base-upload.max-concurrent=2").run(context -> {
+ assertThat(context).hasNotFailed();
+ KnowledgeBaseUploadLimiter limiter = context.getBean(KnowledgeBaseUploadLimiter.class);
+
+ limiter.execute(() -> limiter.execute(() -> {
+ assertThatThrownBy(() -> limiter.execute(() -> "excess upload"))
+ .isInstanceOf(BusinessException.class);
+ return "uploaded";
+ }));
+
+ assertThat(limiter.execute(() -> "uploaded")).isEqualTo("uploaded");
+ });
+ }
+
+ @Test
+ @DisplayName("application.yml 中的环境变量占位符可覆盖默认上传上限")
+ void shouldApplyEnvironmentOverrideFromApplicationYaml() throws IOException {
+ var sources = new YamlPropertySourceLoader()
+ .load("application", new ClassPathResource("application.yml"));
+ contextRunner.withInitializer(context -> {
+ context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(
+ "upload-override", Map.of("APP_KNOWLEDGE_BASE_UPLOAD_MAX_CONCURRENT", "1")));
+ sources.forEach(source -> context.getEnvironment().getPropertySources().addLast(source));
+ }).run(context -> {
+ assertThat(context).hasNotFailed();
+ KnowledgeBaseUploadLimiter limiter = context.getBean(KnowledgeBaseUploadLimiter.class);
+
+ limiter.execute(() -> {
+ assertThatThrownBy(() -> limiter.execute(() -> "excess upload"))
+ .isInstanceOf(BusinessException.class);
+ return "uploaded";
+ });
+
+ assertThat(limiter.execute(() -> "uploaded")).isEqualTo("uploaded");
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -1})
+ @DisplayName("零或负数并发配置在启动时明确失败")
+ void shouldRejectInvalidLimit(int maximum) {
+ contextRunner.withPropertyValues("app.knowledge-base-upload.max-concurrent=" + maximum)
+ .run(context -> {
+ assertThat(context).hasFailed();
+ assertThat(context.getStartupFailure())
+ .hasRootCauseInstanceOf(BindValidationException.class);
+ });
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableConfigurationProperties(KnowledgeBaseUploadProperties.class)
+ @Import(KnowledgeBaseUploadLimiter.class)
+ static class UploadConfiguration {
+ }
+}
diff --git a/app/src/test/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiterTest.java b/app/src/test/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiterTest.java
new file mode 100644
index 00000000..7dd6ab17
--- /dev/null
+++ b/app/src/test/java/interview/guide/infrastructure/file/KnowledgeBaseUploadLimiterTest.java
@@ -0,0 +1,35 @@
+package interview.guide.infrastructure.file;
+
+import interview.guide.common.config.KnowledgeBaseUploadProperties;
+import interview.guide.common.exception.BusinessException;
+import interview.guide.common.exception.ErrorCode;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("上传名额释放边界")
+class KnowledgeBaseUploadLimiterTest {
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ @DisplayName("业务异常与 Error 原样抛出,之后仍可再次执行上传")
+ void shouldReleasePermitAndPreserveFailure(boolean error) {
+ KnowledgeBaseUploadProperties properties = new KnowledgeBaseUploadProperties();
+ properties.setMaxConcurrent(1);
+ KnowledgeBaseUploadLimiter limiter = new KnowledgeBaseUploadLimiter(properties);
+ BusinessException businessFailure = new BusinessException(ErrorCode.STORAGE_UPLOAD_FAILED);
+ AssertionError unexpectedFailure = new AssertionError("unexpected failure");
+
+ assertThatThrownBy(() -> limiter.execute(() -> {
+ if (error) {
+ throw unexpectedFailure;
+ }
+ throw businessFailure;
+ })).isSameAs(error ? unexpectedFailure : businessFailure);
+
+ assertThat(limiter.execute(() -> "uploaded")).isEqualTo("uploaded");
+ }
+}
diff --git a/app/src/test/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadConcurrencyTest.java b/app/src/test/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadConcurrencyTest.java
new file mode 100644
index 00000000..cac521b2
--- /dev/null
+++ b/app/src/test/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadConcurrencyTest.java
@@ -0,0 +1,284 @@
+package interview.guide.modules.knowledgebase.service;
+
+import interview.guide.common.config.KnowledgeBaseUploadProperties;
+import interview.guide.common.exception.BusinessException;
+import interview.guide.common.exception.ErrorCode;
+import interview.guide.common.exception.GlobalExceptionHandler;
+import interview.guide.infrastructure.file.FileHashService;
+import interview.guide.infrastructure.file.FileStorageService;
+import interview.guide.infrastructure.file.FileValidationService;
+import interview.guide.infrastructure.file.KnowledgeBaseUploadLimiter;
+import interview.guide.modules.knowledgebase.KnowledgeBaseController;
+import interview.guide.modules.knowledgebase.listener.VectorizeStreamProducer;
+import interview.guide.modules.knowledgebase.model.KnowledgeBaseEntity;
+import interview.guide.modules.knowledgebase.repository.KnowledgeBaseRepository;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("知识库上传并发保护")
+class KnowledgeBaseUploadConcurrencyTest {
+
+ @Mock
+ private KnowledgeBaseParseService parseService;
+ @Mock
+ private KnowledgeBasePersistenceService persistenceService;
+ @Mock
+ private FileStorageService storageService;
+ @Mock
+ private KnowledgeBaseRepository repository;
+ @Mock
+ private FileValidationService validationService;
+ @Mock
+ private FileHashService hashService;
+ @Mock
+ private VectorizeStreamProducer producer;
+
+ private KnowledgeBaseUploadService service;
+ private MultipartFile file;
+
+ @BeforeEach
+ void setUp() {
+ service = createService(4);
+ file = new MockMultipartFile("file", "guide.pdf", "application/pdf", new byte[] {1, 2, 3});
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ @DisplayName("四个上传处理中时拒绝第五个请求,完成后允许再次上传")
+ void shouldRejectFifthInFlightUploadAndAllowRetryAfterCompletion(boolean virtualThreads)
+ throws Exception {
+ CountDownLatch entered = new CountDownLatch(4);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger validationCalls = new AtomicInteger();
+ doAnswer(invocation -> {
+ if (validationCalls.incrementAndGet() <= 4) {
+ entered.countDown();
+ assertThat(release.await(10, TimeUnit.SECONDS)).isTrue();
+ }
+ return null;
+ }).when(validationService).validateFile(any(), anyLong(), eq("知识库"));
+ stubDuplicateUpload();
+ ThreadPoolExecutor executor = new ThreadPoolExecutor(4, 4, 0, TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(4), virtualThreads
+ ? Thread.ofVirtual().name("upload-regression-", 0).factory()
+ : Thread.ofPlatform().name("upload-regression-", 0).factory());
+ List>> uploads = new ArrayList<>();
+
+ try {
+ for (int index = 0; index < 4; index++) {
+ uploads.add(executor.submit(() -> service.uploadKnowledgeBase(file, null, null)));
+ }
+ assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
+
+ assertThatThrownBy(() -> service.uploadKnowledgeBase(file, null, null))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(ErrorCode.RATE_LIMIT_EXCEEDED.getCode());
+ assertThat(exception.getMessage()).contains("上传繁忙", "重试");
+ });
+ assertThatThrownBy(() -> service.uploadKnowledgeBase(file, null, null))
+ .isInstanceOf(BusinessException.class);
+
+ assertThat(validationCalls).hasValue(4);
+ release.countDown();
+ for (Future