From e637e8a6a45d8e7eb951012f2c9645809b08598e Mon Sep 17 00:00:00 2001 From: wh Date: Sat, 9 May 2026 17:57:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=B5=84=E6=BA=90=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=88=A4=E6=96=AD=EF=BC=8Cbbxo=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E4=B8=B4=E6=97=B6=E7=AD=BE=E5=90=8D=E9=93=BE?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ImageBboxResponse.java | 1 + .../backend/entity/AnnotationResult.java | 1 + .../backend/entity/AnnotationTask.java | 2 + .../backend/entity/ImageBboxAnnotation.java | 12 ++ .../backend/entity/SourceResource.java | 4 + .../labelsys/backend/enums/IndustryType.java | 3 +- .../service/AnnotationResultService.java | 23 ++-- .../backend/service/ObjectStorageService.java | 14 ++- .../service/RustfsObjectStorageService.java | 38 +++++- .../service/SourceResourceService.java | 118 ++++++++++++++++-- .../mapper/ImageBboxAnnotationMapper.xml | 12 +- .../resources/mapper/SourceResourceMapper.xml | 7 +- src/main/resources/sql/schema.sql | 8 +- 13 files changed, 212 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/labelsys/backend/dto/response/ImageBboxResponse.java b/src/main/java/com/labelsys/backend/dto/response/ImageBboxResponse.java index 5da61d1..7edfb74 100644 --- a/src/main/java/com/labelsys/backend/dto/response/ImageBboxResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/ImageBboxResponse.java @@ -9,6 +9,7 @@ import java.util.List; public record ImageBboxResponse( @Schema(description = "bbox标识ID", example = "191000000000000101") Long id, @Schema(description = "资源ID", example = "191000000000000102") Long resourceId, + @Schema(description = "资源文件路径", example = "/data/images/car.jpg") String filepath, @Schema(description = "BBOX坐标列表") List bboxes, @Schema(description = "备注", example = "车辆检测标注") String remark, @Schema(description = "创建人名称", example = "张审核") String creatorName, diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java index 74e43b8..c25c8a4 100644 --- a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java +++ b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java @@ -49,6 +49,7 @@ public class AnnotationResult { @TableField("requires_manual_review") private Boolean requiresManualReview; + @TableLogic(value = "false", delval = "true") @TableField("is_deleted") private Boolean isDeleted; diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationTask.java b/src/main/java/com/labelsys/backend/entity/AnnotationTask.java index 38026ec..cb343f7 100644 --- a/src/main/java/com/labelsys/backend/entity/AnnotationTask.java +++ b/src/main/java/com/labelsys/backend/entity/AnnotationTask.java @@ -2,6 +2,7 @@ package com.labelsys.backend.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import com.labelsys.backend.enums.IndustryType; import com.labelsys.backend.enums.TaskType; @@ -28,6 +29,7 @@ public class AnnotationTask { private IndustryType industryType; private TaskType taskType; private String taskStatus; + @TableLogic(value = "false", delval = "true") private Boolean isDeleted; private LocalDateTime startedAt; private LocalDateTime finishedAt; diff --git a/src/main/java/com/labelsys/backend/entity/ImageBboxAnnotation.java b/src/main/java/com/labelsys/backend/entity/ImageBboxAnnotation.java index 9dc0d05..b5bd6b6 100644 --- a/src/main/java/com/labelsys/backend/entity/ImageBboxAnnotation.java +++ b/src/main/java/com/labelsys/backend/entity/ImageBboxAnnotation.java @@ -2,6 +2,7 @@ package com.labelsys.backend.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import com.labelsys.backend.enums.UserRole; import java.time.LocalDateTime; @@ -65,4 +66,15 @@ public class ImageBboxAnnotation { * 更新时间 */ private LocalDateTime updatedAt; + + /** + * 软删除标记 + */ + @TableLogic(value = "false", delval = "true") + private Boolean deleted; + + /** + * 软删除时间 + */ + private LocalDateTime deletedAt; } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/entity/SourceResource.java b/src/main/java/com/labelsys/backend/entity/SourceResource.java index 3ff3b9e..774b449 100644 --- a/src/main/java/com/labelsys/backend/entity/SourceResource.java +++ b/src/main/java/com/labelsys/backend/entity/SourceResource.java @@ -2,6 +2,7 @@ package com.labelsys.backend.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import com.labelsys.backend.enums.UserRole; import java.time.LocalDateTime; @@ -31,4 +32,7 @@ public class SourceResource { private String remark; private LocalDateTime createdAt; private LocalDateTime updatedAt; + @TableLogic(value = "false", delval = "true") + private Boolean deleted; + private LocalDateTime deletedAt; } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/enums/IndustryType.java b/src/main/java/com/labelsys/backend/enums/IndustryType.java index 8f047c1..cffe909 100644 --- a/src/main/java/com/labelsys/backend/enums/IndustryType.java +++ b/src/main/java/com/labelsys/backend/enums/IndustryType.java @@ -2,8 +2,9 @@ package com.labelsys.backend.enums; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "行业类型,枚举值:TRANSPORT:交通、ELECTRICITY:电力、FINANCE:金融、MEDICAL:医疗、EDUCATION:教育") +@Schema(description = "行业类型,枚举值:TRANSPORT:交通、ELECTRICITY:电力、FINANCE:金融、MEDICAL:医疗、EDUCATION:教育、GENERAL:通用") public enum IndustryType { + GENERAL, TRANSPORT, ELECTRICITY, FINANCE, diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java index 5408086..e8a6eb5 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java @@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; @@ -109,7 +110,7 @@ public class AnnotationResultService { } private AnnotationResultDetailResponse toDetailResponse(AnnotationResult result, - QaContent qaContent, DiffContent diffContent) { + QaContent qaContent, DiffContent diffContent) { // 转换 QA 内容(仅保留 records) AnnotationResultDetailResponse.QaContentDto qaContentDto = new AnnotationResultDetailResponse.QaContentDto( @@ -299,6 +300,10 @@ public class AnnotationResultService { private QaContent loadQaContent(AnnotationResult result) { try { String filePath = result.getQaContentFilePath(); + if (!StringUtils.hasText(filePath)) { + log.warn("Qa content file path is empty, resultId={}", result.getId()); + return new QaContent(null, null, List.of(), null); + } String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); byte[] content = objectStorageService.download(bucketName, objectKey); @@ -306,15 +311,19 @@ public class AnnotationResultService { return objectMapper.readValue(jsonContent, new TypeReference() { }); } catch (Exception e) { - log.error("Failed to load qa content, resultId={}, filePath={}", result.getId(), - result.getQaContentFilePath(), e); - throw new BusinessException(ResultCode.ERROR, "加载问答内容失败"); + log.warn("Failed to load qa content, returning empty content. resultId={}, filePath={}, error={}", + result.getId(), result.getQaContentFilePath(), e.getMessage()); + return new QaContent(null, null, List.of(), null); } } private DiffContent loadDiffSummary(AnnotationResult result) { try { String filePath = result.getDiffSummaryFilePath(); + if (!StringUtils.hasText(filePath)) { + log.warn("Diff summary file path is empty, resultId={}", result.getId()); + return new DiffContent(null, null, List.of(), null); + } String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); byte[] content = objectStorageService.download(bucketName, objectKey); @@ -322,9 +331,9 @@ public class AnnotationResultService { return objectMapper.readValue(jsonContent, new TypeReference() { }); } catch (Exception e) { - log.error("Failed to load diff summary, resultId={}, filePath={}", result.getId(), - result.getDiffSummaryFilePath(), e); - throw new BusinessException(ResultCode.ERROR, "加载差异摘要失败"); + log.warn("Failed to load diff summary, returning empty content. resultId={}, filePath={}, error={}", + result.getId(), result.getDiffSummaryFilePath(), e.getMessage()); + return new DiffContent(null, null, List.of(), null); } } diff --git a/src/main/java/com/labelsys/backend/service/ObjectStorageService.java b/src/main/java/com/labelsys/backend/service/ObjectStorageService.java index b40556b..d2ceef5 100644 --- a/src/main/java/com/labelsys/backend/service/ObjectStorageService.java +++ b/src/main/java/com/labelsys/backend/service/ObjectStorageService.java @@ -1,5 +1,7 @@ package com.labelsys.backend.service; +import java.time.Duration; + public interface ObjectStorageService { String upload(String bucketName, String objectKey, byte[] content, String contentType); @@ -7,4 +9,14 @@ public interface ObjectStorageService { void delete(String bucketName, String objectKey); byte[] download(String bucketName, String objectKey); -} + + /** + * 生成预签名URL(用于私有bucket的临时访问) + * + * @param bucketName bucket名称 + * @param objectKey 对象键 + * @param duration URL有效期 + * @return 预签名URL + */ + String generatePresignedUrl(String bucketName, String objectKey, Duration duration); +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java b/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java index 5b11725..30f5675 100644 --- a/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java +++ b/src/main/java/com/labelsys/backend/service/RustfsObjectStorageService.java @@ -2,20 +2,30 @@ package com.labelsys.backend.service; import com.labelsys.backend.common.ResultCode; import com.labelsys.backend.common.exception.BusinessException; +import com.labelsys.backend.config.ObjectStorageProperties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; +import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; @Service @RequiredArgsConstructor public class RustfsObjectStorageService implements ObjectStorageService { private final S3Client s3Client; + private final ObjectStorageProperties objectStorageProperties; @Override public String upload(String bucketName, String objectKey, byte[] content, String contentType) { @@ -57,4 +67,30 @@ public class RustfsObjectStorageService implements ObjectStorageService { throw new BusinessException(ResultCode.ERROR, "对象存储下载失败"); } } -} + + @Override + public String generatePresignedUrl(String bucketName, String objectKey, Duration duration) { + try (S3Presigner presigner = S3Presigner.builder() + .endpointOverride(URI.create(objectStorageProperties.getEndpoint())) + .region(Region.of(objectStorageProperties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create( + objectStorageProperties.getAccessKey(), + objectStorageProperties.getSecretKey()))) + .build()) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(getObjectRequest) + .build(); + + return presigner.presignGetObject(presignRequest).url().toString(); + } catch (Exception ex) { + throw new BusinessException(ResultCode.ERROR, "生成预签名URL失败"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/SourceResourceService.java b/src/main/java/com/labelsys/backend/service/SourceResourceService.java index fe24e0a..7b0c726 100644 --- a/src/main/java/com/labelsys/backend/service/SourceResourceService.java +++ b/src/main/java/com/labelsys/backend/service/SourceResourceService.java @@ -16,11 +16,17 @@ import com.labelsys.backend.dto.request.SourceUploadRequest; import com.labelsys.backend.dto.response.ImageBboxResponse; import com.labelsys.backend.dto.response.SourceResourceResponse; import com.labelsys.backend.dto.response.SourceUploadResponse; +import com.labelsys.backend.entity.AnnotationResult; +import com.labelsys.backend.entity.AnnotationResultHistory; +import com.labelsys.backend.entity.AnnotationTask; +import com.labelsys.backend.entity.AnnotationTaskResource; import com.labelsys.backend.entity.ImageBboxAnnotation; import com.labelsys.backend.entity.SourceResource; import com.labelsys.backend.entity.SysUser; import com.labelsys.backend.enums.ResourceType; - +import com.labelsys.backend.mapper.AnnotationResultHistoryMapper; +import com.labelsys.backend.mapper.AnnotationResultMapper; +import com.labelsys.backend.mapper.AnnotationTaskMapper; import com.labelsys.backend.mapper.AnnotationTaskResourceMapper; import com.labelsys.backend.mapper.ImageBboxAnnotationMapper; import com.labelsys.backend.mapper.SourceResourceMapper; @@ -35,6 +41,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -43,14 +50,17 @@ import java.util.List; @RequiredArgsConstructor public class SourceResourceService { - private final SourceResourceMapper sourceResourceMapper; - private final AnnotationTaskResourceMapper annotationTaskResourceMapper; - private final SysUserMapper sysUserMapper; - private final DataPermissionService dataPermissionService; - private final ObjectStorageService objectStorageService; - private final ObjectStorageProperties objectStorageProperties; - private final ImageBboxAnnotationMapper imageBboxAnnotationMapper; - private final ObjectMapper objectMapper; + private final SourceResourceMapper sourceResourceMapper; + private final AnnotationResultMapper annotationResultMapper; + private final AnnotationResultHistoryMapper annotationResultHistoryMapper; + private final AnnotationTaskResourceMapper annotationTaskResourceMapper; + private final SysUserMapper sysUserMapper; + private final DataPermissionService dataPermissionService; + private final ObjectStorageService objectStorageService; + private final ObjectStorageProperties objectStorageProperties; + private final ImageBboxAnnotationMapper imageBboxAnnotationMapper; + private final ObjectMapper objectMapper; + private final AnnotationTaskMapper annotationTaskMapper; @Transactional public SourceUploadResponse upload(LoginUser currentUser, SourceUploadRequest request) { @@ -91,7 +101,8 @@ public class SourceResourceService { request.getResourceName() : file.getOriginalFilename()) .resourceType(request.getResourceType()).bucketName(objectStorageProperties.getSourceBucket()) - .filePath(objectKey).fileSize(file.getSize()).storageProvider("rustfs").remark(request.getRemark()).build(); + .filePath(objectKey).fileSize(file.getSize()).storageProvider("rustfs").remark(request.getRemark()) + .build(); sourceResourceMapper.insert(resource); log.info("uploaded source resource, companyId={}, userId={}, resourceId={}", currentUser.companyId(), currentUser.userId(), resourceId); @@ -191,9 +202,22 @@ public class SourceResourceService { resource.getCreatorRole())) { throw new BusinessException(ResultCode.FORBIDDEN, "无权删除资源"); } + + // 检查外键关联记录 + checkForeignKeyAssociations(resourceId); + + // 删除关联资源 + deleteAssociatedRecords(resourceId); + + // 执行软删除 + resource.setDeleted(true); + resource.setDeletedAt(LocalDateTime.now()); + sourceResourceMapper.updateById(resource); + + // 删除对象存储中的文件 objectStorageService.delete(resource.getBucketName(), resource.getFilePath()); - sourceResourceMapper.deleteById(resourceId); - log.info("deleted source resource, companyId={}, userId={}, resourceId={}", currentUser.companyId(), + + log.info("soft deleted source resource, companyId={}, userId={}, resourceId={}", currentUser.companyId(), currentUser.userId(), resourceId); } catch (BusinessException e) { throw e; @@ -204,6 +228,24 @@ public class SourceResourceService { } } + /** + * 删除资源的关联记录 + * + * @param resourceId 资源ID + */ + private void deleteAssociatedRecords(Long resourceId) { + // 删除任务资源关联记录 + annotationTaskResourceMapper.delete(new LambdaQueryWrapper() + .eq(AnnotationTaskResource::getResourceId, resourceId)); + + // 删除标注结果记录(包括已删除的) + annotationResultMapper.delete(new LambdaQueryWrapper() + .eq(AnnotationResult::getResourceId, resourceId)); + + // 删除BBOX标注记录 + imageBboxAnnotationMapper.deleteByResourceId(resourceId); + } + /** * 下载资源(支持 TEXT、IMAGE、VIDEO) * @@ -240,8 +282,14 @@ public class SourceResourceService { } ImageBboxAnnotation annotation = imageBboxAnnotationMapper.selectByResourceId(resourceId); + // 生成预签名URL(有效期1小时),用于私有bucket的临时访问 + String imageUrl = objectStorageService.generatePresignedUrl( + resource.getBucketName(), + resource.getFilePath(), + Duration.ofHours(1)); if (annotation == null) { - return new ImageBboxResponse(null, resourceId, List.of(), null, null, null, null); + return new ImageBboxResponse(null, resourceId, imageUrl, List.of(), null, null, null, + null); } List bboxes = parseBboxJson(annotation.getBboxJson()); @@ -250,6 +298,7 @@ public class SourceResourceService { return new ImageBboxResponse( annotation.getId(), annotation.getResourceId(), + imageUrl, bboxes, annotation.getRemark(), creator == null ? null : creator.getRealName(), @@ -432,4 +481,47 @@ public class SourceResourceService { default -> "application/octet-stream"; }; } + + /** + * 检查资源是否有外键关联记录 + * 如果存在关联记录,抛出业务异常 + * + * @param resourceId 资源ID + */ + private void checkForeignKeyAssociations(Long resourceId) { + // 1. 检查资源关联的任务是否被软删除(未软删除的任务关联则不能删除) + List taskIds = annotationTaskResourceMapper.selectList( + new LambdaQueryWrapper() + .eq(AnnotationTaskResource::getResourceId, resourceId)) + .stream() + .map(AnnotationTaskResource::getTaskId) + .toList(); + + if (!taskIds.isEmpty()) { + Long activeTaskCount = annotationTaskMapper.selectCount( + new LambdaQueryWrapper() + .in(AnnotationTask::getId, taskIds) + .notIn(AnnotationTask::getTaskStatus, "COMPLETED", "FAILED")); + if (activeTaskCount > 0) { + throw new BusinessException(ResultCode.FORBIDDEN, "资源已被标注任务引用,无法删除"); + } + } + + // 2. 检查资源是否存在标注历史记录(只要存在就不能删除) + Long historyCount = annotationResultHistoryMapper.selectCount( + new LambdaQueryWrapper() + .eq(AnnotationResultHistory::getResourceId, resourceId)); + if (historyCount > 0) { + throw new BusinessException(ResultCode.FORBIDDEN, "资源存在标注历史记录,无法删除"); + } + + // 3. 检查资源是否有未被软删除的标注结果 + Long activeResultCount = annotationResultMapper.selectCount( + new LambdaQueryWrapper() + .eq(AnnotationResult::getResourceId, resourceId) + .eq(AnnotationResult::getIsDeleted, false)); + if (activeResultCount > 0) { + throw new BusinessException(ResultCode.FORBIDDEN, "资源已有标注结果,无法删除"); + } + } } \ No newline at end of file diff --git a/src/main/resources/mapper/ImageBboxAnnotationMapper.xml b/src/main/resources/mapper/ImageBboxAnnotationMapper.xml index 0ebf565..5841177 100644 --- a/src/main/resources/mapper/ImageBboxAnnotationMapper.xml +++ b/src/main/resources/mapper/ImageBboxAnnotationMapper.xml @@ -11,14 +11,16 @@ + + - - DELETE FROM image_bbox_annotation WHERE resource_id = #{resourceId} - + + UPDATE image_bbox_annotation SET deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE resource_id = #{resourceId} + \ No newline at end of file diff --git a/src/main/resources/mapper/SourceResourceMapper.xml b/src/main/resources/mapper/SourceResourceMapper.xml index ceba1ac..77805a8 100644 --- a/src/main/resources/mapper/SourceResourceMapper.xml +++ b/src/main/resources/mapper/SourceResourceMapper.xml @@ -15,19 +15,22 @@ + + id, company_id, creator_id, creator_role, resource_name, resource_type, bucket_name, file_path, file_size, storage_provider, remark, - created_at, updated_at + created_at, updated_at, deleted, deleted_at + AND resource_name = #{resourceName} AND deleted = false LIMIT 1 \ No newline at end of file diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 03fa631..e99dafa 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -138,6 +138,8 @@ CREATE TABLE IF NOT EXISTS source_resource remark VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE, + deleted_at TIMESTAMP, CONSTRAINT fk_source_resource_company FOREIGN KEY (company_id) REFERENCES sys_company (id), CONSTRAINT fk_source_resource_creator FOREIGN KEY (creator_id) REFERENCES sys_user (id) ); @@ -169,6 +171,8 @@ CREATE TABLE IF NOT EXISTS image_bbox_annotation creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE, + deleted_at TIMESTAMP, CONSTRAINT fk_image_bbox_annotation_company FOREIGN KEY (company_id) REFERENCES sys_company (id), CONSTRAINT fk_image_bbox_annotation_resource FOREIGN KEY (resource_id) REFERENCES source_resource (id), CONSTRAINT fk_image_bbox_annotation_creator FOREIGN KEY (creator_id) REFERENCES sys_user (id) @@ -184,6 +188,8 @@ COMMENT ON COLUMN image_bbox_annotation.creator_id IS '创建人用户ID。'; COMMENT ON COLUMN image_bbox_annotation.creator_role IS '创建人数据权限角色,默认 EMPLOYEE。'; COMMENT ON COLUMN image_bbox_annotation.created_at IS '创建时间。'; COMMENT ON COLUMN image_bbox_annotation.updated_at IS '更新时间。'; +COMMENT ON COLUMN image_bbox_annotation.deleted IS '软删除标记,默认 FALSE。'; +COMMENT ON COLUMN image_bbox_annotation.deleted_at IS '软删除时间。'; -- 修改 annotation_task 表,删除模型和提示词相关字段 CREATE TABLE IF NOT EXISTS annotation_task @@ -193,7 +199,7 @@ CREATE TABLE IF NOT EXISTS annotation_task creator_id BIGINT NOT NULL, creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE', task_name VARCHAR(255) NOT NULL, - industry_type VARCHAR(32) NOT NULL DEFAULT 'transport', + industry_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL', task_type VARCHAR(32) NOT NULL DEFAULT 'EXTRACT_QA', task_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', is_deleted BOOLEAN NOT NULL DEFAULT FALSE, From 9425ff3a1e8d3c3ee15a0db88f13adce64f5d589 Mon Sep 17 00:00:00 2001 From: wh Date: Sat, 9 May 2026 23:46:56 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E9=80=82=E9=85=8DQA=E9=97=AE=E7=AD=94?= =?UTF-8?q?=E5=AF=B9=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 +- .../AnnotationResultArchiveController.java | 21 +-- .../dto/request/MergeReviewResultRequest.java | 4 +- .../AnnotationResultCompareResponse.java | 36 ++++- .../AnnotationResultDetailResponse.java | 32 +++- ...AnnotationResultHistoryDetailResponse.java | 63 ++++++++ .../AnnotationResultHistoryResponse.java | 3 +- .../response/AnnotationResultResponse.java | 2 - .../backend/entity/AnnotationResult.java | 6 - .../entity/AnnotationResultHistory.java | 1 - .../mapper/AnnotationResultMapper.java | 4 +- .../AnnotationResultArchiveService.java | 142 +++++++++++++++--- .../service/AnnotationResultService.java | 114 +++++++++++--- .../mapper/AnnotationResultMapper.xml | 11 +- src/main/resources/sql/data.sql | 10 +- src/main/resources/sql/schema.sql | 6 - 16 files changed, 359 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryDetailResponse.java diff --git a/pom.xml b/pom.xml index ab3f177..ad3696d 100644 --- a/pom.xml +++ b/pom.xml @@ -111,8 +111,8 @@ maven-compiler-plugin 3.11.0 - ${java.version} - ${java.version} + 21 + 21 org.projectlombok @@ -120,6 +120,7 @@ 1.18.30 + --enable-preview diff --git a/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java b/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java index 1fbcb85..232dbc8 100644 --- a/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java +++ b/src/main/java/com/labelsys/backend/controller/AnnotationResultArchiveController.java @@ -3,8 +3,8 @@ 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.AnnotationResultHistoryDetailResponse; import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse; -import com.labelsys.backend.dto.response.FileContentResponse; import com.labelsys.backend.service.AnnotationResultArchiveService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -34,17 +34,18 @@ public class AnnotationResultArchiveController { @Operation(summary = "查询归档历史详情") @GetMapping("/{id}") - public ResponseEntity getHistory( + public ResponseEntity getHistory( @Parameter(description = "历史记录ID", example = "901") @PathVariable Long id) { return ResponseEntity.ok(annotationResultArchiveService.getHistory(UserContext.requireUser(), id)); } +} - @Operation(summary = "加载归档文件内容") - @GetMapping("/{id}/content") - public ResponseEntity loadFileContent( - @Parameter(description = "历史记录ID", example = "901") - @PathVariable Long id) { - return ResponseEntity.ok(annotationResultArchiveService.loadFileContent(UserContext.requireUser(), id)); - } -} \ No newline at end of file +// @Operation(summary = "加载归档文件内容") +// @GetMapping("/{id}/content") +// public ResponseEntity loadFileContent( +// @Parameter(description = "历史记录ID", example = "901") +// @PathVariable Long id) { +// return ResponseEntity.ok(annotationResultArchiveService.loadFileContent(UserContext.requireUser(), id)); +// } +//} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java b/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java index e7930f0..e45c90c 100644 --- a/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java +++ b/src/main/java/com/labelsys/backend/dto/request/MergeReviewResultRequest.java @@ -9,7 +9,7 @@ public record MergeReviewResultRequest( @Schema(description = "合并后的答案映射,key为qa记录ID,value为合并后的答案") Map mergedAnswers, - @Schema(description = "审核备注") - String reviewComment + @Schema(description = "每条QA记录的审核评论映射,key为qa记录ID,value为审核评论") + Map reviewComments ) { } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java index b389969..9e8b100 100644 --- a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultCompareResponse.java @@ -18,10 +18,25 @@ public record AnnotationResultCompareResponse( @Schema(description = "问答记录") public record QaRecord( @Schema(description = "记录ID", example = "qa_001") String id, + @Schema(description = "批次ID", example = "50") Long batchId, @Schema(description = "问题", example = "运输时效是多久?") String question, @Schema(description = "答案", example = "3天") String answer, - @Schema(description = "是否需要审核", example = "true") Boolean requiresReview - ) {} + @Schema(description = "是否需要审核", example = "true") Boolean requiresReview, + @Schema(description = "源片段信息") SourceSegments sourceSegments, + @Schema(description = "问题分类") String questionCategory, + @Schema(description = "评分") Scores scores, + @Schema(description = "审核评论") String reviewComment + ) { + } + + @Schema(description = "源片段信息") + public record SourceSegments( + @Schema(description = "片段内容") String segment, + @Schema(description = "块索引", example = "0") Integer chunkIndex, + @Schema(description = "块标题") String chunkTitle, + @Schema(description = "块内容") String chunkContent + ) { + } @Schema(description = "差异记录") public record DiffRecord( @@ -30,6 +45,19 @@ public record AnnotationResultCompareResponse( @Schema(description = "提取模型答案", example = "3天") String extractAnswer, @Schema(description = "校验模型答案", example = "72小时") String verifyAnswer, @Schema(description = "差异原因", example = "时间单位不一致") String diffReason, - @Schema(description = "合并后的最终答案", example = "72小时(3天)") String mergedAnswer - ) {} + @Schema(description = "合并后的最终答案", example = "72小时(3天)") String mergedAnswer, + @Schema(description = "问题分类") String questionCategory, + @Schema(description = "评分") Scores scores + ) { + } + + @Schema(description = "评分结构") + public record Scores( + @Schema(description = "相似度", example = "0.7") Double similarity, + @Schema(description = "置信度1", example = "0.9") Double confidence1, + @Schema(description = "置信度2", example = "0.85") Double confidence2, + @Schema(description = "幻觉检测", example = "0.9") Double hallucination, + @Schema(description = "信任度", example = "0.64") Double trust + ) { + } } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultDetailResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultDetailResponse.java index ff0c62a..9fa3257 100644 --- a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultDetailResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultDetailResponse.java @@ -25,8 +25,6 @@ public record AnnotationResultDetailResponse( @Schema(description = "问答内容") QaContentDto qaContent, @Schema(description = "差异摘要(需要审核时有值)") DiffContentDto diffSummary, - @Schema(description = "审核备注", example = "需统一时间字段口径。") String reviewComment, - @Schema(description = "审核时间", example = "2026-04-27T11:00:00") LocalDateTime reviewedAt, @Schema(description = "创建时间", example = "2026-04-27T10:40:00") LocalDateTime createdAt ) { @Schema(description = "问答内容结构") @@ -38,9 +36,23 @@ public record AnnotationResultDetailResponse( @Schema(description = "问答记录") public record QaRecordDto( @Schema(description = "记录ID", example = "q1") String id, + @Schema(description = "批次ID", example = "50") Long batchId, @Schema(description = "问题", example = "产品重量是多少?") String question, @Schema(description = "答案", example = "5kg") String answer, - @Schema(description = "是否需要审核", example = "false") Boolean requiresReview + @Schema(description = "是否需要审核", example = "false") Boolean requiresReview, + @Schema(description = "源片段信息") SourceSegmentsDto sourceSegments, + @Schema(description = "问题分类") String questionCategory, + @Schema(description = "评分") ScoresDto scores, + @Schema(description = "审核评论") String reviewComment + ) { + } + + @Schema(description = "源片段信息") + public record SourceSegmentsDto( + @Schema(description = "片段内容") String segment, + @Schema(description = "块索引", example = "0") Integer chunkIndex, + @Schema(description = "块标题") String chunkTitle, + @Schema(description = "块内容") String chunkContent ) { } @@ -57,7 +69,19 @@ public record AnnotationResultDetailResponse( @Schema(description = "抽取答案", example = "2年") String extractAnswer, @Schema(description = "验证答案", example = "3年") String verifyAnswer, @Schema(description = "差异原因", example = "抽取与验证结果不一致") String diffReason, - @Schema(description = "合并后答案") String mergedAnswer + @Schema(description = "合并后答案") String mergedAnswer, + @Schema(description = "问题分类") String questionCategory, + @Schema(description = "评分") ScoresDto scores + ) { + } + + @Schema(description = "评分结构") + public record ScoresDto( + @Schema(description = "相似度", example = "0.7") Double similarity, + @Schema(description = "置信度1", example = "0.9") Double confidence1, + @Schema(description = "置信度2", example = "0.85") Double confidence2, + @Schema(description = "幻觉检测", example = "0.9") Double hallucination, + @Schema(description = "信任度", example = "0.64") Double trust ) { } } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryDetailResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryDetailResponse.java new file mode 100644 index 0000000..2b2571f --- /dev/null +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryDetailResponse.java @@ -0,0 +1,63 @@ +package com.labelsys.backend.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "归档历史详情响应") +public record AnnotationResultHistoryDetailResponse( + @Schema(description = "历史记录ID", example = "901") Long id, + @Schema(description = "任务ID", example = "191000000000000301") Long taskId, + @Schema(description = "任务名称", example = "产品说明书标注") String taskName, + @Schema(description = "资源ID", example = "191000000000000101") Long resourceId, + @Schema(description = "资源名称", example = "产品A说明书.pdf") String resourceName, + + @Schema(description = "问答内容文件路径", example = "annotation-results/2/qa/802.json") String qaContentFilePath, + @Schema(description = "问答内容") QaContentDto qaContent, + @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, + @Schema(description = "审核人ID,自动归档时为null", example = "5") Long reviewerId, + @Schema(description = "审核人姓名,自动归档时为auto", example = "张三") String reviewerName +) { + @Schema(description = "问答内容结构") + public record QaContentDto( + @Schema(description = "问答记录列表") List records + ) { + } + + @Schema(description = "问答记录") + public record QaRecordDto( + @Schema(description = "记录ID", example = "q1") String id, + @Schema(description = "批次ID", example = "50") Long batchId, + @Schema(description = "问题", example = "产品重量是多少?") String question, + @Schema(description = "答案", example = "5kg") String answer, + @Schema(description = "是否需要审核", example = "false") Boolean requiresReview, + @Schema(description = "源片段信息") SourceSegmentsDto sourceSegments, + @Schema(description = "问题分类") String questionCategory, + @Schema(description = "评分") ScoresDto scores, + @Schema(description = "审核评论") String reviewComment + ) { + } + + @Schema(description = "源片段信息") + public record SourceSegmentsDto( + @Schema(description = "片段内容") String segment, + @Schema(description = "块索引", example = "0") Integer chunkIndex, + @Schema(description = "块标题") String chunkTitle, + @Schema(description = "块内容") String chunkContent + ) { + } + + @Schema(description = "评分结构") + public record ScoresDto( + @Schema(description = "相似度", example = "0.7") Double similarity, + @Schema(description = "置信度1", example = "0.9") Double confidence1, + @Schema(description = "置信度2", example = "0.85") Double confidence2, + @Schema(description = "幻觉检测", example = "0.9") Double hallucination, + @Schema(description = "信任度", example = "0.64") Double trust + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java index 025ec2d..38e90b7 100644 --- a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultHistoryResponse.java @@ -20,7 +20,6 @@ public record AnnotationResultHistoryResponse( @Schema(description = "归档时间", example = "2026-05-06T10:30:00") LocalDateTime archivedAt, @Schema(description = "创建时间", example = "2026-05-06T10:30:00") LocalDateTime createdAt, @Schema(description = "审核人ID,自动归档时为null", example = "5") Long reviewerId, - @Schema(description = "审核人姓名,自动归档时为auto", example = "张三") String reviewerName, - @Schema(description = "审核意见,自动归档时为auto", example = "内容符合要求") String reviewerComment + @Schema(description = "审核人姓名,自动归档时为auto", example = "张三") String reviewerName ) { } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java index 171b692..7f045f7 100644 --- a/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java +++ b/src/main/java/com/labelsys/backend/dto/response/AnnotationResultResponse.java @@ -17,8 +17,6 @@ public record AnnotationResultResponse( @Schema(description = "是否已删除", example = "false") Boolean isDeleted, @Schema(description = "问答内容文件路径", example = "annotation-results/2/qa/801.json") String qaContentFilePath, @Schema(description = "差异摘要文件路径", example = "annotation-results/2/diff/801.json") String diffSummaryFilePath, - @Schema(description = "审核备注", example = "需统一时间字段口径。") String reviewComment, - @Schema(description = "审核时间", example = "2026-04-27T11:00:00") LocalDateTime reviewedAt, @Schema(description = "创建时间", example = "2026-04-27T10:40:00") LocalDateTime createdAt ) { } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java index c25c8a4..884f750 100644 --- a/src/main/java/com/labelsys/backend/entity/AnnotationResult.java +++ b/src/main/java/com/labelsys/backend/entity/AnnotationResult.java @@ -56,12 +56,6 @@ public class AnnotationResult { @TableField("reviewer_id") private Long reviewerId; - @TableField("review_comment") - private String reviewComment; - - @TableField("reviewed_at") - private LocalDateTime reviewedAt; - @TableField("created_at") private LocalDateTime createdAt; diff --git a/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java b/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java index 06b3f2f..b196baf 100644 --- a/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java +++ b/src/main/java/com/labelsys/backend/entity/AnnotationResultHistory.java @@ -37,5 +37,4 @@ public class AnnotationResultHistory { // 新增审核人相关字段 private Long reviewerId; private String reviewerName; - private String reviewerComment; } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java b/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java index bdda150..448ddd4 100644 --- a/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java +++ b/src/main/java/com/labelsys/backend/mapper/AnnotationResultMapper.java @@ -11,7 +11,5 @@ public interface AnnotationResultMapper extends BaseMapper { int markArchived(@Param("id") Long id, @Param("companyId") Long companyId, - @Param("reviewerId") Long reviewerId, - @Param("reviewComment") String reviewComment, - @Param("reviewedAt") LocalDateTime reviewedAt); + @Param("reviewerId") Long reviewerId); } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java index 4570421..f3ea0bd 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultArchiveService.java @@ -8,6 +8,7 @@ import com.labelsys.backend.common.exception.BusinessException; import com.labelsys.backend.context.LoginUser; import com.labelsys.backend.dto.common.PageResult; import com.labelsys.backend.dto.request.AnnotationResultHistoryPageQuery; +import com.labelsys.backend.dto.response.AnnotationResultHistoryDetailResponse; import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse; import com.labelsys.backend.dto.response.FileContentResponse; import com.labelsys.backend.dto.response.MergeReviewResultResponse; @@ -29,6 +30,8 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.List; +import static org.springframework.util.StringUtils.hasText; + @Slf4j @Service @RequiredArgsConstructor @@ -66,6 +69,7 @@ public class AnnotationResultArchiveService { var page = new Page(query.pageNo(), query.pageSize()); var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper); + // 分页查询不加载 qa 内容 var records = resultPage.getRecords().stream() .map(this::toResponse) .toList(); @@ -79,14 +83,17 @@ public class AnnotationResultArchiveService { } } - public AnnotationResultHistoryResponse getHistory(LoginUser currentUser, Long historyId) { + public AnnotationResultHistoryDetailResponse getHistory(LoginUser currentUser, Long historyId) { try { 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); + + // 详情查询加载 QA 内容 + QaContent qaContent = loadQaContent(history.getQaContentFilePath()); + return toDetailResponse(history, qaContent); } catch (Exception e) { log.error("getHistory failed, companyId={}, userId={}, historyId={}, error={}", currentUser.companyId(), currentUser.userId(), historyId, e.getMessage(), e); @@ -104,19 +111,66 @@ public class AnnotationResultArchiveService { private AnnotationResultHistoryResponse toResponse(AnnotationResultHistory history) { return new AnnotationResultHistoryResponse( history.getId(), - // history.getSourceResultId(), history.getTaskId(), - history.getTaskName(), // 新增 + history.getTaskName(), history.getResourceId(), - history.getResourceName(), // 新增 + history.getResourceName(), history.getQaContentFilePath(), history.getArchiveReason(), history.getArchivedBy(), history.getArchivedAt(), history.getCreatedAt(), history.getReviewerId(), - history.getReviewerName(), - history.getReviewerComment() + history.getReviewerName() + ); + } + + private AnnotationResultHistoryDetailResponse toDetailResponse(AnnotationResultHistory history, + QaContent qaContent) { + // 转换 QA 内容 + AnnotationResultHistoryDetailResponse.QaContentDto qaContentDto = null; + if (qaContent != null && qaContent.records() != null) { + qaContentDto = new AnnotationResultHistoryDetailResponse.QaContentDto( + qaContent.records().stream() + .map(r -> new AnnotationResultHistoryDetailResponse.QaRecordDto( + r.id(), + r.batchId(), + r.question(), + r.answer(), + r.requiresReview(), + r.sourceSegments() != null ? + new AnnotationResultHistoryDetailResponse.SourceSegmentsDto( + r.sourceSegments().segment(), + r.sourceSegments().chunkIndex(), + r.sourceSegments().chunkTitle(), + r.sourceSegments().chunkContent()) : + null, + r.questionCategory(), + r.scores() != null ? new AnnotationResultHistoryDetailResponse.ScoresDto( + r.scores().similarity(), + r.scores().confidence1(), + r.scores().confidence2(), + r.scores().hallucination(), + r.scores().trust()) : null, + r.reviewComment())) + .toList() + ); + } + + return new AnnotationResultHistoryDetailResponse( + history.getId(), + history.getTaskId(), + history.getTaskName(), + history.getResourceId(), + history.getResourceName(), + history.getQaContentFilePath(), + qaContentDto, + history.getArchiveReason(), + history.getArchivedBy(), + history.getArchivedAt(), + history.getCreatedAt(), + history.getReviewerId(), + history.getReviewerName() ); } @@ -124,13 +178,14 @@ public class AnnotationResultArchiveService { public int autoArchiveEligibleResults() { try { LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout); - List results = annotationResultMapper.selectList(new LambdaQueryWrapper() - .eq(AnnotationResult::getIsDeleted, false) - .eq(AnnotationResult::getRequiresManualReview, false) - .lt(AnnotationResult::getCreatedAt, cutoff)); + List results = annotationResultMapper.selectList( + new LambdaQueryWrapper() + .eq(AnnotationResult::getIsDeleted, false) + .eq(AnnotationResult::getRequiresManualReview, false) + .lt(AnnotationResult::getCreatedAt, cutoff)); int archivedCount = 0; for (AnnotationResult result : results) { - if (archiveRuntimeResult(result, null, "AUTO_ARCHIVE", null) != null) { + if (archiveRuntimeResult(result, null, "AUTO_ARCHIVE") != null) { archivedCount++; } } @@ -153,8 +208,7 @@ public class AnnotationResultArchiveService { */ private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result, Long reviewerId, - String archiveReason, - String reviewComment) { + String archiveReason) { LocalDateTime archivedAt = LocalDateTime.now(); // 从对象存储读取 qa.json 内容 @@ -177,22 +231,64 @@ public class AnnotationResultArchiveService { .archivedAt(archivedAt) .reviewerId(null) .reviewerName("auto") - .reviewerComment("auto") .build(); annotationResultHistoryMapper.insert(history); int updated = annotationResultMapper.markArchived( result.getId(), result.getCompanyId(), - reviewerId, - reviewComment, - archivedAt); + reviewerId); if (updated == 0) { return null; } return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt); } + /** + * 加载 QA 内容 + */ + private QaContent loadQaContent(String filePath) { + try { + if (!hasText(filePath)) { + log.warn("QA content file path is empty"); + return new QaContent(null, null, List.of(), null); + } + String bucketName = extractBucketName(filePath); + String objectKey = extractObjectKey(filePath); + byte[] content = objectStorageService.download(bucketName, objectKey); + String jsonContent = new String(content, StandardCharsets.UTF_8); + return objectMapper.readValue(jsonContent, new com.fasterxml.jackson.core.type.TypeReference() { + }); + } catch (Exception e) { + log.warn("Failed to load QA content, returning empty content. filePath={}, error={}", filePath, + e.getMessage()); + return new QaContent(null, null, List.of(), null); + } + } + + // 内部类:qa.json 结构 + private record QaContent( + Long taskId, + Long resourceId, + List records, + Metadata metadata + ) { + private record QaRecord(String id, Long batchId, String question, String answer, + Boolean requiresReview, SourceSegments sourceSegments, + String questionCategory, Scores scores, String reviewComment) { + } + + private record SourceSegments(String segment, Integer chunkIndex, String chunkTitle, String chunkContent) { + } + + private record Scores(Double similarity, Double confidence1, Double confidence2, + Double hallucination, Double trust) { + } + + private record Metadata(String createdAt, String updatedAt) { + } + } + /** * 从对象存储读取 qa.json 内容 */ @@ -239,8 +335,9 @@ public class AnnotationResultArchiveService { /** * 加载归档记录的文件内容 + * * @param currentUser 当前用户 - * @param historyId 历史记录ID + * @param historyId 历史记录ID * @return 文件内容响应 */ public FileContentResponse loadFileContent(LoginUser currentUser, Long historyId) { @@ -250,17 +347,17 @@ public class AnnotationResultArchiveService { throw new BusinessException(ResultCode.NOT_FOUND, "历史记录不存在"); } //assertHistoryPermission(currentUser, history); - + String filePath = history.getQaContentFilePath(); if (filePath == null || filePath.isEmpty()) { throw new BusinessException(ResultCode.ERROR, "文件路径为空"); } - + String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); byte[] content = objectStorageService.download(bucketName, objectKey); String contentStr = new String(content, StandardCharsets.UTF_8); - + return new FileContentResponse(filePath, contentStr, content.length); } catch (BusinessException e) { throw e; @@ -270,4 +367,5 @@ public class AnnotationResultArchiveService { throw new BusinessException(ResultCode.ERROR, "加载文件内容失败"); } } + } \ No newline at end of file diff --git a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java index e8a6eb5..c125a6d 100644 --- a/src/main/java/com/labelsys/backend/service/AnnotationResultService.java +++ b/src/main/java/com/labelsys/backend/service/AnnotationResultService.java @@ -116,7 +116,24 @@ public class AnnotationResultService { AnnotationResultDetailResponse.QaContentDto qaContentDto = new AnnotationResultDetailResponse.QaContentDto( qaContent.records().stream() .map(r -> new AnnotationResultDetailResponse.QaRecordDto( - r.id(), r.question(), r.answer(), r.requiresReview())) + r.id(), + r.batchId(), + r.question(), + r.answer(), + r.requiresReview(), + r.sourceSegments() != null ? new AnnotationResultDetailResponse.SourceSegmentsDto( + r.sourceSegments().segment(), + r.sourceSegments().chunkIndex(), + r.sourceSegments().chunkTitle(), + r.sourceSegments().chunkContent()) : null, + r.questionCategory(), + r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( + r.scores().similarity(), + r.scores().confidence1(), + r.scores().confidence2(), + r.scores().hallucination(), + r.scores().trust()) : null, + r.reviewComment())) .toList() ); @@ -127,7 +144,14 @@ public class AnnotationResultService { diffContent.records().stream() .map(r -> new AnnotationResultDetailResponse.DiffRecordDto( r.qaId(), r.question(), r.extractAnswer(), - r.verifyAnswer(), r.diffReason(), r.mergedAnswer())) + r.verifyAnswer(), r.diffReason(), r.mergedAnswer(), + r.questionCategory(), + r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( + r.scores().similarity(), + r.scores().confidence1(), + r.scores().confidence2(), + r.scores().hallucination(), + r.scores().trust()) : null)) .toList() ); } @@ -145,8 +169,6 @@ public class AnnotationResultService { result.getDiffSummaryFilePath(), qaContentDto, diffContentDto, - result.getReviewComment(), - result.getReviewedAt(), result.getCreatedAt() ); } @@ -173,9 +195,23 @@ public class AnnotationResultService { List qaRecords = qaContent.records().stream() .map(qa -> new AnnotationResultCompareResponse.QaRecord( qa.id(), + qa.batchId(), qa.question(), qa.answer(), - qa.requiresReview() + qa.requiresReview(), + qa.sourceSegments() != null ? new AnnotationResultCompareResponse.SourceSegments( + qa.sourceSegments().segment(), + qa.sourceSegments().chunkIndex(), + qa.sourceSegments().chunkTitle(), + qa.sourceSegments().chunkContent()) : null, + qa.questionCategory(), + qa.scores() != null ? new AnnotationResultCompareResponse.Scores( + qa.scores().similarity(), + qa.scores().confidence1(), + qa.scores().confidence2(), + qa.scores().hallucination(), + qa.scores().trust()) : null, + qa.reviewComment() )).toList(); // 转换差异记录 @@ -187,7 +223,14 @@ public class AnnotationResultService { diff.extractAnswer(), diff.verifyAnswer(), diff.diffReason(), - diff.mergedAnswer() + diff.mergedAnswer(), + diff.questionCategory(), + diff.scores() != null ? new AnnotationResultCompareResponse.Scores( + diff.scores().similarity(), + diff.scores().confidence1(), + diff.scores().confidence2(), + diff.scores().hallucination(), + diff.scores().trust()) : null )).toList() : List.of(); return new AnnotationResultCompareResponse( @@ -220,16 +263,22 @@ public class AnnotationResultService { // 读取当前 qa.json QaContent qaContent = loadQaContent(result); - // 更新 qa.json 的 answer 字段 + // 更新 qa.json 的 answer 字段和 reviewComment List updatedQaRecords = qaContent.records().stream() .map(record -> { String mergedAnswer = request.mergedAnswers().get(record.id()); - if (mergedAnswer != null) { + String reviewComment = request.reviewComments() != null ? request.reviewComments().get(record.id()) : null; + if (mergedAnswer != null || reviewComment != null) { return new QaContent.QaRecord( record.id(), + record.batchId(), record.question(), - mergedAnswer, - false + mergedAnswer != null ? mergedAnswer : record.answer(), + false, + record.sourceSegments(), + record.questionCategory(), + record.scores(), + reviewComment != null ? reviewComment : record.reviewComment() ); } return record; @@ -247,11 +296,18 @@ public class AnnotationResultService { ); saveQaContent(result, updatedQaContent); - // 更新数据库记录 - result.setIsDeleted(Boolean.TRUE); - result.setReviewerId(currentUser.userId()); - result.setReviewComment(request.reviewComment()); - result.setReviewedAt(LocalDateTime.now()); + // 更新数据库记录(使用 markArchived 保证幂等性,防止并发重复归档) + int updated = annotationResultMapper.markArchived( + result.getId(), + currentUser.companyId(), + currentUser.userId()); + + if (updated == 0) { + // 记录已被其他进程归档 + throw new BusinessException(ResultCode.CONFLICT, "记录已被归档"); + } + + // 更新 requires_manual_review 字段 result.setRequiresManualReview(false); annotationResultMapper.updateById(result); @@ -281,8 +337,6 @@ public class AnnotationResultService { result.getIsDeleted(), result.getQaContentFilePath(), result.getDiffSummaryFilePath(), - result.getReviewComment(), - result.getReviewedAt(), result.getCreatedAt() ); } @@ -392,17 +446,15 @@ public class AnnotationResultService { // 根据归档类型设置审核人信息 if (isAutoArchive) { - // 自动归档:reviewer_id为NULL,name和comment为"auto" + // 自动归档:reviewer_id为NULL,name为"auto" historyBuilder .reviewerId(null) - .reviewerName("auto") - .reviewerComment("auto"); + .reviewerName("auto"); } else { // 人工审核后归档:使用审核人信息 historyBuilder .reviewerId(result.getReviewerId()) - .reviewerName(currentUser.realName()) - .reviewerComment(result.getReviewComment()); + .reviewerName(currentUser.realName()); } annotationResultHistoryMapper.insert(historyBuilder.build()); @@ -436,7 +488,16 @@ public class AnnotationResultService { List records, Metadata metadata ) { - private record QaRecord(String id, String question, String answer, Boolean requiresReview) { + private record QaRecord(String id, Long batchId, String question, String answer, + Boolean requiresReview, SourceSegments sourceSegments, + String questionCategory, Scores scores, String reviewComment) { + } + + private record SourceSegments(String segment, Integer chunkIndex, String chunkTitle, String chunkContent) { + } + + private record Scores(Double similarity, Double confidence1, Double confidence2, + Double hallucination, Double trust) { } private record Metadata(String createdAt, String updatedAt) { @@ -451,7 +512,12 @@ public class AnnotationResultService { Metadata metadata ) { private record DiffRecord(String qaId, String question, String extractAnswer, - String verifyAnswer, String diffReason, String mergedAnswer) { + String verifyAnswer, String diffReason, String mergedAnswer, + String questionCategory, Scores scores) { + } + + private record Scores(Double similarity, Double confidence1, Double confidence2, + Double hallucination, Double trust) { } private record Metadata(String createdAt) { diff --git a/src/main/resources/mapper/AnnotationResultMapper.xml b/src/main/resources/mapper/AnnotationResultMapper.xml index 76dd7b9..ec18f1a 100644 --- a/src/main/resources/mapper/AnnotationResultMapper.xml +++ b/src/main/resources/mapper/AnnotationResultMapper.xml @@ -13,16 +13,14 @@ - - id, company_id, creator_id, creator_role, task_id, resource_id, qa_content_file_path, - diff_summary_file_path, requires_manual_review, is_deleted, reviewer_id, review_comment, - reviewed_at, created_at, updated_at + diff_summary_file_path, requires_manual_review, is_deleted, reviewer_id, + created_at, updated_at