标注结果和标注结果归档优化
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
package com.labelsys.backend.controller;
|
||||||
|
|
||||||
|
import com.labelsys.backend.context.LoginUser;
|
||||||
|
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,
|
||||||
|
@Parameter(hidden = true) LoginUser currentUser) {
|
||||||
|
return ResponseEntity.ok(annotationResultArchiveService.pageHistory(currentUser, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查询归档历史详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<AnnotationResultHistoryResponse> getHistory(
|
||||||
|
@Parameter(description = "历史记录ID", example = "901")
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Parameter(hidden = true) LoginUser currentUser) {
|
||||||
|
return ResponseEntity.ok(annotationResultArchiveService.getHistory(currentUser, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.LoginUser;
|
||||||
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,50 @@ 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,
|
||||||
|
@Parameter(hidden = true) LoginUser currentUser) {
|
||||||
|
return ResponseEntity.ok(annotationResultService.pageResults(currentUser, 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,
|
||||||
) {
|
@Parameter(hidden = true) LoginUser currentUser) {
|
||||||
return Result.success(annotationResultService.getResult(UserContext.requireUser(), id));
|
return ResponseEntity.ok(annotationResultService.getResult(currentUser, 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));
|
@Parameter(hidden = true) LoginUser currentUser) {
|
||||||
|
return ResponseEntity.ok(annotationResultService.compareResult(currentUser, 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,
|
||||||
) {
|
@Parameter(hidden = true) LoginUser currentUser) {
|
||||||
return Result.success(annotationResultArchiveService.mergeReview(UserContext.requireUser(), id, request));
|
annotationResultService.mergeReviewResult(currentUser, id, request);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加新接口
|
// 添加新接口
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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记录ID,value为合并后的答案")
|
||||||
@Schema(description = "最终问答内容 JSON", example = "[{\"question\":\"运输时效是多久?\",\"answer\":\"72小时\"}]") @NotBlank(message = "问答内容不能为空") String qaContentJson,
|
Map<String, String> mergedAnswers,
|
||||||
@Schema(description = "审核备注", example = "已按审核意见合并,统一为小时口径。") String reviewComment
|
|
||||||
|
@Schema(description = "审核备注")
|
||||||
|
String reviewComment
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) : "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,49 +1,58 @@
|
|||||||
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.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.enums.UserRole;
|
||||||
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.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 com.baomidou.mybatisplus.core.conditions.query.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 +60,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 +80,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 +208,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, "无效的文件路径格式");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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/2/qa/801.json',
|
||||||
'EXTERNAL', 'annotation-results/202604/801-qa.json',
|
'annotation-results/2/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/2/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;
|
||||||
|
|
||||||
|
|||||||
@@ -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表示已标注BBOX;FALSE表示已删除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);
|
||||||
|
|||||||
Reference in New Issue
Block a user