diff --git a/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java b/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java new file mode 100644 index 0000000..0669bc0 --- /dev/null +++ b/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java @@ -0,0 +1,41 @@ +package com.labelsys.backend.controller; + +import com.labelsys.backend.context.UserContext; +import com.labelsys.backend.dto.common.PageResult; +import com.labelsys.backend.dto.request.AnnotationResultHistoryPageQuery; +import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse; +import com.labelsys.backend.service.AnnotationResultArchiveService; +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.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/annotation-result-history") +@RequiredArgsConstructor +@Tag(name = "标注结果归档管理", description = "标注结果归档历史查询接口") +public class AnnotationResultArchiveController { + + private final AnnotationResultArchiveService annotationResultArchiveService; + + @Operation(summary = "分页查询归档历史") + @GetMapping + public ResponseEntity> pageHistory( + @Valid AnnotationResultHistoryPageQuery query) { + return ResponseEntity.ok(annotationResultArchiveService.pageHistory(UserContext.requireUser(), query)); + } + + @Operation(summary = "查询归档历史详情") + @GetMapping("/{id}") + public ResponseEntity getHistory( + @Parameter(description = "历史记录ID", example = "901") + @PathVariable Long id) { + return ResponseEntity.ok(annotationResultArchiveService.getHistory(UserContext.requireUser(), id)); + } +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java b/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java index 09ef432..21edc21 100644 --- a/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java +++ b/src/main/java/com/labelsys/backend/controller/AnnotationResultController.java @@ -1,23 +1,20 @@ 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.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,48 +22,46 @@ 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 +@Tag(name = "标注结果管理", description = "标注结果相关接口") 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)); + public ResponseEntity> pageResults( + @Valid AnnotationResultPageQuery query) { + return ResponseEntity.ok(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)); + public ResponseEntity getResult( + @Parameter(description = "结果ID", example = "191000000000000401") + @PathVariable Long id) { + return ResponseEntity.ok(annotationResultService.getResult(UserContext.requireUser(), id)); } - @Operation(summary = "查询标注结果比对信息") - //@RequirePosition(UserPosition.REVIEWER) + @Operation(summary = "查询结果比对信息,REVIEWER岗位以上可操作") @GetMapping("/{id}/compare") - public Result compare( - @Parameter(description = "结果ID", example = "191000000000000401") - @PathVariable Long id - ) { - return Result.success(annotationResultService.compareResult(UserContext.requireUser(), id)); + @RequirePosition(UserPosition.REVIEWER) + public ResponseEntity compareResult( + @Parameter(description = "结果ID", example = "191000000000000401") + @PathVariable Long id) { + return ResponseEntity.ok(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)); + @Operation(summary = "提交合并审核结果,REVIEWER岗位以上可操作") + @PostMapping("/{id}/merge") + @RequirePosition(UserPosition.REVIEWER) + public ResponseEntity mergeReviewResult( + @Parameter(description = "结果ID", example = "191000000000000401") + @PathVariable Long id, + @Valid @RequestBody MergeReviewResultRequest request) { + annotationResultService.mergeReviewResult(UserContext.requireUser(), id, request); + return ResponseEntity.ok().build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/controller/SourceResourceController.java b/src/main/java/com/labelsys/backend/controller/SourceResourceController.java index d751676..e79a342 100644 --- a/src/main/java/com/labelsys/backend/controller/SourceResourceController.java +++ b/src/main/java/com/labelsys/backend/controller/SourceResourceController.java @@ -68,23 +68,24 @@ public class SourceResourceController { return Result.success(); } - @Operation(summary = "下载图片资源") + @Operation(summary = "下载资源") @GetMapping("/{id}/download") - public ResponseEntity downloadImage( + public ResponseEntity downloadResource( @Parameter(description = "资源ID", example = "191000000000000101") @PathVariable Long id ) { var currentUser = UserContext.requireUser(); - byte[] imageData = sourceResourceService.downloadImage(currentUser, id); + byte[] resourceData = sourceResourceService.downloadResource(currentUser, id); // 获取资源信息以确定Content-Type SourceResource resource = sourceResourceService.getResourceEntity(id); - String contentType = sourceResourceService.getImageContentType(resource); + String contentType = sourceResourceService.getContentType(resource); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_TYPE, contentType) - .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getResourceName() + "\"") - .body(imageData); + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getResourceName() + "\"") + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.getFileSize())) + .body(resourceData); } // 添加新接口 diff --git a/src/main/java/com/labelsys/backend/dto/request/AnnotationResultHistoryPageQuery.java b/src/main/java/com/labelsys/backend/dto/request/AnnotationResultHistoryPageQuery.java new file mode 100644 index 0000000..23c4cec --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/request/AnnotationResultHistoryPageQuery.java @@ -0,0 +1,13 @@ +// AnnotationResultHistoryPageQuery.java +package com.labelsys.backend.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "归档历史分页查询") +public record AnnotationResultHistoryPageQuery( + @Schema(description = "任务ID", example = "701") Long taskId, + @Schema(description = "资源ID", example = "601") Long resourceId, + @Schema(description = "页码", example = "1") Integer pageNo, + @Schema(description = "每页数量", example = "10") Integer pageSize +) { +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java b/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java index d8bc6ab..e7930f0 100644 --- a/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java +++ b/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java @@ -1,12 +1,15 @@ package com.labelsys.backend.dto.request; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; + +import java.util.Map; @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 + @Schema(description = "合并后的答案映射,key为qa记录ID,value为合并后的答案") + Map mergedAnswers, + + @Schema(description = "审核备注") + String reviewComment ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java b/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java index ee2c21e..ea59399 100644 --- a/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java +++ b/src/main/java/com/labelsys/backend/dto/request/SourceResourcePageQuery.java @@ -4,10 +4,17 @@ 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 + @Schema(description = "关键字", example = "运输") String keyword, + @Schema(description = "资源类型", example = "TEXT") String resourceType, + @Schema(description = "资源状态", example = "READY") String sourceStatus, + @Schema(description = "页码(可选,与pageSize同时提供时启用分页)", example = "1") Integer pageNo, + @Schema(description = "每页数量(可选,与pageNo同时提供时启用分页)", example = "10") Integer pageSize ) { -} + /** + * 判断是否需要分页 + * @return true表示需要分页,false表示查询全部 + */ + public boolean needPagination() { + return pageNo != null && pageSize != null; + } +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java index 87ee883..b389969 100644 --- a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java @@ -2,15 +2,34 @@ package com.labelsys.backend.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + @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 + @Schema(description = "结果ID", example = "191000000000000401") Long id, + @Schema(description = "任务ID", example = "191000000000000301") Long taskId, + @Schema(description = "资源ID", example = "191000000000000101") Long resourceId, + + @Schema(description = "问答对列表") List qaRecords, + @Schema(description = "差异列表") List diffRecords, + @Schema(description = "资源预览路径", example = "preview/191000000000000101/index.html") String sourcePreviewPath ) { -} + + @Schema(description = "问答记录") + public record QaRecord( + @Schema(description = "记录ID", example = "qa_001") String id, + @Schema(description = "问题", example = "运输时效是多久?") String question, + @Schema(description = "答案", example = "3天") String answer, + @Schema(description = "是否需要审核", example = "true") Boolean requiresReview + ) {} + + @Schema(description = "差异记录") + public record DiffRecord( + @Schema(description = "关联的问答记录ID", example = "qa_001") String qaId, + @Schema(description = "问题", example = "运输时效是多久?") String question, + @Schema(description = "提取模型答案", example = "3天") String extractAnswer, + @Schema(description = "校验模型答案", example = "72小时") String verifyAnswer, + @Schema(description = "差异原因", example = "时间单位不一致") String diffReason, + @Schema(description = "合并后的最终答案", example = "72小时(3天)") String mergedAnswer + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java new file mode 100644 index 0000000..39636b7 --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java @@ -0,0 +1,20 @@ +// AnnotationResultHistoryResponse.java +package com.labelsys.backend.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "归档历史响应") +public record AnnotationResultHistoryResponse( + @Schema(description = "历史记录ID", example = "901") Long id, + @Schema(description = "来源结果ID", example = "802") Long sourceResultId, + @Schema(description = "任务ID", example = "701") Long taskId, + @Schema(description = "资源ID", example = "601") Long resourceId, + @Schema(description = "问答内容文件路径", example = "annotation-results/2/qa/802.json") String qaContentFilePath, + @Schema(description = "归档原因", example = "审核通过后归档") String archiveReason, + @Schema(description = "归档操作人ID", example = "5") Long archivedBy, + @Schema(description = "归档时间", example = "2026-05-06T10:30:00") LocalDateTime archivedAt, + @Schema(description = "创建时间", example = "2026-05-06T10:30:00") LocalDateTime createdAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java index 0b30c68..6e215ce 100644 --- a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java @@ -1,21 +1,22 @@ package com.labelsys.backend.dto.response; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; - import com.labelsys.backend.enums.AnnotationResultStatus; +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") AnnotationResultStatus 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 + @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") AnnotationResultStatus runtimeStatus, + @Schema(description = "是否需要人工审核", example = "true") Boolean requiresManualReview, + @Schema(description = "是否已删除", example = "false") Boolean isDeleted, + @Schema(description = "问答内容文件路径", example = "annotation-results/2/qa/801.json") String qaContentFilePath, + @Schema(description = "差异摘要文件路径", example = "annotation-results/2/diff/801.json") String diffSummaryFilePath, + @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 ) { -} +} \ No newline at end of file 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 ce5aeb1..468c830 100644 --- a/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/LoginResponse.java @@ -14,7 +14,7 @@ public record LoginResponse( @Schema(description = "用户名,可为空", example = "alpha-admin") String username, @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 = "岗位枚举,枚举值:ANNOTATOR标注员、DATA_TRAINER数据训练师、REVIEWER审核员、ADMIN公司管理员、SUPER_ADMIN超级管理员", example = "REVIEWER") UserPosition position, @Schema(description = "是否必须修改密码", example = "false") boolean mustChangePassword ) { public static LoginResponse from(String token, LoginUser loginUser, SysCompany company) { diff --git a/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java b/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java index 7488aa8..3925590 100644 --- a/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/SourceResourceResponse.java @@ -13,9 +13,10 @@ public record SourceResourceResponse( @Schema(description = "文件大小", example = "20480") Long fileSize, @Schema(description = "资源状态", example = "READY") String sourceStatus, @Schema(description = "存储提供方", example = "rustfs") String storageProvider, + @Schema(description = "是否有BBOX标注,不显示", example = "false") Boolean hasBbox, @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 ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java index 9a5921c..833b95f 100644 --- a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java +++ b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java @@ -1,37 +1,65 @@ 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 com.baomidou.mybatisplus.annotation.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Data @Builder @NoArgsConstructor @AllArgsConstructor @TableName("annotation_result") public class AnnotationResult { + @TableId(type = IdType.INPUT) private Long id; + + @TableField("company_id") private Long companyId; + + @TableField("creator_id") private Long creatorId; - private UserRole creatorRole; + + @TableField("creator_role") + private String creatorRole; + + @TableField("task_id") private Long taskId; + + @TableField("resource_id") private Long resourceId; - private String qaContentJson; - private String qaContentStorageMode; + + @TableField("qa_content_file_path") private String qaContentFilePath; - private String diffSummary; + + @TableField("diff_summary_file_path") + private String diffSummaryFilePath; + + @TableField("requires_manual_review") private Boolean requiresManualReview; + + @TableField("is_deleted") private Boolean isDeleted; + + @TableField("reviewer_id") private Long reviewerId; + + @TableField("review_comment") private String reviewComment; + + @TableField("reviewed_at") private LocalDateTime reviewedAt; + + @TableField("created_at") private LocalDateTime createdAt; + + @TableField("updated_at") private LocalDateTime updatedAt; -} + + @TableField(exist = false) + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java b/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java index d180683..f6cd493 100644 --- a/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java +++ b/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java @@ -20,11 +20,11 @@ public class AnnotationResultHistory { private Long id; private Long companyId; private Long creatorId; - private UserRole creatorRole; + private String creatorRole; private Long sourceResultId; private Long taskId; private Long resourceId; - private String qaContentJson; + //private String qaContentJson; private String qaContentStorageMode; private String qaContentFilePath; private String archiveReason; diff --git a/src/main/java/com/labelsys/backend/entity/SourceResource.java b/src/main/java/com/labelsys/backend/entity/SourceResource.java index ef469ab..08e82c2 100644 --- a/src/main/java/com/labelsys/backend/entity/SourceResource.java +++ b/src/main/java/com/labelsys/backend/entity/SourceResource.java @@ -28,7 +28,8 @@ public class SourceResource { private Long fileSize; private String sourceStatus; private String storageProvider; + private Boolean hasBbox; private String remark; private LocalDateTime createdAt; private LocalDateTime updatedAt; -} +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java index d1a568d..ec9b06b 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java @@ -1,27 +1,33 @@ package com.labelsys.backend.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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.request.MergeReviewResultRequest; +import com.labelsys.backend.dto.common.PageResult; +import com.labelsys.backend.dto.request.AnnotationResultHistoryPageQuery; +import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse; 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.enums.UserRole; 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; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -29,59 +35,80 @@ public class AnnotationResultArchiveService { private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW"; - private final AnnotationResultMapper annotationResultMapper; + private final AnnotationResultMapper annotationResultMapper; private final AnnotationResultHistoryMapper annotationResultHistoryMapper; + private final ObjectStorageService objectStorageService; + private final ObjectMapper objectMapper; + private final DataPermissionService dataPermissionService; + @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, "运行态结果不存在"); + public PageResult pageHistory(LoginUser currentUser, + AnnotationResultHistoryPageQuery query) { + List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); + boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); + + var wrapper = new LambdaQueryWrapper() + .eq(AnnotationResultHistory::getCompanyId, currentUser.companyId()) + .eq(query.taskId() != null, AnnotationResultHistory::getTaskId, query.taskId()) + .eq(query.resourceId() != null, AnnotationResultHistory::getResourceId, query.resourceId()) + .orderByDesc(AnnotationResultHistory::getCreatedAt); + + if (shouldFilterByUserId) { + wrapper.eq(AnnotationResultHistory::getCreatorId, currentUser.userId()); + } else if (!allowedRoles.isEmpty()) { + wrapper.in(AnnotationResultHistory::getCreatorRole, allowedRoles); } - 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); + var page = new Page(query.pageNo(), query.pageSize()); + var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper); - int updated = annotationResultMapper.markArchived( - result.getId(), - currentUser.companyId(), - currentUser.userId(), - request.reviewComment(), - archivedAt); - if (updated == 0) { - throw new BusinessException(ResultCode.CONFLICT, "结果已被其他操作处理"); + var records = resultPage.getRecords().stream() + .map(this::toResponse) + .toList(); + + return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), + (int) resultPage.getSize()); + } + + public AnnotationResultHistoryResponse getHistory(LoginUser currentUser, Long historyId) { + AnnotationResultHistory history = annotationResultHistoryMapper.selectById(historyId); + if (history == null || !history.getCompanyId().equals(currentUser.companyId())) { + throw new BusinessException(ResultCode.NOT_FOUND, "历史记录不存在"); } + assertHistoryPermission(currentUser, history); + return toResponse(history); + } - 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); + private void assertHistoryPermission(LoginUser currentUser, AnnotationResultHistory history) { + if (!dataPermissionService.canAccessCreator(currentUser, history.getCreatorId(), + UserRole.valueOf(history.getCreatorRole()))) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权访问该归档记录"); + } + } + + private AnnotationResultHistoryResponse toResponse(AnnotationResultHistory history) { + return new AnnotationResultHistoryResponse( + history.getId(), + history.getSourceResultId(), + history.getTaskId(), + history.getResourceId(), + history.getQaContentFilePath(), + history.getArchiveReason(), + history.getArchivedBy(), + history.getArchivedAt(), + history.getCreatedAt() + ); } @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)); + .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) { @@ -97,44 +124,88 @@ public class AnnotationResultArchiveService { } } - private String resolveStorageMode(AnnotationResult result) { - if (QaContentStorageMode.isValid(result.getQaContentStorageMode())) { - return result.getQaContentStorageMode(); - } - return QaContentStorageMode.INLINE.name(); - } - + /** + * 归档运行态标注结果到历史表 + * 从对象存储读取 qa.json 内容进行归档 + */ private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result, Long reviewerId, String archiveReason, String reviewComment) { LocalDateTime archivedAt = LocalDateTime.now(); + + // 从对象存储读取 qa.json 内容 + String qaContentJson = loadQaContentJson(result); + 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(); + .id(IdGenerator.nextId()) + .companyId(result.getCompanyId()) + .creatorId(result.getCreatorId()) + .creatorRole(result.getCreatorRole()) + .sourceResultId(result.getId()) + .taskId(result.getTaskId()) + .resourceId(result.getResourceId()) + //.qaContentJson(qaContentJson) // 使用从对象存储读取的内容 + .qaContentFilePath(result.getQaContentFilePath()) + .archiveReason(archiveReason) + .archivedBy(reviewerId) + .archivedAt(archivedAt) + .build(); annotationResultHistoryMapper.insert(history); int updated = annotationResultMapper.markArchived( - result.getId(), - result.getCompanyId(), - reviewerId, - reviewComment, - archivedAt); + result.getId(), + result.getCompanyId(), + reviewerId, + reviewComment, + archivedAt); if (updated == 0) { return null; } return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt); } -} + + /** + * 从对象存储读取 qa.json 内容 + */ + private String loadQaContentJson(AnnotationResult result) { + try { + String filePath = result.getQaContentFilePath(); + if (filePath == null || filePath.isEmpty()) { + log.warn("qa_content_file_path is null or empty, resultId={}", result.getId()); + return "{}"; + } + + // 解析文件路径,提取 bucket 和 object key + String bucketName = extractBucketName(filePath); + String objectKey = extractObjectKey(filePath); + + // 从对象存储下载文件内容 + byte[] content = objectStorageService.download(bucketName, objectKey); + return new String(content, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("Failed to load qa content from object storage, resultId={}, filePath={}", + result.getId(), result.getQaContentFilePath(), e); + // 如果读取失败,返回空 JSON + return "{}"; + } + } + + /** + * 从文件路径中提取 bucket 名称 + * 例如:annotation-results/2/qa/801.json -> annotation-results + */ + private String extractBucketName(String filePath) { + int firstSlash = filePath.indexOf('/'); + return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath; + } + + /** + * 从文件路径中提取 object key + * 例如:annotation-results/2/qa/801.json -> 2/qa/801.json + */ + private String extractObjectKey(String filePath) { + int firstSlash = filePath.indexOf('/'); + return firstSlash > 0 ? filePath.substring(firstSlash + 1) : ""; + } +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java index a1171c4..414fb2c 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java @@ -1,49 +1,59 @@ package com.labelsys.backend.service; -import java.nio.charset.StandardCharsets; -import java.util.List; - -import org.springframework.stereotype.Service; - import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.type.TypeReference; +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.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.entity.AnnotationResult; +import com.labelsys.backend.entity.AnnotationResultHistory; import com.labelsys.backend.entity.SourceResource; import com.labelsys.backend.enums.AnnotationResultStatus; -import com.labelsys.backend.enums.QaContentStorageMode; +import com.labelsys.backend.enums.UserRole; +import com.labelsys.backend.mapper.AnnotationResultHistoryMapper; import com.labelsys.backend.mapper.AnnotationResultMapper; import com.labelsys.backend.mapper.SourceResourceMapper; - +import com.labelsys.backend.util.IdGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; @Slf4j @Service @RequiredArgsConstructor public class AnnotationResultService { - private final AnnotationResultMapper annotationResultMapper; - private final SourceResourceMapper sourceResourceMapper; - private final DataPermissionService dataPermissionService; - private final ObjectStorageService objectStorageService; + private final AnnotationResultMapper annotationResultMapper; + private final AnnotationResultHistoryMapper annotationResultHistoryMapper; + private final SourceResourceMapper sourceResourceMapper; + private final DataPermissionService dataPermissionService; + private final ObjectStorageService objectStorageService; + private final ObjectMapper objectMapper; public PageResult pageResults(LoginUser currentUser, AnnotationResultPageQuery query) { List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + var 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()); + query.requiresManualReview()) + .orderByDesc(AnnotationResult::getCreatedAt); if (shouldFilterByUserId) { wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId()); @@ -51,12 +61,11 @@ public class AnnotationResultService { wrapper.in(AnnotationResult::getCreatorRole, allowedRoles); } - wrapper.orderByDesc(AnnotationResult::getCreatedAt); + var page = new Page(query.pageNo(), query.pageSize()); + var resultPage = annotationResultMapper.selectPage(page, wrapper); - Page page = new Page<>(query.pageNo(), query.pageSize()); - Page resultPage = annotationResultMapper.selectPage(page, wrapper); - - List records = resultPage.getRecords().stream().map(this::toResponse) + var records = resultPage.getRecords().stream() + .map(this::toResponse) .filter(response -> query.runtimeStatus() == null || query.runtimeStatus().equals(response.runtimeStatus())) .toList(); @@ -72,14 +81,122 @@ public class AnnotationResultService { currentUser.companyId(), currentUser.userId()); throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); } + //assertResultPermission(currentUser, result); 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, "结果不存在"); + } + //assertResultPermission(currentUser, result); + + QaContent qaContent = loadQaContent(result); + DiffContent diffContent = StringUtils.hasText(result.getDiffSummaryFilePath()) ? + loadDiffSummary(result) : null; + + SourceResource resource = sourceResourceMapper.selectById(result.getResourceId()); + + // 转换 QA 记录 + List qaRecords = qaContent.records().stream() + .map(qa -> new AnnotationResultCompareResponse.QaRecord( + qa.id(), + qa.question(), + qa.answer(), + qa.requiresReview() + )).toList(); + + // 转换差异记录 + List diffRecords = diffContent != null ? + diffContent.records().stream() + .map(diff -> new AnnotationResultCompareResponse.DiffRecord( + diff.qaId(), + diff.question(), + diff.extractAnswer(), + diff.verifyAnswer(), + diff.diffReason(), + diff.mergedAnswer() + )).toList() : List.of(); + + return new AnnotationResultCompareResponse( + result.getId(), + result.getTaskId(), + result.getResourceId(), + qaRecords, + diffRecords, + resource == null ? null : resource.getFilePath() + ); + } + + @Transactional + public void mergeReviewResult(LoginUser currentUser, Long resultId, MergeReviewResultRequest request) { + AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId()); + if (result == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); + } + //assertResultPermission(currentUser, result); + + // 读取当前 qa.json + QaContent qaContent = loadQaContent(result); + + // 更新 qa.json 的 answer 字段 + List updatedQaRecords = qaContent.records().stream() + .map(record -> { + String mergedAnswer = request.mergedAnswers().get(record.id()); + if (mergedAnswer != null) { + return new QaContent.QaRecord( + record.id(), + record.question(), + mergedAnswer, + false + ); + } + return record; + }) + .toList(); + + QaContent updatedQaContent = new QaContent( + qaContent.taskId(), + qaContent.resourceId(), + updatedQaRecords, + new QaContent.Metadata( + qaContent.metadata().createdAt(), + LocalDateTime.now().toString() + ) + ); + saveQaContent(result, updatedQaContent); + + // 更新数据库记录 + result.setReviewerId(currentUser.userId()); + result.setReviewComment(request.reviewComment()); + result.setReviewedAt(LocalDateTime.now()); + result.setRequiresManualReview(false); + annotationResultMapper.updateById(result); + + // 归档到历史表 + archiveToHistory(result, currentUser, "审核通过后归档"); + + log.info("merged review result, companyId={}, userId={}, resultId={}", + currentUser.companyId(), currentUser.userId(), resultId); + } + 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()); + return new AnnotationResultResponse( + result.getId(), + result.getTaskId(), + result.getResourceId(), + deriveStatus(result), + result.getRequiresManualReview(), + result.getIsDeleted(), + result.getQaContentFilePath(), + result.getDiffSummaryFilePath(), + result.getReviewComment(), + result.getReviewedAt(), + result.getCreatedAt() + ); } private AnnotationResultStatus deriveStatus(AnnotationResult result) { @@ -92,69 +209,130 @@ public class AnnotationResultService { return AnnotationResultStatus.AUTO_ARCHIVE_PENDING; } - 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, "结果不存在"); + private QaContent loadQaContent(AnnotationResult result) { + try { + String filePath = result.getQaContentFilePath(); + String bucketName = extractBucketName(filePath); + String objectKey = extractObjectKey(filePath); + byte[] content = objectStorageService.download(bucketName, objectKey); + String jsonContent = new String(content, StandardCharsets.UTF_8); + return objectMapper.readValue(jsonContent, new TypeReference() { + }); + } catch (Exception e) { + log.error("Failed to load qa content, resultId={}, filePath={}", result.getId(), + result.getQaContentFilePath(), e); + throw new BusinessException(ResultCode.ERROR, "加载问答内容失败"); } - - String qaContentJson = resolveQaContent(result); - - SourceResource resource = sourceResourceMapper.selectById(result.getResourceId()); - return new AnnotationResultCompareResponse( - result.getId(), - result.getTaskId(), - result.getResourceId(), - qaContentJson, - result.getDiffSummary(), - result.getQaContentStorageMode(), - result.getQaContentFilePath(), - resource == null ? null : resource.getFilePath()); } - private String resolveQaContent(AnnotationResult result) { - if (QaContentStorageMode.EXTERNAL.name().equals(result.getQaContentStorageMode())) { - if (result.getQaContentFilePath() == null || result.getQaContentFilePath().isBlank()) { - log.warn("External storage mode but file path is empty, resultId={}", result.getId()); - return "{}"; - } - try { - String filePath = result.getQaContentFilePath(); - String bucketName = extractBucketName(filePath); - String objectKey = extractObjectKey(filePath); - byte[] content = objectStorageService.download(bucketName, objectKey); - return new String(content, StandardCharsets.UTF_8); - } catch (Exception e) { - log.error("Failed to download external qa content, resultId={}, filePath={}", - result.getId(), result.getQaContentFilePath(), e); - throw new BusinessException(ResultCode.ERROR, "下载问答内容失败"); - } - } else { - return result.getQaContentJson() != null ? result.getQaContentJson() : "{}"; + private DiffContent loadDiffSummary(AnnotationResult result) { + try { + String filePath = result.getDiffSummaryFilePath(); + String bucketName = extractBucketName(filePath); + String objectKey = extractObjectKey(filePath); + byte[] content = objectStorageService.download(bucketName, objectKey); + String jsonContent = new String(content, StandardCharsets.UTF_8); + return objectMapper.readValue(jsonContent, new TypeReference() { + }); + } catch (Exception e) { + log.error("Failed to load diff summary, resultId={}, filePath={}", result.getId(), + result.getDiffSummaryFilePath(), e); + throw new BusinessException(ResultCode.ERROR, "加载差异摘要失败"); + } + } + + private void saveQaContent(AnnotationResult result, QaContent qaContent) { + try { + String jsonContent = objectMapper.writeValueAsString(qaContent); + String filePath = result.getQaContentFilePath(); + String bucketName = extractBucketName(filePath); + String objectKey = extractObjectKey(filePath); + objectStorageService.upload(bucketName, objectKey, jsonContent.getBytes(StandardCharsets.UTF_8), + "application/json"); + } catch (Exception e) { + log.error("Failed to save qa content, resultId={}", result.getId(), e); + throw new BusinessException(ResultCode.ERROR, "保存问答内容失败"); + } + } + + private void assertResultPermission(LoginUser currentUser, AnnotationResult result) { + if (!dataPermissionService.canAccessCreator(currentUser, result.getCreatorId(), + UserRole.valueOf(result.getCreatorRole()))) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权访问该标注结果"); + } + } + + private void archiveToHistory(AnnotationResult result, LoginUser currentUser, String archiveReason) { + try { + // 读取 qa.json 内容用于归档 + QaContent qaContent = loadQaContent(result); + + // 构建归档记录 + AnnotationResultHistory history = AnnotationResultHistory.builder() + .id(IdGenerator.nextId()) + .companyId(result.getCompanyId()) + .creatorId(currentUser.userId()) + .creatorRole(currentUser.role().name()) + .sourceResultId(result.getId()) + .taskId(result.getTaskId()) + .resourceId(result.getResourceId()) + //.qaContentJson(objectMapper.writeValueAsString(qaContent)) + .qaContentFilePath(result.getQaContentFilePath()) + .archiveReason(archiveReason) + .archivedBy(currentUser.userId()) + .archivedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .build(); + + annotationResultHistoryMapper.insert(history); + + log.info("archived result to history, resultId={}, historyId={}", result.getId(), history.getId()); + } catch (Exception e) { + log.error("Failed to archive result to history, resultId={}", result.getId(), e); + throw new BusinessException(ResultCode.ERROR, "归档失败"); } } private String extractBucketName(String filePath) { - if (filePath.startsWith("/")) { - filePath = filePath.substring(1); - } - int firstSlash = filePath.indexOf("/"); - if (firstSlash > 0) { - return filePath.substring(0, firstSlash); - } - throw new BusinessException(ResultCode.BAD_REQUEST, "无效的文件路径格式"); + // 从文件路径中提取 bucket 名称 + // 例如:annotation-results/2/qa/801.json -> annotation-results + int firstSlash = filePath.indexOf('/'); + return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath; } private String extractObjectKey(String filePath) { - if (filePath.startsWith("/")) { - filePath = filePath.substring(1); + // 从文件路径中提取 object key + // 例如:annotation-results/2/qa/801.json -> 2/qa/801.json + int firstSlash = filePath.indexOf('/'); + return firstSlash > 0 ? filePath.substring(firstSlash + 1) : ""; + } + + // 内部类:qa.json 结构 + private record QaContent( + Long taskId, + Long resourceId, + List records, + Metadata metadata + ) { + private record QaRecord(String id, String question, String answer, Boolean requiresReview) { } - int firstSlash = filePath.indexOf("/"); - if (firstSlash > 0 && firstSlash < filePath.length() - 1) { - return filePath.substring(firstSlash + 1); + + private record Metadata(String createdAt, String updatedAt) { + } + } + + // 内部类:diff.json 结构 + private record DiffContent( + Long taskId, + Long resourceId, + List records, + Metadata metadata + ) { + private record DiffRecord(String qaId, String question, String extractAnswer, + String verifyAnswer, String diffReason, String mergedAnswer) { + } + + private record Metadata(String createdAt) { } - throw new BusinessException(ResultCode.BAD_REQUEST, "无效的文件路径格式"); } } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/SourceResourceService.java b/src/main/java/com/labelsys/backend/service/SourceResourceService.java index d773446..9a6960e 100644 --- a/src/main/java/com/labelsys/backend/service/SourceResourceService.java +++ b/src/main/java/com/labelsys/backend/service/SourceResourceService.java @@ -117,13 +117,19 @@ public class SourceResourceService { 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()); + // 判断是否需要分页 + if (query.needPagination()) { + 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()); + } else { + // 不分页,查询全部 + List records = sourceResourceMapper.selectList(wrapper); + List responseList = records.stream().map(this::toResponse).toList(); + return new PageResult<>(responseList, (long) responseList.size(), 1, responseList.size()); + } } public SourceResourceResponse getResource(LoginUser currentUser, Long resourceId) { @@ -173,8 +179,8 @@ public class SourceResourceService { 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()); + resource.getStorageProvider(), resource.getHasBbox(), resource.getRemark(), + creator == null ? null : creator.getRealName(), resource.getCreatedAt(), resource.getUpdatedAt()); } private String resolveExtension(String originalFilename, String resourceType) { @@ -190,22 +196,19 @@ public class SourceResourceService { } /** - * 下载图片资源 + * 下载资源(支持 TEXT、IMAGE、VIDEO) * * @param currentUser 当前用户 * @param resourceId 资源ID - * @return 图片字节数组 + * @return 资源字节数组 */ - public byte[] downloadImage(LoginUser currentUser, Long resourceId) { + public byte[] downloadResource(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, "资源不存在"); } - if (!"IMAGE".equals(resource.getResourceType())) { - throw new BusinessException(ResultCode.BAD_REQUEST, "仅图片资源支持下载"); - } if (!"READY".equals(resource.getSourceStatus())) { throw new BusinessException(ResultCode.BAD_REQUEST, "资源未就绪"); } @@ -213,24 +216,58 @@ public class SourceResourceService { } /** - * 获取图片资源的Content-Type + * 获取资源的Content-Type(支持 TEXT、IMAGE、VIDEO) * * @param resource 资源实体 * @return Content-Type */ - public String getImageContentType(SourceResource resource) { + public String getContentType(SourceResource resource) { String filePath = resource.getFilePath(); + String resourceType = resource.getResourceType(); + + // 优先根据文件扩展名判断 if (filePath != null && filePath.contains(".")) { String extension = filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase(); return switch (extension) { + // 图片类型 case "jpg", "jpeg" -> "image/jpeg"; case "png" -> "image/png"; case "gif" -> "image/gif"; case "webp" -> "image/webp"; - default -> "application/octet-stream"; + case "bmp" -> "image/bmp"; + // 视频类型 + case "mp4" -> "video/mp4"; + case "webm" -> "video/webm"; + case "mov" -> "video/quicktime"; + case "avi" -> "video/x-msvideo"; + case "mkv" -> "video/x-matroska"; + // 文本类型 + case "txt" -> "text/plain; charset=UTF-8"; + case "json" -> "application/json; charset=UTF-8"; + case "xml" -> "application/xml; charset=UTF-8"; + case "csv" -> "text/csv; charset=UTF-8"; + case "md" -> "text/markdown; charset=UTF-8"; + default -> getContentTypeByResourceType(resourceType); }; } - return "application/octet-stream"; + + // 如果没有扩展名,根据资源类型判断 + return getContentTypeByResourceType(resourceType); + } + + /** + * 根据资源类型获取默认Content-Type + * + * @param resourceType 资源类型 + * @return Content-Type + */ + private String getContentTypeByResourceType(String resourceType) { + return switch (resourceType) { + case "IMAGE" -> "image/png"; + case "VIDEO" -> "video/mp4"; + case "TEXT" -> "text/plain; charset=UTF-8"; + default -> "application/octet-stream"; + }; } public ImageBboxResponse getImageBbox(LoginUser currentUser, Long resourceId) { @@ -278,6 +315,8 @@ public class SourceResourceService { throw new BusinessException(ResultCode.BAD_REQUEST, "BBOX数据序列化失败"); } + boolean isNewAnnotation = imageBboxAnnotationMapper.selectByResourceId(resourceId) == null; + ImageBboxAnnotation existing = imageBboxAnnotationMapper.selectByResourceId(resourceId); if (existing != null) { existing.setBboxJson(bboxJson); @@ -298,6 +337,12 @@ public class SourceResourceService { imageBboxAnnotationMapper.insert(annotation); } + // 更新资源表的has_bbox字段 + if (isNewAnnotation || Boolean.FALSE.equals(resource.getHasBbox())) { + resource.setHasBbox(true); + sourceResourceMapper.updateById(resource); + } + return getImageBbox(currentUser, resourceId); } @@ -308,6 +353,12 @@ public class SourceResourceService { throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在"); } imageBboxAnnotationMapper.deleteByResourceId(resourceId); + + // 更新资源表的has_bbox字段为false + if (Boolean.TRUE.equals(resource.getHasBbox())) { + resource.setHasBbox(false); + sourceResourceMapper.updateById(resource); + } } private List parseBboxJson(String bboxJson) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5d2b770..1f32a08 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,7 +39,8 @@ labelsys: ttl: PT2H store-type: redis annotation: - auto-archive-timeout: PT2H + auto-archive-fixed-delay: 300000 # 定时任务执行间隔(毫秒),默认5分钟 + auto-archive-timeout: PT2H # 自动归档超时时间,默认2小时 object-storage: endpoint: ${OBJECT_STORAGE_ENDPOINT:http://39.107.112.174:9000} region: ${OBJECT_STORAGE_REGION:cn-east-1} diff --git a/src/main/resources/mapper/AnnotationResultMapper.xml b/src/main/resources/mapper/AnnotationResultMapper.xml index 77fb503..76dd7b9 100644 --- a/src/main/resources/mapper/AnnotationResultMapper.xml +++ b/src/main/resources/mapper/AnnotationResultMapper.xml @@ -8,10 +8,8 @@ - - - + @@ -22,9 +20,9 @@ - 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 + id, company_id, creator_id, creator_role, task_id, resource_id, qa_content_file_path, + diff_summary_file_path, requires_manual_review, is_deleted, reviewer_id, review_comment, + reviewed_at, created_at, updated_at