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/common/Result.java b/src/main/java/com/labelsys/backend/common/Result.java
index b54991f..783a10f 100644
--- a/src/main/java/com/labelsys/backend/common/Result.java
+++ b/src/main/java/com/labelsys/backend/common/Result.java
@@ -11,13 +11,13 @@ import lombok.NoArgsConstructor;
@Schema(description = "统一返回结果")
public class Result {
- @Schema(description = "业务状态码")
+ @Schema(description = "业务状态码", example = "0")
private Integer code;
- @Schema(description = "返回消息")
+ @Schema(description = "返回消息", example = "success")
private String message;
- @Schema(description = "返回数据")
+ @Schema(description = "返回数据", example = "{}")
private T data;
public static Result success() {
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..d9dbce0
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/config/MybatisPlusConfig.java
@@ -0,0 +1,23 @@
+package com.labelsys.backend.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+
+@Configuration
+public class MybatisPlusConfig {
+
+ @Bean
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.POSTGRE_SQL);
+ paginationInnerInterceptor.setOverflow(false);
+ paginationInnerInterceptor.setMaxLimit(200L);
+ interceptor.addInnerInterceptor(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/context/LoginUser.java b/src/main/java/com/labelsys/backend/context/LoginUser.java
index 7d1d124..c302bff 100644
--- a/src/main/java/com/labelsys/backend/context/LoginUser.java
+++ b/src/main/java/com/labelsys/backend/context/LoginUser.java
@@ -7,37 +7,28 @@ import com.labelsys.backend.enums.UserRole;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "当前登录用户上下文")
-public record LoginUser(
- @Schema(description = "用户ID") Long userId,
- @Schema(description = "公司ID") Long companyId,
- @Schema(description = "公司编码") String companyCode,
- @Schema(description = "公司名称") String companyName,
+public record LoginUser(@Schema(description = "用户ID") Long userId, @Schema(description = "公司ID") Long companyId,
+ @Schema(description = "公司编码") String companyCode, @Schema(description = "公司名称") String companyName,
@Schema(description = "手机号") String phone,
@Schema(description = "用户名,可为空", example = "alpha-reviewer") String username,
@Schema(description = "真实姓名") String realName,
@Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role,
- @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position,
+ @Schema(
+ description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员、SUPER_ADMIN系统管理员") UserPosition position,
@Schema(description = "是否必须修改密码") boolean mustChangePassword,
- @Schema(description = "会话版本") Integer sessionVersion
-) {
+ @Schema(description = "会话版本") Integer sessionVersion) {
public static LoginUser from(SysUser user, SysCompany company) {
- return new LoginUser(
- user.getId(),
- company.getId(),
- company.getCompanyCode(),
- company.getCompanyName(),
- user.getPhone(),
- user.getUsername(),
- user.getRealName(),
- user.getRole(),
- user.getPosition(),
- Boolean.TRUE.equals(user.getMustChangePassword()),
- user.getSessionVersion()
- );
+ return new LoginUser(user.getId(), company.getId(), company.getCompanyCode(), company.getCompanyName(),
+ user.getPhone(), user.getUsername(), user.getRealName(), user.getRole(), user.getPosition(),
+ Boolean.TRUE.equals(user.getMustChangePassword()), user.getSessionVersion());
}
- public boolean isPlatformAdmin() {
- return "PLATFORM".equals(companyCode) && position == UserPosition.ADMIN;
+ // public boolean isPlatformAdmin() {
+ // return "PLATFORM".equals(companyCode) && position == UserPosition.ADMIN;
+ // }
+
+ public boolean isSuperAdmin() {
+ return position == UserPosition.SUPER_ADMIN;
}
}
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..412b1dd
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java
@@ -0,0 +1,72 @@
+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.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+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(@ParameterObject AnnotationResultPageQuery query) {
+ return Result.success(annotationResultService.pageResults(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询标注结果详情")
+ @GetMapping("/{id}")
+ public Result detail(
+ @Parameter(description = "结果ID", example = "191000000000000401")
+ @PathVariable Long id
+ ) {
+ return Result.success(annotationResultService.getResult(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "查询标注结果比对信息")
+ @RequirePosition(UserPosition.REVIEWER)
+ @GetMapping("/{id}/compare")
+ public Result compare(
+ @Parameter(description = "结果ID", example = "191000000000000401")
+ @PathVariable Long id
+ ) {
+ return Result.success(annotationResultService.compareResult(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "提交合并审核结果")
+ @RequirePosition(UserPosition.REVIEWER)
+ @PostMapping("/{id}/merge-review")
+ public Result mergeReview(
+ @Parameter(description = "结果ID", example = "191000000000000401")
+ @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..1723173
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/AnnotationTaskController.java
@@ -0,0 +1,74 @@
+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.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+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(
+ @Parameter(description = "任务ID", example = "191000000000000301")
+ @PathVariable Long id,
+ @Valid @RequestBody UpdateAnnotationTaskRequest request
+ ) {
+ return Result.success(annotationTaskService.updateTask(UserContext.requireUser(), id, request));
+ }
+
+ @Operation(summary = "分页查询标注任务")
+ @GetMapping
+ public Result> page(@ParameterObject AnnotationTaskPageQuery query) {
+ return Result.success(annotationTaskService.pageTasks(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询标注任务详情")
+ @GetMapping("/{id}")
+ public Result detail(
+ @Parameter(description = "任务ID", example = "191000000000000301")
+ @PathVariable Long id
+ ) {
+ return Result.success(annotationTaskService.getTask(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "删除标注任务")
+ @DeleteMapping("/{id}")
+ public Result delete(
+ @Parameter(description = "任务ID", example = "191000000000000301")
+ @PathVariable Long id
+ ) {
+ annotationTaskService.deleteTask(UserContext.requireUser(), id);
+ return Result.success();
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/controller/AuthController.java b/src/main/java/com/labelsys/backend/controller/AuthController.java
index 34bcc6e..e0dc11c 100644
--- a/src/main/java/com/labelsys/backend/controller/AuthController.java
+++ b/src/main/java/com/labelsys/backend/controller/AuthController.java
@@ -10,6 +10,7 @@ import com.labelsys.backend.dto.response.CurrentUserResponse;
import com.labelsys.backend.dto.response.LoginResponse;
import com.labelsys.backend.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
@@ -33,7 +34,10 @@ public class AuthController {
@Operation(summary = "根据手机号查询可登录公司")
@GetMapping("/companies")
- public Result> listCompanies(@RequestParam String phone) {
+ public Result> listCompanies(
+ @Parameter(description = "手机号", example = "13800138000")
+ @RequestParam String phone
+ ) {
return Result.success(authService.listAvailableCompanies(phone));
}
@@ -52,7 +56,10 @@ public class AuthController {
@Operation(summary = "退出登录")
@PostMapping("/logout")
- public Result logout(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
+ public Result logout(
+ @Parameter(description = "Bearer 访问令牌", example = "Bearer eyJhbGciOiJIUzI1NiJ9.demo.token")
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization
+ ) {
authService.logout(extractToken(authorization));
return Result.success();
}
diff --git a/src/main/java/com/labelsys/backend/controller/CompanyUserController.java b/src/main/java/com/labelsys/backend/controller/CompanyUserController.java
index 078c727..5885b91 100644
--- a/src/main/java/com/labelsys/backend/controller/CompanyUserController.java
+++ b/src/main/java/com/labelsys/backend/controller/CompanyUserController.java
@@ -10,6 +10,7 @@ import com.labelsys.backend.dto.response.UserResponse;
import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
@@ -45,14 +46,22 @@ public class CompanyUserController {
@Operation(summary = "修改员工角色和岗位")
@PutMapping("/{userId}/assignment")
- public Result updateAssignment(@PathVariable Long userId, @Valid @RequestBody UpdateUserAssignmentRequest request) {
+ public Result updateAssignment(
+ @Parameter(description = "用户ID", example = "191000000000000021")
+ @PathVariable Long userId,
+ @Valid @RequestBody UpdateUserAssignmentRequest request
+ ) {
userService.updateAssignment(UserContext.requireUser(), userId, request);
return Result.success();
}
@Operation(summary = "修改员工状态")
@PutMapping("/{userId}/status")
- public Result updateStatus(@PathVariable Long userId, @Valid @RequestBody UpdateUserStatusRequest request) {
+ public Result updateStatus(
+ @Parameter(description = "用户ID", example = "191000000000000021")
+ @PathVariable Long userId,
+ @Valid @RequestBody UpdateUserStatusRequest request
+ ) {
userService.updateStatus(UserContext.requireUser(), userId, request);
return Result.success();
}
diff --git a/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java b/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java
index 51c98d8..b7d33db 100644
--- a/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java
+++ b/src/main/java/com/labelsys/backend/controller/PlatformCompanyAdminController.java
@@ -4,11 +4,13 @@ import com.labelsys.backend.annotation.RequirePosition;
import com.labelsys.backend.common.Result;
import com.labelsys.backend.context.UserContext;
import com.labelsys.backend.dto.request.CreateCompanyAdminRequest;
+import com.labelsys.backend.dto.request.CreateSystemEngineerAdminRequest;
import com.labelsys.backend.dto.request.UpdateUserStatusRequest;
import com.labelsys.backend.dto.response.UserResponse;
import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
@@ -25,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "平台公司管理员管理")
@RestController
@RequestMapping("/api/platform/company-admins")
-@RequirePosition(UserPosition.ADMIN)
+@RequirePosition(UserPosition.SUPER_ADMIN)
@RequiredArgsConstructor
public class PlatformCompanyAdminController {
@@ -33,20 +35,37 @@ public class PlatformCompanyAdminController {
@Operation(summary = "查询指定公司管理员列表")
@GetMapping
- public Result> listCompanyAdmins(@RequestParam Long companyId) {
+ public Result> listCompanyAdmins(
+ @Parameter(description = "公司ID", example = "191000000000000001")
+ @RequestParam Long companyId
+ ) {
return Result.success(userService.listCompanyAdmins(UserContext.requireUser(), companyId).stream().map(UserResponse::from).toList());
}
+ @Operation(summary = "查询所有公司用户列表")
+ @GetMapping("/all")
+ public Result> listAllUsers() {
+ return Result.success(userService.listAllUsers(UserContext.requireUser()).stream().map(UserResponse::from).toList());
+ }
+
@Operation(summary = "创建公司管理员")
@PostMapping
public Result createCompanyAdmin(@Valid @RequestBody CreateCompanyAdminRequest request) {
return Result.success(UserResponse.from(userService.createCompanyAdmin(UserContext.requireUser(), request)));
}
+ @Operation(summary = "创建系统管理员")
+ @PostMapping("/system-engineer")
+ public Result createSystemEngineerAdmin(@Valid @RequestBody CreateSystemEngineerAdminRequest request) {
+ return Result.success(UserResponse.from(userService.createSystemEngineerAdmin(UserContext.requireUser(), request)));
+ }
+
@Operation(summary = "修改公司管理员状态")
@PutMapping("/{companyId}/{userId}/status")
public Result updateCompanyAdminStatus(
+ @Parameter(description = "公司ID", example = "191000000000000001")
@PathVariable Long companyId,
+ @Parameter(description = "用户ID", example = "191000000000000021")
@PathVariable Long userId,
@Valid @RequestBody UpdateUserStatusRequest request
) {
diff --git a/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java b/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java
index 2a6e7ad..ed3c27e 100644
--- a/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java
+++ b/src/main/java/com/labelsys/backend/controller/PlatformCompanyController.java
@@ -9,6 +9,7 @@ import com.labelsys.backend.dto.response.CompanyResponse;
import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.service.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
@@ -24,7 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "平台公司管理")
@RestController
@RequestMapping("/api/platform/companies")
-@RequirePosition(UserPosition.ADMIN)
+@RequirePosition(UserPosition.SUPER_ADMIN)
@RequiredArgsConstructor
public class PlatformCompanyController {
@@ -44,7 +45,11 @@ public class PlatformCompanyController {
@Operation(summary = "修改公司状态")
@PutMapping("/{companyId}/status")
- public Result updateCompanyStatus(@PathVariable Long companyId, @Valid @RequestBody UpdateCompanyStatusRequest request) {
+ public Result updateCompanyStatus(
+ @Parameter(description = "公司ID", example = "191000000000000001")
+ @PathVariable Long companyId,
+ @Valid @RequestBody UpdateCompanyStatusRequest request
+ ) {
companyService.updateStatus(UserContext.requireUser(), companyId, request);
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..2891642
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/SourceResourceController.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.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.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+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(@ParameterObject @ModelAttribute SourceUploadRequest request) {
+ return Result.success(sourceResourceService.upload(UserContext.requireUser(), request));
+ }
+
+ @Operation(summary = "分页查询资源")
+ @GetMapping
+ public Result> page(@ParameterObject @ModelAttribute SourceResourcePageQuery query) {
+ return Result.success(sourceResourceService.pageResources(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询资源详情")
+ @GetMapping("/{id}")
+ public Result detail(
+ @Parameter(description = "资源ID", example = "191000000000000101")
+ @PathVariable Long id
+ ) {
+ return Result.success(sourceResourceService.getResource(UserContext.requireUser(), id));
+ }
+
+ @Operation(summary = "删除资源")
+ @DeleteMapping("/{id}")
+ public Result delete(
+ @Parameter(description = "资源ID", example = "191000000000000101")
+ @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..56fceee
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/controller/SysConfigController.java
@@ -0,0 +1,66 @@
+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.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+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(
+ @Parameter(description = "配置ID", example = "191000000000000501")
+ @PathVariable Long id,
+ @Valid @RequestBody SaveSysConfigRequest request
+ ) {
+ return Result.success(sysConfigService.toResponse(sysConfigService.updateConfig(UserContext.requireUser(), id, request)));
+ }
+
+ @Operation(summary = "分页查询系统配置")
+ @GetMapping
+ public Result> page(@ParameterObject SysConfigPageQuery query) {
+ return Result.success(sysConfigService.pageConfigs(UserContext.requireUser(), query));
+ }
+
+ @Operation(summary = "查询系统配置详情")
+ @GetMapping("/{id}")
+ public Result detail(
+ @Parameter(description = "配置ID", example = "191000000000000501")
+ @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..39011f1
--- /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 = "当前页记录", example = "[]") List records,
+ @Schema(description = "总记录数", example = "2") Long total,
+ @Schema(description = "页码", example = "1") Integer pageNo,
+ @Schema(description = "每页数量", example = "10") 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..21e5ff6
--- /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", example = "191000000000000301") Long taskId,
+ @Schema(description = "资源ID", example = "191000000000000101") Long resourceId,
+ @Schema(description = "是否需要人工审核", example = "true") Boolean requiresManualReview,
+ @Schema(description = "运行态状态", example = "MANUAL_REVIEW_PENDING") String runtimeStatus,
+ @Schema(description = "页码", example = "1") Integer pageNo,
+ @Schema(description = "每页数量", example = "10") 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..a15b67e
--- /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 = "关键字", example = "运输") String keyword,
+ @Schema(description = "任务类型", example = "EXTRACT_QA") String taskType,
+ @Schema(description = "任务状态", example = "PENDING") String taskStatus,
+ @Schema(description = "资源ID", example = "191000000000000101") Long resourceId,
+ @Schema(description = "是否已删除", example = "false") Boolean isDeleted,
+ @Schema(description = "页码", example = "1") Integer pageNo,
+ @Schema(description = "每页数量", example = "10") Integer pageSize
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java b/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java
index 14e9f53..fa55b90 100644
--- a/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/ChangePasswordRequest.java
@@ -5,8 +5,8 @@ import jakarta.validation.constraints.NotBlank;
@Schema(description = "修改密码请求")
public record ChangePasswordRequest(
- @Schema(description = "旧密码") @NotBlank(message = "不能为空") String oldPassword,
- @Schema(description = "新密码") @NotBlank(message = "不能为空") String newPassword,
- @Schema(description = "确认新密码") @NotBlank(message = "不能为空") String confirmPassword
+ @Schema(description = "旧密码", example = "P@ssw0rd!") @NotBlank(message = "不能为空") String oldPassword,
+ @Schema(description = "新密码", example = "N3wP@ssw0rd!") @NotBlank(message = "不能为空") String newPassword,
+ @Schema(description = "确认新密码", example = "N3wP@ssw0rd!") @NotBlank(message = "不能为空") String confirmPassword
) {
}
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..be1b837
--- /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 = "任务名称", example = "运输文档问答抽取任务") @NotBlank(message = "任务名称不能为空") String taskName,
+ @Schema(description = "行业类型", example = "transport") String industryType,
+ @Schema(description = "任务类型", example = "EXTRACT_QA") String taskType,
+ @Schema(description = "资源ID列表", example = "[191000000000000101,191000000000000102]") @NotEmpty(message = "资源列表不能为空") List resourceIds,
+ @Schema(description = "抽取模型配置", example = "{\"mode\":\"SELECT\",\"selectedConfigName\":\"qwen-plus-extract\"}") @Valid TaskModelConfigRequest extractModel,
+ @Schema(description = "校验模型配置", example = "{\"mode\":\"MANUAL\",\"manualConfig\":{\"modelName\":\"qwen-max\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"apiKey\":\"sk-demo5678\"}}") @Valid TaskModelConfigRequest verifyModel,
+ @Schema(description = "抽取提示词配置", example = "{\"selectedConfigName\":\"qa-extract-v1\"}") @Valid PromptConfigOptionRequest extractPrompt,
+ @Schema(description = "校验提示词配置", example = "{\"promptText\":\"请对抽取结果进行逐项校验,并输出差异说明。\"}") @Valid PromptConfigOptionRequest verifyPrompt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java
index cc6f906..707068d 100644
--- a/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyAdminRequest.java
@@ -6,9 +6,9 @@ import jakarta.validation.constraints.NotNull;
@Schema(description = "创建公司管理员请求")
public record CreateCompanyAdminRequest(
- @Schema(description = "公司ID") @NotNull(message = "不能为空") Long companyId,
- @Schema(description = "手机号") @NotBlank(message = "不能为空") String phone,
+ @Schema(description = "公司ID", example = "191000000000000001") @NotNull(message = "不能为空") Long companyId,
+ @Schema(description = "手机号", example = "13800138001") @NotBlank(message = "不能为空") String phone,
@Schema(description = "用户名,前端展示用,可为空", example = "platform-ops") String username,
- @Schema(description = "真实姓名") @NotBlank(message = "不能为空") String realName
+ @Schema(description = "真实姓名", example = "平台运维") @NotBlank(message = "不能为空") String realName
) {
}
diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java
index 5dbd65f..11979fd 100644
--- a/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/CreateCompanyRequest.java
@@ -5,7 +5,7 @@ import jakarta.validation.constraints.NotBlank;
@Schema(description = "创建公司请求")
public record CreateCompanyRequest(
- @Schema(description = "公司编码") @NotBlank(message = "不能为空") String companyCode,
- @Schema(description = "公司名称") @NotBlank(message = "不能为空") String companyName
+ @Schema(description = "公司编码", example = "ALPHA") @NotBlank(message = "不能为空") String companyCode,
+ @Schema(description = "公司名称", example = "阿尔法标注有限公司") @NotBlank(message = "不能为空") String companyName
) {
}
diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateSystemEngineerAdminRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateSystemEngineerAdminRequest.java
new file mode 100644
index 0000000..e3a50a2
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/dto/request/CreateSystemEngineerAdminRequest.java
@@ -0,0 +1,10 @@
+package com.labelsys.backend.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "创建系统工程师管理员请求")
+public record CreateSystemEngineerAdminRequest(
+ @Schema(description = "手机号", example = "13800138002") @NotBlank(message = "不能为空") String phone,
+ @Schema(description = "用户名,前端展示用,可为空", example = "system-engineer") String username,
+ @Schema(description = "真实姓名", example = "系统工程师") @NotBlank(message = "不能为空") String realName) {}
\ No newline at end of file
diff --git a/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java b/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java
index 42bc05e..e0cbf52 100644
--- a/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/CreateUserRequest.java
@@ -8,10 +8,10 @@ import jakarta.validation.constraints.NotNull;
@Schema(description = "创建员工请求")
public record CreateUserRequest(
- @Schema(description = "手机号") @NotBlank(message = "不能为空") String phone,
+ @Schema(description = "手机号", example = "13800138002") @NotBlank(message = "不能为空") String phone,
@Schema(description = "用户名,前端展示用,可为空", example = "alpha-admin") String username,
- @Schema(description = "真实姓名") @NotBlank(message = "不能为空") String realName,
- @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") @NotNull(message = "不能为空") UserRole role,
- @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") @NotNull(message = "不能为空") UserPosition position
+ @Schema(description = "真实姓名", example = "张审核") @NotBlank(message = "不能为空") String realName,
+ @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师", example = "EMPLOYEE") @NotNull(message = "不能为空") UserRole role,
+ @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员", example = "ANNOTATOR") @NotNull(message = "不能为空") UserPosition position
) {
}
diff --git a/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java b/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java
index a47f8f9..ccddbcf 100644
--- a/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/LoginRequest.java
@@ -5,8 +5,8 @@ import jakarta.validation.constraints.NotBlank;
@Schema(description = "登录请求")
public record LoginRequest(
- @Schema(description = "手机号") @NotBlank(message = "不能为空") String phone,
- @Schema(description = "公司编码") @NotBlank(message = "不能为空") String companyCode,
- @Schema(description = "登录密码") @NotBlank(message = "不能为空") String password
+ @Schema(description = "手机号", example = "平台管理员:13900000000") @NotBlank(message = "不能为空") String phone,
+ @Schema(description = "公司编码", example = "平台编号:PLATFORM") @NotBlank(message = "不能为空") String companyCode,
+ @Schema(description = "登录密码", example = "平台密码:admin@123") @NotBlank(message = "不能为空") String password
) {
}
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..85d2eeb
--- /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 = "模型名称", example = "qwen-plus") @NotBlank(message = "模型名称不能为空") String modelName,
+ @Schema(description = "模型地址", example = "https://dashscope.aliyuncs.com/compatible-mode/v1") @NotBlank(message = "模型地址不能为空") String modelUrl,
+ @Schema(description = "模型密钥", example = "sk-demo1234") @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..d8bc6ab
--- /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", example = "{\"changed\":[{\"field\":\"answer\",\"from\":\"3天\",\"to\":\"72小时\"}],\"summary\":\"统一时间表达\"}") @NotBlank(message = "差异摘要不能为空") String diffSummary,
+ @Schema(description = "最终问答内容 JSON", example = "[{\"question\":\"运输时效是多久?\",\"answer\":\"72小时\"}]") @NotBlank(message = "问答内容不能为空") String qaContentJson,
+ @Schema(description = "审核备注", example = "已按审核意见合并,统一为小时口径。") 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..e8a1195
--- /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 = "已选择的提示词配置名称", example = "qa-extract-v1") String selectedConfigName,
+ @Schema(description = "手动输入的提示词内容", example = "请抽取文档中的问答对,并按 JSON 数组输出。") 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..e03e773
--- /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 = "配置类型", example = "MODEL") @NotBlank(message = "配置类型不能为空") String configType,
+ @Schema(description = "配置名称", example = "qwen-plus-extract") @NotBlank(message = "配置名称不能为空") String configName,
+ @Schema(description = "配置值", example = "{\"modelName\":\"qwen-plus\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"apiKey\":\"sk-demo1234\"}") @NotBlank(message = "配置值不能为空") String configValue,
+ @Schema(description = "配置状态", example = "ENABLED") @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..ee2c21e
--- /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 = "关键字", example = "运输") String keyword,
+ @Schema(description = "资源类型", example = "TEXT") String resourceType,
+ @Schema(description = "资源状态", example = "READY") String sourceStatus,
+ @Schema(description = "页码", example = "1") Integer pageNo,
+ @Schema(description = "每页数量", example = "10") 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..38eb0ea
--- /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 = "资源名称", example = "2026年4月运输巡检记录")
+ private String resourceName;
+
+ @Schema(description = "资源类型:TEXT、IMAGE、VIDEO", example = "TEXT")
+ private String resourceType;
+
+ @Schema(description = "备注", example = "第一批导入样本")
+ private String remark;
+
+ @Schema(description = "上传文件", example = "inspection-record.txt")
+ 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..e2af1be
--- /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 = "配置类型", example = "MODEL") String configType,
+ @Schema(description = "配置名称", example = "qwen-plus-extract") String configName,
+ @Schema(description = "配置状态", example = "ENABLED") String status,
+ @Schema(description = "页码", example = "1") Integer pageNo,
+ @Schema(description = "每页数量", example = "10") 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..c5d59fc
--- /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", example = "SELECT") @NotBlank(message = "配置模式不能为空") String mode,
+ @Schema(description = "已选择的配置名称", example = "qwen-plus-extract") String selectedConfigName,
+ @Schema(description = "手动录入的模型配置", example = "{\"modelName\":\"qwen-plus\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"apiKey\":\"sk-demo1234\"}") @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..556d67c
--- /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 = "行业类型", example = "transport") String industryType,
+ @Schema(description = "任务类型", example = "EXTRACT_QA") String taskType,
+ @Schema(description = "资源ID列表", example = "[191000000000000101,191000000000000102]") @NotEmpty(message = "资源列表不能为空") List resourceIds,
+ @Schema(description = "抽取模型配置", example = "{\"mode\":\"SELECT\",\"selectedConfigName\":\"qwen-plus-extract\"}") @Valid TaskModelConfigRequest extractModel,
+ @Schema(description = "校验模型配置", example = "{\"mode\":\"MANUAL\",\"manualConfig\":{\"modelName\":\"qwen-max\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"apiKey\":\"sk-demo5678\"}}") @Valid TaskModelConfigRequest verifyModel,
+ @Schema(description = "抽取提示词配置", example = "{\"selectedConfigName\":\"qa-extract-v1\"}") @Valid PromptConfigOptionRequest extractPrompt,
+ @Schema(description = "校验提示词配置", example = "{\"promptText\":\"请对抽取结果进行逐项校验,并输出差异说明。\"}") @Valid PromptConfigOptionRequest verifyPrompt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java
index 6b42560..fcb7544 100644
--- a/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/UpdateCompanyStatusRequest.java
@@ -6,6 +6,6 @@ import jakarta.validation.constraints.NotNull;
@Schema(description = "修改公司状态请求")
public record UpdateCompanyStatusRequest(
- @Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用") @NotNull(message = "不能为空") CompanyStatus status
+ @Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用", example = "ENABLED") @NotNull(message = "不能为空") CompanyStatus status
) {
}
diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java
index 6e050dd..d057c7d 100644
--- a/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/UpdateUserAssignmentRequest.java
@@ -7,7 +7,7 @@ import jakarta.validation.constraints.NotNull;
@Schema(description = "修改员工角色岗位请求")
public record UpdateUserAssignmentRequest(
- @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") @NotNull(message = "不能为空") UserRole role,
- @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") @NotNull(message = "不能为空") UserPosition position
+ @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师", example = "EMPLOYEE") @NotNull(message = "不能为空") UserRole role,
+ @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员", example = "REVIEWER") @NotNull(message = "不能为空") UserPosition position
) {
}
diff --git a/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java b/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java
index 9548dd4..b9d220c 100644
--- a/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java
+++ b/src/main/java/com/labelsys/backend/dto/request/UpdateUserStatusRequest.java
@@ -6,6 +6,6 @@ import jakarta.validation.constraints.NotNull;
@Schema(description = "修改员工状态请求")
public record UpdateUserStatusRequest(
- @Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用") @NotNull(message = "不能为空") UserStatus status
+ @Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用", example = "DISABLED") @NotNull(message = "不能为空") UserStatus status
) {
}
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..87ee883
--- /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", example = "191000000000000401") Long id,
+ @Schema(description = "任务ID", example = "191000000000000301") Long taskId,
+ @Schema(description = "资源ID", example = "191000000000000101") Long resourceId,
+ @Schema(description = "问答内容 JSON", example = "[{\"question\":\"运输时效是多久?\",\"answer\":\"3天\"}]") String qaContentJson,
+ @Schema(description = "差异摘要 JSON", example = "{\"changed\":[{\"field\":\"answer\",\"from\":\"3天\",\"to\":\"72小时\"}],\"summary\":\"统一时间表达\"}") String diffSummary,
+ @Schema(description = "问答存储模式", example = "EXTERNAL") String qaContentStorageMode,
+ @Schema(description = "外置问答文件路径", example = "review/191000000000000401/qa-content.json") String qaContentFilePath,
+ @Schema(description = "资源预览路径", example = "preview/191000000000000101/index.html") 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..34d0135
--- /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", example = "191000000000000401") Long id,
+ @Schema(description = "任务ID", example = "191000000000000301") Long taskId,
+ @Schema(description = "资源ID", example = "191000000000000101") Long resourceId,
+ @Schema(description = "运行态状态", example = "MANUAL_REVIEW_PENDING") String runtimeStatus,
+ @Schema(description = "是否需要人工审核", example = "true") Boolean requiresManualReview,
+ @Schema(description = "是否已删除", example = "false") Boolean isDeleted,
+ @Schema(description = "问答存储模式", example = "INLINE") String qaContentStorageMode,
+ @Schema(description = "审核备注", example = "需统一时间字段口径。") String reviewComment,
+ @Schema(description = "审核时间", example = "2026-04-27T11:00:00") LocalDateTime reviewedAt,
+ @Schema(description = "创建时间", example = "2026-04-27T10:40:00") 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..0769434
--- /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", example = "191000000000000301") Long id,
+ @Schema(description = "任务名称", example = "运输文档问答抽取任务") String taskName,
+ @Schema(description = "行业类型:默认值transport,暂不显示", example = "transport") String industryType,
+ @Schema(description = "任务类型:暂不显示", example = "EXTRACT_QA") String taskType,
+ @Schema(description = "任务状态", example = "PENDING") String taskStatus,
+ @Schema(description = "资源ID列表", example = "[191000000000000101,191000000000000102]") List resourceIds,
+ @Schema(description = "抽取模型配置", example = "{\"configId\":191000000000000511,\"configName\":\"qwen-plus-extract\",\"modelName\":\"qwen-plus\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"maskedApiKey\":\"****1234\"}") TaskModelConfigResponse extractModel,
+ @Schema(description = "校验模型配置", example = "{\"configId\":191000000000000512,\"configName\":\"qwen-max-verify\",\"modelName\":\"qwen-max\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"maskedApiKey\":\"****5678\"}") TaskModelConfigResponse verifyModel,
+ @Schema(description = "抽取提示词配置", example = "{\"configId\":191000000000000521,\"configName\":\"qa-extract-v1\",\"promptText\":\"请抽取文档中的问答对,并按 JSON 数组输出。\"}") TaskPromptConfigResponse extractPrompt,
+ @Schema(description = "校验提示词配置", example = "{\"configId\":191000000000000522,\"configName\":\"qa-verify-v1\",\"promptText\":\"请对抽取结果进行逐项校验,并输出差异说明。\"}") TaskPromptConfigResponse verifyPrompt,
+ @Schema(description = "创建时间", example = "2026-04-27T10:20:00") LocalDateTime createdAt,
+ @Schema(description = "更新时间", example = "2026-04-27T10:30:00") LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java b/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java
index 4123641..cacc555 100644
--- a/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java
+++ b/src/main/java/com/labelsys/backend/dto/response/CompanyOptionResponse.java
@@ -5,9 +5,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "可登录公司选项")
public record CompanyOptionResponse(
- @Schema(description = "公司ID") Long companyId,
- @Schema(description = "公司编码") String companyCode,
- @Schema(description = "公司名称") String companyName
+ @Schema(description = "公司ID", example = "191000000000000001") Long companyId,
+ @Schema(description = "公司编码", example = "ALPHA") String companyCode,
+ @Schema(description = "公司名称", example = "阿尔法标注有限公司") String companyName
) {
public static CompanyOptionResponse from(SysCompany company) {
return new CompanyOptionResponse(company.getId(), company.getCompanyCode(), company.getCompanyName());
diff --git a/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java b/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java
index 2ed7439..c7416fe 100644
--- a/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java
+++ b/src/main/java/com/labelsys/backend/dto/response/CompanyResponse.java
@@ -6,10 +6,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "公司响应")
public record CompanyResponse(
- @Schema(description = "公司ID") Long companyId,
- @Schema(description = "公司编码") String companyCode,
- @Schema(description = "公司名称") String companyName,
- @Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用") CompanyStatus status
+ @Schema(description = "公司ID", example = "191000000000000001") Long companyId,
+ @Schema(description = "公司编码", example = "ALPHA") String companyCode,
+ @Schema(description = "公司名称", example = "阿尔法标注有限公司") String companyName,
+ @Schema(description = "公司状态,枚举值:ENABLED启用、DISABLED禁用", example = "ENABLED") CompanyStatus status
) {
public static CompanyResponse from(SysCompany company) {
return new CompanyResponse(company.getId(), company.getCompanyCode(), company.getCompanyName(), company.getStatus());
diff --git a/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java b/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java
index 5f5514f..844a9d9 100644
--- a/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java
+++ b/src/main/java/com/labelsys/backend/dto/response/CurrentUserResponse.java
@@ -7,16 +7,16 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "当前登录用户响应")
public record CurrentUserResponse(
- @Schema(description = "用户ID") Long userId,
- @Schema(description = "公司ID") Long companyId,
- @Schema(description = "公司编码") String companyCode,
- @Schema(description = "公司名称") String companyName,
- @Schema(description = "手机号") String phone,
+ @Schema(description = "用户ID", example = "191000000000000021") Long userId,
+ @Schema(description = "公司ID", example = "191000000000000001") Long companyId,
+ @Schema(description = "公司编码", example = "ALPHA") String companyCode,
+ @Schema(description = "公司名称", example = "阿尔法标注有限公司") String companyName,
+ @Schema(description = "手机号", example = "13800138002") String phone,
@Schema(description = "用户名,可为空", example = "alpha-admin") String username,
- @Schema(description = "真实姓名") String realName,
- @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role,
- @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position,
- @Schema(description = "是否必须修改密码") boolean mustChangePassword
+ @Schema(description = "真实姓名", example = "张审核") String realName,
+ @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师", example = "EMPLOYEE") UserRole role,
+ @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员", example = "REVIEWER") UserPosition position,
+ @Schema(description = "是否必须修改密码", example = "false") boolean mustChangePassword
) {
public static CurrentUserResponse from(LoginUser loginUser) {
return new CurrentUserResponse(
diff --git a/src/main/java/com/labelsys/backend/dto/response/DataRecordResponse.java b/src/main/java/com/labelsys/backend/dto/response/DataRecordResponse.java
deleted file mode 100644
index fbdcdb4..0000000
--- a/src/main/java/com/labelsys/backend/dto/response/DataRecordResponse.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.labelsys.backend.dto.response;
-
-import com.labelsys.backend.entity.BizDataRecord;
-import com.labelsys.backend.enums.UserRole;
-import io.swagger.v3.oas.annotations.media.Schema;
-
-@Schema(description = "数据记录响应")
-public record DataRecordResponse(
- @Schema(description = "记录ID") Long id,
- @Schema(description = "公司ID") Long companyId,
- @Schema(description = "创建人ID") Long creatorId,
- @Schema(description = "创建人角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole creatorRole,
- @Schema(description = "记录名称") String recordName
-) {
- public static DataRecordResponse from(BizDataRecord record) {
- return new DataRecordResponse(
- record.getId(),
- record.getCompanyId(),
- record.getCreatorId(),
- record.getCreatorRole(),
- record.getRecordName()
- );
- }
-}
diff --git a/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java b/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java
index 01d4617..ce5aeb1 100644
--- a/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java
+++ b/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java
@@ -8,14 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "登录响应")
public record LoginResponse(
- @Schema(description = "访问令牌") String token,
- @Schema(description = "当前公司信息") CompanyOptionResponse company,
- @Schema(description = "手机号") String phone,
+ @Schema(description = "访问令牌", example = "eyJhbGciOiJIUzI1NiJ9.demo.token") String token,
+ @Schema(description = "当前公司信息", example = "{\"companyId\":191000000000000001,\"companyCode\":\"ALPHA\",\"companyName\":\"阿尔法标注有限公司\"}") CompanyOptionResponse company,
+ @Schema(description = "手机号", example = "13800138002") String phone,
@Schema(description = "用户名,可为空", example = "alpha-admin") String username,
- @Schema(description = "真实姓名") String realName,
- @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role,
- @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position,
- @Schema(description = "是否必须修改密码") boolean mustChangePassword
+ @Schema(description = "真实姓名", example = "张审核") String realName,
+ @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师", example = "EMPLOYEE") UserRole role,
+ @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员", example = "REVIEWER") UserPosition position,
+ @Schema(description = "是否必须修改密码", example = "false") boolean mustChangePassword
) {
public static LoginResponse from(String token, LoginUser loginUser, SysCompany company) {
return new LoginResponse(
diff --git a/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java b/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java
index 66c67a9..eb28005 100644
--- a/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java
+++ b/src/main/java/com/labelsys/backend/dto/response/MenuResponse.java
@@ -5,10 +5,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "菜单响应")
public record MenuResponse(
- @Schema(description = "菜单编码") String menuCode,
- @Schema(description = "菜单名称") String menuName,
- @Schema(description = "菜单路径") String path,
- @Schema(description = "排序") Integer sortOrder
+ @Schema(description = "菜单编码", example = "annotation.task.list") String menuCode,
+ @Schema(description = "菜单名称", example = "标注任务") String menuName,
+ @Schema(description = "菜单路径", example = "/annotation/tasks") String path,
+ @Schema(description = "排序", example = "10") Integer sortOrder
) {
public static MenuResponse from(SysMenu menu) {
return new MenuResponse(menu.getMenuCode(), menu.getMenuName(), menu.getPath(), menu.getSortOrder());
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..7dda5fd
--- /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", example = "191000000000000401") Long resultId,
+ @Schema(description = "历史结果ID", example = "191000000000000451") Long historyId,
+ @Schema(description = "归档原因", example = "MANUAL_REVIEW_MERGED") String archiveReason,
+ @Schema(description = "归档时间", example = "2026-04-27T11:05:00") 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..7488aa8
--- /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", example = "191000000000000101") Long id,
+ @Schema(description = "资源名称", example = "2026年4月运输巡检记录") String resourceName,
+ @Schema(description = "资源类型", example = "TEXT") String resourceType,
+ @Schema(description = "桶名称", example = "annotation-source") String bucketName,
+ @Schema(description = "文件路径", example = "company/191000000000000001/text/191000000000000101.txt") String filePath,
+ @Schema(description = "文件大小", example = "20480") Long fileSize,
+ @Schema(description = "资源状态", example = "READY") String sourceStatus,
+ @Schema(description = "存储提供方", example = "rustfs") String storageProvider,
+ @Schema(description = "备注", example = "第一批导入样本") String remark,
+ @Schema(description = "创建人名称", example = "张审核") String creatorName,
+ @Schema(description = "创建时间", example = "2026-04-27T10:00:00") LocalDateTime createdAt,
+ @Schema(description = "更新时间", example = "2026-04-27T10:05:00") 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..6145246
--- /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", example = "191000000000000101") Long id,
+ @Schema(description = "资源名称", example = "2026年4月运输巡检记录") String resourceName,
+ @Schema(description = "资源类型", example = "TEXT") String resourceType,
+ @Schema(description = "桶名称", example = "annotation-source") String bucketName,
+ @Schema(description = "文件路径", example = "company/191000000000000001/text/191000000000000101.txt") String filePath,
+ @Schema(description = "文件大小", example = "20480") Long fileSize,
+ @Schema(description = "资源状态", example = "READY") String sourceStatus,
+ @Schema(description = "创建时间", example = "2026-04-27T10:00:00") 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..f0ff9c8
--- /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", example = "191000000000000501") Long id,
+ @Schema(description = "配置类型", example = "MODEL") String configType,
+ @Schema(description = "配置名称", example = "qwen-plus-extract") String configName,
+ @Schema(description = "配置值", example = "{\"modelName\":\"qwen-plus\",\"modelUrl\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\",\"apiKey\":\"sk-demo1234\"}") String configValue,
+ @Schema(description = "配置状态", example = "ENABLED") String status,
+ @Schema(description = "创建人ID", example = "191000000000000021") Long creatorId,
+ @Schema(description = "创建时间", example = "2026-04-27T09:50:00") LocalDateTime createdAt,
+ @Schema(description = "更新时间", example = "2026-04-27T10:15:00") 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..fdb608b
--- /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", example = "191000000000000511") Long configId,
+ @Schema(description = "配置名称", example = "qwen-plus-extract") String configName,
+ @Schema(description = "模型名称", example = "qwen-plus") String modelName,
+ @Schema(description = "模型地址", example = "https://dashscope.aliyuncs.com/compatible-mode/v1") String modelUrl,
+ @Schema(description = "脱敏后的模型密钥", example = "****1234") 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..ad34b8a
--- /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", example = "191000000000000521") Long configId,
+ @Schema(description = "配置名称", example = "qa-extract-v1") String configName,
+ @Schema(description = "提示词内容", example = "请抽取文档中的问答对,并按 JSON 数组输出。") String promptText
+) {
+}
diff --git a/src/main/java/com/labelsys/backend/dto/response/UserResponse.java b/src/main/java/com/labelsys/backend/dto/response/UserResponse.java
index 6cc9148..7201efa 100644
--- a/src/main/java/com/labelsys/backend/dto/response/UserResponse.java
+++ b/src/main/java/com/labelsys/backend/dto/response/UserResponse.java
@@ -8,15 +8,15 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "用户响应")
public record UserResponse(
- @Schema(description = "用户ID") Long userId,
- @Schema(description = "公司ID") Long companyId,
- @Schema(description = "手机号") String phone,
+ @Schema(description = "用户ID", example = "191000000000000021") Long userId,
+ @Schema(description = "公司ID", example = "191000000000000001") Long companyId,
+ @Schema(description = "手机号", example = "13800138002") String phone,
@Schema(description = "用户名,可为空", example = "alpha-admin") String username,
- @Schema(description = "真实姓名") String realName,
- @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师") UserRole role,
- @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员") UserPosition position,
- @Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用") UserStatus status,
- @Schema(description = "是否必须修改密码") boolean mustChangePassword
+ @Schema(description = "真实姓名", example = "张审核") String realName,
+ @Schema(description = "角色,枚举值:EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师", example = "EMPLOYEE") UserRole role,
+ @Schema(description = "岗位,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员", example = "REVIEWER") UserPosition position,
+ @Schema(description = "用户状态,枚举值:ENABLED启用、DISABLED禁用", example = "ENABLED") UserStatus status,
+ @Schema(description = "是否必须修改密码", example = "false") boolean mustChangePassword
) {
public static UserResponse from(SysUser user) {
return new UserResponse(
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
index 7d2a7ee..9acc68c 100644
--- a/src/main/java/com/labelsys/backend/entity/BizDataRecord.java
+++ b/src/main/java/com/labelsys/backend/entity/BizDataRecord.java
@@ -1,8 +1,5 @@
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;
@@ -14,9 +11,7 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
-@TableName("biz_data_record")
public class BizDataRecord {
- @TableId(type = IdType.INPUT)
private Long id;
private Long companyId;
private Long creatorId;
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/entity/SysMenu.java b/src/main/java/com/labelsys/backend/entity/SysMenu.java
index e1a124b..cd6395c 100644
--- a/src/main/java/com/labelsys/backend/entity/SysMenu.java
+++ b/src/main/java/com/labelsys/backend/entity/SysMenu.java
@@ -18,10 +18,10 @@ public class SysMenu {
@TableId(type = IdType.INPUT)
private Long id;
private Long companyId;
- private String permissionCode;
private String menuCode;
private String menuName;
private String path;
+ private String visiblePositions;
private Integer sortOrder;
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/enums/UserPosition.java b/src/main/java/com/labelsys/backend/enums/UserPosition.java
index 3681151..55f3795 100644
--- a/src/main/java/com/labelsys/backend/enums/UserPosition.java
+++ b/src/main/java/com/labelsys/backend/enums/UserPosition.java
@@ -6,13 +6,13 @@ import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
-@Schema(description = "岗位枚举,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN超级管理员")
+@Schema(description = "岗位枚举,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN公司管理员、SUPER_ADMIN超级管理员")
public enum UserPosition {
- ANNOTATOR(1), DATA_TRAINER(2), REVIEWER(3), ADMIN(4);
+ ANNOTATOR(1), DATA_TRAINER(2), REVIEWER(3), ADMIN(4), SUPER_ADMIN(5);
private final int level;
public boolean canAccess(UserPosition required) {
return this.level >= required.level;
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java
index 37acd88..8dadd0d 100644
--- a/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java
+++ b/src/main/java/com/labelsys/backend/interceptor/AuthInterceptor.java
@@ -1,5 +1,13 @@
package com.labelsys.backend.interceptor;
+import java.time.Duration;
+import java.util.Set;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+
import com.labelsys.backend.annotation.RequirePosition;
import com.labelsys.backend.common.exception.ForbiddenException;
import com.labelsys.backend.common.exception.UnauthorizedException;
@@ -8,48 +16,36 @@ import com.labelsys.backend.context.UserContext;
import com.labelsys.backend.entity.SysCompany;
import com.labelsys.backend.entity.SysUser;
import com.labelsys.backend.enums.CompanyStatus;
-import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.enums.UserStatus;
import com.labelsys.backend.mapper.SysCompanyMapper;
import com.labelsys.backend.mapper.SysUserMapper;
import com.labelsys.backend.service.session.TokenSessionRepository;
+
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import java.time.Duration;
-import java.util.Set;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-import org.springframework.web.method.HandlerMethod;
-import org.springframework.web.servlet.HandlerInterceptor;
@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"
- );
+ "/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"
- );
+ "/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;
private final SysCompanyMapper sysCompanyMapper;
private final Duration sessionTtl;
- public AuthInterceptor(
- TokenSessionRepository tokenSessionRepository,
- SysUserMapper sysUserMapper,
- SysCompanyMapper sysCompanyMapper,
- @Value("${labelsys.session.ttl:PT2H}") Duration sessionTtl
- ) {
+ public AuthInterceptor(TokenSessionRepository tokenSessionRepository, SysUserMapper sysUserMapper,
+ SysCompanyMapper sysCompanyMapper, @Value("${labelsys.session.ttl:PT2H}") Duration sessionTtl) {
this.tokenSessionRepository = tokenSessionRepository;
this.sysUserMapper = sysUserMapper;
this.sysCompanyMapper = sysCompanyMapper;
@@ -69,12 +65,13 @@ public class AuthInterceptor implements HandlerInterceptor {
return true;
}
String token = extractToken(request.getHeader("Authorization"));
- LoginUser loginUser = tokenSessionRepository.find(token)
- .orElseThrow(() -> new UnauthorizedException("未登录或登录已过期"));
+ LoginUser loginUser =
+ tokenSessionRepository.find(token).orElseThrow(() -> new UnauthorizedException("未登录或登录已过期"));
SysUser user = sysUserMapper.findByIdAndCompanyId(loginUser.userId(), loginUser.companyId());
SysCompany company = sysCompanyMapper.selectById(loginUser.companyId());
- if (user == null || company == null || user.getStatus() != UserStatus.ENABLED || company.getStatus() != CompanyStatus.ENABLED) {
+ if (user == null || company == null || user.getStatus() != UserStatus.ENABLED
+ || company.getStatus() != CompanyStatus.ENABLED) {
throw new UnauthorizedException("未登录或登录已过期");
}
if (!user.getSessionVersion().equals(loginUser.sessionVersion())) {
@@ -96,7 +93,8 @@ public class AuthInterceptor implements HandlerInterceptor {
}
@Override
- public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
+ Exception ex) {
UserContext.clear();
}
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..bdda150
--- /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);
+}
\ No newline at end of file
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..35bd601
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/AnnotationTaskMapper.java
@@ -0,0 +1,11 @@
+package com.labelsys.backend.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.labelsys.backend.entity.AnnotationTask;
+
+public interface AnnotationTaskMapper extends BaseMapper {
+
+ AnnotationTask findByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId);
+}
\ No newline at end of file
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/BizDataRecordMapper.java b/src/main/java/com/labelsys/backend/mapper/BizDataRecordMapper.java
deleted file mode 100644
index 7f68e16..0000000
--- a/src/main/java/com/labelsys/backend/mapper/BizDataRecordMapper.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.labelsys.backend.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.labelsys.backend.entity.BizDataRecord;
-import java.util.List;
-import org.apache.ibatis.annotations.Param;
-
-public interface BizDataRecordMapper extends BaseMapper {
-
- List listVisibleByEmployee(@Param("companyId") Long companyId, @Param("creatorId") Long creatorId);
-
- List listVisibleByManager(@Param("companyId") Long companyId);
-
- List listVisibleByEngineer(@Param("companyId") Long companyId);
-}
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..b6eefd7
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/mapper/SourceResourceMapper.java
@@ -0,0 +1,13 @@
+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);
+
+ SourceResource selectByCompanyIdAndResourceName(@Param("companyId") Long companyId, @Param("resourceName") String resourceName);
+}
\ No newline at end of file
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/mapper/SysMenuMapper.java b/src/main/java/com/labelsys/backend/mapper/SysMenuMapper.java
index 5a42212..823e073 100644
--- a/src/main/java/com/labelsys/backend/mapper/SysMenuMapper.java
+++ b/src/main/java/com/labelsys/backend/mapper/SysMenuMapper.java
@@ -7,5 +7,5 @@ import org.apache.ibatis.annotations.Param;
public interface SysMenuMapper extends BaseMapper {
- List listCurrentMenus(@Param("companyId") Long companyId, @Param("positionCodes") List positionCodes);
+ List listCurrentMenus(@Param("companyId") Long companyId, @Param("visiblePositions") List visiblePositions);
}
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..a5f66e8
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java
@@ -0,0 +1,113 @@
+package com.labelsys.backend.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+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 com.labelsys.backend.service.DataPermissionService;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnnotationResultService {
+
+ private final AnnotationResultMapper annotationResultMapper;
+ private final SourceResourceMapper sourceResourceMapper;
+ private final DataPermissionService dataPermissionService;
+
+ public PageResult pageResults(LoginUser currentUser, AnnotationResultPageQuery query) {
+ List allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
+ boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
+
+ 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());
+
+ if (shouldFilterByUserId) {
+ wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId());
+ } else if (!allowedRoles.isEmpty()) {
+ wrapper.in(AnnotationResult::getCreatorRole, allowedRoles);
+ }
+
+ wrapper.orderByDesc(AnnotationResult::getCreatedAt);
+
+ Page page = new Page<>(query.pageNo(), query.pageSize());
+ Page resultPage = annotationResultMapper.selectPage(page, wrapper);
+
+ List records = resultPage.getRecords().stream()
+ .map(this::toResponse)
+ .filter(response -> query.runtimeStatus() == null || query.runtimeStatus().equals(response.runtimeStatus()))
+ .toList();
+
+ return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), (int) resultPage.getSize());
+ }
+
+ public AnnotationResultResponse getResult(LoginUser currentUser, Long resultId) {
+ AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId());
+ if (result == null) {
+ log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}",
+ resultId, currentUser.companyId(), currentUser.userId());
+ throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在");
+ }
+ return toResponse(result);
+ }
+
+ public AnnotationResultCompareResponse compareResult(LoginUser currentUser, Long resultId) {
+ AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId());
+ if (result == null) {
+ log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}",
+ resultId, currentUser.companyId(), currentUser.userId());
+ 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();
+ }
+}
\ No newline at end of file
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..ca4364d
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/AnnotationTaskService.java
@@ -0,0 +1,295 @@
+package com.labelsys.backend.service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+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 lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@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) {
+ List allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
+ boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
+
+ 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());
+
+ if (shouldFilterByUserId) {
+ wrapper.eq(AnnotationTask::getCreatorId, currentUser.userId());
+ } else if (!allowedRoles.isEmpty()) {
+ wrapper.in(AnnotationTask::getCreatorRole, allowedRoles);
+ }
+
+ wrapper.orderByDesc(AnnotationTask::getCreatedAt);
+
+ Page page = new Page<>(query.pageNo(), query.pageSize());
+ Page resultPage = annotationTaskMapper.selectPage(page, wrapper);
+
+ List records = resultPage.getRecords().stream()
+ .filter(task -> query.resourceId() == null ||
+ annotationTaskResourceMapper.listResourceIdsByTaskId(task.getId()).contains(query.resourceId()))
+ .map(task -> buildTaskResponse(task,
+ normalizeIds(annotationTaskResourceMapper.listResourceIdsByTaskId(task.getId()))))
+ .toList();
+
+ return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), (int) resultPage.getSize());
+ }
+
+ @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);
+ }
+}
diff --git a/src/main/java/com/labelsys/backend/service/CompanyService.java b/src/main/java/com/labelsys/backend/service/CompanyService.java
index 4bffbde..e73ad1f 100644
--- a/src/main/java/com/labelsys/backend/service/CompanyService.java
+++ b/src/main/java/com/labelsys/backend/service/CompanyService.java
@@ -47,8 +47,8 @@ public class CompanyService {
}
private void assertPlatformAdmin(LoginUser currentUser) {
- if (!currentUser.isPlatformAdmin()) {
- throw new ForbiddenException("仅平台管理员可操作");
+ if (!currentUser.isSuperAdmin()) {
+ throw new ForbiddenException("仅系统管理员可操作");
}
}
}
diff --git a/src/main/java/com/labelsys/backend/service/DataPermissionService.java b/src/main/java/com/labelsys/backend/service/DataPermissionService.java
index db04686..43c3c3a 100644
--- a/src/main/java/com/labelsys/backend/service/DataPermissionService.java
+++ b/src/main/java/com/labelsys/backend/service/DataPermissionService.java
@@ -3,27 +3,18 @@ package com.labelsys.backend.service;
import com.labelsys.backend.context.LoginUser;
import com.labelsys.backend.entity.BizDataRecord;
import com.labelsys.backend.enums.UserRole;
-import com.labelsys.backend.mapper.BizDataRecordMapper;
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 BizDataRecordMapper bizDataRecordMapper;
-
- public List listVisibleRecords(LoginUser currentUser) {
- return switch (currentUser.role()) {
- case EMPLOYEE -> bizDataRecordMapper.listVisibleByEmployee(currentUser.companyId(), currentUser.userId());
- case MANAGER -> bizDataRecordMapper.listVisibleByManager(currentUser.companyId());
- case ENGINEER -> bizDataRecordMapper.listVisibleByEngineer(currentUser.companyId());
- };
- }
+ private final JdbcTemplate jdbcTemplate;
public boolean canAccessCreator(LoginUser currentUser, Long creatorId, UserRole creatorRole) {
return switch (currentUser.role()) {
@@ -33,22 +24,15 @@ public class DataPermissionService {
};
}
- /**
- * 通用数据过滤方法
- *
- * @param currentUser 当前登录用户
- * @param allRecords 待过滤的全量数据列表
- * @param roleExtractor 从数据对象中提取“关联角色”或“创建者角色”的函数
- * @param ownerIdExtractor 从数据对象中提取“所有者ID”的函数(用于员工只能看自己的情况)
- * @param 数据类型
- * @return 过滤后的数据列表
+ /**
+ * Generic in-memory role-based data filter for records already loaded in memory.
*/
public List filterByRole(
LoginUser currentUser,
List allRecords,
Function roleExtractor,
Function ownerIdExtractor) {
-
+
if (allRecords == null || allRecords.isEmpty()) {
return List.of();
}
@@ -62,32 +46,50 @@ public class DataPermissionService {
Long recordOwnerId = ownerIdExtractor.apply(record);
return switch (currentRole) {
- case EMPLOYEE ->
- // 员工只能查看自己创建/拥有的数据
- currentUserId.equals(recordOwnerId);
-
- case MANAGER ->
- // 经理可以查看员工和经理的数据,不能查看总工程师的数据
- recordRole == UserRole.EMPLOYEE || recordRole == UserRole.MANAGER;
-
- case ENGINEER ->
- // 总工程师可以查看所有数据
- true;
+ case EMPLOYEE -> currentUserId.equals(recordOwnerId);
+ case MANAGER -> recordRole == UserRole.EMPLOYEE || recordRole == UserRole.MANAGER;
+ case ENGINEER -> true;
};
})
.collect(Collectors.toList());
}
/**
- * 针对 BizDataRecord 的便捷调用方法
+ * Returns the creator roles visible to the current user for SQL-side filtering.
*/
- // public List listVisibleRecordsGeneric(LoginUser currentUser, List allRecords) {
- // return filterByRole(
- // currentUser,
- // allRecords,
- // BizDataRecord::
- // BizDataRecord::getCreatorRole, // 提取创建者角色
- // BizDataRecord::getCreatorId // 提取创建者ID
- // );
- // }
+ public List getAllowedRoles(LoginUser currentUser) {
+ return switch (currentUser.role()) {
+ case EMPLOYEE -> List.of();
+ case MANAGER -> List.of("EMPLOYEE", "MANAGER");
+ case ENGINEER -> List.of("EMPLOYEE", "MANAGER", "ENGINEER");
+ };
+ }
+
+ /**
+ * Whether SQL queries should additionally restrict by creator/user id.
+ */
+ 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/MenuService.java b/src/main/java/com/labelsys/backend/service/MenuService.java
index 31af6d6..fdae154 100644
--- a/src/main/java/com/labelsys/backend/service/MenuService.java
+++ b/src/main/java/com/labelsys/backend/service/MenuService.java
@@ -14,11 +14,11 @@ public class MenuService {
private final SysMenuMapper sysMenuMapper;
public List listCurrentMenus(LoginUser currentUser) {
- List positionCodes = java.util.Arrays.stream(com.labelsys.backend.enums.UserPosition.values())
+ List visiblePositions = java.util.Arrays.stream(com.labelsys.backend.enums.UserPosition.values())
.filter(position -> currentUser.position().canAccess(position))
.map(Enum::name)
.toList();
- return sysMenuMapper.listCurrentMenus(currentUser.companyId(), positionCodes)
+ return sysMenuMapper.listCurrentMenus(currentUser.companyId(), visiblePositions)
.stream()
.map(MenuResponse::from)
.toList();
diff --git a/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java b/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java
new file mode 100644
index 0000000..ead66ce
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/ObjectStorageInitializer.java
@@ -0,0 +1,62 @@
+package com.labelsys.backend.service;
+
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import com.labelsys.backend.config.ObjectStorageProperties;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
+import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ObjectStorageInitializer {
+
+ private final S3Client s3Client;
+ private final ObjectStorageProperties properties;
+
+ @EventListener(ApplicationReadyEvent.class)
+ public void initializeBuckets() {
+ log.info("开始初始化对象存储桶...");
+
+ String[] buckets = {
+ properties.getSourceBucket(),
+ properties.getArtifactBucket(),
+ properties.getExportBucket()
+ };
+
+ for (String bucketName : buckets) {
+ try {
+ if (!bucketExists(bucketName)) {
+ createBucket(bucketName);
+ log.info("成功创建存储桶: {}", bucketName);
+ } else {
+ log.info("存储桶已存在: {}", bucketName);
+ }
+ } catch (Exception e) {
+ log.error("初始化存储桶 {} 失败: {}", bucketName, e.getMessage());
+ }
+ }
+
+ log.info("对象存储桶初始化完成");
+ }
+
+ private boolean bucketExists(String bucketName) {
+ try {
+ s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build());
+ return true;
+ } catch (NoSuchBucketException e) {
+ return false;
+ }
+ }
+
+ private void createBucket(String bucketName) {
+ s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build());
+ }
+}
\ No newline at end of file
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..447dd20
--- /dev/null
+++ b/src/main/java/com/labelsys/backend/service/SourceResourceService.java
@@ -0,0 +1,168 @@
+package com.labelsys.backend.service;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+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 lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@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, "资源类型非法");
+ }
+ String resourceName =
+ StringUtils.hasText(request.getResourceName()) ? request.getResourceName() : file.getOriginalFilename();
+ SourceResource existingResource =
+ sourceResourceMapper.selectByCompanyIdAndResourceName(currentUser.companyId(), resourceName);
+ if (existingResource != null) {
+ throw new BusinessException(ResultCode.BAD_REQUEST, "资源名称已存在:" + resourceName);
+ }
+
+ 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) {
+ List allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
+ boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
+
+ 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());
+
+ if (shouldFilterByUserId) {
+ wrapper.eq(SourceResource::getCreatorId, currentUser.userId());
+ } else if (!allowedRoles.isEmpty()) {
+ wrapper.in(SourceResource::getCreatorRole, allowedRoles);
+ }
+
+ wrapper.orderByDesc(SourceResource::getCreatedAt);
+
+ Page page = new Page<>(query.pageNo(), query.pageSize());
+ Page resultPage = sourceResourceMapper.selectPage(page, wrapper);
+
+ List records = resultPage.getRecords().stream().map(this::toResponse).toList();
+
+ return new PageResult<>(records, resultPage.getTotal(), (int)resultPage.getCurrent(),
+ (int)resultPage.getSize());
+ }
+
+ public SourceResourceResponse getResource(LoginUser currentUser, Long resourceId) {
+ SourceResource resource = sourceResourceMapper.selectById(resourceId);
+ if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) {
+ log.warn("Resource not found or cross-tenant access attempt: resourceId={}, companyId={}, userId={}",
+ resourceId, currentUser.companyId(), currentUser.userId());
+ throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在");
+ }
+ 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";
+ };
+ }
+}
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/service/UserService.java b/src/main/java/com/labelsys/backend/service/UserService.java
index 776e632..59e2b5b 100644
--- a/src/main/java/com/labelsys/backend/service/UserService.java
+++ b/src/main/java/com/labelsys/backend/service/UserService.java
@@ -12,6 +12,7 @@ import com.labelsys.backend.common.exception.BusinessException;
import com.labelsys.backend.common.exception.ForbiddenException;
import com.labelsys.backend.context.LoginUser;
import com.labelsys.backend.dto.request.CreateCompanyAdminRequest;
+import com.labelsys.backend.dto.request.CreateSystemEngineerAdminRequest;
import com.labelsys.backend.dto.request.CreateUserRequest;
import com.labelsys.backend.dto.request.UpdateUserAssignmentRequest;
import com.labelsys.backend.dto.request.UpdateUserStatusRequest;
@@ -39,6 +40,12 @@ public class UserService {
private final PasswordEncoder passwordEncoder;
private final TokenSessionRepository tokenSessionRepository;
+ public List listAllUsers(LoginUser currentUser) {
+ assertSystemAdmin(currentUser);
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper().orderByAsc(SysUser::getId);
+ return sysUserMapper.selectList(wrapper);
+ }
+
public List listCompanyUsers(LoginUser currentUser) {
assertCompanyAdmin(currentUser);
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
@@ -47,17 +54,32 @@ public class UserService {
}
public List listCompanyAdmins(LoginUser currentUser, Long companyId) {
- assertPlatformAdmin(currentUser);
+ assertSystemAdmin(currentUser);
return sysUserMapper.listCompanyAdmins(companyId);
}
public SysUser createCompanyAdmin(LoginUser currentUser, CreateCompanyAdminRequest request) {
- assertPlatformAdmin(currentUser);
+ assertSystemAdmin(currentUser);
ensureEnabledCompany(request.companyId());
- return createUser(
- request.companyId(),
- new CreateUserRequest(request.phone(), request.username(), request.realName(), UserRole.EMPLOYEE, UserPosition.ADMIN)
- );
+ return createUser(request.companyId(), new CreateUserRequest(request.phone(), request.username(),
+ request.realName(), UserRole.EMPLOYEE, UserPosition.ADMIN));
+ }
+
+ public SysUser createSystemEngineerAdmin(LoginUser currentUser, CreateSystemEngineerAdminRequest request) {
+ assertSystemAdmin(currentUser);
+ Long systemCompanyId = 1L;
+ ensureEnabledCompany(systemCompanyId);
+
+ if (sysUserMapper.findByCompanyIdAndPhone(systemCompanyId, request.phone()) != null) {
+ throw new BusinessException(ResultCode.CONFLICT, "同一公司内手机号已存在");
+ }
+
+ SysUser user = SysUser.builder().id(IdGenerator.nextId()).companyId(systemCompanyId).phone(request.phone())
+ .username(request.username()).realName(request.realName()).role(UserRole.ENGINEER)
+ .position(UserPosition.SUPER_ADMIN).passwordHash(passwordEncoder.encode(DEFAULT_PASSWORD))
+ .mustChangePassword(true).status(UserStatus.ENABLED).sessionVersion(1).build();
+ sysUserMapper.insert(user);
+ return user;
}
public SysUser createCompanyUser(LoginUser currentUser, CreateUserRequest request) {
@@ -66,7 +88,7 @@ public class UserService {
}
public SysUser createCompanyUser(LoginUser currentUser, Long companyId, CreateUserRequest request) {
- assertPlatformAdmin(currentUser);
+ assertSystemAdmin(currentUser);
ensureEnabledCompany(companyId);
return createUser(companyId, request);
}
@@ -90,8 +112,9 @@ public class UserService {
}
@Transactional
- public void updateCompanyAdminStatus(LoginUser currentUser, Long companyId, Long userId, UpdateUserStatusRequest request) {
- assertPlatformAdmin(currentUser);
+ public void updateCompanyAdminStatus(LoginUser currentUser, Long companyId, Long userId,
+ UpdateUserStatusRequest request) {
+ assertSystemAdmin(currentUser);
if (sysUserMapper.updateStatus(userId, companyId, request.status()) == 0) {
throw new BusinessException(ResultCode.NOT_FOUND, "用户不存在");
}
@@ -102,19 +125,10 @@ public class UserService {
if (sysUserMapper.findByCompanyIdAndPhone(companyId, request.phone()) != null) {
throw new BusinessException(ResultCode.CONFLICT, "同一公司内手机号已存在");
}
- SysUser user = SysUser.builder()
- .id(IdGenerator.nextId())
- .companyId(companyId)
- .phone(request.phone())
- .username(request.username())
- .realName(request.realName())
- .role(request.role())
- .position(request.position())
- .passwordHash(passwordEncoder.encode(DEFAULT_PASSWORD))
- .mustChangePassword(true)
- .status(UserStatus.ENABLED)
- .sessionVersion(1)
- .build();
+ SysUser user = SysUser.builder().id(IdGenerator.nextId()).companyId(companyId).phone(request.phone())
+ .username(request.username()).realName(request.realName()).role(request.role()).position(request.position())
+ .passwordHash(passwordEncoder.encode(DEFAULT_PASSWORD)).mustChangePassword(true).status(UserStatus.ENABLED)
+ .sessionVersion(1).build();
sysUserMapper.insert(user);
return user;
}
@@ -126,14 +140,20 @@ public class UserService {
}
}
- private void assertPlatformAdmin(LoginUser currentUser) {
- if (!currentUser.isPlatformAdmin()) {
- throw new ForbiddenException("仅平台管理员可操作");
+ // private void assertPlatformAdmin(LoginUser currentUser) {
+ // if (!currentUser.isPlatformAdmin()) {
+ // throw new ForbiddenException("仅平台管理员可操作");
+ // }
+ // }
+
+ private void assertSystemAdmin(LoginUser currentUser) {
+ if (!currentUser.isSuperAdmin()) {
+ throw new ForbiddenException("仅超级管理员可操作");
}
}
private void assertCompanyAdmin(LoginUser currentUser) {
- if (currentUser.isPlatformAdmin() || currentUser.position() != UserPosition.ADMIN) {
+ if (currentUser.isSuperAdmin() || currentUser.position() != UserPosition.ADMIN) {
throw new ForbiddenException("仅公司管理员可操作");
}
}
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..5d2b770 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -27,13 +27,28 @@ mybatis-plus:
map-underscore-to-camel-case: true
springdoc:
+ api-docs:
+ enabled: true
+ path: /v3/api-docs
swagger-ui:
+ enabled: true
path: /swagger-ui.html
labelsys:
session:
ttl: PT2H
store-type: redis
+ annotation:
+ auto-archive-timeout: PT2H
+ object-storage:
+ endpoint: ${OBJECT_STORAGE_ENDPOINT:http://39.107.112.174:9000}
+ region: ${OBJECT_STORAGE_REGION:cn-east-1}
+ access-key: ${OBJECT_STORAGE_ACCESS_KEY:admin}
+ secret-key: ${OBJECT_STORAGE_SECRET_KEY:your_strong_password}
+ 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..77fb503
--- /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
+
+
\ No newline at end of file
diff --git a/src/main/resources/mapper/AnnotationTaskMapper.xml b/src/main/resources/mapper/AnnotationTaskMapper.xml
new file mode 100644
index 0000000..f5b378c
--- /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
+
+
+
+
\ No newline at end of file
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/BizDataRecordMapper.xml b/src/main/resources/mapper/BizDataRecordMapper.xml
deleted file mode 100644
index bd3a0bd..0000000
--- a/src/main/resources/mapper/BizDataRecordMapper.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- id, company_id, creator_id, creator_role, record_name, created_at, updated_at
-
-
-
-
-
-
-
-
diff --git a/src/main/resources/mapper/SourceResourceMapper.xml b/src/main/resources/mapper/SourceResourceMapper.xml
new file mode 100644
index 0000000..c496633
--- /dev/null
+++ b/src/main/resources/mapper/SourceResourceMapper.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
\ No newline at end of file
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
+
+
+
+
+
+
diff --git a/src/main/resources/mapper/SysMenuMapper.xml b/src/main/resources/mapper/SysMenuMapper.xml
index 9aedbdf..dde48e6 100644
--- a/src/main/resources/mapper/SysMenuMapper.xml
+++ b/src/main/resources/mapper/SysMenuMapper.xml
@@ -4,26 +4,24 @@
diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql
index 568c728..c67b8ae 100644
--- a/src/main/resources/sql/data.sql
+++ b/src/main/resources/sql/data.sql
@@ -1,23 +1,150 @@
-insert into sys_company (id, company_code, company_name, status) values
+BEGIN;
+
+INSERT INTO sys_company (id, company_code, company_name, status) VALUES
(1, 'PLATFORM', '平台公司', 'ENABLED'),
(2, 'ALPHA', '甲公司', 'ENABLED')
-on conflict do nothing;
+ON CONFLICT DO NOTHING;
-insert into sys_user (id, company_id, phone, username, role, position, real_name, password_hash, must_change_password, status, session_version) values
- (1, 1, '13900000000', 'platform-admin', 'ENGINEER', 'ADMIN', '平台管理员', '$2a$10$TGPk5rNNhKNJQvTWImw5J.LVzw9HDFWR6hyNJCkLDcp0GU8/vp0aS', false, 'ENABLED', 1),
- (2, 2, '13800138000', 'alpha-admin', 'EMPLOYEE', 'ADMIN', '甲公司管理员', '$2a$10$/hSD8ch7A9lFWi/DOb8yJOHdlrhV57p95CBv9Uv93Yky7t6c4Rs/S', true, 'ENABLED', 1),
- (3, 2, '13700000000', 'alpha-annotator', 'EMPLOYEE', 'ANNOTATOR', '甲公司标注员', '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', false, 'ENABLED', 1),
- (4, 2, '13600000000', 'alpha-manager', 'MANAGER', 'REVIEWER', '甲公司经理', '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', false, 'ENABLED', 1),
- (5, 2, '13500000000', 'alpha-engineer', 'ENGINEER', 'ADMIN', '甲公司工程师', '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', false, 'ENABLED', 1)
-on conflict do nothing;
+INSERT INTO sys_user (
+ id, company_id, phone, username, role, position, real_name,
+ password_hash, must_change_password, status, session_version
+) VALUES
+ (1, 1, '13900000000', 'platform-admin', 'ENGINEER', 'SUPER_ADMIN', '平台管理员',
+ '$2a$10$TGPk5rNNhKNJQvTWImw5J.LVzw9HDFWR6hyNJCkLDcp0GU8/vp0aS', FALSE, 'ENABLED', 1),
+ (2, 2, '13800138000', 'alpha-admin', 'ENGINEER', 'ADMIN', '甲公司管理员',
+ '$2a$10$/hSD8ch7A9lFWi/DOb8yJOHdlrhV57p95CBv9Uv93Yky7t6c4Rs/S', TRUE, 'ENABLED', 1),
+ (3, 2, '13700000000', 'alpha-annotator', 'EMPLOYEE', 'ANNOTATOR', '甲公司标注员',
+ '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', TRUE, 'ENABLED', 1),
+ (4, 2, '13600000000', 'alpha-trainer', 'EMPLOYEE', 'DATA_TRAINER', '甲公司数据训练师',
+ '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', TRUE, 'ENABLED', 1),
+ (5, 2, '13500000000', 'alpha-reviewer', 'MANAGER', 'REVIEWER', '甲公司审核员',
+ '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', TRUE, 'ENABLED', 1),
+ (6, 2, '13400000000', 'alpha-chief-engineer', 'ENGINEER', 'REVIEWER', '甲公司总工程师',
+ '$2a$10$bRMZPcIaiB1BUx6HPw6FSODPSuph8kUi8/JZOM6lACwjjhkbBL5mq', TRUE, 'ENABLED', 1)
+ON CONFLICT DO NOTHING;
-insert into sys_menu (id, company_id, permission_code, menu_code, menu_name, path, sort_order) values
- (201, 2, 'USER_MANAGE', 'USER_MANAGE', '员工管理', '/users', 1),
- (202, 2, 'DATA_RECORD_VIEW', 'DATA_RECORDS', '数据记录', '/data-records', 2)
-on conflict do nothing;
+INSERT INTO sys_menu (id, company_id, menu_code, menu_name, path, visible_positions, sort_order) VALUES
+ (201, 2, 'USER_MANAGE', '用户管理', '/users', 'ADMIN', 1),
+ (202, 2, 'RESOURCE_MANAGE', '资源管理', '/resources', 'ANNOTATOR,DATA_TRAINER,REVIEWER,ADMIN', 2),
+ (203, 2, 'TASK_MANAGE', '任务管理', '/tasks', 'ANNOTATOR,DATA_TRAINER,REVIEWER,ADMIN', 3),
+ (204, 2, 'RESULT_REVIEW', '结果审核', '/results', 'REVIEWER,ADMIN', 4),
+ (205, 2, 'DATASET_EXPORT', '训练集导出', '/datasets', 'DATA_TRAINER,ADMIN', 5),
+ (206, 2, 'SYSTEM_CONFIG', '系统配置', '/configs', 'ADMIN', 6)
+ON CONFLICT DO NOTHING;
-insert into biz_data_record (id, company_id, creator_id, creator_role, record_name) values
- (401, 2, 3, 'EMPLOYEE', '员工创建的数据'),
- (402, 2, 4, 'MANAGER', '经理创建的数据'),
- (403, 2, 5, 'ENGINEER', '工程师创建的数据')
-on conflict do nothing;
+INSERT INTO sys_config (
+ id, company_id, config_type, config_name, config_value, status, creator_id
+) VALUES
+ (401, 2, 'MODEL', 'qwen-max',
+ '{"modelUrl":"https://api.example.com/extract","apiKey":"extract-api-key-demo"}', 'ENABLED', 2),
+ (402, 2, 'MODEL', 'glm-4.5',
+ '{"modelUrl":"https://api.example.com/verify","apiKey":"verify-api-key-demo"}', 'ENABLED', 2),
+ (403, 2, 'PROMPT', 'extractPrompt',
+ '请根据输入内容提取结构化问答对。', 'ENABLED', 2),
+ (404, 2, 'PROMPT', 'verifyPrompt',
+ '请核验抽取结果是否准确,并给出修正答案。', 'ENABLED', 2),
+ (406, 2, 'MODEL', 'qwen-vl-max',
+ '{"modelUrl":"https://api.example.com/extract-vl","apiKey":"extract-vl-api-key-demo"}', 'ENABLED', 2),
+ (407, 2, 'MODEL', 'glm-4.5v',
+ '{"modelUrl":"https://api.example.com/verify-vl","apiKey":"verify-vl-api-key-demo"}', 'ENABLED', 2),
+ (408, 2, 'PROMPT', 'imageExtractPrompt',
+ '请根据输入图片内容提取结构化问答对。', 'ENABLED', 2),
+ (409, 2, 'PROMPT', 'imageVerifyPrompt',
+ '请核验图片问答结果是否准确。', 'ENABLED', 2),
+ (405, 2, 'SYSTEM', 'storageProvider',
+ '{"provider":"rustfs","defaultBucket":"source-data"}', 'ENABLED', 2)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO source_resource (
+ id, company_id, creator_id, creator_role, resource_name, resource_type,
+ bucket_name, file_path, file_size, source_status, storage_provider, remark
+) VALUES
+ (601, 2, 3, 'EMPLOYEE', '设备巡检规范.txt', 'TEXT',
+ 'source-data', 'text/202604/601.txt', 20480, 'READY', 'rustfs', '文本资源示例'),
+ (602, 2, 3, 'EMPLOYEE', '控制柜照片.jpg', 'IMAGE',
+ 'source-data', 'image/202604/602.jpg', 532480, 'READY', 'rustfs', '图片资源示例')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO annotation_task (
+ 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
+) VALUES
+ (701, 2, 4, 'EMPLOYEE', '多资源问答抽取任务', 'electricity', 'EXTRACT_QA',
+ 401,
+ 'qwen-max', 'https://api.example.com/extract', 'extract-api-key-demo',
+ 402,
+ 'glm-4.5', 'https://api.example.com/verify', 'verify-api-key-demo',
+ 403, '请根据输入文本提取结构化问答对。',
+ 404, '请核验生成答案是否与原始内容一致。',
+ 'PENDING', FALSE, NULL, NULL, NULL),
+ (702, 2, 4, 'EMPLOYEE', '图片问答抽取任务', 'transport', 'EXTRACT_QA',
+ 406,
+ 'qwen-vl-max', 'https://api.example.com/extract-vl', 'extract-vl-api-key-demo',
+ 407,
+ 'glm-4.5v', 'https://api.example.com/verify-vl', 'verify-vl-api-key-demo',
+ 408, '请根据输入图片内容提取结构化问答对。',
+ 409, '请核验图片问答结果是否准确。',
+ 'COMPLETED', FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO annotation_task_resource (
+ id, company_id, task_id, resource_id
+) VALUES
+ (711, 2, 701, 601),
+ (712, 2, 701, 602),
+ (713, 2, 702, 602)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO annotation_result (
+ 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
+) VALUES
+ (801, 2, 3, 'EMPLOYEE', 701, 601,
+ '{"question":"巡检开始前需要做什么?","answer":"详见外置结果文件,包含完整步骤与注意事项。"}',
+ 'EXTERNAL', 'annotation-results/202604/801-qa.json',
+ '{"extract_question":"巡检开始前需要做什么?","extract_answer":"开始前检查设备状态和作业环境。","verify_answer":"开始前应确认设备状态、防护用品和现场环境安全。","mismatch_fields":["answer"],"reason":"抽取答案遗漏了安全检查要点。"}',
+ TRUE, FALSE, NULL, NULL, NULL),
+ (802, 2, 3, 'EMPLOYEE', 702, 602,
+ '{"question":"图片中的控制柜当前状态如何?","answer":"控制柜处于运行状态,绿色指示灯亮起。"}',
+ 'INLINE', NULL,
+ '{"extract_question":"图片中的控制柜当前状态如何?","extract_answer":"控制柜处于运行状态,绿色指示灯亮起。","verify_answer":"控制柜正在运行,指示灯显示正常。","mismatch_fields":[],"reason":"校验结果与抽取结果基本一致。"}',
+ FALSE, FALSE, 5, '结果可通过。', CURRENT_TIMESTAMP)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO annotation_result_history (
+ id, company_id, creator_id, creator_role, source_result_id, task_id, resource_id,
+ qa_content_json, qa_content_storage_mode, qa_content_file_path, archive_reason, archived_by, archived_at
+) VALUES
+ (901, 2, 3, 'EMPLOYEE', 802, 702, 602,
+ '{"question":"图片中的控制柜当前状态如何?","answer":"控制柜处于运行状态,绿色指示灯亮起。"}',
+ 'INLINE',
+ NULL,
+ '审核通过后归档', 5, CURRENT_TIMESTAMP)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO training_dataset (
+ id, company_id, creator_id, creator_role, result_history_id, sample_type, glm_format_json, dataset_status
+) VALUES
+ (1001, 2, 4, 'EMPLOYEE', 901, 'TEXT',
+ '{"messages":[{"role":"system","content":"你是专业知识问答助手"},{"role":"user","content":"图片中的控制柜当前状态如何?"},{"role":"assistant","content":"控制柜处于运行状态,绿色指示灯亮起。"}]}',
+ 'DRAFT')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO export_batch (
+ id, company_id, creator_id, creator_role, batch_no, dataset_file_path, sample_count, finetune_job_id, finetune_status
+) VALUES
+ (1101, 2, 4, 'EMPLOYEE', 'BATCH-20260424-001', 'export/BATCH-20260424-001.jsonl', 1, NULL, 'NOT_STARTED')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO export_batch_item (id, batch_id, dataset_id) VALUES
+ (1201, 1101, 1001)
+ON CONFLICT DO NOTHING;
+
+COMMIT;
diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql
index b598e1e..e9e97e1 100644
--- a/src/main/resources/sql/schema.sql
+++ b/src/main/resources/sql/schema.sql
@@ -1,47 +1,414 @@
-create table if not exists sys_company (
- id bigint primary key,
- company_code varchar(64) not null unique,
- company_name varchar(128) not null,
- status varchar(32) not null,
- created_at timestamp default current_timestamp,
- updated_at timestamp default current_timestamp
+-- Active: 1775801470429@@39.107.112.174@5432@lablesystem
+begin;
+
+-- Drop Tables (按依赖关系倒序删除)
+DROP TABLE IF EXISTS export_batch_item CASCADE;
+DROP TABLE IF EXISTS export_batch CASCADE;
+DROP TABLE IF EXISTS training_dataset CASCADE;
+DROP TABLE IF EXISTS annotation_result_history CASCADE;
+DROP TABLE IF EXISTS annotation_result CASCADE;
+DROP TABLE IF EXISTS annotation_task_resource CASCADE;
+DROP TABLE IF EXISTS annotation_task CASCADE;
+DROP TABLE IF EXISTS source_resource CASCADE;
+DROP TABLE IF EXISTS sys_config CASCADE;
+DROP TABLE IF EXISTS sys_menu CASCADE;
+DROP TABLE IF EXISTS sys_user CASCADE;
+DROP TABLE IF EXISTS sys_company CASCADE;
+
+
+CREATE TABLE IF NOT EXISTS sys_company (
+ id BIGINT PRIMARY KEY,
+ company_code VARCHAR(64) NOT NULL UNIQUE,
+ company_name VARCHAR(128) NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-create table if not exists sys_user (
- id bigint primary key,
- company_id bigint not null,
- phone varchar(32) not null,
- username varchar(64),
- role varchar(32) not null,
- position varchar(32) not null,
- real_name varchar(64) not null,
- password_hash varchar(255) not null,
- must_change_password boolean not null default true,
- status varchar(32) not null,
- session_version integer not null default 1,
- created_at timestamp default current_timestamp,
- updated_at timestamp default current_timestamp,
- constraint uq_sys_user_company_phone unique (company_id, phone)
+COMMENT ON TABLE sys_company IS '公司表。';
+COMMENT ON COLUMN sys_company.id IS '公司主键ID。';
+COMMENT ON COLUMN sys_company.company_code IS '公司编码,登录时通过 companyCode 使用。';
+COMMENT ON COLUMN sys_company.company_name IS '公司名称。';
+COMMENT ON COLUMN sys_company.status IS '公司状态,默认 ENABLED,可按需改为 DISABLED 等状态。';
+COMMENT ON COLUMN sys_company.created_at IS '创建时间。';
+COMMENT ON COLUMN sys_company.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS sys_user (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ phone VARCHAR(32) NOT NULL,
+ username VARCHAR(64),
+ role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ position VARCHAR(32) NOT NULL DEFAULT 'ANNOTATOR',
+ real_name VARCHAR(64) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
+ status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
+ session_version INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT uq_sys_user_company_phone UNIQUE (company_id, phone),
+ CONSTRAINT fk_sys_user_company FOREIGN KEY (company_id) REFERENCES sys_company(id)
);
-create table if not exists sys_menu (
- id bigint primary key,
- company_id bigint not null,
- permission_code varchar(64) not null,
- menu_code varchar(64) not null,
- menu_name varchar(128) not null,
- path varchar(255) not null,
- sort_order integer not null default 0,
- created_at timestamp default current_timestamp,
- updated_at timestamp default current_timestamp
+COMMENT ON TABLE sys_user IS '用户表。role 表示数据权限角色,position 表示岗位。';
+COMMENT ON COLUMN sys_user.id IS '用户主键ID。';
+COMMENT ON COLUMN sys_user.company_id IS '所属公司ID,关联 sys_company.id。';
+COMMENT ON COLUMN sys_user.phone IS '登录手机号,同公司内唯一。';
+COMMENT ON COLUMN sys_user.username IS '用户名或账号别名,用于展示。';
+COMMENT ON COLUMN sys_user.role IS '数据权限角色,默认 EMPLOYEE,可选 EMPLOYEE、MANAGER、ENGINEER。';
+COMMENT ON COLUMN sys_user.position IS '岗位,默认 ANNOTATOR,可选 ANNOTATOR、DATA_TRAINER、REVIEWER、ADMIN、SUPER_ADMIN。';
+COMMENT ON COLUMN sys_user.real_name IS '用户真实姓名。';
+COMMENT ON COLUMN sys_user.password_hash IS '密码哈希值。';
+COMMENT ON COLUMN sys_user.must_change_password IS '是否首次登录强制改密。';
+COMMENT ON COLUMN sys_user.status IS '用户状态,默认 ENABLED。';
+COMMENT ON COLUMN sys_user.session_version IS '会话版本号,用于强制旧 Token 失效。';
+COMMENT ON COLUMN sys_user.created_at IS '创建时间。';
+COMMENT ON COLUMN sys_user.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS sys_menu (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ menu_code VARCHAR(64) NOT NULL,
+ menu_name VARCHAR(128) NOT NULL,
+ path VARCHAR(255) NOT NULL,
+ visible_positions VARCHAR(255) NOT NULL DEFAULT 'ADMIN',
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_sys_menu_company FOREIGN KEY (company_id) REFERENCES sys_company(id)
);
-create table if not exists biz_data_record (
- id bigint primary key,
- company_id bigint not null,
- creator_id bigint not null,
- creator_role varchar(32) not null,
- record_name varchar(255) not null,
- created_at timestamp default current_timestamp,
- updated_at timestamp default current_timestamp
+COMMENT ON TABLE sys_menu IS '菜单表。';
+COMMENT ON COLUMN sys_menu.id IS '菜单主键ID。';
+COMMENT ON COLUMN sys_menu.company_id IS '所属公司ID。';
+COMMENT ON COLUMN sys_menu.menu_code IS '菜单编码。';
+COMMENT ON COLUMN sys_menu.menu_name IS '菜单名称。';
+COMMENT ON COLUMN sys_menu.path IS '前端路由路径。';
+COMMENT ON COLUMN sys_menu.visible_positions IS '可见岗位列表,使用逗号分隔存储,如 ANNOTATOR,DATA_TRAINER,REVIEWER,ADMIN。';
+COMMENT ON COLUMN sys_menu.sort_order IS '菜单排序号,默认 0。';
+COMMENT ON COLUMN sys_menu.created_at IS '创建时间。';
+COMMENT ON COLUMN sys_menu.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS sys_config (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ config_type VARCHAR(32) NOT NULL DEFAULT 'SYSTEM',
+ config_name VARCHAR(128) NOT NULL,
+ config_value TEXT NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'ENABLED',
+ creator_id BIGINT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT uq_sys_config_company_name UNIQUE (company_id, config_name),
+ CONSTRAINT fk_sys_config_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_sys_config_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id)
);
+
+COMMENT ON TABLE sys_config IS '系统配置表,保存模型配置、Prompt 配置和系统参数配置。';
+COMMENT ON COLUMN sys_config.id IS '配置主键ID。';
+COMMENT ON COLUMN sys_config.company_id IS '所属公司ID。';
+COMMENT ON COLUMN sys_config.config_type IS '配置类型,默认 SYSTEM,可选 MODEL、PROMPT、SYSTEM。';
+COMMENT ON COLUMN sys_config.config_name IS '配置名称。MODEL 类型时存模型名如 qwen-max;PROMPT 类型时存名称如 extractPrompt、verifyPrompt;SYSTEM 类型时存参数名。';
+COMMENT ON COLUMN sys_config.config_value IS '配置值。MODEL 类型建议保存 JSON,至少包含 modelName、modelUrl、apiKey;PROMPT 类型保存提示词文本;SYSTEM 类型预留后续扩展。';
+COMMENT ON COLUMN sys_config.status IS '配置状态,默认 ENABLED。';
+COMMENT ON COLUMN sys_config.creator_id IS '创建人用户ID。';
+COMMENT ON COLUMN sys_config.created_at IS '创建时间。';
+COMMENT ON COLUMN sys_config.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS source_resource (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ creator_id BIGINT NOT NULL,
+ creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ resource_name VARCHAR(255) NOT NULL,
+ resource_type VARCHAR(32) NOT NULL DEFAULT 'TEXT',
+ bucket_name VARCHAR(128) NOT NULL,
+ file_path VARCHAR(512) NOT NULL,
+ file_size BIGINT NOT NULL DEFAULT 0,
+ source_status VARCHAR(32) NOT NULL DEFAULT 'UPLOADED',
+ storage_provider VARCHAR(64) NOT NULL DEFAULT 'rustfs',
+ remark VARCHAR(255),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_source_resource_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_source_resource_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id)
+);
+
+COMMENT ON TABLE source_resource IS '资源表,保存文本、图片、视频资源元数据。';
+COMMENT ON COLUMN source_resource.id IS '资源主键ID。';
+COMMENT ON COLUMN source_resource.company_id IS '所属公司ID。';
+COMMENT ON COLUMN source_resource.creator_id IS '上传人或创建人用户ID。';
+COMMENT ON COLUMN source_resource.creator_role IS '创建人数据权限角色,默认 EMPLOYEE。';
+COMMENT ON COLUMN source_resource.resource_name IS '资源名称。';
+COMMENT ON COLUMN source_resource.resource_type IS '资源类型,默认 TEXT,可选 TEXT、IMAGE、VIDEO。';
+COMMENT ON COLUMN source_resource.bucket_name IS '对象存储桶名称。';
+COMMENT ON COLUMN source_resource.file_path IS '文件存储路径,表示对象在存储系统中的实际路径。';
+COMMENT ON COLUMN source_resource.file_size IS '文件大小,单位字节,默认 0。';
+COMMENT ON COLUMN source_resource.source_status IS '资源状态,默认 UPLOADED,可选 PROCESSING、READY、ARCHIVED。';
+COMMENT ON COLUMN source_resource.storage_provider IS '存储提供方,默认 rustfs。';
+COMMENT ON COLUMN source_resource.remark IS '备注说明。';
+COMMENT ON COLUMN source_resource.created_at IS '创建时间。';
+COMMENT ON COLUMN source_resource.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS annotation_task (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ creator_id BIGINT NOT NULL,
+ creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ task_name VARCHAR(255) NOT NULL,
+ industry_type VARCHAR(32) NOT NULL DEFAULT 'transport',
+ task_type VARCHAR(32) NOT NULL DEFAULT 'EXTRACT_QA',
+ extract_model_config_id BIGINT,
+ extract_model_name VARCHAR(128),
+ extract_model_url VARCHAR(255),
+ extract_model_api_key VARCHAR(255),
+ verify_model_config_id BIGINT,
+ verify_model_name VARCHAR(128),
+ verify_model_url VARCHAR(255),
+ verify_model_api_key VARCHAR(255),
+ extract_prompt_config_id BIGINT,
+ extract_prompt TEXT,
+ verify_prompt_config_id BIGINT,
+ verify_prompt TEXT,
+ task_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ started_at TIMESTAMP,
+ finished_at TIMESTAMP,
+ error_message TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_annotation_task_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_annotation_task_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id),
+ CONSTRAINT fk_annotation_task_extract_model_config FOREIGN KEY (extract_model_config_id) REFERENCES sys_config(id),
+ CONSTRAINT fk_annotation_task_verify_model_config FOREIGN KEY (verify_model_config_id) REFERENCES sys_config(id),
+ CONSTRAINT fk_annotation_task_extract_prompt_config FOREIGN KEY (extract_prompt_config_id) REFERENCES sys_config(id),
+ CONSTRAINT fk_annotation_task_verify_prompt_config FOREIGN KEY (verify_prompt_config_id) REFERENCES sys_config(id)
+);
+
+COMMENT ON TABLE annotation_task IS '任务表,保存任务、配置引用与执行快照。';
+COMMENT ON COLUMN annotation_task.id IS '任务主键ID。';
+COMMENT ON COLUMN annotation_task.company_id IS '所属公司ID。';
+COMMENT ON COLUMN annotation_task.creator_id IS '任务创建人用户ID。';
+COMMENT ON COLUMN annotation_task.creator_role IS '任务创建人数据权限角色,默认 EMPLOYEE。';
+COMMENT ON COLUMN annotation_task.task_name IS '任务名称。';
+COMMENT ON COLUMN annotation_task.industry_type IS '行业类型简写,默认 transport,可选值按业务扩展,例如 electricity。';
+COMMENT ON COLUMN annotation_task.task_type IS '任务类型,默认 EXTRACT_QA。';
+COMMENT ON COLUMN annotation_task.extract_model_config_id IS '抽取模型配置ID,关联 sys_config.id。';
+COMMENT ON COLUMN annotation_task.extract_model_name IS '抽取模型名称。';
+COMMENT ON COLUMN annotation_task.extract_model_url IS '抽取模型调用地址。';
+COMMENT ON COLUMN annotation_task.extract_model_api_key IS '抽取模型调用密钥。';
+COMMENT ON COLUMN annotation_task.verify_model_config_id IS '校验模型配置ID,关联 sys_config.id。';
+COMMENT ON COLUMN annotation_task.verify_model_name IS '校验模型名称。';
+COMMENT ON COLUMN annotation_task.verify_model_url IS '校验模型调用地址。';
+COMMENT ON COLUMN annotation_task.verify_model_api_key IS '校验模型调用密钥。';
+COMMENT ON COLUMN annotation_task.extract_prompt_config_id IS '抽取Prompt配置ID,关联 sys_config.id。';
+COMMENT ON COLUMN annotation_task.extract_prompt IS '抽取 Prompt 文本。';
+COMMENT ON COLUMN annotation_task.verify_prompt_config_id IS '校验Prompt配置ID,关联 sys_config.id。';
+COMMENT ON COLUMN annotation_task.verify_prompt IS '校验 Prompt 文本。';
+COMMENT ON COLUMN annotation_task.task_status IS '任务状态,默认 PENDING,可选 RUNNING、COMPLETED、FAILED。';
+COMMENT ON COLUMN annotation_task.is_deleted IS '任务软删除标记,默认 FALSE。';
+COMMENT ON COLUMN annotation_task.started_at IS '任务开始时间。';
+COMMENT ON COLUMN annotation_task.finished_at IS '任务结束时间。';
+COMMENT ON COLUMN annotation_task.error_message IS '任务失败错误信息。';
+COMMENT ON COLUMN annotation_task.created_at IS '创建时间。';
+COMMENT ON COLUMN annotation_task.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS annotation_task_resource (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ task_id BIGINT NOT NULL,
+ resource_id BIGINT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT uq_annotation_task_resource UNIQUE (task_id, resource_id),
+ CONSTRAINT fk_annotation_task_resource_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_annotation_task_resource_task FOREIGN KEY (task_id) REFERENCES annotation_task(id),
+ CONSTRAINT fk_annotation_task_resource_resource FOREIGN KEY (resource_id) REFERENCES source_resource(id)
+);
+
+COMMENT ON TABLE annotation_task_resource IS '任务与资源关联表,一个任务可绑定多个资源。';
+COMMENT ON COLUMN annotation_task_resource.id IS '任务资源关联主键ID。';
+COMMENT ON COLUMN annotation_task_resource.company_id IS '所属公司ID。';
+COMMENT ON COLUMN annotation_task_resource.task_id IS '任务ID。';
+COMMENT ON COLUMN annotation_task_resource.resource_id IS '资源ID。';
+COMMENT ON COLUMN annotation_task_resource.created_at IS '创建时间。';
+
+CREATE TABLE IF NOT EXISTS annotation_result (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ creator_id BIGINT NOT NULL,
+ creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ task_id BIGINT NOT NULL,
+ resource_id BIGINT NOT NULL,
+ qa_content_json TEXT NOT NULL DEFAULT '{}',
+ qa_content_storage_mode VARCHAR(32) NOT NULL DEFAULT 'INLINE',
+ qa_content_file_path VARCHAR(512),
+ diff_summary TEXT NOT NULL DEFAULT '{}',
+ requires_manual_review BOOLEAN NOT NULL DEFAULT FALSE,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ reviewer_id BIGINT,
+ review_comment TEXT,
+ reviewed_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_annotation_result_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_annotation_result_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id),
+ CONSTRAINT fk_annotation_result_task FOREIGN KEY (task_id) REFERENCES annotation_task(id),
+ CONSTRAINT fk_annotation_result_resource FOREIGN KEY (resource_id) REFERENCES source_resource(id),
+ CONSTRAINT fk_annotation_result_reviewer FOREIGN KEY (reviewer_id) REFERENCES sys_user(id)
+);
+
+COMMENT ON TABLE annotation_result IS '当前标注结果表。';
+COMMENT ON COLUMN annotation_result.id IS '标注结果主键ID。';
+COMMENT ON COLUMN annotation_result.company_id IS '所属公司ID。';
+COMMENT ON COLUMN annotation_result.creator_id IS '结果创建人用户ID。';
+COMMENT ON COLUMN annotation_result.creator_role IS '结果创建人数据权限角色,默认 EMPLOYEE。';
+COMMENT ON COLUMN annotation_result.task_id IS '关联任务ID。';
+COMMENT ON COLUMN annotation_result.resource_id IS '关联资源ID。';
+COMMENT ON COLUMN annotation_result.qa_content_json IS '问答内容 JSON 字符串。字段类型为 TEXT,建议结构为 {\"question\":\"...\",\"answer\":\"...\"}。中小体积内容默认直接入库。';
+COMMENT ON COLUMN annotation_result.qa_content_storage_mode IS '问答内容存储模式,默认 INLINE,可选 INLINE、EXTERNAL。当完整问答内容较大时,可设为 EXTERNAL,仅在表内保留摘要或索引信息。';
+COMMENT ON COLUMN annotation_result.qa_content_file_path IS '当 qa_content_storage_mode = EXTERNAL 时,记录外置问答内容文件路径。';
+COMMENT ON COLUMN annotation_result.diff_summary IS '差异摘要 JSON 字符串。字段类型为 TEXT,建议结构为 {\"extract_question\":\"...\",\"extract_answer\":\"...\",\"verify_answer\":\"...\",\"mismatch_fields\":[\"question\",\"answer\"],\"reason\":\"...\"}。';
+COMMENT ON COLUMN annotation_result.requires_manual_review IS '是否需要人工审核,默认 FALSE。';
+COMMENT ON COLUMN annotation_result.is_deleted IS '软删除标记,默认 FALSE。';
+COMMENT ON COLUMN annotation_result.reviewer_id IS '审核人用户ID。';
+COMMENT ON COLUMN annotation_result.review_comment IS '审核意见。';
+COMMENT ON COLUMN annotation_result.reviewed_at IS '审核时间。';
+COMMENT ON COLUMN annotation_result.created_at IS '创建时间。';
+COMMENT ON COLUMN annotation_result.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS annotation_result_history (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ creator_id BIGINT NOT NULL,
+ creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ source_result_id BIGINT,
+ task_id BIGINT NOT NULL,
+ resource_id BIGINT NOT NULL,
+ qa_content_json TEXT NOT NULL DEFAULT '{}',
+ qa_content_storage_mode VARCHAR(32) NOT NULL DEFAULT 'INLINE',
+ qa_content_file_path VARCHAR(512),
+ archive_reason VARCHAR(255),
+ archived_by BIGINT,
+ archived_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_annotation_result_history_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_annotation_result_history_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id),
+ CONSTRAINT fk_annotation_result_history_result FOREIGN KEY (source_result_id) REFERENCES annotation_result(id),
+ CONSTRAINT fk_annotation_result_history_task FOREIGN KEY (task_id) REFERENCES annotation_task(id),
+ CONSTRAINT fk_annotation_result_history_resource FOREIGN KEY (resource_id) REFERENCES source_resource(id),
+ CONSTRAINT fk_annotation_result_history_archived_by FOREIGN KEY (archived_by) REFERENCES sys_user(id)
+);
+
+COMMENT ON TABLE annotation_result_history IS '历史归档结果表。';
+COMMENT ON COLUMN annotation_result_history.id IS '历史结果主键ID。';
+COMMENT ON COLUMN annotation_result_history.company_id IS '所属公司ID。';
+COMMENT ON COLUMN annotation_result_history.creator_id IS '历史记录创建人用户ID。';
+COMMENT ON COLUMN annotation_result_history.creator_role IS '历史记录创建人数据权限角色,默认 EMPLOYEE。';
+COMMENT ON COLUMN annotation_result_history.source_result_id IS '来源运行态结果ID。';
+COMMENT ON COLUMN annotation_result_history.task_id IS '关联任务ID。';
+COMMENT ON COLUMN annotation_result_history.resource_id IS '关联资源ID。';
+COMMENT ON COLUMN annotation_result_history.qa_content_json IS '归档后的问答内容 JSON 字符串。字段类型为 TEXT,建议结构为 {"question":"...","answer":"..."}。';
+COMMENT ON COLUMN annotation_result_history.qa_content_storage_mode IS '归档后的问答内容存储模式,默认 INLINE,可选 INLINE、EXTERNAL。';
+COMMENT ON COLUMN annotation_result_history.qa_content_file_path IS '当 qa_content_storage_mode = EXTERNAL 时,记录归档后的外置问答内容文件路径。';
+COMMENT ON COLUMN annotation_result_history.archive_reason IS '归档原因说明。';
+COMMENT ON COLUMN annotation_result_history.archived_by IS '归档操作人用户ID。';
+COMMENT ON COLUMN annotation_result_history.archived_at IS '归档时间。';
+COMMENT ON COLUMN annotation_result_history.created_at IS '创建时间。';
+
+CREATE TABLE IF NOT EXISTS training_dataset (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ creator_id BIGINT NOT NULL,
+ creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ result_history_id BIGINT NOT NULL,
+ sample_type VARCHAR(32) NOT NULL DEFAULT 'TEXT',
+ glm_format_json TEXT NOT NULL,
+ dataset_status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_training_dataset_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_training_dataset_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id),
+ CONSTRAINT fk_training_dataset_result_history FOREIGN KEY (result_history_id) REFERENCES annotation_result_history(id)
+);
+
+COMMENT ON TABLE training_dataset IS '训练样本表。';
+COMMENT ON COLUMN training_dataset.id IS '训练样本主键ID。';
+COMMENT ON COLUMN training_dataset.company_id IS '所属公司ID。';
+COMMENT ON COLUMN training_dataset.creator_id IS '样本创建人用户ID。';
+COMMENT ON COLUMN training_dataset.creator_role IS '样本创建人数据权限角色,默认 EMPLOYEE。';
+COMMENT ON COLUMN training_dataset.result_history_id IS '来源历史结果ID。';
+COMMENT ON COLUMN training_dataset.sample_type IS '样本类型,默认 TEXT,可按需扩展 IMAGE、VIDEO。';
+COMMENT ON COLUMN training_dataset.glm_format_json IS 'GLM微调格式 JSON 字符串。';
+COMMENT ON COLUMN training_dataset.dataset_status IS '样本状态,默认 DRAFT,可选 EXPORTED。';
+COMMENT ON COLUMN training_dataset.created_at IS '创建时间。';
+COMMENT ON COLUMN training_dataset.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS export_batch (
+ id BIGINT PRIMARY KEY,
+ company_id BIGINT NOT NULL,
+ creator_id BIGINT NOT NULL,
+ creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
+ batch_no VARCHAR(64) NOT NULL UNIQUE,
+ dataset_file_path VARCHAR(512),
+ sample_count INTEGER NOT NULL DEFAULT 0,
+ finetune_job_id VARCHAR(128),
+ finetune_status VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_export_batch_company FOREIGN KEY (company_id) REFERENCES sys_company(id),
+ CONSTRAINT fk_export_batch_creator FOREIGN KEY (creator_id) REFERENCES sys_user(id)
+);
+
+COMMENT ON TABLE export_batch IS '导出批次表。';
+COMMENT ON COLUMN export_batch.id IS '导出批次主键ID。';
+COMMENT ON COLUMN export_batch.company_id IS '所属公司ID。';
+COMMENT ON COLUMN export_batch.creator_id IS '批次创建人用户ID。';
+COMMENT ON COLUMN export_batch.creator_role IS '批次创建人数据权限角色,默认 EMPLOYEE。';
+COMMENT ON COLUMN export_batch.batch_no IS '批次编号,全局唯一。';
+COMMENT ON COLUMN export_batch.dataset_file_path IS '导出的数据文件路径。';
+COMMENT ON COLUMN export_batch.sample_count IS '批次包含的样本数量,默认 0。';
+COMMENT ON COLUMN export_batch.finetune_job_id IS '微调任务ID。';
+COMMENT ON COLUMN export_batch.finetune_status IS '微调状态,默认 NOT_STARTED,可选 RUNNING、SUCCESS、FAILED。';
+COMMENT ON COLUMN export_batch.created_at IS '创建时间。';
+COMMENT ON COLUMN export_batch.updated_at IS '更新时间。';
+
+CREATE TABLE IF NOT EXISTS export_batch_item (
+ id BIGINT PRIMARY KEY,
+ batch_id BIGINT NOT NULL,
+ dataset_id BIGINT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_export_batch_item_batch FOREIGN KEY (batch_id) REFERENCES export_batch(id),
+ CONSTRAINT fk_export_batch_item_dataset FOREIGN KEY (dataset_id) REFERENCES training_dataset(id),
+ CONSTRAINT uq_export_batch_item UNIQUE (batch_id, dataset_id)
+);
+
+COMMENT ON TABLE export_batch_item IS '导出批次与训练样本关系表。';
+COMMENT ON COLUMN export_batch_item.id IS '批次样本关系主键ID。';
+COMMENT ON COLUMN export_batch_item.batch_id IS '关联导出批次ID。';
+COMMENT ON COLUMN export_batch_item.dataset_id IS '关联训练样本ID。';
+COMMENT ON COLUMN export_batch_item.created_at IS '创建时间。';
+
+CREATE INDEX IF NOT EXISTS idx_sys_user_company ON sys_user(company_id);
+CREATE INDEX IF NOT EXISTS idx_sys_user_role ON sys_user(company_id, role);
+CREATE INDEX IF NOT EXISTS idx_sys_user_position ON sys_user(company_id, position);
+CREATE INDEX IF NOT EXISTS idx_sys_menu_company_sort ON sys_menu(company_id, sort_order);
+CREATE INDEX IF NOT EXISTS idx_sys_config_company_type ON sys_config(company_id, config_type);
+CREATE INDEX IF NOT EXISTS idx_source_resource_company_type ON source_resource(company_id, resource_type);
+CREATE INDEX IF NOT EXISTS idx_source_resource_company_status ON source_resource(company_id, source_status);
+CREATE INDEX IF NOT EXISTS idx_source_resource_creator ON source_resource(company_id, creator_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_task_company_status ON annotation_task(company_id, task_status);
+CREATE INDEX IF NOT EXISTS idx_annotation_task_company_deleted ON annotation_task(company_id, is_deleted);
+CREATE INDEX IF NOT EXISTS idx_annotation_task_creator ON annotation_task(company_id, creator_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_task_resource_company_task ON annotation_task_resource(company_id, task_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_task_resource_company_resource ON annotation_task_resource(company_id, resource_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_result_company_deleted ON annotation_result(company_id, is_deleted);
+CREATE INDEX IF NOT EXISTS idx_annotation_result_company_manual ON annotation_result(company_id, requires_manual_review);
+CREATE INDEX IF NOT EXISTS idx_annotation_result_task ON annotation_result(company_id, task_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_result_history_company ON annotation_result_history(company_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_result_history_task ON annotation_result_history(company_id, task_id);
+CREATE INDEX IF NOT EXISTS idx_annotation_result_history_resource ON annotation_result_history(company_id, resource_id);
+CREATE INDEX IF NOT EXISTS idx_training_dataset_company_status ON training_dataset(company_id, dataset_status);
+CREATE INDEX IF NOT EXISTS idx_export_batch_company_status ON export_batch(company_id, finetune_status);
+
+COMMIT;