diff --git a/pom.xml b/pom.xml
index 3cf8ba9..e95862b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -82,6 +82,14 @@
lombok
true
+
+ software.amazon.awssdk
+ s3
+
+
+ software.amazon.awssdk
+ sts
+
com.h2database
h2
@@ -126,4 +134,4 @@
-
\ No newline at end of file
+
diff --git a/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java b/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java
index bbf8c6c..e502d34 100644
--- a/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java
+++ b/src/main/java/com/labelsys/backend/LabelsysBackendApplication.java
@@ -3,9 +3,11 @@ package com.labelsys.backend;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@MapperScan("com.labelsys.backend.mapper")
+@EnableScheduling
public class LabelsysBackendApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java b/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java
new file mode 100644
index 0000000..1f7f155
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java
@@ -0,0 +1,17 @@
+package com.labelsys.backend.config;
+
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class MybatisPlusConfig {
+
+ @Bean
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
+ return interceptor;
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java b/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java
new file mode 100644
index 0000000..161698b
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/config/ObjectStorageConfig.java
@@ -0,0 +1,31 @@
+package com.labelsys.backend.config;
+
+import java.net.URI;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+
+@Configuration
+@RequiredArgsConstructor
+@EnableConfigurationProperties(ObjectStorageProperties.class)
+public class ObjectStorageConfig {
+
+ private final ObjectStorageProperties properties;
+
+ @Bean
+ public S3Client s3Client() {
+ return S3Client.builder()
+ .endpointOverride(URI.create(properties.getEndpoint()))
+ .region(Region.of(properties.getRegion()))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
+ .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(properties.isPathStyleAccess()).build())
+ .build();
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java b/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java
new file mode 100644
index 0000000..b3ca457
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/config/ObjectStorageProperties.java
@@ -0,0 +1,17 @@
+package com.labelsys.backend.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "labelsys.object-storage")
+public class ObjectStorageProperties {
+ private String endpoint;
+ private String region;
+ private String accessKey;
+ private String secretKey;
+ private boolean pathStyleAccess = true;
+ private String sourceBucket;
+ private String artifactBucket;
+ private String exportBucket;
+}
diff --git a/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java b/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java
new file mode 100644
index 0000000..0a9cdac
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java
@@ -0,0 +1,61 @@
+package com.labelsys.backend.controller;
+
+import com.labelsys.backend.annotation.RequirePosition;
+import com.labelsys.backend.common.Result;
+import com.labelsys.backend.context.UserContext;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.AnnotationResultPageQuery;
+import com.labelsys.backend.dto.request.MergeReviewResultRequest;
+import com.labelsys.backend.dto.response.AnnotationResultCompareResponse;
+import com.labelsys.backend.dto.response.AnnotationResultResponse;
+import com.labelsys.backend.dto.response.MergeReviewResultResponse;
+import com.labelsys.backend.enums.UserPosition;
+import com.labelsys.backend.service.AnnotationResultArchiveService;
+import com.labelsys.backend.service.AnnotationResultService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+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;
+
+@Tag(name = "标注结果管理")
+@RestController
+@RequestMapping("/api/annotation-results")
+@RequiredArgsConstructor
+public class AnnotationResultController {
+
+ private final AnnotationResultService annotationResultService;
+ private final AnnotationResultArchiveService annotationResultArchiveService;
+
+ @Operation(summary = "分页查询标注结果")
+ @GetMapping
+ public Result> page(AnnotationResultPageQuery query) {
+ return Result.success(annotationResultService.pageResults(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询标注结果详情")
+ @GetMapping("/{id}")
+ public Result detail(@PathVariable Long id) {
+ return Result.success(annotationResultService.getResult(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "查询标注结果比对信息")
+ @RequirePosition(UserPosition.REVIEWER)
+ @GetMapping("/{id}/compare")
+ public Result compare(@PathVariable Long id) {
+ return Result.success(annotationResultService.compareResult(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "提交合并审核结果")
+ @RequirePosition(UserPosition.REVIEWER)
+ @PostMapping("/{id}/merge-review")
+ public Result mergeReview(@PathVariable Long id,
+ @Valid @RequestBody MergeReviewResultRequest request) {
+ return Result.success(annotationResultArchiveService.mergeReview(UserContext.requireUser(), id, request));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/controller/AnnotationTaskController.java b/src/main/java/com/labelsys/backend/controller/AnnotationTaskController.java
new file mode 100644
index 0000000..f36274c
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/AnnotationTaskController.java
@@ -0,0 +1,62 @@
+package com.labelsys.backend.controller;
+
+import com.labelsys.backend.common.Result;
+import com.labelsys.backend.context.UserContext;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.AnnotationTaskPageQuery;
+import com.labelsys.backend.dto.request.CreateAnnotationTaskRequest;
+import com.labelsys.backend.dto.request.UpdateAnnotationTaskRequest;
+import com.labelsys.backend.dto.response.AnnotationTaskResponse;
+import com.labelsys.backend.service.AnnotationTaskService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+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.RestController;
+
+@Tag(name = "标注任务管理")
+@RestController
+@RequestMapping("/api/annotation-tasks")
+@RequiredArgsConstructor
+public class AnnotationTaskController {
+
+ private final AnnotationTaskService annotationTaskService;
+
+ @Operation(summary = "创建标注任务")
+ @PostMapping
+ public Result create(@Valid @RequestBody CreateAnnotationTaskRequest request) {
+ return Result.success(annotationTaskService.createTask(UserContext.requireUser(), request));
+ }
+
+ @Operation(summary = "更新标注任务")
+ @PutMapping("/{id}")
+ public Result update(@PathVariable Long id, @Valid @RequestBody UpdateAnnotationTaskRequest request) {
+ return Result.success(annotationTaskService.updateTask(UserContext.requireUser(), id, request));
+ }
+
+ @Operation(summary = "分页查询标注任务")
+ @GetMapping
+ public Result> page(AnnotationTaskPageQuery query) {
+ return Result.success(annotationTaskService.pageTasks(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询标注任务详情")
+ @GetMapping("/{id}")
+ public Result detail(@PathVariable Long id) {
+ return Result.success(annotationTaskService.getTask(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "删除标注任务")
+ @DeleteMapping("/{id}")
+ public Result delete(@PathVariable Long id) {
+ annotationTaskService.deleteTask(UserContext.requireUser(), id);
+ return Result.success();
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/controller/SourceResourceController.java b/src/main/java/com/labelsys/backend/controller/SourceResourceController.java
new file mode 100644
index 0000000..36fb83b
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/SourceResourceController.java
@@ -0,0 +1,54 @@
+package com.labelsys.backend.controller;
+
+import com.labelsys.backend.common.Result;
+import com.labelsys.backend.context.UserContext;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.SourceResourcePageQuery;
+import com.labelsys.backend.dto.request.SourceUploadRequest;
+import com.labelsys.backend.dto.response.SourceResourceResponse;
+import com.labelsys.backend.dto.response.SourceUploadResponse;
+import com.labelsys.backend.service.SourceResourceService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "资源管理")
+@RestController
+@RequestMapping("/api/source-resources")
+@RequiredArgsConstructor
+public class SourceResourceController {
+
+ private final SourceResourceService sourceResourceService;
+
+ @Operation(summary = "上传资源")
+ @PostMapping("/upload")
+ public Result upload(@ModelAttribute SourceUploadRequest request) {
+ return Result.success(sourceResourceService.upload(UserContext.requireUser(), request));
+ }
+
+ @Operation(summary = "分页查询资源")
+ @GetMapping
+ public Result> page(SourceResourcePageQuery query) {
+ return Result.success(sourceResourceService.pageResources(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询资源详情")
+ @GetMapping("/{id}")
+ public Result detail(@PathVariable Long id) {
+ return Result.success(sourceResourceService.getResource(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "删除资源")
+ @DeleteMapping("/{id}")
+ public Result delete(@PathVariable Long id) {
+ sourceResourceService.deleteResource(UserContext.requireUser(), id);
+ return Result.success();
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/controller/SysConfigController.java b/src/main/java/com/labelsys/backend/controller/SysConfigController.java
new file mode 100644
index 0000000..959e057
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/SysConfigController.java
@@ -0,0 +1,57 @@
+package com.labelsys.backend.controller;
+
+import com.labelsys.backend.annotation.RequirePosition;
+import com.labelsys.backend.common.Result;
+import com.labelsys.backend.context.UserContext;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.SaveSysConfigRequest;
+import com.labelsys.backend.dto.request.SysConfigPageQuery;
+import com.labelsys.backend.dto.response.SysConfigResponse;
+import com.labelsys.backend.enums.UserPosition;
+import com.labelsys.backend.service.SysConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+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.RestController;
+
+@Tag(name = "系统配置管理")
+@RestController
+@RequestMapping("/api/sys-configs")
+@RequiredArgsConstructor
+public class SysConfigController {
+
+ private final SysConfigService sysConfigService;
+
+ @Operation(summary = "创建系统配置")
+ @RequirePosition(UserPosition.ADMIN)
+ @PostMapping
+ public Result create(@Valid @RequestBody SaveSysConfigRequest request) {
+ return Result.success(sysConfigService.toResponse(sysConfigService.saveConfig(UserContext.requireUser(), request)));
+ }
+
+ @Operation(summary = "更新系统配置")
+ @RequirePosition(UserPosition.ADMIN)
+ @PutMapping("/{id}")
+ public Result update(@PathVariable Long id, @Valid @RequestBody SaveSysConfigRequest request) {
+ return Result.success(sysConfigService.toResponse(sysConfigService.updateConfig(UserContext.requireUser(), id, request)));
+ }
+
+ @Operation(summary = "分页查询系统配置")
+ @GetMapping
+ public Result> page(SysConfigPageQuery query) {
+ return Result.success(sysConfigService.pageConfigs(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询系统配置详情")
+ @GetMapping("/{id}")
+ public Result detail(@PathVariable Long id) {
+ return Result.success(sysConfigService.getConfig(UserContext.requireUser(), id));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/dto/common/PageResult.java b/src/main/java/com/labelsys/backend/dto/common/PageResult.java
new file mode 100644
index 0000000..62f40e1
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/common/PageResult.java
@@ -0,0 +1,17 @@
+package com.labelsys.backend.dto.common;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+@Schema(description = "分页结果")
+public record PageResult(
+ @Schema(description = "当前页记录") List records,
+ @Schema(description = "总记录数") Long total,
+ @Schema(description = "页码") Integer pageNo,
+ @Schema(description = "每页数量") Integer pageSize
+) {
+ public static PageResult from(IPage page) {
+ return new PageResult<>(page.getRecords(), page.getTotal(), (int) page.getCurrent(), (int) page.getSize());
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/AnnotationResultPageQuery.java b/src/main/java/com/labelsys/backend/dto/request/AnnotationResultPageQuery.java
new file mode 100644
index 0000000..d07cbcf
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/AnnotationResultPageQuery.java
@@ -0,0 +1,14 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "标注结果分页查询请求")
+public record AnnotationResultPageQuery(
+ @Schema(description = "任务ID") Long taskId,
+ @Schema(description = "资源ID") Long resourceId,
+ @Schema(description = "是否需要人工审核") Boolean requiresManualReview,
+ @Schema(description = "运行态状态") String runtimeStatus,
+ @Schema(description = "页码") Integer pageNo,
+ @Schema(description = "每页数量") Integer pageSize
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/AnnotationTaskPageQuery.java b/src/main/java/com/labelsys/backend/dto/request/AnnotationTaskPageQuery.java
new file mode 100644
index 0000000..bdd5d43
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/AnnotationTaskPageQuery.java
@@ -0,0 +1,15 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "标注任务分页查询请求")
+public record AnnotationTaskPageQuery(
+ @Schema(description = "关键字") String keyword,
+ @Schema(description = "任务类型") String taskType,
+ @Schema(description = "任务状态") String taskStatus,
+ @Schema(description = "资源ID") Long resourceId,
+ @Schema(description = "是否已删除") Boolean isDeleted,
+ @Schema(description = "页码") Integer pageNo,
+ @Schema(description = "每页数量") Integer pageSize
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateAnnotationTaskRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateAnnotationTaskRequest.java
new file mode 100644
index 0000000..5dbc324
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/CreateAnnotationTaskRequest.java
@@ -0,0 +1,20 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+
+@Schema(description = "创建标注任务请求")
+public record CreateAnnotationTaskRequest(
+ @Schema(description = "任务名称") @NotBlank(message = "任务名称不能为空") String taskName,
+ @Schema(description = "行业类型") String industryType,
+ @Schema(description = "任务类型") String taskType,
+ @Schema(description = "资源ID列表") @NotEmpty(message = "资源列表不能为空") List resourceIds,
+ @Schema(description = "抽取模型配置") @Valid TaskModelConfigRequest extractModel,
+ @Schema(description = "校验模型配置") @Valid TaskModelConfigRequest verifyModel,
+ @Schema(description = "抽取提示词配置") @Valid PromptConfigOptionRequest extractPrompt,
+ @Schema(description = "校验提示词配置") @Valid PromptConfigOptionRequest verifyPrompt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/ManualModelConfigRequest.java b/src/main/java/com/labelsys/backend/dto/request/ManualModelConfigRequest.java
new file mode 100644
index 0000000..6fce101
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/ManualModelConfigRequest.java
@@ -0,0 +1,12 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "手动模型配置请求")
+public record ManualModelConfigRequest(
+ @Schema(description = "模型名称") @NotBlank(message = "模型名称不能为空") String modelName,
+ @Schema(description = "模型地址") @NotBlank(message = "模型地址不能为空") String modelUrl,
+ @Schema(description = "模型密钥") @NotBlank(message = "模型密钥不能为空") String apiKey
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java b/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java
new file mode 100644
index 0000000..dc51d22
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java
@@ -0,0 +1,12 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "合并审核结果请求")
+public record MergeReviewResultRequest(
+ @Schema(description = "差异摘要 JSON") @NotBlank(message = "差异摘要不能为空") String diffSummary,
+ @Schema(description = "最终问答内容 JSON") @NotBlank(message = "问答内容不能为空") String qaContentJson,
+ @Schema(description = "审核备注") String reviewComment
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/PromptConfigOptionRequest.java b/src/main/java/com/labelsys/backend/dto/request/PromptConfigOptionRequest.java
new file mode 100644
index 0000000..19f0b9c
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/PromptConfigOptionRequest.java
@@ -0,0 +1,10 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "任务提示词配置请求")
+public record PromptConfigOptionRequest(
+ @Schema(description = "已选择的提示词配置名称") String selectedConfigName,
+ @Schema(description = "手动输入的提示词内容") String promptText
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/SaveSysConfigRequest.java b/src/main/java/com/labelsys/backend/dto/request/SaveSysConfigRequest.java
new file mode 100644
index 0000000..566c56f
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/SaveSysConfigRequest.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "保存系统配置请求")
+public record SaveSysConfigRequest(
+ @Schema(description = "配置类型") @NotBlank(message = "配置类型不能为空") String configType,
+ @Schema(description = "配置名称") @NotBlank(message = "配置名称不能为空") String configName,
+ @Schema(description = "配置值") @NotBlank(message = "配置值不能为空") String configValue,
+ @Schema(description = "配置状态") @NotBlank(message = "配置状态不能为空") String status
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java b/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java
new file mode 100644
index 0000000..84a50ab
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "资源分页查询请求")
+public record SourceResourcePageQuery(
+ @Schema(description = "关键字") String keyword,
+ @Schema(description = "资源类型") String resourceType,
+ @Schema(description = "资源状态") String sourceStatus,
+ @Schema(description = "页码") Integer pageNo,
+ @Schema(description = "每页数量") Integer pageSize
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/SourceUploadRequest.java b/src/main/java/com/labelsys/backend/dto/request/SourceUploadRequest.java
new file mode 100644
index 0000000..e84a548
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/SourceUploadRequest.java
@@ -0,0 +1,22 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import org.springframework.web.multipart.MultipartFile;
+
+@Data
+@Schema(description = "资源上传请求")
+public class SourceUploadRequest {
+
+ @Schema(description = "资源名称")
+ private String resourceName;
+
+ @Schema(description = "资源类型:TEXT、IMAGE、VIDEO")
+ private String resourceType;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ @Schema(description = "上传文件")
+ private MultipartFile file;
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/SysConfigPageQuery.java b/src/main/java/com/labelsys/backend/dto/request/SysConfigPageQuery.java
new file mode 100644
index 0000000..38aff48
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/SysConfigPageQuery.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "系统配置分页查询请求")
+public record SysConfigPageQuery(
+ @Schema(description = "配置类型") String configType,
+ @Schema(description = "配置名称") String configName,
+ @Schema(description = "配置状态") String status,
+ @Schema(description = "页码") Integer pageNo,
+ @Schema(description = "每页数量") Integer pageSize
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/TaskModelConfigRequest.java b/src/main/java/com/labelsys/backend/dto/request/TaskModelConfigRequest.java
new file mode 100644
index 0000000..b242b89
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/TaskModelConfigRequest.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "任务模型配置请求")
+public record TaskModelConfigRequest(
+ @Schema(description = "配置模式:SELECT 或 MANUAL") @NotBlank(message = "配置模式不能为空") String mode,
+ @Schema(description = "已选择的配置名称") String selectedConfigName,
+ @Schema(description = "手动录入的模型配置") @Valid ManualModelConfigRequest manualConfig
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateAnnotationTaskRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateAnnotationTaskRequest.java
new file mode 100644
index 0000000..4b8b55b
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/UpdateAnnotationTaskRequest.java
@@ -0,0 +1,18 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+
+@Schema(description = "更新标注任务请求")
+public record UpdateAnnotationTaskRequest(
+ @Schema(description = "行业类型") String industryType,
+ @Schema(description = "任务类型") String taskType,
+ @Schema(description = "资源ID列表") @NotEmpty(message = "资源列表不能为空") List resourceIds,
+ @Schema(description = "抽取模型配置") @Valid TaskModelConfigRequest extractModel,
+ @Schema(description = "校验模型配置") @Valid TaskModelConfigRequest verifyModel,
+ @Schema(description = "抽取提示词配置") @Valid PromptConfigOptionRequest extractPrompt,
+ @Schema(description = "校验提示词配置") @Valid PromptConfigOptionRequest verifyPrompt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java
new file mode 100644
index 0000000..45d5e05
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java
@@ -0,0 +1,16 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "标注结果比对响应")
+public record AnnotationResultCompareResponse(
+ @Schema(description = "结果ID") Long id,
+ @Schema(description = "任务ID") Long taskId,
+ @Schema(description = "资源ID") Long resourceId,
+ @Schema(description = "问答内容 JSON") String qaContentJson,
+ @Schema(description = "差异摘要 JSON") String diffSummary,
+ @Schema(description = "问答存储模式") String qaContentStorageMode,
+ @Schema(description = "外置问答文件路径") String qaContentFilePath,
+ @Schema(description = "资源预览路径") String sourcePreviewPath
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java
new file mode 100644
index 0000000..88bdcb7
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java
@@ -0,0 +1,19 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+
+@Schema(description = "标注结果响应")
+public record AnnotationResultResponse(
+ @Schema(description = "结果ID") Long id,
+ @Schema(description = "任务ID") Long taskId,
+ @Schema(description = "资源ID") Long resourceId,
+ @Schema(description = "运行态状态") String runtimeStatus,
+ @Schema(description = "是否需要人工审核") Boolean requiresManualReview,
+ @Schema(description = "是否已删除") Boolean isDeleted,
+ @Schema(description = "问答存储模式") String qaContentStorageMode,
+ @Schema(description = "审核备注") String reviewComment,
+ @Schema(description = "审核时间") LocalDateTime reviewedAt,
+ @Schema(description = "创建时间") LocalDateTime createdAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationTaskResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationTaskResponse.java
new file mode 100644
index 0000000..d9255d3
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationTaskResponse.java
@@ -0,0 +1,22 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "标注任务响应")
+public record AnnotationTaskResponse(
+ @Schema(description = "任务ID") Long id,
+ @Schema(description = "任务名称") String taskName,
+ @Schema(description = "行业类型") String industryType,
+ @Schema(description = "任务类型") String taskType,
+ @Schema(description = "任务状态") String taskStatus,
+ @Schema(description = "资源ID列表") List resourceIds,
+ @Schema(description = "抽取模型配置") TaskModelConfigResponse extractModel,
+ @Schema(description = "校验模型配置") TaskModelConfigResponse verifyModel,
+ @Schema(description = "抽取提示词配置") TaskPromptConfigResponse extractPrompt,
+ @Schema(description = "校验提示词配置") TaskPromptConfigResponse verifyPrompt,
+ @Schema(description = "创建时间") LocalDateTime createdAt,
+ @Schema(description = "更新时间") LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/MergeReviewResultResponse.java b/src/main/java/com/labelsys/backend/dto/response/MergeReviewResultResponse.java
new file mode 100644
index 0000000..4af8e53
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/MergeReviewResultResponse.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+
+@Schema(description = "合并审核结果响应")
+public record MergeReviewResultResponse(
+ @Schema(description = "运行态结果ID") Long resultId,
+ @Schema(description = "历史结果ID") Long historyId,
+ @Schema(description = "归档原因") String archiveReason,
+ @Schema(description = "归档时间") LocalDateTime archivedAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java b/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java
new file mode 100644
index 0000000..1922ccc
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java
@@ -0,0 +1,21 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+
+@Schema(description = "资源响应")
+public record SourceResourceResponse(
+ @Schema(description = "资源ID") Long id,
+ @Schema(description = "资源名称") String resourceName,
+ @Schema(description = "资源类型") String resourceType,
+ @Schema(description = "桶名称") String bucketName,
+ @Schema(description = "文件路径") String filePath,
+ @Schema(description = "文件大小") Long fileSize,
+ @Schema(description = "资源状态") String sourceStatus,
+ @Schema(description = "存储提供方") String storageProvider,
+ @Schema(description = "备注") String remark,
+ @Schema(description = "创建人名称") String creatorName,
+ @Schema(description = "创建时间") LocalDateTime createdAt,
+ @Schema(description = "更新时间") LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/SourceUploadResponse.java b/src/main/java/com/labelsys/backend/dto/response/SourceUploadResponse.java
new file mode 100644
index 0000000..7029138
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/SourceUploadResponse.java
@@ -0,0 +1,17 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+
+@Schema(description = "资源上传响应")
+public record SourceUploadResponse(
+ @Schema(description = "资源ID") Long id,
+ @Schema(description = "资源名称") String resourceName,
+ @Schema(description = "资源类型") String resourceType,
+ @Schema(description = "桶名称") String bucketName,
+ @Schema(description = "文件路径") String filePath,
+ @Schema(description = "文件大小") Long fileSize,
+ @Schema(description = "资源状态") String sourceStatus,
+ @Schema(description = "创建时间") LocalDateTime createdAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/SysConfigResponse.java b/src/main/java/com/labelsys/backend/dto/response/SysConfigResponse.java
new file mode 100644
index 0000000..67b53fa
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/SysConfigResponse.java
@@ -0,0 +1,17 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+
+@Schema(description = "系统配置响应")
+public record SysConfigResponse(
+ @Schema(description = "配置ID") Long id,
+ @Schema(description = "配置类型") String configType,
+ @Schema(description = "配置名称") String configName,
+ @Schema(description = "配置值") String configValue,
+ @Schema(description = "配置状态") String status,
+ @Schema(description = "创建人ID") Long creatorId,
+ @Schema(description = "创建时间") LocalDateTime createdAt,
+ @Schema(description = "更新时间") LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/TaskModelConfigResponse.java b/src/main/java/com/labelsys/backend/dto/response/TaskModelConfigResponse.java
new file mode 100644
index 0000000..6ead3e0
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/TaskModelConfigResponse.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "任务模型配置响应")
+public record TaskModelConfigResponse(
+ @Schema(description = "配置ID") Long configId,
+ @Schema(description = "配置名称") String configName,
+ @Schema(description = "模型名称") String modelName,
+ @Schema(description = "模型地址") String modelUrl,
+ @Schema(description = "脱敏后的模型密钥") String maskedApiKey
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/TaskPromptConfigResponse.java b/src/main/java/com/labelsys/backend/dto/response/TaskPromptConfigResponse.java
new file mode 100644
index 0000000..91a4326
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/response/TaskPromptConfigResponse.java
@@ -0,0 +1,11 @@
+package com.labelsys.backend.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "任务提示词配置响应")
+public record TaskPromptConfigResponse(
+ @Schema(description = "配置ID") Long configId,
+ @Schema(description = "配置名称") String configName,
+ @Schema(description = "提示词内容") String promptText
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java
new file mode 100644
index 0000000..9a5921c
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java
@@ -0,0 +1,37 @@
+package com.labelsys.backend.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.labelsys.backend.enums.UserRole;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("annotation_result")
+public class AnnotationResult {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long companyId;
+ private Long creatorId;
+ private UserRole creatorRole;
+ private Long taskId;
+ private Long resourceId;
+ private String qaContentJson;
+ private String qaContentStorageMode;
+ private String qaContentFilePath;
+ private String diffSummary;
+ private Boolean requiresManualReview;
+ private Boolean isDeleted;
+ private Long reviewerId;
+ private String reviewComment;
+ private LocalDateTime reviewedAt;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java b/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java
new file mode 100644
index 0000000..d180683
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java
@@ -0,0 +1,34 @@
+package com.labelsys.backend.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.labelsys.backend.enums.UserRole;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("annotation_result_history")
+public class AnnotationResultHistory {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long companyId;
+ private Long creatorId;
+ private UserRole creatorRole;
+ private Long sourceResultId;
+ private Long taskId;
+ private Long resourceId;
+ private String qaContentJson;
+ private String qaContentStorageMode;
+ private String qaContentFilePath;
+ private String archiveReason;
+ private Long archivedBy;
+ private LocalDateTime archivedAt;
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationTask.java b/src/main/java/com/labelsys/backend/entity/AnnotationTask.java
new file mode 100644
index 0000000..31b3898
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/AnnotationTask.java
@@ -0,0 +1,46 @@
+package com.labelsys.backend.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.labelsys.backend.enums.UserRole;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("annotation_task")
+public class AnnotationTask {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long companyId;
+ private Long creatorId;
+ private UserRole creatorRole;
+ private String taskName;
+ private String industryType;
+ private String taskType;
+ private Long extractModelConfigId;
+ private String extractModelName;
+ private String extractModelUrl;
+ private String extractModelApiKey;
+ private Long verifyModelConfigId;
+ private String verifyModelName;
+ private String verifyModelUrl;
+ private String verifyModelApiKey;
+ private Long extractPromptConfigId;
+ private String extractPrompt;
+ private Long verifyPromptConfigId;
+ private String verifyPrompt;
+ private String taskStatus;
+ private Boolean isDeleted;
+ private LocalDateTime startedAt;
+ private LocalDateTime finishedAt;
+ private String errorMessage;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationTaskResource.java b/src/main/java/com/labelsys/backend/entity/AnnotationTaskResource.java
new file mode 100644
index 0000000..c5c18ce
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/AnnotationTaskResource.java
@@ -0,0 +1,24 @@
+package com.labelsys.backend.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("annotation_task_resource")
+public class AnnotationTaskResource {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long companyId;
+ private Long taskId;
+ private Long resourceId;
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/com/labelsys/backend/entity/BizDataRecord.java b/src/main/java/com/labelsys/backend/entity/BizDataRecord.java
new file mode 100644
index 0000000..9acc68c
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/BizDataRecord.java
@@ -0,0 +1,22 @@
+package com.labelsys.backend.entity;
+
+import com.labelsys.backend.enums.UserRole;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BizDataRecord {
+ private Long id;
+ private Long companyId;
+ private Long creatorId;
+ private UserRole creatorRole;
+ private String recordName;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/labelsys/backend/entity/SourceResource.java b/src/main/java/com/labelsys/backend/entity/SourceResource.java
new file mode 100644
index 0000000..ef469ab
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/SourceResource.java
@@ -0,0 +1,34 @@
+package com.labelsys.backend.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.labelsys.backend.enums.UserRole;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("source_resource")
+public class SourceResource {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long companyId;
+ private Long creatorId;
+ private UserRole creatorRole;
+ private String resourceName;
+ private String resourceType;
+ private String bucketName;
+ private String filePath;
+ private Long fileSize;
+ private String sourceStatus;
+ private String storageProvider;
+ private String remark;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/labelsys/backend/entity/SysConfig.java b/src/main/java/com/labelsys/backend/entity/SysConfig.java
new file mode 100644
index 0000000..26b5559
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/entity/SysConfig.java
@@ -0,0 +1,28 @@
+package com.labelsys.backend.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TableName("sys_config")
+public class SysConfig {
+ @TableId(type = IdType.INPUT)
+ private Long id;
+ private Long companyId;
+ private String configType;
+ private String configName;
+ private String configValue;
+ private String status;
+ private Long creatorId;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/labelsys/backend/enums/ConfigType.java b/src/main/java/com/labelsys/backend/enums/ConfigType.java
new file mode 100644
index 0000000..6a52f31
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/enums/ConfigType.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.enums;
+
+import java.util.Arrays;
+
+public enum ConfigType {
+ MODEL,
+ PROMPT,
+ SYSTEM;
+
+ public static boolean isValid(String value) {
+ return Arrays.stream(values()).anyMatch(type -> type.name().equals(value));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/enums/QaContentStorageMode.java b/src/main/java/com/labelsys/backend/enums/QaContentStorageMode.java
new file mode 100644
index 0000000..20e6c11
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/enums/QaContentStorageMode.java
@@ -0,0 +1,12 @@
+package com.labelsys.backend.enums;
+
+import java.util.Arrays;
+
+public enum QaContentStorageMode {
+ INLINE,
+ EXTERNAL;
+
+ public static boolean isValid(String value) {
+ return Arrays.stream(values()).anyMatch(mode -> mode.name().equals(value));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/enums/ResourceType.java b/src/main/java/com/labelsys/backend/enums/ResourceType.java
new file mode 100644
index 0000000..f3e4883
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/enums/ResourceType.java
@@ -0,0 +1,13 @@
+package com.labelsys.backend.enums;
+
+import java.util.Arrays;
+
+public enum ResourceType {
+ TEXT,
+ IMAGE,
+ VIDEO;
+
+ public static boolean isValid(String value) {
+ return Arrays.stream(values()).anyMatch(type -> type.name().equals(value));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/enums/RuntimeResultStatus.java b/src/main/java/com/labelsys/backend/enums/RuntimeResultStatus.java
new file mode 100644
index 0000000..8f3878d
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/enums/RuntimeResultStatus.java
@@ -0,0 +1,7 @@
+package com.labelsys.backend.enums;
+
+public enum RuntimeResultStatus {
+ MANUAL_REVIEW_PENDING,
+ AUTO_ARCHIVE_PENDING,
+ ARCHIVED
+}
diff --git a/src/main/java/com/labelsys/backend/enums/SourceStatus.java b/src/main/java/com/labelsys/backend/enums/SourceStatus.java
new file mode 100644
index 0000000..9dfede0
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/enums/SourceStatus.java
@@ -0,0 +1,14 @@
+package com.labelsys.backend.enums;
+
+import java.util.Arrays;
+
+public enum SourceStatus {
+ UPLOADED,
+ PROCESSING,
+ READY,
+ ARCHIVED;
+
+ public static boolean isValid(String value) {
+ return Arrays.stream(values()).anyMatch(status -> status.name().equals(value));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/enums/TaskStatus.java b/src/main/java/com/labelsys/backend/enums/TaskStatus.java
new file mode 100644
index 0000000..1ab8ab9
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/enums/TaskStatus.java
@@ -0,0 +1,14 @@
+package com.labelsys.backend.enums;
+
+import java.util.Arrays;
+
+public enum TaskStatus {
+ PENDING,
+ RUNNING,
+ COMPLETED,
+ FAILED;
+
+ public static boolean isValid(String value) {
+ return Arrays.stream(values()).anyMatch(status -> status.name().equals(value));
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java
index 7467e42..8dadd0d 100644
--- a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java
+++ b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java
@@ -27,11 +27,17 @@ import jakarta.servlet.http.HttpServletResponse;
@Component
public class AuthInterceptor implements HandlerInterceptor {
- private static final Set OPEN_PATHS = Set.of("/label/api/auth/companies", "/label/api/auth/login",
- "/label/swagger-ui.html", "/label/v3/api-docs", "/label/v3/api-docs/swagger-config");
+ private static final Set OPEN_PATHS = Set.of(
+ "/api/auth/companies", "/label/api/auth/companies",
+ "/api/auth/login", "/label/api/auth/login",
+ "/swagger-ui.html", "/label/swagger-ui.html",
+ "/v3/api-docs", "/label/v3/api-docs",
+ "/v3/api-docs/swagger-config", "/label/v3/api-docs/swagger-config");
- private static final Set ALLOWED_WHEN_MUST_CHANGE_PASSWORD =
- Set.of("/label/api/auth/change-password", "/label/api/auth/logout", "/label/api/auth/me");
+ private static final Set ALLOWED_WHEN_MUST_CHANGE_PASSWORD = Set.of(
+ "/api/auth/change-password", "/label/api/auth/change-password",
+ "/api/auth/logout", "/label/api/auth/logout",
+ "/api/auth/me", "/label/api/auth/me");
private final TokenSessionRepository tokenSessionRepository;
private final SysUserMapper sysUserMapper;
diff --git a/src/main/java/com/labelsys/backend/mapper/AnnotationResultHistoryMapper.java b/src/main/java/com/labelsys/backend/mapper/AnnotationResultHistoryMapper.java
new file mode 100644
index 0000000..203a3d1
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/AnnotationResultHistoryMapper.java
@@ -0,0 +1,7 @@
+package com.labelsys.backend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.AnnotationResultHistory;
+
+public interface AnnotationResultHistoryMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java b/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java
new file mode 100644
index 0000000..2fc55bf
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java
@@ -0,0 +1,17 @@
+package com.labelsys.backend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.AnnotationResult;
+import java.time.LocalDateTime;
+import org.apache.ibatis.annotations.Param;
+
+public interface AnnotationResultMapper extends BaseMapper {
+
+ AnnotationResult findActiveByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId);
+
+ int markArchived(@Param("id") Long id,
+ @Param("companyId") Long companyId,
+ @Param("reviewerId") Long reviewerId,
+ @Param("reviewComment") String reviewComment,
+ @Param("reviewedAt") LocalDateTime reviewedAt);
+}
diff --git a/src/main/java/com/labelsys/backend/mapper/AnnotationTaskMapper.java b/src/main/java/com/labelsys/backend/mapper/AnnotationTaskMapper.java
new file mode 100644
index 0000000..2be455b
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/AnnotationTaskMapper.java
@@ -0,0 +1,10 @@
+package com.labelsys.backend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.AnnotationTask;
+import org.apache.ibatis.annotations.Param;
+
+public interface AnnotationTaskMapper extends BaseMapper {
+
+ AnnotationTask findByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId);
+}
diff --git a/src/main/java/com/labelsys/backend/mapper/AnnotationTaskResourceMapper.java b/src/main/java/com/labelsys/backend/mapper/AnnotationTaskResourceMapper.java
new file mode 100644
index 0000000..07c05e8
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/AnnotationTaskResourceMapper.java
@@ -0,0 +1,15 @@
+package com.labelsys.backend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.AnnotationTaskResource;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface AnnotationTaskResourceMapper extends BaseMapper {
+
+ List listResourceIdsByTaskId(@Param("taskId") Long taskId);
+
+ int deleteByTaskId(@Param("taskId") Long taskId);
+
+ int countByResourceId(@Param("resourceId") Long resourceId);
+}
diff --git a/src/main/java/com/labelsys/backend/mapper/SourceResourceMapper.java b/src/main/java/com/labelsys/backend/mapper/SourceResourceMapper.java
new file mode 100644
index 0000000..0270e76
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/SourceResourceMapper.java
@@ -0,0 +1,11 @@
+package com.labelsys.backend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.SourceResource;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface SourceResourceMapper extends BaseMapper {
+
+ List selectByCompanyIdAndIds(@Param("companyId") Long companyId, @Param("resourceIds") List resourceIds);
+}
diff --git a/src/main/java/com/labelsys/backend/mapper/SysConfigMapper.java b/src/main/java/com/labelsys/backend/mapper/SysConfigMapper.java
new file mode 100644
index 0000000..2a81c2b
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/SysConfigMapper.java
@@ -0,0 +1,14 @@
+package com.labelsys.backend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.SysConfig;
+import org.apache.ibatis.annotations.Param;
+
+public interface SysConfigMapper extends BaseMapper {
+
+ SysConfig findByCompanyIdAndConfigName(@Param("companyId") Long companyId, @Param("configName") String configName);
+
+ SysConfig findByCompanyIdAndConfigNameAndType(@Param("companyId") Long companyId,
+ @Param("configName") String configName,
+ @Param("configType") String configType);
+}
diff --git a/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java b/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java
new file mode 100644
index 0000000..f18bbd1
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/scheduled/AutoArchiveAnnotationResultJob.java
@@ -0,0 +1,23 @@
+package com.labelsys.backend.scheduled;
+
+import com.labelsys.backend.service.AnnotationResultArchiveService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AutoArchiveAnnotationResultJob {
+
+ private final AnnotationResultArchiveService annotationResultArchiveService;
+
+ @Scheduled(fixedDelayString = "${labelsys.annotation.auto-archive-fixed-delay:300000}")
+ public void autoArchiveEligibleResults() {
+ int archivedCount = annotationResultArchiveService.autoArchiveEligibleResults();
+ if (archivedCount > 0) {
+ log.info("auto archived annotation results, count={}", archivedCount);
+ }
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java
new file mode 100644
index 0000000..d1a568d
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java
@@ -0,0 +1,140 @@
+package com.labelsys.backend.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.labelsys.backend.common.ResultCode;
+import com.labelsys.backend.common.exception.BusinessException;
+import com.labelsys.backend.context.LoginUser;
+import com.labelsys.backend.dto.request.MergeReviewResultRequest;
+import com.labelsys.backend.dto.response.MergeReviewResultResponse;
+import com.labelsys.backend.entity.AnnotationResult;
+import com.labelsys.backend.entity.AnnotationResultHistory;
+import com.labelsys.backend.enums.QaContentStorageMode;
+import com.labelsys.backend.enums.UserPosition;
+import com.labelsys.backend.mapper.AnnotationResultHistoryMapper;
+import com.labelsys.backend.mapper.AnnotationResultMapper;
+import com.labelsys.backend.util.IdGenerator;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnnotationResultArchiveService {
+
+ private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW";
+
+ private final AnnotationResultMapper annotationResultMapper;
+ private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
+ @Value("${labelsys.annotation.auto-archive-timeout:PT2H}")
+ private Duration autoArchiveTimeout;
+
+ @Transactional
+ public MergeReviewResultResponse mergeReview(LoginUser currentUser, Long resultId, MergeReviewResultRequest request) {
+ assertReviewer(currentUser);
+ AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId());
+ if (result == null) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "运行态结果不存在");
+ }
+
+ LocalDateTime archivedAt = LocalDateTime.now();
+ AnnotationResultHistory history = AnnotationResultHistory.builder()
+ .id(IdGenerator.nextId())
+ .companyId(result.getCompanyId())
+ .creatorId(result.getCreatorId())
+ .creatorRole(result.getCreatorRole())
+ .sourceResultId(result.getId())
+ .taskId(result.getTaskId())
+ .resourceId(result.getResourceId())
+ .qaContentJson(request.qaContentJson())
+ .qaContentStorageMode(resolveStorageMode(result))
+ .qaContentFilePath(result.getQaContentFilePath())
+ .archiveReason(MANUAL_ARCHIVE_REASON)
+ .archivedBy(currentUser.userId())
+ .archivedAt(archivedAt)
+ .build();
+ annotationResultHistoryMapper.insert(history);
+
+ int updated = annotationResultMapper.markArchived(
+ result.getId(),
+ currentUser.companyId(),
+ currentUser.userId(),
+ request.reviewComment(),
+ archivedAt);
+ if (updated == 0) {
+ throw new BusinessException(ResultCode.CONFLICT, "结果已被其他操作处理");
+ }
+
+ log.info("merged review result, companyId={}, reviewerId={}, resultId={}, historyId={}",
+ currentUser.companyId(), currentUser.userId(), resultId, history.getId());
+ return new MergeReviewResultResponse(resultId, history.getId(), MANUAL_ARCHIVE_REASON, archivedAt);
+ }
+
+ @Transactional
+ public int autoArchiveEligibleResults() {
+ LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout);
+ List results = annotationResultMapper.selectList(new LambdaQueryWrapper()
+ .eq(AnnotationResult::getIsDeleted, false)
+ .eq(AnnotationResult::getRequiresManualReview, false)
+ .lt(AnnotationResult::getCreatedAt, cutoff));
+ int archivedCount = 0;
+ for (AnnotationResult result : results) {
+ if (archiveRuntimeResult(result, null, "AUTO_ARCHIVE", null) != null) {
+ archivedCount++;
+ }
+ }
+ return archivedCount;
+ }
+
+ private void assertReviewer(LoginUser currentUser) {
+ if (currentUser.position() != UserPosition.REVIEWER && currentUser.position() != UserPosition.ADMIN) {
+ throw new BusinessException(ResultCode.FORBIDDEN, "当前用户没有审核权限");
+ }
+ }
+
+ private String resolveStorageMode(AnnotationResult result) {
+ if (QaContentStorageMode.isValid(result.getQaContentStorageMode())) {
+ return result.getQaContentStorageMode();
+ }
+ return QaContentStorageMode.INLINE.name();
+ }
+
+ private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result,
+ Long reviewerId,
+ String archiveReason,
+ String reviewComment) {
+ LocalDateTime archivedAt = LocalDateTime.now();
+ AnnotationResultHistory history = AnnotationResultHistory.builder()
+ .id(IdGenerator.nextId())
+ .companyId(result.getCompanyId())
+ .creatorId(result.getCreatorId())
+ .creatorRole(result.getCreatorRole())
+ .sourceResultId(result.getId())
+ .taskId(result.getTaskId())
+ .resourceId(result.getResourceId())
+ .qaContentJson(result.getQaContentJson())
+ .qaContentStorageMode(resolveStorageMode(result))
+ .qaContentFilePath(result.getQaContentFilePath())
+ .archiveReason(archiveReason)
+ .archivedBy(reviewerId)
+ .archivedAt(archivedAt)
+ .build();
+ annotationResultHistoryMapper.insert(history);
+
+ int updated = annotationResultMapper.markArchived(
+ result.getId(),
+ result.getCompanyId(),
+ reviewerId,
+ reviewComment,
+ archivedAt);
+ if (updated == 0) {
+ return null;
+ }
+ return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt);
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java
new file mode 100644
index 0000000..195ef85
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java
@@ -0,0 +1,99 @@
+package com.labelsys.backend.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.labelsys.backend.common.ResultCode;
+import com.labelsys.backend.common.exception.BusinessException;
+import com.labelsys.backend.context.LoginUser;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.AnnotationResultPageQuery;
+import com.labelsys.backend.dto.response.AnnotationResultCompareResponse;
+import com.labelsys.backend.dto.response.AnnotationResultResponse;
+import com.labelsys.backend.entity.AnnotationResult;
+import com.labelsys.backend.entity.SourceResource;
+import com.labelsys.backend.enums.RuntimeResultStatus;
+import com.labelsys.backend.mapper.AnnotationResultMapper;
+import com.labelsys.backend.mapper.SourceResourceMapper;
+import java.util.Comparator;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class AnnotationResultService {
+
+ private final AnnotationResultMapper annotationResultMapper;
+ private final SourceResourceMapper sourceResourceMapper;
+
+ public PageResult pageResults(LoginUser currentUser, AnnotationResultPageQuery query) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(AnnotationResult::getCompanyId, currentUser.companyId())
+ .eq(query.taskId() != null, AnnotationResult::getTaskId, query.taskId())
+ .eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId())
+ .eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview, query.requiresManualReview())
+ .orderByDesc(AnnotationResult::getCreatedAt);
+ List records = annotationResultMapper.selectList(wrapper).stream()
+ .map(this::toResponse)
+ .filter(response -> query.runtimeStatus() == null || query.runtimeStatus().equals(response.runtimeStatus()))
+ .sorted(Comparator.comparing(AnnotationResultResponse::createdAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed())
+ .toList();
+ return paginate(records, query.pageNo(), query.pageSize());
+ }
+
+ public AnnotationResultResponse getResult(LoginUser currentUser, Long resultId) {
+ AnnotationResult result = annotationResultMapper.selectById(resultId);
+ if (result == null || !currentUser.companyId().equals(result.getCompanyId())) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在");
+ }
+ return toResponse(result);
+ }
+
+ public AnnotationResultCompareResponse compareResult(LoginUser currentUser, Long resultId) {
+ AnnotationResult result = annotationResultMapper.selectById(resultId);
+ if (result == null || !currentUser.companyId().equals(result.getCompanyId())) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在");
+ }
+ SourceResource resource = sourceResourceMapper.selectById(result.getResourceId());
+ return new AnnotationResultCompareResponse(
+ result.getId(),
+ result.getTaskId(),
+ result.getResourceId(),
+ result.getQaContentJson(),
+ result.getDiffSummary(),
+ result.getQaContentStorageMode(),
+ result.getQaContentFilePath(),
+ resource == null ? null : resource.getFilePath());
+ }
+
+ private AnnotationResultResponse toResponse(AnnotationResult result) {
+ return new AnnotationResultResponse(
+ result.getId(),
+ result.getTaskId(),
+ result.getResourceId(),
+ deriveStatus(result),
+ result.getRequiresManualReview(),
+ result.getIsDeleted(),
+ result.getQaContentStorageMode(),
+ result.getReviewComment(),
+ result.getReviewedAt(),
+ result.getCreatedAt());
+ }
+
+ private String deriveStatus(AnnotationResult result) {
+ if (Boolean.TRUE.equals(result.getIsDeleted())) {
+ return RuntimeResultStatus.ARCHIVED.name();
+ }
+ if (Boolean.TRUE.equals(result.getRequiresManualReview())) {
+ return RuntimeResultStatus.MANUAL_REVIEW_PENDING.name();
+ }
+ return RuntimeResultStatus.AUTO_ARCHIVE_PENDING.name();
+ }
+
+ private PageResult paginate(List records, Integer pageNo, Integer pageSize) {
+ int actualPageNo = pageNo == null || pageNo < 1 ? 1 : pageNo;
+ int actualPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
+ int fromIndex = Math.min((actualPageNo - 1) * actualPageSize, records.size());
+ int toIndex = Math.min(fromIndex + actualPageSize, records.size());
+ return new PageResult<>(records.subList(fromIndex, toIndex), (long) records.size(), actualPageNo, actualPageSize);
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java b/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java
new file mode 100644
index 0000000..9773109
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java
@@ -0,0 +1,284 @@
+package com.labelsys.backend.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.labelsys.backend.common.ResultCode;
+import com.labelsys.backend.common.exception.BusinessException;
+import com.labelsys.backend.context.LoginUser;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.AnnotationTaskPageQuery;
+import com.labelsys.backend.dto.request.CreateAnnotationTaskRequest;
+import com.labelsys.backend.dto.request.UpdateAnnotationTaskRequest;
+import com.labelsys.backend.dto.response.AnnotationTaskResponse;
+import com.labelsys.backend.dto.response.TaskModelConfigResponse;
+import com.labelsys.backend.dto.response.TaskPromptConfigResponse;
+import com.labelsys.backend.entity.AnnotationTask;
+import com.labelsys.backend.entity.AnnotationTaskResource;
+import com.labelsys.backend.entity.SourceResource;
+import com.labelsys.backend.enums.SourceStatus;
+import com.labelsys.backend.enums.TaskStatus;
+import com.labelsys.backend.mapper.AnnotationTaskMapper;
+import com.labelsys.backend.mapper.AnnotationTaskResourceMapper;
+import com.labelsys.backend.mapper.SourceResourceMapper;
+import com.labelsys.backend.util.IdGenerator;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnnotationTaskService {
+
+ private final AnnotationTaskMapper annotationTaskMapper;
+ private final AnnotationTaskResourceMapper annotationTaskResourceMapper;
+ private final SourceResourceMapper sourceResourceMapper;
+ private final SysConfigService sysConfigService;
+ private final DataPermissionService dataPermissionService;
+
+ @Transactional
+ public AnnotationTaskResponse createTask(LoginUser currentUser, CreateAnnotationTaskRequest request) {
+ List resources = loadAndValidateResources(currentUser, request.resourceIds());
+ SysConfigService.ResolvedModelConfig extractModel = sysConfigService.resolveModelConfig(currentUser, request.extractModel());
+ SysConfigService.ResolvedModelConfig verifyModel = sysConfigService.resolveModelConfig(currentUser, request.verifyModel());
+ SysConfigService.ResolvedPromptConfig extractPrompt = sysConfigService.resolvePromptConfig(currentUser, request.extractPrompt());
+ SysConfigService.ResolvedPromptConfig verifyPrompt = sysConfigService.resolvePromptConfig(currentUser, request.verifyPrompt());
+
+ AnnotationTask task = AnnotationTask.builder()
+ .id(IdGenerator.nextId())
+ .companyId(currentUser.companyId())
+ .creatorId(currentUser.userId())
+ .creatorRole(currentUser.role())
+ .taskName(request.taskName())
+ .industryType(defaultIndustryType(request.industryType()))
+ .taskType(defaultTaskType(request.taskType()))
+ .extractModelConfigId(extractModel.configId())
+ .extractModelName(extractModel.modelName())
+ .extractModelUrl(extractModel.modelUrl())
+ .extractModelApiKey(extractModel.apiKey())
+ .verifyModelConfigId(verifyModel.configId())
+ .verifyModelName(verifyModel.modelName())
+ .verifyModelUrl(verifyModel.modelUrl())
+ .verifyModelApiKey(verifyModel.apiKey())
+ .extractPromptConfigId(extractPrompt.configId())
+ .extractPrompt(extractPrompt.promptText())
+ .verifyPromptConfigId(verifyPrompt.configId())
+ .verifyPrompt(verifyPrompt.promptText())
+ .taskStatus(TaskStatus.PENDING.name())
+ .isDeleted(false)
+ .build();
+ annotationTaskMapper.insert(task);
+ saveTaskBindings(task.getId(), currentUser.companyId(), resources);
+ log.info("created annotation task, companyId={}, userId={}, taskId={}, resourceCount={}",
+ currentUser.companyId(), currentUser.userId(), task.getId(), resources.size());
+ return buildTaskResponse(task, resourceIds(resources), extractModel, verifyModel, extractPrompt, verifyPrompt);
+ }
+
+ @Transactional
+ public AnnotationTaskResponse updateTask(LoginUser currentUser, Long taskId, UpdateAnnotationTaskRequest request) {
+ AnnotationTask task = annotationTaskMapper.findByIdAndCompanyId(taskId, currentUser.companyId());
+ if (task == null) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在");
+ }
+ assertTaskPermission(currentUser, task);
+
+ List currentResourceIds = normalizeIds(annotationTaskResourceMapper.listResourceIdsByTaskId(taskId));
+ List targetResourceIds = normalizeIds(request.resourceIds());
+ boolean resourcesChanged = !currentResourceIds.equals(targetResourceIds);
+ if (TaskStatus.RUNNING.name().equals(task.getTaskStatus()) && resourcesChanged) {
+ throw new BusinessException(ResultCode.CONFLICT, "运行中的任务不允许修改资源");
+ }
+
+ List resources = loadAndValidateResources(currentUser, request.resourceIds());
+ SysConfigService.ResolvedModelConfig extractModel = sysConfigService.resolveModelConfig(currentUser, request.extractModel());
+ SysConfigService.ResolvedModelConfig verifyModel = sysConfigService.resolveModelConfig(currentUser, request.verifyModel());
+ SysConfigService.ResolvedPromptConfig extractPrompt = sysConfigService.resolvePromptConfig(currentUser, request.extractPrompt());
+ SysConfigService.ResolvedPromptConfig verifyPrompt = sysConfigService.resolvePromptConfig(currentUser, request.verifyPrompt());
+
+ task.setIndustryType(defaultIndustryType(request.industryType()));
+ task.setTaskType(defaultTaskType(request.taskType()));
+ task.setExtractModelConfigId(extractModel.configId());
+ task.setExtractModelName(extractModel.modelName());
+ task.setExtractModelUrl(extractModel.modelUrl());
+ task.setExtractModelApiKey(extractModel.apiKey());
+ task.setVerifyModelConfigId(verifyModel.configId());
+ task.setVerifyModelName(verifyModel.modelName());
+ task.setVerifyModelUrl(verifyModel.modelUrl());
+ task.setVerifyModelApiKey(verifyModel.apiKey());
+ task.setExtractPromptConfigId(extractPrompt.configId());
+ task.setExtractPrompt(extractPrompt.promptText());
+ task.setVerifyPromptConfigId(verifyPrompt.configId());
+ task.setVerifyPrompt(verifyPrompt.promptText());
+ annotationTaskMapper.updateById(task);
+
+ if (resourcesChanged) {
+ annotationTaskResourceMapper.deleteByTaskId(taskId);
+ saveTaskBindings(taskId, currentUser.companyId(), resources);
+ }
+ log.info("updated annotation task, companyId={}, userId={}, taskId={}, resourcesChanged={}",
+ currentUser.companyId(), currentUser.userId(), taskId, resourcesChanged);
+ return buildTaskResponse(task, resourceIds(resources), extractModel, verifyModel, extractPrompt, verifyPrompt);
+ }
+
+ public AnnotationTaskResponse getTask(LoginUser currentUser, Long taskId) {
+ AnnotationTask task = annotationTaskMapper.findByIdAndCompanyId(taskId, currentUser.companyId());
+ if (task == null) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在");
+ }
+ assertTaskPermission(currentUser, task);
+ return buildTaskResponse(task, normalizeIds(annotationTaskResourceMapper.listResourceIdsByTaskId(taskId)));
+ }
+
+ public PageResult pageTasks(LoginUser currentUser, AnnotationTaskPageQuery query) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(AnnotationTask::getCompanyId, currentUser.companyId())
+ .eq(StringUtils.hasText(query.taskType()), AnnotationTask::getTaskType, query.taskType())
+ .eq(StringUtils.hasText(query.taskStatus()), AnnotationTask::getTaskStatus, query.taskStatus())
+ .eq(query.isDeleted() != null, AnnotationTask::getIsDeleted, query.isDeleted())
+ .like(StringUtils.hasText(query.keyword()), AnnotationTask::getTaskName, query.keyword())
+ .orderByDesc(AnnotationTask::getCreatedAt);
+ List records = annotationTaskMapper.selectList(wrapper).stream()
+ .filter(task -> dataPermissionService.canAccessCreator(currentUser, task.getCreatorId(), task.getCreatorRole()))
+ .filter(task -> query.resourceId() == null || annotationTaskResourceMapper.listResourceIdsByTaskId(task.getId()).contains(query.resourceId()))
+ .sorted(Comparator.comparing(AnnotationTask::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed())
+ .map(task -> buildTaskResponse(task, normalizeIds(annotationTaskResourceMapper.listResourceIdsByTaskId(task.getId()))))
+ .toList();
+ return paginate(records, query.pageNo(), query.pageSize());
+ }
+
+ @Transactional
+ public void deleteTask(LoginUser currentUser, Long taskId) {
+ AnnotationTask task = annotationTaskMapper.findByIdAndCompanyId(taskId, currentUser.companyId());
+ if (task == null) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在");
+ }
+ assertTaskPermission(currentUser, task);
+ if (TaskStatus.RUNNING.name().equals(task.getTaskStatus())) {
+ throw new BusinessException(ResultCode.CONFLICT, "运行中的任务不允许删除");
+ }
+ task.setIsDeleted(true);
+ annotationTaskMapper.updateById(task);
+ log.info("deleted annotation task logically, companyId={}, userId={}, taskId={}",
+ currentUser.companyId(), currentUser.userId(), taskId);
+ }
+
+ private List loadAndValidateResources(LoginUser currentUser, List resourceIds) {
+ if (resourceIds == null || resourceIds.isEmpty()) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "任务资源不能为空");
+ }
+ List normalizedIds = normalizeIds(resourceIds);
+ List resources = sourceResourceMapper.selectByCompanyIdAndIds(currentUser.companyId(), normalizedIds);
+ if (resources.size() != normalizedIds.size()) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "存在无效资源");
+ }
+ for (SourceResource resource : resources) {
+ if (!dataPermissionService.canAccessCreator(currentUser, resource.getCreatorId(), resource.getCreatorRole())) {
+ throw new BusinessException(ResultCode.FORBIDDEN, "无权访问资源");
+ }
+ if (!SourceStatus.READY.name().equals(resource.getSourceStatus())) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "仅允许选择已就绪资源");
+ }
+ }
+ resources.sort(Comparator.comparing(SourceResource::getId));
+ return resources;
+ }
+
+ private void saveTaskBindings(Long taskId, Long companyId, List resources) {
+ for (SourceResource resource : resources) {
+ annotationTaskResourceMapper.insert(AnnotationTaskResource.builder()
+ .id(IdGenerator.nextId())
+ .companyId(companyId)
+ .taskId(taskId)
+ .resourceId(resource.getId())
+ .build());
+ }
+ }
+
+ private AnnotationTaskResponse buildTaskResponse(AnnotationTask task,
+ List resourceIds,
+ SysConfigService.ResolvedModelConfig extractModel,
+ SysConfigService.ResolvedModelConfig verifyModel,
+ SysConfigService.ResolvedPromptConfig extractPrompt,
+ SysConfigService.ResolvedPromptConfig verifyPrompt) {
+ return new AnnotationTaskResponse(
+ task.getId(),
+ task.getTaskName(),
+ task.getIndustryType(),
+ task.getTaskType(),
+ task.getTaskStatus(),
+ resourceIds,
+ sysConfigService.toResponse(extractModel),
+ sysConfigService.toResponse(verifyModel),
+ sysConfigService.toResponse(extractPrompt),
+ sysConfigService.toResponse(verifyPrompt),
+ task.getCreatedAt(),
+ task.getUpdatedAt());
+ }
+
+ private AnnotationTaskResponse buildTaskResponse(AnnotationTask task, List resourceIds) {
+ return new AnnotationTaskResponse(
+ task.getId(),
+ task.getTaskName(),
+ task.getIndustryType(),
+ task.getTaskType(),
+ task.getTaskStatus(),
+ resourceIds,
+ new TaskModelConfigResponse(task.getExtractModelConfigId(), null, task.getExtractModelName(),
+ task.getExtractModelUrl(), maskSecret(task.getExtractModelApiKey())),
+ new TaskModelConfigResponse(task.getVerifyModelConfigId(), null, task.getVerifyModelName(),
+ task.getVerifyModelUrl(), maskSecret(task.getVerifyModelApiKey())),
+ new TaskPromptConfigResponse(task.getExtractPromptConfigId(), null, task.getExtractPrompt()),
+ new TaskPromptConfigResponse(task.getVerifyPromptConfigId(), null, task.getVerifyPrompt()),
+ task.getCreatedAt(),
+ task.getUpdatedAt());
+ }
+
+ private List resourceIds(List resources) {
+ return resources.stream().map(SourceResource::getId).sorted().toList();
+ }
+
+ private List normalizeIds(List resourceIds) {
+ Set uniqueIds = new HashSet<>(resourceIds);
+ List sortedIds = new ArrayList<>(uniqueIds);
+ sortedIds.sort(Long::compareTo);
+ return sortedIds;
+ }
+
+ private void assertTaskPermission(LoginUser currentUser, AnnotationTask task) {
+ if (!dataPermissionService.canAccessCreator(currentUser, task.getCreatorId(), task.getCreatorRole())) {
+ throw new BusinessException(ResultCode.FORBIDDEN, "无权操作任务");
+ }
+ }
+
+ private String defaultIndustryType(String industryType) {
+ return StringUtils.hasText(industryType) ? industryType : "transport";
+ }
+
+ private String defaultTaskType(String taskType) {
+ return StringUtils.hasText(taskType) ? taskType : "EXTRACT_QA";
+ }
+
+ private String maskSecret(String secret) {
+ if (!StringUtils.hasText(secret)) {
+ return null;
+ }
+ if (secret.length() <= 4) {
+ return "****";
+ }
+ return "****" + secret.substring(secret.length() - 4);
+ }
+
+ private PageResult paginate(List records, Integer pageNo, Integer pageSize) {
+ int actualPageNo = pageNo == null || pageNo < 1 ? 1 : pageNo;
+ int actualPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
+ int fromIndex = Math.min((actualPageNo - 1) * actualPageSize, records.size());
+ int toIndex = Math.min(fromIndex + actualPageSize, records.size());
+ return new PageResult<>(records.subList(fromIndex, toIndex), (long) records.size(), actualPageNo, actualPageSize);
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/DataPermissionService.java b/src/main/java/com/labelsys/backend/service/DataPermissionService.java
index d674f8f..43c3c3a 100644
--- a/src/main/java/com/labelsys/backend/service/DataPermissionService.java
+++ b/src/main/java/com/labelsys/backend/service/DataPermissionService.java
@@ -1,15 +1,21 @@
package com.labelsys.backend.service;
import com.labelsys.backend.context.LoginUser;
+import com.labelsys.backend.entity.BizDataRecord;
import com.labelsys.backend.enums.UserRole;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.jdbc.core.JdbcTemplate;
@Service
+@RequiredArgsConstructor
public class DataPermissionService {
+ private final JdbcTemplate jdbcTemplate;
+
public boolean canAccessCreator(LoginUser currentUser, Long creatorId, UserRole creatorRole) {
return switch (currentUser.role()) {
case EMPLOYEE -> currentUser.userId().equals(creatorId);
@@ -65,4 +71,25 @@ public class DataPermissionService {
public boolean shouldFilterByUserId(LoginUser currentUser) {
return currentUser.role() == UserRole.EMPLOYEE;
}
+
+ public List listVisibleRecords(LoginUser currentUser) {
+ List allRecords = jdbcTemplate.query("""
+ select id, company_id, creator_id, creator_role, record_name, created_at, updated_at
+ from biz_data_record
+ where company_id = ?
+ order by id
+ """,
+ (rs, rowNum) -> BizDataRecord.builder()
+ .id(rs.getLong("id"))
+ .companyId(rs.getLong("company_id"))
+ .creatorId(rs.getLong("creator_id"))
+ .creatorRole(UserRole.valueOf(rs.getString("creator_role")))
+ .recordName(rs.getString("record_name"))
+ .createdAt(rs.getTimestamp("created_at") == null ? null : rs.getTimestamp("created_at").toLocalDateTime())
+ .updatedAt(rs.getTimestamp("updated_at") == null ? null : rs.getTimestamp("updated_at").toLocalDateTime())
+ .build(),
+ currentUser.companyId());
+
+ return filterByRole(currentUser, allRecords, BizDataRecord::getCreatorRole, BizDataRecord::getCreatorId);
+ }
}
diff --git a/src/main/java/com/labelsys/backend/service/ObjectStorageService.java b/src/main/java/com/labelsys/backend/service/ObjectStorageService.java
new file mode 100644
index 0000000..b043112
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/ObjectStorageService.java
@@ -0,0 +1,8 @@
+package com.labelsys.backend.service;
+
+public interface ObjectStorageService {
+
+ String upload(String bucketName, String objectKey, byte[] content, String contentType);
+
+ void delete(String bucketName, String objectKey);
+}
diff --git a/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java b/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java
new file mode 100644
index 0000000..0dd9365
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java
@@ -0,0 +1,45 @@
+package com.labelsys.backend.service;
+
+import com.labelsys.backend.common.ResultCode;
+import com.labelsys.backend.common.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+
+@Service
+@RequiredArgsConstructor
+public class RustfsObjectStorageService implements ObjectStorageService {
+
+ private final S3Client s3Client;
+
+ @Override
+ public String upload(String bucketName, String objectKey, byte[] content, String contentType) {
+ try {
+ PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
+ .bucket(bucketName)
+ .key(objectKey);
+ if (contentType != null && !contentType.isBlank()) {
+ requestBuilder.contentType(contentType);
+ }
+ s3Client.putObject(requestBuilder.build(), RequestBody.fromBytes(content));
+ return objectKey;
+ } catch (Exception ex) {
+ throw new BusinessException(ResultCode.ERROR, "对象存储上传失败");
+ }
+ }
+
+ @Override
+ public void delete(String bucketName, String objectKey) {
+ try {
+ s3Client.deleteObject(DeleteObjectRequest.builder()
+ .bucket(bucketName)
+ .key(objectKey)
+ .build());
+ } catch (Exception ex) {
+ throw new BusinessException(ResultCode.ERROR, "对象存储删除失败");
+ }
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/SourceResourceService.java b/src/main/java/com/labelsys/backend/service/SourceResourceService.java
new file mode 100644
index 0000000..cfae1d8
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/SourceResourceService.java
@@ -0,0 +1,179 @@
+package com.labelsys.backend.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.labelsys.backend.common.ResultCode;
+import com.labelsys.backend.common.exception.BusinessException;
+import com.labelsys.backend.context.LoginUser;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.SourceResourcePageQuery;
+import com.labelsys.backend.dto.request.SourceUploadRequest;
+import com.labelsys.backend.dto.response.SourceResourceResponse;
+import com.labelsys.backend.dto.response.SourceUploadResponse;
+import com.labelsys.backend.entity.SourceResource;
+import com.labelsys.backend.entity.SysUser;
+import com.labelsys.backend.enums.ResourceType;
+import com.labelsys.backend.enums.SourceStatus;
+import com.labelsys.backend.mapper.AnnotationTaskResourceMapper;
+import com.labelsys.backend.mapper.SourceResourceMapper;
+import com.labelsys.backend.mapper.SysUserMapper;
+import com.labelsys.backend.util.IdGenerator;
+import com.labelsys.backend.util.ObjectStoragePathBuilder;
+import java.io.IOException;
+import java.util.Comparator;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SourceResourceService {
+
+ private final SourceResourceMapper sourceResourceMapper;
+ private final AnnotationTaskResourceMapper annotationTaskResourceMapper;
+ private final SysUserMapper sysUserMapper;
+ private final DataPermissionService dataPermissionService;
+ private final ObjectStorageService objectStorageService;
+ private final com.labelsys.backend.config.ObjectStorageProperties objectStorageProperties;
+
+ @Transactional
+ public SourceUploadResponse upload(LoginUser currentUser, SourceUploadRequest request) {
+ MultipartFile file = request.getFile();
+ if (file == null || file.isEmpty()) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "上传文件不能为空");
+ }
+ if (!ResourceType.isValid(request.getResourceType())) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "资源类型非法");
+ }
+ long resourceId = IdGenerator.nextId();
+ String extension = resolveExtension(file.getOriginalFilename(), request.getResourceType());
+ String objectKey = ObjectStoragePathBuilder.sourceObjectKey(
+ currentUser.companyId(), request.getResourceType(), resourceId, extension);
+ try {
+ objectStorageService.upload(
+ objectStorageProperties.getSourceBucket(),
+ objectKey,
+ file.getBytes(),
+ file.getContentType());
+ } catch (IOException ex) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "读取上传文件失败");
+ }
+
+ SourceResource resource = SourceResource.builder()
+ .id(resourceId)
+ .companyId(currentUser.companyId())
+ .creatorId(currentUser.userId())
+ .creatorRole(currentUser.role())
+ .resourceName(StringUtils.hasText(request.getResourceName()) ? request.getResourceName() : file.getOriginalFilename())
+ .resourceType(request.getResourceType())
+ .bucketName(objectStorageProperties.getSourceBucket())
+ .filePath(objectKey)
+ .fileSize(file.getSize())
+ .sourceStatus(SourceStatus.READY.name())
+ .storageProvider("rustfs")
+ .remark(request.getRemark())
+ .build();
+ sourceResourceMapper.insert(resource);
+ log.info("uploaded source resource, companyId={}, userId={}, resourceId={}",
+ currentUser.companyId(), currentUser.userId(), resourceId);
+ return new SourceUploadResponse(
+ resource.getId(),
+ resource.getResourceName(),
+ resource.getResourceType(),
+ resource.getBucketName(),
+ resource.getFilePath(),
+ resource.getFileSize(),
+ resource.getSourceStatus(),
+ resource.getCreatedAt());
+ }
+
+ public PageResult pageResources(LoginUser currentUser, SourceResourcePageQuery query) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(SourceResource::getCompanyId, currentUser.companyId())
+ .eq(StringUtils.hasText(query.resourceType()), SourceResource::getResourceType, query.resourceType())
+ .eq(StringUtils.hasText(query.sourceStatus()), SourceResource::getSourceStatus, query.sourceStatus())
+ .like(StringUtils.hasText(query.keyword()), SourceResource::getResourceName, query.keyword())
+ .orderByDesc(SourceResource::getCreatedAt);
+ List records = sourceResourceMapper.selectList(wrapper).stream()
+ .filter(resource -> dataPermissionService.canAccessCreator(currentUser, resource.getCreatorId(), resource.getCreatorRole()))
+ .sorted(Comparator.comparing(SourceResource::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed())
+ .map(this::toResponse)
+ .toList();
+ return paginate(records, query.pageNo(), query.pageSize());
+ }
+
+ public SourceResourceResponse getResource(LoginUser currentUser, Long resourceId) {
+ SourceResource resource = sourceResourceMapper.selectById(resourceId);
+ if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在");
+ }
+ if (!dataPermissionService.canAccessCreator(currentUser, resource.getCreatorId(), resource.getCreatorRole())) {
+ throw new BusinessException(ResultCode.FORBIDDEN, "无权访问资源");
+ }
+ return toResponse(resource);
+ }
+
+ @Transactional
+ public void deleteResource(LoginUser currentUser, Long resourceId) {
+ SourceResource resource = sourceResourceMapper.selectById(resourceId);
+ if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在");
+ }
+ if (!dataPermissionService.canAccessCreator(currentUser, resource.getCreatorId(), resource.getCreatorRole())) {
+ throw new BusinessException(ResultCode.FORBIDDEN, "无权删除资源");
+ }
+ int bindCount = annotationTaskResourceMapper.countByResourceId(resourceId);
+ if (bindCount > 0) {
+ resource.setSourceStatus(SourceStatus.ARCHIVED.name());
+ sourceResourceMapper.updateById(resource);
+ log.info("archived referenced source resource, companyId={}, userId={}, resourceId={}",
+ currentUser.companyId(), currentUser.userId(), resourceId);
+ return;
+ }
+ objectStorageService.delete(resource.getBucketName(), resource.getFilePath());
+ sourceResourceMapper.deleteById(resourceId);
+ log.info("deleted source resource, companyId={}, userId={}, resourceId={}",
+ currentUser.companyId(), currentUser.userId(), resourceId);
+ }
+
+ private SourceResourceResponse toResponse(SourceResource resource) {
+ SysUser creator = sysUserMapper.selectById(resource.getCreatorId());
+ return new SourceResourceResponse(
+ resource.getId(),
+ resource.getResourceName(),
+ resource.getResourceType(),
+ resource.getBucketName(),
+ resource.getFilePath(),
+ resource.getFileSize(),
+ resource.getSourceStatus(),
+ resource.getStorageProvider(),
+ resource.getRemark(),
+ creator == null ? null : creator.getRealName(),
+ resource.getCreatedAt(),
+ resource.getUpdatedAt());
+ }
+
+ private String resolveExtension(String originalFilename, String resourceType) {
+ if (StringUtils.hasText(originalFilename) && originalFilename.contains(".")) {
+ return originalFilename.substring(originalFilename.lastIndexOf('.') + 1);
+ }
+ return switch (resourceType) {
+ case "TEXT" -> "txt";
+ case "IMAGE" -> "png";
+ case "VIDEO" -> "mp4";
+ default -> "bin";
+ };
+ }
+
+ private PageResult paginate(List records, Integer pageNo, Integer pageSize) {
+ int actualPageNo = pageNo == null || pageNo < 1 ? 1 : pageNo;
+ int actualPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
+ int fromIndex = Math.min((actualPageNo - 1) * actualPageSize, records.size());
+ int toIndex = Math.min(fromIndex + actualPageSize, records.size());
+ return new PageResult<>(records.subList(fromIndex, toIndex), (long) records.size(), actualPageNo, actualPageSize);
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/SysConfigService.java b/src/main/java/com/labelsys/backend/service/SysConfigService.java
new file mode 100644
index 0000000..0979969
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/SysConfigService.java
@@ -0,0 +1,258 @@
+package com.labelsys.backend.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.labelsys.backend.common.ResultCode;
+import com.labelsys.backend.common.exception.BusinessException;
+import com.labelsys.backend.context.LoginUser;
+import com.labelsys.backend.dto.common.PageResult;
+import com.labelsys.backend.dto.request.ManualModelConfigRequest;
+import com.labelsys.backend.dto.request.PromptConfigOptionRequest;
+import com.labelsys.backend.dto.request.SaveSysConfigRequest;
+import com.labelsys.backend.dto.request.SysConfigPageQuery;
+import com.labelsys.backend.dto.request.TaskModelConfigRequest;
+import com.labelsys.backend.dto.response.SysConfigResponse;
+import com.labelsys.backend.dto.response.TaskModelConfigResponse;
+import com.labelsys.backend.dto.response.TaskPromptConfigResponse;
+import com.labelsys.backend.entity.SysConfig;
+import com.labelsys.backend.enums.ConfigType;
+import com.labelsys.backend.mapper.SysConfigMapper;
+import com.labelsys.backend.util.IdGenerator;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SysConfigService {
+
+ private final SysConfigMapper sysConfigMapper;
+ private final ObjectMapper objectMapper;
+
+ @Transactional
+ public SysConfig saveConfig(LoginUser currentUser, SaveSysConfigRequest request) {
+ validateConfigType(request.configType());
+ SysConfig existing = sysConfigMapper.findByCompanyIdAndConfigName(currentUser.companyId(), request.configName());
+ if (existing != null) {
+ throw new BusinessException(ResultCode.CONFLICT, "配置名称已存在");
+ }
+ SysConfig config = SysConfig.builder()
+ .id(IdGenerator.nextId())
+ .companyId(currentUser.companyId())
+ .configType(request.configType())
+ .configName(request.configName())
+ .configValue(request.configValue())
+ .status(request.status())
+ .creatorId(currentUser.userId())
+ .build();
+ sysConfigMapper.insert(config);
+ log.info("saved sys config, companyId={}, userId={}, configName={}, configType={}",
+ currentUser.companyId(), currentUser.userId(), request.configName(), request.configType());
+ return config;
+ }
+
+ @Transactional
+ public SysConfig updateConfig(LoginUser currentUser, Long configId, SaveSysConfigRequest request) {
+ validateConfigType(request.configType());
+ SysConfig existing = getConfigEntity(currentUser, configId);
+ SysConfig duplicate = sysConfigMapper.findByCompanyIdAndConfigName(currentUser.companyId(), request.configName());
+ if (duplicate != null && !duplicate.getId().equals(configId)) {
+ throw new BusinessException(ResultCode.CONFLICT, "配置名称已存在");
+ }
+ existing.setConfigType(request.configType());
+ existing.setConfigName(request.configName());
+ existing.setConfigValue(request.configValue());
+ existing.setStatus(request.status());
+ sysConfigMapper.updateById(existing);
+ log.info("updated sys config, companyId={}, userId={}, configId={}",
+ currentUser.companyId(), currentUser.userId(), configId);
+ return existing;
+ }
+
+ public SysConfigResponse getConfig(LoginUser currentUser, Long configId) {
+ return toResponse(getConfigEntity(currentUser, configId));
+ }
+
+ public PageResult pageConfigs(LoginUser currentUser, SysConfigPageQuery query) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(SysConfig::getCompanyId, currentUser.companyId())
+ .eq(StringUtils.hasText(query.configType()), SysConfig::getConfigType, query.configType())
+ .eq(StringUtils.hasText(query.status()), SysConfig::getStatus, query.status())
+ .like(StringUtils.hasText(query.configName()), SysConfig::getConfigName, query.configName())
+ .orderByDesc(SysConfig::getCreatedAt);
+ List records = sysConfigMapper.selectList(wrapper).stream()
+ .sorted(Comparator.comparing(SysConfig::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed())
+ .map(this::toResponse)
+ .toList();
+ return paginate(records, query.pageNo(), query.pageSize());
+ }
+
+ @Transactional
+ public ResolvedModelConfig resolveModelConfig(LoginUser currentUser, TaskModelConfigRequest request) {
+ if (request == null || !StringUtils.hasText(request.mode())) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "模型配置不能为空");
+ }
+ if ("SELECT".equalsIgnoreCase(request.mode())) {
+ return resolveSelectedModel(currentUser, request.selectedConfigName());
+ }
+ if ("MANUAL".equalsIgnoreCase(request.mode())) {
+ return resolveManualModel(currentUser, request.manualConfig());
+ }
+ throw new BusinessException(ResultCode.BAD_REQUEST, "不支持的模型配置模式");
+ }
+
+ public ResolvedPromptConfig resolvePromptConfig(LoginUser currentUser, PromptConfigOptionRequest request) {
+ if (request == null) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "提示词配置不能为空");
+ }
+ if (StringUtils.hasText(request.selectedConfigName())) {
+ SysConfig config = sysConfigMapper.findByCompanyIdAndConfigNameAndType(
+ currentUser.companyId(), request.selectedConfigName(), ConfigType.PROMPT.name());
+ if (config == null) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "提示词配置不存在");
+ }
+ return new ResolvedPromptConfig(config.getId(), config.getConfigName(), config.getConfigValue());
+ }
+ if (!StringUtils.hasText(request.promptText())) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "提示词内容不能为空");
+ }
+ return new ResolvedPromptConfig(null, null, request.promptText());
+ }
+
+ public TaskModelConfigResponse toResponse(ResolvedModelConfig config) {
+ return new TaskModelConfigResponse(
+ config.configId(),
+ config.configName(),
+ config.modelName(),
+ config.modelUrl(),
+ maskSecret(config.apiKey()));
+ }
+
+ public TaskPromptConfigResponse toResponse(ResolvedPromptConfig config) {
+ return new TaskPromptConfigResponse(config.configId(), config.configName(), config.promptText());
+ }
+
+ public SysConfigResponse toResponse(SysConfig config) {
+ return new SysConfigResponse(
+ config.getId(),
+ config.getConfigType(),
+ config.getConfigName(),
+ config.getConfigValue(),
+ config.getStatus(),
+ config.getCreatorId(),
+ config.getCreatedAt(),
+ config.getUpdatedAt());
+ }
+
+ private ResolvedModelConfig resolveSelectedModel(LoginUser currentUser, String configName) {
+ if (!StringUtils.hasText(configName)) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "模型配置名称不能为空");
+ }
+ SysConfig config = sysConfigMapper.findByCompanyIdAndConfigNameAndType(
+ currentUser.companyId(), configName, ConfigType.MODEL.name());
+ if (config == null) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "模型配置不存在");
+ }
+ ModelConfigValue configValue = parseModelConfig(config.getConfigValue());
+ return new ResolvedModelConfig(config.getId(), config.getConfigName(),
+ configValue.modelName(), configValue.modelUrl(), configValue.apiKey());
+ }
+
+ private ResolvedModelConfig resolveManualModel(LoginUser currentUser, ManualModelConfigRequest request) {
+ if (request == null || !StringUtils.hasText(request.modelName())
+ || !StringUtils.hasText(request.modelUrl()) || !StringUtils.hasText(request.apiKey())) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "手动模型配置不完整");
+ }
+ SysConfig existing = sysConfigMapper.findByCompanyIdAndConfigName(currentUser.companyId(), request.modelName());
+ if (existing == null) {
+ String configValue = writeModelConfig(request);
+ SysConfig config = SysConfig.builder()
+ .id(IdGenerator.nextId())
+ .companyId(currentUser.companyId())
+ .configType(ConfigType.MODEL.name())
+ .configName(request.modelName())
+ .configValue(configValue)
+ .status("ENABLED")
+ .creatorId(currentUser.userId())
+ .build();
+ sysConfigMapper.insert(config);
+ log.info("auto created model config, companyId={}, userId={}, configName={}",
+ currentUser.companyId(), currentUser.userId(), request.modelName());
+ return new ResolvedModelConfig(config.getId(), config.getConfigName(),
+ request.modelName(), request.modelUrl(), request.apiKey());
+ }
+ if (!ConfigType.MODEL.name().equals(existing.getConfigType())) {
+ throw new BusinessException(ResultCode.CONFLICT, "同名配置已被其他类型占用");
+ }
+ ModelConfigValue configValue = parseModelConfig(existing.getConfigValue());
+ return new ResolvedModelConfig(existing.getId(), existing.getConfigName(),
+ configValue.modelName(), configValue.modelUrl(), configValue.apiKey());
+ }
+
+ private SysConfig getConfigEntity(LoginUser currentUser, Long configId) {
+ SysConfig config = sysConfigMapper.selectById(configId);
+ if (config == null || !currentUser.companyId().equals(config.getCompanyId())) {
+ throw new BusinessException(ResultCode.NOT_FOUND, "配置不存在");
+ }
+ return config;
+ }
+
+ private void validateConfigType(String configType) {
+ if (!ConfigType.isValid(configType)) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "配置类型非法");
+ }
+ }
+
+ private ModelConfigValue parseModelConfig(String value) {
+ try {
+ return objectMapper.readValue(value, ModelConfigValue.class);
+ } catch (JsonProcessingException ex) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "模型配置值格式非法");
+ }
+ }
+
+ private String writeModelConfig(ManualModelConfigRequest request) {
+ try {
+ return objectMapper.writeValueAsString(Map.of(
+ "modelName", request.modelName(),
+ "modelUrl", request.modelUrl(),
+ "apiKey", request.apiKey()));
+ } catch (JsonProcessingException ex) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "模型配置值生成失败");
+ }
+ }
+
+ private String maskSecret(String secret) {
+ if (!StringUtils.hasText(secret)) {
+ return null;
+ }
+ if (secret.length() <= 4) {
+ return "****";
+ }
+ return "****" + secret.substring(secret.length() - 4);
+ }
+
+ private PageResult paginate(List records, Integer pageNo, Integer pageSize) {
+ int actualPageNo = pageNo == null || pageNo < 1 ? 1 : pageNo;
+ int actualPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
+ int fromIndex = Math.min((actualPageNo - 1) * actualPageSize, records.size());
+ int toIndex = Math.min(fromIndex + actualPageSize, records.size());
+ return new PageResult<>(records.subList(fromIndex, toIndex), (long) records.size(), actualPageNo, actualPageSize);
+ }
+
+ private record ModelConfigValue(String modelName, String modelUrl, String apiKey) {
+ }
+
+ public record ResolvedModelConfig(Long configId, String configName, String modelName, String modelUrl, String apiKey) {
+ }
+
+ public record ResolvedPromptConfig(Long configId, String configName, String promptText) {
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java b/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java
new file mode 100644
index 0000000..30574a1
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/util/ObjectStoragePathBuilder.java
@@ -0,0 +1,38 @@
+package com.labelsys.backend.util;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+public final class ObjectStoragePathBuilder {
+
+ private static final DateTimeFormatter YEAR_MONTH = DateTimeFormatter.ofPattern("yyyyMM");
+
+ private ObjectStoragePathBuilder() {
+ }
+
+ public static String sourceObjectKey(Long companyId, String resourceType, Long resourceId, String extension) {
+ String category = resourceType.toLowerCase();
+ return "source/%d/%s/%s/%d/original.%s".formatted(
+ companyId,
+ category,
+ YEAR_MONTH.format(LocalDateTime.now()),
+ resourceId,
+ extension.toLowerCase());
+ }
+
+ public static String resultQaObjectKey(Long companyId, Long taskId, Long resultId) {
+ return "result/%d/%s/%d/%d/qa.json".formatted(
+ companyId,
+ YEAR_MONTH.format(LocalDateTime.now()),
+ taskId,
+ resultId);
+ }
+
+ public static String resultDiffObjectKey(Long companyId, Long taskId, Long resultId) {
+ return "result/%d/%s/%d/%d/diff.json".formatted(
+ companyId,
+ YEAR_MONTH.format(LocalDateTime.now()),
+ taskId,
+ resultId);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a175856..7e911ec 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -34,6 +34,17 @@ labelsys:
session:
ttl: PT2H
store-type: redis
+ annotation:
+ auto-archive-timeout: PT2H
+ object-storage:
+ endpoint: ${OBJECT_STORAGE_ENDPOINT:http://127.0.0.1:9000}
+ region: ${OBJECT_STORAGE_REGION:us-east-1}
+ access-key: ${OBJECT_STORAGE_ACCESS_KEY:demo-access-key}
+ secret-key: ${OBJECT_STORAGE_SECRET_KEY:demo-secret-key}
+ path-style-access: ${OBJECT_STORAGE_PATH_STYLE:true}
+ source-bucket: ${OBJECT_STORAGE_SOURCE_BUCKET:source-data}
+ artifact-bucket: ${OBJECT_STORAGE_ARTIFACT_BUCKET:annotation-artifacts}
+ export-bucket: ${OBJECT_STORAGE_EXPORT_BUCKET:finetune-export}
logging:
level:
diff --git a/src/main/resources/mapper/AnnotationResultMapper.xml b/src/main/resources/mapper/AnnotationResultMapper.xml
new file mode 100644
index 0000000..1b1ebd8
--- /dev/null
+++ b/src/main/resources/mapper/AnnotationResultMapper.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ id, company_id, creator_id, creator_role, task_id, resource_id, qa_content_json,
+ qa_content_storage_mode, qa_content_file_path, diff_summary, requires_manual_review,
+ is_deleted, reviewer_id, review_comment, reviewed_at, created_at, updated_at
+
+
+
+
+
+ update annotation_result
+ set is_deleted = true,
+ reviewer_id = #{reviewerId},
+ review_comment = #{reviewComment},
+ reviewed_at = #{reviewedAt},
+ updated_at = #{reviewedAt}
+ where id = #{id}
+ and company_id = #{companyId}
+ and is_deleted = false
+
+
diff --git a/src/main/resources/mapper/AnnotationTaskMapper.xml b/src/main/resources/mapper/AnnotationTaskMapper.xml
new file mode 100644
index 0000000..68f6e69
--- /dev/null
+++ b/src/main/resources/mapper/AnnotationTaskMapper.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ id, company_id, creator_id, creator_role, task_name, industry_type, task_type,
+ extract_model_config_id, extract_model_name, extract_model_url, extract_model_api_key,
+ verify_model_config_id, verify_model_name, verify_model_url, verify_model_api_key,
+ extract_prompt_config_id, extract_prompt, verify_prompt_config_id, verify_prompt,
+ task_status, is_deleted, started_at, finished_at, error_message, created_at, updated_at
+
+
+
+
diff --git a/src/main/resources/mapper/AnnotationTaskResourceMapper.xml b/src/main/resources/mapper/AnnotationTaskResourceMapper.xml
new file mode 100644
index 0000000..407be46
--- /dev/null
+++ b/src/main/resources/mapper/AnnotationTaskResourceMapper.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ delete from annotation_task_resource where task_id = #{taskId}
+
+
+
+
diff --git a/src/main/resources/mapper/SourceResourceMapper.xml b/src/main/resources/mapper/SourceResourceMapper.xml
new file mode 100644
index 0000000..f725ee9
--- /dev/null
+++ b/src/main/resources/mapper/SourceResourceMapper.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ id, company_id, creator_id, creator_role, resource_name, resource_type, bucket_name, file_path,
+ file_size, source_status, storage_provider, remark, created_at, updated_at
+
+
+
+
diff --git a/src/main/resources/mapper/SysConfigMapper.xml b/src/main/resources/mapper/SysConfigMapper.xml
new file mode 100644
index 0000000..4040d56
--- /dev/null
+++ b/src/main/resources/mapper/SysConfigMapper.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ id, company_id, config_type, config_name, config_value, status, creator_id, created_at, updated_at
+
+
+
+
+
+