Merge branch 'dev54'

This commit is contained in:
wh
2026-05-07 00:24:21 +08:00
21 changed files with 733 additions and 310 deletions

View File

@@ -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<PageResult<AnnotationResultHistoryResponse>> pageHistory(
@Valid AnnotationResultHistoryPageQuery query) {
return ResponseEntity.ok(annotationResultArchiveService.pageHistory(UserContext.requireUser(), query));
}
@Operation(summary = "查询归档历史详情")
@GetMapping("/{id}")
public ResponseEntity<AnnotationResultHistoryResponse> getHistory(
@Parameter(description = "历史记录ID", example = "901")
@PathVariable Long id) {
return ResponseEntity.ok(annotationResultArchiveService.getHistory(UserContext.requireUser(), id));
}
}

View File

@@ -1,23 +1,20 @@
package com.labelsys.backend.controller; package com.labelsys.backend.controller;
import com.labelsys.backend.annotation.RequirePosition; import com.labelsys.backend.annotation.RequirePosition;
import com.labelsys.backend.common.Result;
import com.labelsys.backend.context.UserContext; import com.labelsys.backend.context.UserContext;
import com.labelsys.backend.dto.common.PageResult; import com.labelsys.backend.dto.common.PageResult;
import com.labelsys.backend.dto.request.AnnotationResultPageQuery; import com.labelsys.backend.dto.request.AnnotationResultPageQuery;
import com.labelsys.backend.dto.request.MergeReviewResultRequest; import com.labelsys.backend.dto.request.MergeReviewResultRequest;
import com.labelsys.backend.dto.response.AnnotationResultCompareResponse; import com.labelsys.backend.dto.response.AnnotationResultCompareResponse;
import com.labelsys.backend.dto.response.AnnotationResultResponse; import com.labelsys.backend.dto.response.AnnotationResultResponse;
import com.labelsys.backend.dto.response.MergeReviewResultResponse;
import com.labelsys.backend.enums.UserPosition; import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.service.AnnotationResultArchiveService;
import com.labelsys.backend.service.AnnotationResultService; import com.labelsys.backend.service.AnnotationResultService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Tag(name = "标注结果管理")
@RestController @RestController
@RequestMapping("/api/annotation-results") @RequestMapping("/api/annotation-results")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "标注结果管理", description = "标注结果相关接口")
public class AnnotationResultController { public class AnnotationResultController {
private final AnnotationResultService annotationResultService; private final AnnotationResultService annotationResultService;
private final AnnotationResultArchiveService annotationResultArchiveService;
@Operation(summary = "分页查询标注结果") @Operation(summary = "分页查询标注结果")
@GetMapping @GetMapping
public Result<PageResult<AnnotationResultResponse>> page(@ParameterObject AnnotationResultPageQuery query) { public ResponseEntity<PageResult<AnnotationResultResponse>> pageResults(
return Result.success(annotationResultService.pageResults(UserContext.requireUser(), query)); @Valid AnnotationResultPageQuery query) {
return ResponseEntity.ok(annotationResultService.pageResults(UserContext.requireUser(), query));
} }
@Operation(summary = "查询标注结果详情") @Operation(summary = "查询标注结果详情")
@GetMapping("/{id}") @GetMapping("/{id}")
public Result<AnnotationResultResponse> detail( public ResponseEntity<AnnotationResultResponse> getResult(
@Parameter(description = "结果ID", example = "191000000000000401") @Parameter(description = "结果ID", example = "191000000000000401")
@PathVariable Long id @PathVariable Long id) {
) { return ResponseEntity.ok(annotationResultService.getResult(UserContext.requireUser(), id));
return Result.success(annotationResultService.getResult(UserContext.requireUser(), id));
} }
@Operation(summary = "查询标注结果比对信息") @Operation(summary = "查询结果比对信息REVIEWER岗位以上可操作")
//@RequirePosition(UserPosition.REVIEWER)
@GetMapping("/{id}/compare") @GetMapping("/{id}/compare")
public Result<AnnotationResultCompareResponse> compare( @RequirePosition(UserPosition.REVIEWER)
@Parameter(description = "结果ID", example = "191000000000000401") public ResponseEntity<AnnotationResultCompareResponse> compareResult(
@PathVariable Long id @Parameter(description = "结果ID", example = "191000000000000401")
) { @PathVariable Long id) {
return Result.success(annotationResultService.compareResult(UserContext.requireUser(), id)); return ResponseEntity.ok(annotationResultService.compareResult(UserContext.requireUser(), id));
} }
@Operation(summary = "提交合并审核结果") @Operation(summary = "提交合并审核结果,REVIEWER岗位以上可操作")
//@RequirePosition(UserPosition.REVIEWER) @PostMapping("/{id}/merge")
@PostMapping("/{id}/merge-review") @RequirePosition(UserPosition.REVIEWER)
public Result<MergeReviewResultResponse> mergeReview( public ResponseEntity<Void> mergeReviewResult(
@Parameter(description = "结果ID", example = "191000000000000401") @Parameter(description = "结果ID", example = "191000000000000401")
@PathVariable Long id, @PathVariable Long id,
@Valid @RequestBody MergeReviewResultRequest request @Valid @RequestBody MergeReviewResultRequest request) {
) { annotationResultService.mergeReviewResult(UserContext.requireUser(), id, request);
return Result.success(annotationResultArchiveService.mergeReview(UserContext.requireUser(), id, request)); return ResponseEntity.ok().build();
} }
} }

View File

@@ -68,23 +68,24 @@ public class SourceResourceController {
return Result.success(); return Result.success();
} }
@Operation(summary = "下载图片资源") @Operation(summary = "下载资源")
@GetMapping("/{id}/download") @GetMapping("/{id}/download")
public ResponseEntity<byte[]> downloadImage( public ResponseEntity<byte[]> downloadResource(
@Parameter(description = "资源ID", example = "191000000000000101") @Parameter(description = "资源ID", example = "191000000000000101")
@PathVariable Long id @PathVariable Long id
) { ) {
var currentUser = UserContext.requireUser(); var currentUser = UserContext.requireUser();
byte[] imageData = sourceResourceService.downloadImage(currentUser, id); byte[] resourceData = sourceResourceService.downloadResource(currentUser, id);
// 获取资源信息以确定Content-Type // 获取资源信息以确定Content-Type
SourceResource resource = sourceResourceService.getResourceEntity(id); SourceResource resource = sourceResourceService.getResourceEntity(id);
String contentType = sourceResourceService.getImageContentType(resource); String contentType = sourceResourceService.getContentType(resource);
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, contentType) .header(HttpHeaders.CONTENT_TYPE, contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getResourceName() + "\"") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getResourceName() + "\"")
.body(imageData); .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.getFileSize()))
.body(resourceData);
} }
// 添加新接口 // 添加新接口

View File

@@ -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
) {
}

View File

@@ -1,12 +1,15 @@
package com.labelsys.backend.dto.request; package com.labelsys.backend.dto.request;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
@Schema(description = "合并审核结果请求") @Schema(description = "合并审核结果请求")
public record MergeReviewResultRequest( public record MergeReviewResultRequest(
@Schema(description = "差异摘要 JSON", example = "{\"changed\":[{\"field\":\"answer\",\"from\":\"3天\",\"to\":\"72小时\"}],\"summary\":\"统一时间表达\"}") @NotBlank(message = "差异摘要不能为空") String diffSummary, @Schema(description = "合并后的答案映射key为qa记录IDvalue为合并后的答案")
@Schema(description = "最终问答内容 JSON", example = "[{\"question\":\"运输时效是多久?\",\"answer\":\"72小时\"}]") @NotBlank(message = "问答内容不能为空") String qaContentJson, Map<String, String> mergedAnswers,
@Schema(description = "审核备注", example = "已按审核意见合并,统一为小时口径。") String reviewComment
@Schema(description = "审核备注")
String reviewComment
) { ) {
} }

View File

@@ -4,10 +4,17 @@ import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "资源分页查询请求") @Schema(description = "资源分页查询请求")
public record SourceResourcePageQuery( public record SourceResourcePageQuery(
@Schema(description = "关键字", example = "运输") String keyword, @Schema(description = "关键字", example = "运输") String keyword,
@Schema(description = "资源类型", example = "TEXT") String resourceType, @Schema(description = "资源类型", example = "TEXT") String resourceType,
@Schema(description = "资源状态", example = "READY") String sourceStatus, @Schema(description = "资源状态", example = "READY") String sourceStatus,
@Schema(description = "页码", example = "1") Integer pageNo, @Schema(description = "页码可选与pageSize同时提供时启用分页", example = "1") Integer pageNo,
@Schema(description = "每页数量", example = "10") Integer pageSize @Schema(description = "每页数量可选与pageNo同时提供时启用分页", example = "10") Integer pageSize
) { ) {
/**
* 判断是否需要分页
* @return true表示需要分页false表示查询全部
*/
public boolean needPagination() {
return pageNo != null && pageSize != null;
}
} }

View File

@@ -2,15 +2,34 @@ package com.labelsys.backend.dto.response;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@Schema(description = "标注结果比对响应") @Schema(description = "标注结果比对响应")
public record AnnotationResultCompareResponse( public record AnnotationResultCompareResponse(
@Schema(description = "结果ID", example = "191000000000000401") Long id, @Schema(description = "结果ID", example = "191000000000000401") Long id,
@Schema(description = "任务ID", example = "191000000000000301") Long taskId, @Schema(description = "任务ID", example = "191000000000000301") Long taskId,
@Schema(description = "资源ID", example = "191000000000000101") Long resourceId, @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 = "问答对列表") List<QaRecord> qaRecords,
@Schema(description = "问答存储模式", example = "EXTERNAL") String qaContentStorageMode, @Schema(description = "差异列表") List<DiffRecord> diffRecords,
@Schema(description = "外置问答文件路径", example = "review/191000000000000401/qa-content.json") String qaContentFilePath, @Schema(description = "资源预览路径", example = "preview/191000000000000101/index.html") String sourcePreviewPath
@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
) {}
} }

View File

@@ -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
) {
}

View File

@@ -1,21 +1,22 @@
package com.labelsys.backend.dto.response; 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 com.labelsys.backend.enums.AnnotationResultStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
@Schema(description = "标注结果响应") @Schema(description = "标注结果响应")
public record AnnotationResultResponse( public record AnnotationResultResponse(
@Schema(description = "结果ID", example = "191000000000000401") Long id, @Schema(description = "结果ID", example = "191000000000000401") Long id,
@Schema(description = "任务ID", example = "191000000000000301") Long taskId, @Schema(description = "任务ID", example = "191000000000000301") Long taskId,
@Schema(description = "资源ID", example = "191000000000000101") Long resourceId, @Schema(description = "资源ID", example = "191000000000000101") Long resourceId,
@Schema(description = "标注结果状态", example = "MANUAL_REVIEW_PENDING") AnnotationResultStatus runtimeStatus, @Schema(description = "标注结果状态", example = "MANUAL_REVIEW_PENDING") AnnotationResultStatus runtimeStatus,
@Schema(description = "是否需要人工审核", example = "true") Boolean requiresManualReview, @Schema(description = "是否需要人工审核", example = "true") Boolean requiresManualReview,
@Schema(description = "是否已删除", example = "false") Boolean isDeleted, @Schema(description = "是否已删除", example = "false") Boolean isDeleted,
@Schema(description = "问答存储模式", example = "INLINE") String qaContentStorageMode, @Schema(description = "问答内容文件路径", example = "annotation-results/2/qa/801.json") String qaContentFilePath,
@Schema(description = "审核备注", example = "需统一时间字段口径。") String reviewComment, @Schema(description = "差异摘要文件路径", example = "annotation-results/2/diff/801.json") String diffSummaryFilePath,
@Schema(description = "审核时间", example = "2026-04-27T11:00:00") LocalDateTime reviewedAt, @Schema(description = "审核备注", example = "需统一时间字段口径。") String reviewComment,
@Schema(description = "创建时间", example = "2026-04-27T10:40:00") LocalDateTime createdAt @Schema(description = "审核时间", example = "2026-04-27T11:00:00") LocalDateTime reviewedAt,
@Schema(description = "创建时间", example = "2026-04-27T10:40:00") LocalDateTime createdAt
) { ) {
} }

View File

@@ -14,7 +14,7 @@ public record LoginResponse(
@Schema(description = "用户名,可为空", example = "alpha-admin") String username, @Schema(description = "用户名,可为空", example = "alpha-admin") String username,
@Schema(description = "真实姓名", example = "张审核") String realName, @Schema(description = "真实姓名", example = "张审核") String realName,
@Schema(description = "角色枚举值EMPLOYEE员工、MANAGER部门经理、ENGINEER总工程师", example = "EMPLOYEE") UserRole role, @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 @Schema(description = "是否必须修改密码", example = "false") boolean mustChangePassword
) { ) {
public static LoginResponse from(String token, LoginUser loginUser, SysCompany company) { public static LoginResponse from(String token, LoginUser loginUser, SysCompany company) {

View File

@@ -13,6 +13,7 @@ public record SourceResourceResponse(
@Schema(description = "文件大小", example = "20480") Long fileSize, @Schema(description = "文件大小", example = "20480") Long fileSize,
@Schema(description = "资源状态", example = "READY") String sourceStatus, @Schema(description = "资源状态", example = "READY") String sourceStatus,
@Schema(description = "存储提供方", example = "rustfs") String storageProvider, @Schema(description = "存储提供方", example = "rustfs") String storageProvider,
@Schema(description = "是否有BBOX标注,不显示", example = "false") Boolean hasBbox,
@Schema(description = "备注", example = "第一批导入样本") String remark, @Schema(description = "备注", example = "第一批导入样本") String remark,
@Schema(description = "创建人名称", example = "张审核") String creatorName, @Schema(description = "创建人名称", example = "张审核") String creatorName,
@Schema(description = "创建时间", example = "2026-04-27T10:00:00") LocalDateTime createdAt, @Schema(description = "创建时间", example = "2026-04-27T10:00:00") LocalDateTime createdAt,

View File

@@ -1,37 +1,65 @@
package com.labelsys.backend.entity; package com.labelsys.backend.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.*;
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.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@TableName("annotation_result") @TableName("annotation_result")
public class AnnotationResult { public class AnnotationResult {
@TableId(type = IdType.INPUT) @TableId(type = IdType.INPUT)
private Long id; private Long id;
@TableField("company_id")
private Long companyId; private Long companyId;
@TableField("creator_id")
private Long creatorId; private Long creatorId;
private UserRole creatorRole;
@TableField("creator_role")
private String creatorRole;
@TableField("task_id")
private Long taskId; private Long taskId;
@TableField("resource_id")
private Long resourceId; private Long resourceId;
private String qaContentJson;
private String qaContentStorageMode; @TableField("qa_content_file_path")
private String qaContentFilePath; private String qaContentFilePath;
private String diffSummary;
@TableField("diff_summary_file_path")
private String diffSummaryFilePath;
@TableField("requires_manual_review")
private Boolean requiresManualReview; private Boolean requiresManualReview;
@TableField("is_deleted")
private Boolean isDeleted; private Boolean isDeleted;
@TableField("reviewer_id")
private Long reviewerId; private Long reviewerId;
@TableField("review_comment")
private String reviewComment; private String reviewComment;
@TableField("reviewed_at")
private LocalDateTime reviewedAt; private LocalDateTime reviewedAt;
@TableField("created_at")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
} }

View File

@@ -20,11 +20,11 @@ public class AnnotationResultHistory {
private Long id; private Long id;
private Long companyId; private Long companyId;
private Long creatorId; private Long creatorId;
private UserRole creatorRole; private String creatorRole;
private Long sourceResultId; private Long sourceResultId;
private Long taskId; private Long taskId;
private Long resourceId; private Long resourceId;
private String qaContentJson; //private String qaContentJson;
private String qaContentStorageMode; private String qaContentStorageMode;
private String qaContentFilePath; private String qaContentFilePath;
private String archiveReason; private String archiveReason;

View File

@@ -28,6 +28,7 @@ public class SourceResource {
private Long fileSize; private Long fileSize;
private String sourceStatus; private String sourceStatus;
private String storageProvider; private String storageProvider;
private Boolean hasBbox;
private String remark; private String remark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;

View File

@@ -1,27 +1,33 @@
package com.labelsys.backend.service; package com.labelsys.backend.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.ResultCode;
import com.labelsys.backend.common.exception.BusinessException; import com.labelsys.backend.common.exception.BusinessException;
import com.labelsys.backend.context.LoginUser; 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.dto.response.MergeReviewResultResponse;
import com.labelsys.backend.entity.AnnotationResult; import com.labelsys.backend.entity.AnnotationResult;
import com.labelsys.backend.entity.AnnotationResultHistory; import com.labelsys.backend.entity.AnnotationResultHistory;
import com.labelsys.backend.enums.QaContentStorageMode;
import com.labelsys.backend.enums.UserPosition; import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.enums.UserRole;
import com.labelsys.backend.mapper.AnnotationResultHistoryMapper; import com.labelsys.backend.mapper.AnnotationResultHistoryMapper;
import com.labelsys.backend.mapper.AnnotationResultMapper; import com.labelsys.backend.mapper.AnnotationResultMapper;
import com.labelsys.backend.util.IdGenerator; import com.labelsys.backend.util.IdGenerator;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -29,59 +35,80 @@ public class AnnotationResultArchiveService {
private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW"; private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW";
private final AnnotationResultMapper annotationResultMapper; private final AnnotationResultMapper annotationResultMapper;
private final AnnotationResultHistoryMapper annotationResultHistoryMapper; private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
private final ObjectStorageService objectStorageService;
private final ObjectMapper objectMapper;
private final DataPermissionService dataPermissionService;
@Value("${labelsys.annotation.auto-archive-timeout:PT2H}") @Value("${labelsys.annotation.auto-archive-timeout:PT2H}")
private Duration autoArchiveTimeout; private Duration autoArchiveTimeout;
@Transactional public PageResult<AnnotationResultHistoryResponse> pageHistory(LoginUser currentUser,
public MergeReviewResultResponse mergeReview(LoginUser currentUser, Long resultId, MergeReviewResultRequest request) { AnnotationResultHistoryPageQuery query) {
assertReviewer(currentUser); List<String> allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId()); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
if (result == null) {
throw new BusinessException(ResultCode.NOT_FOUND, "运行态结果不存在"); var wrapper = new LambdaQueryWrapper<AnnotationResultHistory>()
.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(); var page = new Page<AnnotationResultHistory>(query.pageNo(), query.pageSize());
AnnotationResultHistory history = AnnotationResultHistory.builder() var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper);
.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( var records = resultPage.getRecords().stream()
result.getId(), .map(this::toResponse)
currentUser.companyId(), .toList();
currentUser.userId(),
request.reviewComment(), return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(),
archivedAt); (int) resultPage.getSize());
if (updated == 0) { }
throw new BusinessException(ResultCode.CONFLICT, "结果已被其他操作处理");
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={}", private void assertHistoryPermission(LoginUser currentUser, AnnotationResultHistory history) {
currentUser.companyId(), currentUser.userId(), resultId, history.getId()); if (!dataPermissionService.canAccessCreator(currentUser, history.getCreatorId(),
return new MergeReviewResultResponse(resultId, history.getId(), MANUAL_ARCHIVE_REASON, archivedAt); 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 @Transactional
public int autoArchiveEligibleResults() { public int autoArchiveEligibleResults() {
LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout); LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout);
List<AnnotationResult> results = annotationResultMapper.selectList(new LambdaQueryWrapper<AnnotationResult>() List<AnnotationResult> results = annotationResultMapper.selectList(new LambdaQueryWrapper<AnnotationResult>()
.eq(AnnotationResult::getIsDeleted, false) .eq(AnnotationResult::getIsDeleted, false)
.eq(AnnotationResult::getRequiresManualReview, false) .eq(AnnotationResult::getRequiresManualReview, false)
.lt(AnnotationResult::getCreatedAt, cutoff)); .lt(AnnotationResult::getCreatedAt, cutoff));
int archivedCount = 0; int archivedCount = 0;
for (AnnotationResult result : results) { for (AnnotationResult result : results) {
if (archiveRuntimeResult(result, null, "AUTO_ARCHIVE", null) != null) { 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(); * 从对象存储读取 qa.json 内容进行归档
} */
return QaContentStorageMode.INLINE.name();
}
private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result, private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result,
Long reviewerId, Long reviewerId,
String archiveReason, String archiveReason,
String reviewComment) { String reviewComment) {
LocalDateTime archivedAt = LocalDateTime.now(); LocalDateTime archivedAt = LocalDateTime.now();
// 从对象存储读取 qa.json 内容
String qaContentJson = loadQaContentJson(result);
AnnotationResultHistory history = AnnotationResultHistory.builder() AnnotationResultHistory history = AnnotationResultHistory.builder()
.id(IdGenerator.nextId()) .id(IdGenerator.nextId())
.companyId(result.getCompanyId()) .companyId(result.getCompanyId())
.creatorId(result.getCreatorId()) .creatorId(result.getCreatorId())
.creatorRole(result.getCreatorRole()) .creatorRole(result.getCreatorRole())
.sourceResultId(result.getId()) .sourceResultId(result.getId())
.taskId(result.getTaskId()) .taskId(result.getTaskId())
.resourceId(result.getResourceId()) .resourceId(result.getResourceId())
.qaContentJson(result.getQaContentJson()) //.qaContentJson(qaContentJson) // 使用从对象存储读取的内容
.qaContentStorageMode(resolveStorageMode(result)) .qaContentFilePath(result.getQaContentFilePath())
.qaContentFilePath(result.getQaContentFilePath()) .archiveReason(archiveReason)
.archiveReason(archiveReason) .archivedBy(reviewerId)
.archivedBy(reviewerId) .archivedAt(archivedAt)
.archivedAt(archivedAt) .build();
.build();
annotationResultHistoryMapper.insert(history); annotationResultHistoryMapper.insert(history);
int updated = annotationResultMapper.markArchived( int updated = annotationResultMapper.markArchived(
result.getId(), result.getId(),
result.getCompanyId(), result.getCompanyId(),
reviewerId, reviewerId,
reviewComment, reviewComment,
archivedAt); archivedAt);
if (updated == 0) { if (updated == 0) {
return null; return null;
} }
return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt); 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) : "";
}
} }

View File

@@ -1,49 +1,59 @@
package com.labelsys.backend.service; 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.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.ResultCode;
import com.labelsys.backend.common.exception.BusinessException; import com.labelsys.backend.common.exception.BusinessException;
import com.labelsys.backend.context.LoginUser; import com.labelsys.backend.context.LoginUser;
import com.labelsys.backend.dto.common.PageResult; import com.labelsys.backend.dto.common.PageResult;
import com.labelsys.backend.dto.request.AnnotationResultPageQuery; 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.AnnotationResultCompareResponse;
import com.labelsys.backend.dto.response.AnnotationResultResponse; import com.labelsys.backend.dto.response.AnnotationResultResponse;
import com.labelsys.backend.entity.AnnotationResult; import com.labelsys.backend.entity.AnnotationResult;
import com.labelsys.backend.entity.AnnotationResultHistory;
import com.labelsys.backend.entity.SourceResource; import com.labelsys.backend.entity.SourceResource;
import com.labelsys.backend.enums.AnnotationResultStatus; 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.AnnotationResultMapper;
import com.labelsys.backend.mapper.SourceResourceMapper; import com.labelsys.backend.mapper.SourceResourceMapper;
import com.labelsys.backend.util.IdGenerator;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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 @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AnnotationResultService { public class AnnotationResultService {
private final AnnotationResultMapper annotationResultMapper; private final AnnotationResultMapper annotationResultMapper;
private final SourceResourceMapper sourceResourceMapper; private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
private final DataPermissionService dataPermissionService; private final SourceResourceMapper sourceResourceMapper;
private final ObjectStorageService objectStorageService; private final DataPermissionService dataPermissionService;
private final ObjectStorageService objectStorageService;
private final ObjectMapper objectMapper;
public PageResult<AnnotationResultResponse> pageResults(LoginUser currentUser, AnnotationResultPageQuery query) { public PageResult<AnnotationResultResponse> pageResults(LoginUser currentUser, AnnotationResultPageQuery query) {
List<String> allowedRoles = dataPermissionService.getAllowedRoles(currentUser); List<String> allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
LambdaQueryWrapper<AnnotationResult> wrapper = new LambdaQueryWrapper<AnnotationResult>() var wrapper = new LambdaQueryWrapper<AnnotationResult>()
.eq(AnnotationResult::getCompanyId, currentUser.companyId()) .eq(AnnotationResult::getCompanyId, currentUser.companyId())
.eq(query.taskId() != null, AnnotationResult::getTaskId, query.taskId()) .eq(query.taskId() != null, AnnotationResult::getTaskId, query.taskId())
.eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId()) .eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId())
.eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview, .eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview,
query.requiresManualReview()); query.requiresManualReview())
.orderByDesc(AnnotationResult::getCreatedAt);
if (shouldFilterByUserId) { if (shouldFilterByUserId) {
wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId()); wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId());
@@ -51,12 +61,11 @@ public class AnnotationResultService {
wrapper.in(AnnotationResult::getCreatorRole, allowedRoles); wrapper.in(AnnotationResult::getCreatorRole, allowedRoles);
} }
wrapper.orderByDesc(AnnotationResult::getCreatedAt); var page = new Page<AnnotationResult>(query.pageNo(), query.pageSize());
var resultPage = annotationResultMapper.selectPage(page, wrapper);
Page<AnnotationResult> page = new Page<>(query.pageNo(), query.pageSize()); var records = resultPage.getRecords().stream()
Page<AnnotationResult> resultPage = annotationResultMapper.selectPage(page, wrapper); .map(this::toResponse)
List<AnnotationResultResponse> records = resultPage.getRecords().stream().map(this::toResponse)
.filter(response -> query.runtimeStatus() == null .filter(response -> query.runtimeStatus() == null
|| query.runtimeStatus().equals(response.runtimeStatus())) || query.runtimeStatus().equals(response.runtimeStatus()))
.toList(); .toList();
@@ -72,14 +81,122 @@ public class AnnotationResultService {
currentUser.companyId(), currentUser.userId()); currentUser.companyId(), currentUser.userId());
throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在");
} }
//assertResultPermission(currentUser, result);
return toResponse(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<AnnotationResultCompareResponse.QaRecord> qaRecords = qaContent.records().stream()
.map(qa -> new AnnotationResultCompareResponse.QaRecord(
qa.id(),
qa.question(),
qa.answer(),
qa.requiresReview()
)).toList();
// 转换差异记录
List<AnnotationResultCompareResponse.DiffRecord> 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<QaContent.QaRecord> 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) { private AnnotationResultResponse toResponse(AnnotationResult result) {
return new AnnotationResultResponse(result.getId(), result.getTaskId(), result.getResourceId(), return new AnnotationResultResponse(
deriveStatus(result), result.getRequiresManualReview(), result.getIsDeleted(), result.getId(),
result.getQaContentStorageMode(), result.getReviewComment(), result.getReviewedAt(), result.getTaskId(),
result.getCreatedAt()); result.getResourceId(),
deriveStatus(result),
result.getRequiresManualReview(),
result.getIsDeleted(),
result.getQaContentFilePath(),
result.getDiffSummaryFilePath(),
result.getReviewComment(),
result.getReviewedAt(),
result.getCreatedAt()
);
} }
private AnnotationResultStatus deriveStatus(AnnotationResult result) { private AnnotationResultStatus deriveStatus(AnnotationResult result) {
@@ -92,69 +209,130 @@ public class AnnotationResultService {
return AnnotationResultStatus.AUTO_ARCHIVE_PENDING; return AnnotationResultStatus.AUTO_ARCHIVE_PENDING;
} }
public AnnotationResultCompareResponse compareResult(LoginUser currentUser, Long resultId) { private QaContent loadQaContent(AnnotationResult result) {
AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId()); try {
if (result == null) { String filePath = result.getQaContentFilePath();
log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", resultId, String bucketName = extractBucketName(filePath);
currentUser.companyId(), currentUser.userId()); String objectKey = extractObjectKey(filePath);
throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); byte[] content = objectStorageService.download(bucketName, objectKey);
String jsonContent = new String(content, StandardCharsets.UTF_8);
return objectMapper.readValue(jsonContent, new TypeReference<QaContent>() {
});
} 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) { private DiffContent loadDiffSummary(AnnotationResult result) {
if (QaContentStorageMode.EXTERNAL.name().equals(result.getQaContentStorageMode())) { try {
if (result.getQaContentFilePath() == null || result.getQaContentFilePath().isBlank()) { String filePath = result.getDiffSummaryFilePath();
log.warn("External storage mode but file path is empty, resultId={}", result.getId()); String bucketName = extractBucketName(filePath);
return "{}"; String objectKey = extractObjectKey(filePath);
} byte[] content = objectStorageService.download(bucketName, objectKey);
try { String jsonContent = new String(content, StandardCharsets.UTF_8);
String filePath = result.getQaContentFilePath(); return objectMapper.readValue(jsonContent, new TypeReference<DiffContent>() {
String bucketName = extractBucketName(filePath); });
String objectKey = extractObjectKey(filePath); } catch (Exception e) {
byte[] content = objectStorageService.download(bucketName, objectKey); log.error("Failed to load diff summary, resultId={}, filePath={}", result.getId(),
return new String(content, StandardCharsets.UTF_8); result.getDiffSummaryFilePath(), e);
} catch (Exception e) { throw new BusinessException(ResultCode.ERROR, "加载差异摘要失败");
log.error("Failed to download external qa content, resultId={}, filePath={}", }
result.getId(), result.getQaContentFilePath(), e); }
throw new BusinessException(ResultCode.ERROR, "下载问答内容失败");
} private void saveQaContent(AnnotationResult result, QaContent qaContent) {
} else { try {
return result.getQaContentJson() != null ? result.getQaContentJson() : "{}"; 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) { private String extractBucketName(String filePath) {
if (filePath.startsWith("/")) { // 从文件路径中提取 bucket 名称
filePath = filePath.substring(1); // 例如annotation-results/2/qa/801.json -> annotation-results
} int firstSlash = filePath.indexOf('/');
int firstSlash = filePath.indexOf("/"); return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath;
if (firstSlash > 0) {
return filePath.substring(0, firstSlash);
}
throw new BusinessException(ResultCode.BAD_REQUEST, "无效的文件路径格式");
} }
private String extractObjectKey(String filePath) { private String extractObjectKey(String filePath) {
if (filePath.startsWith("/")) { // 从文件路径中提取 object key
filePath = filePath.substring(1); // 例如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<QaRecord> 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) { private record Metadata(String createdAt, String updatedAt) {
return filePath.substring(firstSlash + 1); }
}
// 内部类diff.json 结构
private record DiffContent(
Long taskId,
Long resourceId,
List<DiffRecord> 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, "无效的文件路径格式");
} }
} }

View File

@@ -117,13 +117,19 @@ public class SourceResourceService {
wrapper.orderByDesc(SourceResource::getCreatedAt); wrapper.orderByDesc(SourceResource::getCreatedAt);
Page<SourceResource> page = new Page<>(query.pageNo(), query.pageSize()); // 判断是否需要分页
Page<SourceResource> resultPage = sourceResourceMapper.selectPage(page, wrapper); if (query.needPagination()) {
Page<SourceResource> page = new Page<>(query.pageNo(), query.pageSize());
List<SourceResourceResponse> records = resultPage.getRecords().stream().map(this::toResponse).toList(); Page<SourceResource> resultPage = sourceResourceMapper.selectPage(page, wrapper);
List<SourceResourceResponse> records = resultPage.getRecords().stream().map(this::toResponse).toList();
return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(),
(int) resultPage.getSize()); (int) resultPage.getSize());
} else {
// 不分页,查询全部
List<SourceResource> records = sourceResourceMapper.selectList(wrapper);
List<SourceResourceResponse> responseList = records.stream().map(this::toResponse).toList();
return new PageResult<>(responseList, (long) responseList.size(), 1, responseList.size());
}
} }
public SourceResourceResponse getResource(LoginUser currentUser, Long resourceId) { public SourceResourceResponse getResource(LoginUser currentUser, Long resourceId) {
@@ -173,8 +179,8 @@ public class SourceResourceService {
SysUser creator = sysUserMapper.selectById(resource.getCreatorId()); SysUser creator = sysUserMapper.selectById(resource.getCreatorId());
return new SourceResourceResponse(resource.getId(), resource.getResourceName(), resource.getResourceType(), return new SourceResourceResponse(resource.getId(), resource.getResourceName(), resource.getResourceType(),
resource.getBucketName(), resource.getFilePath(), resource.getFileSize(), resource.getSourceStatus(), resource.getBucketName(), resource.getFilePath(), resource.getFileSize(), resource.getSourceStatus(),
resource.getStorageProvider(), resource.getRemark(), creator == null ? null : creator.getRealName(), resource.getStorageProvider(), resource.getHasBbox(), resource.getRemark(),
resource.getCreatedAt(), resource.getUpdatedAt()); creator == null ? null : creator.getRealName(), resource.getCreatedAt(), resource.getUpdatedAt());
} }
private String resolveExtension(String originalFilename, String resourceType) { private String resolveExtension(String originalFilename, String resourceType) {
@@ -190,22 +196,19 @@ public class SourceResourceService {
} }
/** /**
* 下载图片资源 * 下载资源(支持 TEXT、IMAGE、VIDEO
* *
* @param currentUser 当前用户 * @param currentUser 当前用户
* @param resourceId 资源ID * @param resourceId 资源ID
* @return 图片字节数组 * @return 资源字节数组
*/ */
public byte[] downloadImage(LoginUser currentUser, Long resourceId) { public byte[] downloadResource(LoginUser currentUser, Long resourceId) {
SourceResource resource = sourceResourceMapper.selectById(resourceId); SourceResource resource = sourceResourceMapper.selectById(resourceId);
if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) { if (resource == null || !currentUser.companyId().equals(resource.getCompanyId())) {
log.warn("Resource not found or cross-tenant access attempt: resourceId={}, companyId={}, userId={}", log.warn("Resource not found or cross-tenant access attempt: resourceId={}, companyId={}, userId={}",
resourceId, currentUser.companyId(), currentUser.userId()); resourceId, currentUser.companyId(), currentUser.userId());
throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在"); throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在");
} }
if (!"IMAGE".equals(resource.getResourceType())) {
throw new BusinessException(ResultCode.BAD_REQUEST, "仅图片资源支持下载");
}
if (!"READY".equals(resource.getSourceStatus())) { if (!"READY".equals(resource.getSourceStatus())) {
throw new BusinessException(ResultCode.BAD_REQUEST, "资源未就绪"); throw new BusinessException(ResultCode.BAD_REQUEST, "资源未就绪");
} }
@@ -213,24 +216,58 @@ public class SourceResourceService {
} }
/** /**
* 获取图片资源的Content-Type * 获取资源的Content-Type(支持 TEXT、IMAGE、VIDEO
* *
* @param resource 资源实体 * @param resource 资源实体
* @return Content-Type * @return Content-Type
*/ */
public String getImageContentType(SourceResource resource) { public String getContentType(SourceResource resource) {
String filePath = resource.getFilePath(); String filePath = resource.getFilePath();
String resourceType = resource.getResourceType();
// 优先根据文件扩展名判断
if (filePath != null && filePath.contains(".")) { if (filePath != null && filePath.contains(".")) {
String extension = filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase(); String extension = filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase();
return switch (extension) { return switch (extension) {
// 图片类型
case "jpg", "jpeg" -> "image/jpeg"; case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png"; case "png" -> "image/png";
case "gif" -> "image/gif"; case "gif" -> "image/gif";
case "webp" -> "image/webp"; 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) { public ImageBboxResponse getImageBbox(LoginUser currentUser, Long resourceId) {
@@ -278,6 +315,8 @@ public class SourceResourceService {
throw new BusinessException(ResultCode.BAD_REQUEST, "BBOX数据序列化失败"); throw new BusinessException(ResultCode.BAD_REQUEST, "BBOX数据序列化失败");
} }
boolean isNewAnnotation = imageBboxAnnotationMapper.selectByResourceId(resourceId) == null;
ImageBboxAnnotation existing = imageBboxAnnotationMapper.selectByResourceId(resourceId); ImageBboxAnnotation existing = imageBboxAnnotationMapper.selectByResourceId(resourceId);
if (existing != null) { if (existing != null) {
existing.setBboxJson(bboxJson); existing.setBboxJson(bboxJson);
@@ -298,6 +337,12 @@ public class SourceResourceService {
imageBboxAnnotationMapper.insert(annotation); imageBboxAnnotationMapper.insert(annotation);
} }
// 更新资源表的has_bbox字段
if (isNewAnnotation || Boolean.FALSE.equals(resource.getHasBbox())) {
resource.setHasBbox(true);
sourceResourceMapper.updateById(resource);
}
return getImageBbox(currentUser, resourceId); return getImageBbox(currentUser, resourceId);
} }
@@ -308,6 +353,12 @@ public class SourceResourceService {
throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在"); throw new BusinessException(ResultCode.NOT_FOUND, "资源不存在");
} }
imageBboxAnnotationMapper.deleteByResourceId(resourceId); imageBboxAnnotationMapper.deleteByResourceId(resourceId);
// 更新资源表的has_bbox字段为false
if (Boolean.TRUE.equals(resource.getHasBbox())) {
resource.setHasBbox(false);
sourceResourceMapper.updateById(resource);
}
} }
private List<ImageBboxResponse.BboxCoordinateResponse> parseBboxJson(String bboxJson) { private List<ImageBboxResponse.BboxCoordinateResponse> parseBboxJson(String bboxJson) {

View File

@@ -39,7 +39,8 @@ labelsys:
ttl: PT2H ttl: PT2H
store-type: redis store-type: redis
annotation: annotation:
auto-archive-timeout: PT2H auto-archive-fixed-delay: 300000 # 定时任务执行间隔毫秒默认5分钟
auto-archive-timeout: PT2H # 自动归档超时时间默认2小时
object-storage: object-storage:
endpoint: ${OBJECT_STORAGE_ENDPOINT:http://39.107.112.174:9000} endpoint: ${OBJECT_STORAGE_ENDPOINT:http://39.107.112.174:9000}
region: ${OBJECT_STORAGE_REGION:cn-east-1} region: ${OBJECT_STORAGE_REGION:cn-east-1}

View File

@@ -8,10 +8,8 @@
<result column="creator_role" property="creatorRole"/> <result column="creator_role" property="creatorRole"/>
<result column="task_id" property="taskId"/> <result column="task_id" property="taskId"/>
<result column="resource_id" property="resourceId"/> <result column="resource_id" property="resourceId"/>
<result column="qa_content_json" property="qaContentJson"/>
<result column="qa_content_storage_mode" property="qaContentStorageMode"/>
<result column="qa_content_file_path" property="qaContentFilePath"/> <result column="qa_content_file_path" property="qaContentFilePath"/>
<result column="diff_summary" property="diffSummary"/> <result column="diff_summary_file_path" property="diffSummaryFilePath"/>
<result column="requires_manual_review" property="requiresManualReview"/> <result column="requires_manual_review" property="requiresManualReview"/>
<result column="is_deleted" property="isDeleted"/> <result column="is_deleted" property="isDeleted"/>
<result column="reviewer_id" property="reviewerId"/> <result column="reviewer_id" property="reviewerId"/>
@@ -22,9 +20,9 @@
</resultMap> </resultMap>
<sql id="AnnotationResultColumns"> <sql id="AnnotationResultColumns">
id, company_id, creator_id, creator_role, task_id, resource_id, qa_content_json, id, company_id, creator_id, creator_role, task_id, resource_id, qa_content_file_path,
qa_content_storage_mode, qa_content_file_path, diff_summary, requires_manual_review, diff_summary_file_path, requires_manual_review, is_deleted, reviewer_id, review_comment,
is_deleted, reviewer_id, review_comment, reviewed_at, created_at, updated_at reviewed_at, created_at, updated_at
</sql> </sql>
<select id="findActiveByIdAndCompanyId" resultMap="AnnotationResultResultMap"> <select id="findActiveByIdAndCompanyId" resultMap="AnnotationResultResultMap">

View File

@@ -57,12 +57,12 @@ ON CONFLICT DO NOTHING;
INSERT INTO source_resource ( INSERT INTO source_resource (
id, company_id, creator_id, creator_role, resource_name, resource_type, id, company_id, creator_id, creator_role, resource_name, resource_type,
bucket_name, file_path, file_size, source_status, storage_provider, remark bucket_name, file_path, file_size, source_status, storage_provider, has_bbox, remark
) VALUES ) VALUES
(601, 2, 3, 'EMPLOYEE', '设备巡检规范.txt', 'TEXT', (601, 2, 3, 'EMPLOYEE', '设备巡检规范.txt', 'TEXT',
'source-data', 'text/202604/601.txt', 20480, 'READY', 'rustfs', '文本资源示例'), 'source-data', 'text/202604/601.txt', 20480, 'READY', 'rustfs', NULL, '文本资源示例'),
(602, 2, 3, 'EMPLOYEE', '控制柜照片.jpg', 'IMAGE', (602, 2, 3, 'EMPLOYEE', '控制柜照片.jpg', 'IMAGE',
'source-data', 'image/202604/602.jpg', 532480, 'READY', 'rustfs', '图片资源示例') 'source-data', 'image/202604/602.jpg', 532480, 'READY', 'rustfs', TRUE, '图片资源示例')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO annotation_task ( INSERT INTO annotation_task (
@@ -85,29 +85,25 @@ ON CONFLICT DO NOTHING;
INSERT INTO annotation_result ( INSERT INTO annotation_result (
id, company_id, creator_id, creator_role, task_id, resource_id, id, company_id, creator_id, creator_role, task_id, resource_id,
qa_content_json, qa_content_storage_mode, qa_content_file_path, diff_summary, qa_content_file_path, diff_summary_file_path,
requires_manual_review, is_deleted, reviewer_id, review_comment, reviewed_at requires_manual_review, is_deleted, reviewer_id, review_comment, reviewed_at
) VALUES ) VALUES
(801, 2, 3, 'EMPLOYEE', 701, 601, (801, 2, 3, 'EMPLOYEE', 701, 601,
'{"question":"巡检开始前需要做什么?","answer":"详见外置结果文件,包含完整步骤与注意事项。"}', 'annotation-results/qa/801.json',
'EXTERNAL', 'annotation-results/202604/801-qa.json', 'annotation-results/diff/801.json',
'{"extract_question":"巡检开始前需要做什么?","extract_answer":"开始前检查设备状态和作业环境。","verify_answer":"开始前应确认设备状态、防护用品和现场环境安全。","mismatch_fields":["answer"],"reason":"抽取答案遗漏了安全检查要点。"}',
TRUE, FALSE, NULL, NULL, NULL), TRUE, FALSE, NULL, NULL, NULL),
(802, 2, 3, 'EMPLOYEE', 702, 602, (802, 2, 3, 'EMPLOYEE', 702, 602,
'{"question":"图片中的控制柜当前状态如何?","answer":"控制柜处于运行状态,绿色指示灯亮起。"}', 'annotation-results/qa/802.json',
'INLINE', NULL, NULL,
'{"extract_question":"图片中的控制柜当前状态如何?","extract_answer":"控制柜处于运行状态,绿色指示灯亮起。","verify_answer":"控制柜正在运行,指示灯显示正常。","mismatch_fields":[],"reason":"校验结果与抽取结果基本一致。"}',
FALSE, FALSE, 5, '结果可通过。', CURRENT_TIMESTAMP) FALSE, FALSE, 5, '结果可通过。', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO annotation_result_history ( INSERT INTO annotation_result_history (
id, company_id, creator_id, creator_role, source_result_id, task_id, resource_id, 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 qa_content_file_path, archive_reason, archived_by, archived_at
) VALUES ) VALUES
(901, 2, 3, 'EMPLOYEE', 802, 702, 602, (901, 2, 3, 'EMPLOYEE', 802, 702, 602,
'{"question":"图片中的控制柜当前状态如何?","answer":"控制柜处于运行状态,绿色指示灯亮起。"}', 'annotation-results/2/qa/802.json',
'INLINE',
NULL,
'审核通过后归档', 5, CURRENT_TIMESTAMP) '审核通过后归档', 5, CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;

View File

@@ -135,6 +135,7 @@ CREATE TABLE IF NOT EXISTS source_resource
file_size BIGINT NOT NULL DEFAULT 0, file_size BIGINT NOT NULL DEFAULT 0,
source_status VARCHAR(32) NOT NULL DEFAULT 'UPLOADED', source_status VARCHAR(32) NOT NULL DEFAULT 'UPLOADED',
storage_provider VARCHAR(64) NOT NULL DEFAULT 'rustfs', storage_provider VARCHAR(64) NOT NULL DEFAULT 'rustfs',
has_bbox BOOLEAN,
remark VARCHAR(255), remark VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -154,6 +155,7 @@ COMMENT ON COLUMN source_resource.file_path IS '文件存储路径,表示对
COMMENT ON COLUMN source_resource.file_size IS '文件大小,单位字节,默认 0。'; 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.source_status IS '资源状态,默认 UPLOADED可选 PROCESSING、READY、ARCHIVED。';
COMMENT ON COLUMN source_resource.storage_provider IS '存储提供方,默认 rustfs。'; COMMENT ON COLUMN source_resource.storage_provider IS '存储提供方,默认 rustfs。';
COMMENT ON COLUMN source_resource.has_bbox IS '是否有BBOX标注。NULL表示非图片资源或未标注TRUE表示已标注BBOXFALSE表示已删除BBOX标注。';
COMMENT ON COLUMN source_resource.remark IS '备注说明。'; COMMENT ON COLUMN source_resource.remark IS '备注说明。';
COMMENT ON COLUMN source_resource.created_at IS '创建时间。'; COMMENT ON COLUMN source_resource.created_at IS '创建时间。';
COMMENT ON COLUMN source_resource.updated_at IS '更新时间。'; COMMENT ON COLUMN source_resource.updated_at IS '更新时间。';
@@ -242,25 +244,24 @@ 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.resource_id IS '资源ID。';
COMMENT ON COLUMN annotation_task_resource.created_at IS '创建时间。'; COMMENT ON COLUMN annotation_task_resource.created_at IS '创建时间。';
CREATE TABLE IF NOT EXISTS annotation_result CREATE TABLE IF NOT EXISTS annotation_result
( (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
company_id BIGINT NOT NULL, company_id BIGINT NOT NULL,
creator_id BIGINT NOT NULL, creator_id BIGINT NOT NULL,
creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE', creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
task_id BIGINT NOT NULL, task_id BIGINT NOT NULL,
resource_id BIGINT NOT NULL, resource_id BIGINT NOT NULL,
qa_content_json TEXT NOT NULL DEFAULT '{}', qa_content_file_path VARCHAR(512) NOT NULL,
qa_content_storage_mode VARCHAR(32) NOT NULL DEFAULT 'INLINE', diff_summary_file_path VARCHAR(512),
qa_content_file_path VARCHAR(512), requires_manual_review BOOLEAN NOT NULL DEFAULT FALSE,
diff_summary TEXT NOT NULL DEFAULT '{}', is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
requires_manual_review BOOLEAN NOT NULL DEFAULT FALSE, reviewer_id BIGINT,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE, review_comment TEXT,
reviewer_id BIGINT, reviewed_at TIMESTAMP,
review_comment TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_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_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_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_task FOREIGN KEY (task_id) REFERENCES annotation_task (id),
@@ -268,18 +269,16 @@ CREATE TABLE IF NOT EXISTS annotation_result
CONSTRAINT fk_annotation_result_reviewer FOREIGN KEY (reviewer_id) REFERENCES sys_user (id) CONSTRAINT fk_annotation_result_reviewer FOREIGN KEY (reviewer_id) REFERENCES sys_user (id)
); );
COMMENT ON TABLE annotation_result IS '当前标注结果表。'; COMMENT ON TABLE annotation_result IS '当前标注结果表。问答内容和差异摘要统一存储在对象存储中。';
COMMENT ON COLUMN annotation_result.id IS '标注结果主键ID。'; COMMENT ON COLUMN annotation_result.id IS '标注结果主键ID。';
COMMENT ON COLUMN annotation_result.company_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_id IS '结果创建人用户ID。';
COMMENT ON COLUMN annotation_result.creator_role IS '结果创建人数据权限角色,默认 EMPLOYEE。'; COMMENT ON COLUMN annotation_result.creator_role IS '结果创建人数据权限角色,默认 EMPLOYEE。';
COMMENT ON COLUMN annotation_result.task_id IS '关联任务ID。'; COMMENT ON COLUMN annotation_result.task_id IS '关联任务ID。';
COMMENT ON COLUMN annotation_result.resource_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_file_path IS '问答内容文件路径存储在对象存储中。文件格式包含taskId、resourceId、records数组id、question、answer、requiresReview';
COMMENT ON COLUMN annotation_result.qa_content_storage_mode IS '问答内容存储模式,默认 INLINE可选 INLINE、EXTERNAL。当完整问答内容较大时可设为 EXTERNAL仅在表内保留摘要或索引信息'; COMMENT ON COLUMN annotation_result.diff_summary_file_path IS '差异摘要文件路径存储在对象存储中。当存在差异时生成包含taskId、resourceId、records数组qaId、question、extractAnswer、verifyAnswer、diffReason、mergedAnswer';
COMMENT ON COLUMN annotation_result.qa_content_file_path IS '当 qa_content_storage_mode = EXTERNAL 时,记录外置问答内容文件路径'; COMMENT ON COLUMN annotation_result.requires_manual_review IS '是否需要人工审核,默认 FALSE。当diff_summary_file_path不为空时为TRUE';
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.is_deleted IS '软删除标记,默认 FALSE。';
COMMENT ON COLUMN annotation_result.reviewer_id IS '审核人用户ID。'; COMMENT ON COLUMN annotation_result.reviewer_id IS '审核人用户ID。';
COMMENT ON COLUMN annotation_result.review_comment IS '审核意见。'; COMMENT ON COLUMN annotation_result.review_comment IS '审核意见。';
@@ -287,22 +286,21 @@ COMMENT ON COLUMN annotation_result.reviewed_at IS '审核时间。';
COMMENT ON COLUMN annotation_result.created_at IS '创建时间。'; COMMENT ON COLUMN annotation_result.created_at IS '创建时间。';
COMMENT ON COLUMN annotation_result.updated_at IS '更新时间。'; COMMENT ON COLUMN annotation_result.updated_at IS '更新时间。';
CREATE TABLE IF NOT EXISTS annotation_result_history CREATE TABLE IF NOT EXISTS annotation_result_history
( (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
company_id BIGINT NOT NULL, company_id BIGINT NOT NULL,
creator_id BIGINT NOT NULL, creator_id BIGINT NOT NULL,
creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE', creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
source_result_id BIGINT, source_result_id BIGINT,
task_id BIGINT NOT NULL, task_id BIGINT NOT NULL,
resource_id BIGINT NOT NULL, resource_id BIGINT NOT NULL,
qa_content_json TEXT NOT NULL DEFAULT '{}', qa_content_file_path VARCHAR(512) NOT NULL,
qa_content_storage_mode VARCHAR(32) NOT NULL DEFAULT 'INLINE', archive_reason VARCHAR(256),
qa_content_file_path VARCHAR(512), archived_by BIGINT,
archive_reason VARCHAR(255), archived_at TIMESTAMP,
archived_by BIGINT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
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_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_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_result FOREIGN KEY (source_result_id) REFERENCES annotation_result (id),
@@ -311,7 +309,7 @@ CREATE TABLE IF NOT EXISTS annotation_result_history
CONSTRAINT fk_annotation_result_history_archived_by FOREIGN KEY (archived_by) REFERENCES sys_user (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 TABLE annotation_result_history IS '历史归档结果表。问答内容存储在对象存储中。';
COMMENT ON COLUMN annotation_result_history.id IS '历史结果主键ID。'; 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.company_id IS '所属公司ID。';
COMMENT ON COLUMN annotation_result_history.creator_id IS '历史记录创建人用户ID。'; COMMENT ON COLUMN annotation_result_history.creator_id IS '历史记录创建人用户ID。';
@@ -319,9 +317,7 @@ COMMENT ON COLUMN annotation_result_history.creator_role IS '历史记录创建
COMMENT ON COLUMN annotation_result_history.source_result_id IS '来源运行态结果ID。'; 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.task_id IS '关联任务ID。';
COMMENT ON COLUMN annotation_result_history.resource_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_file_path IS '归档后的问答内容文件路径,存储在对象存储中';
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.archive_reason IS '归档原因说明。';
COMMENT ON COLUMN annotation_result_history.archived_by IS '归档操作人用户ID。'; 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.archived_at IS '归档时间。';
@@ -411,6 +407,8 @@ CREATE INDEX IF NOT EXISTS idx_sys_config_company_type ON sys_config (company_id
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_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_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_source_resource_creator ON source_resource (company_id, creator_id);
CREATE INDEX IF NOT EXISTS idx_source_resource_has_bbox ON source_resource (company_id, has_bbox);
CREATE INDEX IF NOT EXISTS idx_source_resource_has_bbox ON source_resource (company_id, has_bbox);
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_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_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_creator ON annotation_task (company_id, creator_id);