package com.labelsys.backend.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fasterxml.jackson.databind.ObjectMapper; import com.labelsys.backend.common.ResultCode; import com.labelsys.backend.common.exception.BusinessException; import com.labelsys.backend.context.LoginUser; import com.labelsys.backend.dto.common.PageResult; import com.labelsys.backend.dto.request.AnnotationResultHistoryPageQuery; import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse; import com.labelsys.backend.dto.response.MergeReviewResultResponse; import com.labelsys.backend.entity.AnnotationResult; import com.labelsys.backend.entity.AnnotationResultHistory; import com.labelsys.backend.enums.UserPosition; import com.labelsys.backend.enums.UserRole; import com.labelsys.backend.mapper.AnnotationResultHistoryMapper; import com.labelsys.backend.mapper.AnnotationResultMapper; import com.labelsys.backend.util.IdGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @Slf4j @Service @RequiredArgsConstructor public class AnnotationResultArchiveService { private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW"; private final AnnotationResultMapper annotationResultMapper; private final AnnotationResultHistoryMapper annotationResultHistoryMapper; private final ObjectStorageService objectStorageService; private final ObjectMapper objectMapper; private final DataPermissionService dataPermissionService; @Value("${labelsys.annotation.auto-archive-timeout:PT2H}") private Duration autoArchiveTimeout; public PageResult pageHistory(LoginUser currentUser, AnnotationResultHistoryPageQuery query) { List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); var wrapper = new LambdaQueryWrapper() .eq(AnnotationResultHistory::getCompanyId, currentUser.companyId()) .eq(query.taskId() != null, AnnotationResultHistory::getTaskId, query.taskId()) .eq(query.resourceId() != null, AnnotationResultHistory::getResourceId, query.resourceId()) .orderByDesc(AnnotationResultHistory::getCreatedAt); if (shouldFilterByUserId) { wrapper.eq(AnnotationResultHistory::getCreatorId, currentUser.userId()); } else if (!allowedRoles.isEmpty()) { wrapper.in(AnnotationResultHistory::getCreatorRole, allowedRoles); } var page = new Page(query.pageNo(), query.pageSize()); var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper); var records = resultPage.getRecords().stream() .map(this::toResponse) .toList(); return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), (int) resultPage.getSize()); } public AnnotationResultHistoryResponse getHistory(LoginUser currentUser, Long historyId) { AnnotationResultHistory history = annotationResultHistoryMapper.selectById(historyId); if (history == null || !history.getCompanyId().equals(currentUser.companyId())) { throw new BusinessException(ResultCode.NOT_FOUND, "历史记录不存在"); } assertHistoryPermission(currentUser, history); return toResponse(history); } private void assertHistoryPermission(LoginUser currentUser, AnnotationResultHistory history) { if (!dataPermissionService.canAccessCreator(currentUser, history.getCreatorId(), UserRole.valueOf(history.getCreatorRole()))) { throw new BusinessException(ResultCode.FORBIDDEN, "无权访问该归档记录"); } } private AnnotationResultHistoryResponse toResponse(AnnotationResultHistory history) { return new AnnotationResultHistoryResponse( history.getId(), history.getSourceResultId(), history.getTaskId(), history.getResourceId(), history.getQaContentFilePath(), history.getArchiveReason(), history.getArchivedBy(), history.getArchivedAt(), history.getCreatedAt() ); } @Transactional public int autoArchiveEligibleResults() { LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout); List results = annotationResultMapper.selectList(new LambdaQueryWrapper() .eq(AnnotationResult::getIsDeleted, false) .eq(AnnotationResult::getRequiresManualReview, false) .lt(AnnotationResult::getCreatedAt, cutoff)); int archivedCount = 0; for (AnnotationResult result : results) { if (archiveRuntimeResult(result, null, "AUTO_ARCHIVE", null) != null) { archivedCount++; } } return archivedCount; } private void assertReviewer(LoginUser currentUser) { if (currentUser.position() != UserPosition.REVIEWER && currentUser.position() != UserPosition.ADMIN) { throw new BusinessException(ResultCode.FORBIDDEN, "当前用户没有审核权限"); } } /** * 归档运行态标注结果到历史表 * 从对象存储读取 qa.json 内容进行归档 */ private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result, Long reviewerId, String archiveReason, String reviewComment) { LocalDateTime archivedAt = LocalDateTime.now(); // 从对象存储读取 qa.json 内容 String qaContentJson = loadQaContentJson(result); AnnotationResultHistory history = AnnotationResultHistory.builder() .id(IdGenerator.nextId()) .companyId(result.getCompanyId()) .creatorId(result.getCreatorId()) .creatorRole(result.getCreatorRole()) .sourceResultId(result.getId()) .taskId(result.getTaskId()) .resourceId(result.getResourceId()) //.qaContentJson(qaContentJson) // 使用从对象存储读取的内容 .qaContentFilePath(result.getQaContentFilePath()) .archiveReason(archiveReason) .archivedBy(reviewerId) .archivedAt(archivedAt) .build(); annotationResultHistoryMapper.insert(history); int updated = annotationResultMapper.markArchived( result.getId(), result.getCompanyId(), reviewerId, reviewComment, archivedAt); if (updated == 0) { return null; } return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt); } /** * 从对象存储读取 qa.json 内容 */ private String loadQaContentJson(AnnotationResult result) { try { String filePath = result.getQaContentFilePath(); if (filePath == null || filePath.isEmpty()) { log.warn("qa_content_file_path is null or empty, resultId={}", result.getId()); return "{}"; } // 解析文件路径,提取 bucket 和 object key String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); // 从对象存储下载文件内容 byte[] content = objectStorageService.download(bucketName, objectKey); return new String(content, StandardCharsets.UTF_8); } catch (Exception e) { log.error("Failed to load qa content from object storage, resultId={}, filePath={}", result.getId(), result.getQaContentFilePath(), e); // 如果读取失败,返回空 JSON return "{}"; } } /** * 从文件路径中提取 bucket 名称 * 例如:annotation-results/2/qa/801.json -> annotation-results */ private String extractBucketName(String filePath) { int firstSlash = filePath.indexOf('/'); return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath; } /** * 从文件路径中提取 object key * 例如:annotation-results/2/qa/801.json -> 2/qa/801.json */ private String extractObjectKey(String filePath) { int firstSlash = filePath.indexOf('/'); return firstSlash > 0 ? filePath.substring(firstSlash + 1) : ""; } }