2026-04-27 10:27:57 +08:00
|
|
|
|
package com.labelsys.backend.service;
|
|
|
|
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
2026-05-06 19:07:45 +08:00
|
|
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
2026-04-27 10:27:57 +08:00
|
|
|
|
import com.labelsys.backend.common.ResultCode;
|
|
|
|
|
|
import com.labelsys.backend.common.exception.BusinessException;
|
|
|
|
|
|
import com.labelsys.backend.context.LoginUser;
|
2026-05-06 19:07:45 +08:00
|
|
|
|
import com.labelsys.backend.dto.common.PageResult;
|
|
|
|
|
|
import com.labelsys.backend.dto.request.AnnotationResultHistoryPageQuery;
|
|
|
|
|
|
import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse;
|
2026-05-07 16:00:17 +08:00
|
|
|
|
import com.labelsys.backend.dto.response.FileContentResponse;
|
2026-04-27 10:27:57 +08:00
|
|
|
|
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;
|
2026-05-06 19:07:45 +08:00
|
|
|
|
import com.labelsys.backend.enums.UserRole;
|
2026-04-27 10:27:57 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
|
|
import java.time.Duration;
|
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
|
import java.util.List;
|
|
|
|
|
|
|
2026-04-27 10:27:57 +08:00
|
|
|
|
@Slf4j
|
|
|
|
|
|
@Service
|
|
|
|
|
|
@RequiredArgsConstructor
|
|
|
|
|
|
public class AnnotationResultArchiveService {
|
|
|
|
|
|
|
|
|
|
|
|
private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW";
|
|
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
private final AnnotationResultMapper annotationResultMapper;
|
2026-04-27 10:27:57 +08:00
|
|
|
|
private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
|
2026-05-06 19:07:45 +08:00
|
|
|
|
private final ObjectStorageService objectStorageService;
|
|
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
|
private final DataPermissionService dataPermissionService;
|
|
|
|
|
|
|
2026-04-27 10:27:57 +08:00
|
|
|
|
@Value("${labelsys.annotation.auto-archive-timeout:PT2H}")
|
|
|
|
|
|
private Duration autoArchiveTimeout;
|
|
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
public PageResult<AnnotationResultHistoryResponse> pageHistory(LoginUser currentUser,
|
|
|
|
|
|
AnnotationResultHistoryPageQuery query) {
|
|
|
|
|
|
List<String> allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
|
|
|
|
|
|
boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-27 10:27:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
var page = new Page<AnnotationResultHistory>(query.pageNo(), query.pageSize());
|
|
|
|
|
|
var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper);
|
2026-04-27 10:27:57 +08:00
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
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, "历史记录不存在");
|
2026-04-27 10:27:57 +08:00
|
|
|
|
}
|
2026-05-06 19:07:45 +08:00
|
|
|
|
assertHistoryPermission(currentUser, history);
|
|
|
|
|
|
return toResponse(history);
|
|
|
|
|
|
}
|
2026-04-27 10:27:57 +08:00
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
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(),
|
2026-05-07 16:00:17 +08:00
|
|
|
|
// history.getSourceResultId(),
|
2026-05-06 19:07:45 +08:00
|
|
|
|
history.getTaskId(),
|
2026-05-07 16:00:17 +08:00
|
|
|
|
history.getTaskName(), // 新增
|
2026-05-06 19:07:45 +08:00
|
|
|
|
history.getResourceId(),
|
2026-05-07 16:00:17 +08:00
|
|
|
|
history.getResourceName(), // 新增
|
2026-05-06 19:07:45 +08:00
|
|
|
|
history.getQaContentFilePath(),
|
|
|
|
|
|
history.getArchiveReason(),
|
|
|
|
|
|
history.getArchivedBy(),
|
|
|
|
|
|
history.getArchivedAt(),
|
2026-05-07 16:00:17 +08:00
|
|
|
|
history.getCreatedAt(),
|
|
|
|
|
|
history.getReviewerId(),
|
|
|
|
|
|
history.getReviewerName(),
|
|
|
|
|
|
history.getReviewerComment()
|
2026-05-06 19:07:45 +08:00
|
|
|
|
);
|
2026-04-27 10:27:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
|
public int autoArchiveEligibleResults() {
|
|
|
|
|
|
LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout);
|
|
|
|
|
|
List<AnnotationResult> results = annotationResultMapper.selectList(new LambdaQueryWrapper<AnnotationResult>()
|
2026-05-06 19:07:45 +08:00
|
|
|
|
.eq(AnnotationResult::getIsDeleted, false)
|
|
|
|
|
|
.eq(AnnotationResult::getRequiresManualReview, false)
|
|
|
|
|
|
.lt(AnnotationResult::getCreatedAt, cutoff));
|
2026-04-27 10:27:57 +08:00
|
|
|
|
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, "当前用户没有审核权限");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 19:07:45 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 归档运行态标注结果到历史表
|
|
|
|
|
|
* 从对象存储读取 qa.json 内容进行归档
|
|
|
|
|
|
*/
|
2026-04-27 10:27:57 +08:00
|
|
|
|
private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result,
|
|
|
|
|
|
Long reviewerId,
|
|
|
|
|
|
String archiveReason,
|
|
|
|
|
|
String reviewComment) {
|
|
|
|
|
|
LocalDateTime archivedAt = LocalDateTime.now();
|
2026-05-06 19:07:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 从对象存储读取 qa.json 内容
|
2026-05-07 16:00:17 +08:00
|
|
|
|
// String qaContentJson = loadQaContentJson(result);
|
2026-05-06 19:07:45 +08:00
|
|
|
|
|
2026-04-27 10:27:57 +08:00
|
|
|
|
AnnotationResultHistory history = AnnotationResultHistory.builder()
|
2026-05-06 19:07:45 +08:00
|
|
|
|
.id(IdGenerator.nextId())
|
|
|
|
|
|
.companyId(result.getCompanyId())
|
|
|
|
|
|
.creatorId(result.getCreatorId())
|
|
|
|
|
|
.creatorRole(result.getCreatorRole())
|
|
|
|
|
|
.sourceResultId(result.getId())
|
|
|
|
|
|
.taskId(result.getTaskId())
|
2026-05-07 16:00:17 +08:00
|
|
|
|
.taskName(result.getTaskName())
|
2026-05-06 19:07:45 +08:00
|
|
|
|
.resourceId(result.getResourceId())
|
2026-05-07 16:00:17 +08:00
|
|
|
|
.resourceName(result.getResourceName())
|
2026-05-06 19:07:45 +08:00
|
|
|
|
//.qaContentJson(qaContentJson) // 使用从对象存储读取的内容
|
|
|
|
|
|
.qaContentFilePath(result.getQaContentFilePath())
|
|
|
|
|
|
.archiveReason(archiveReason)
|
|
|
|
|
|
.archivedBy(reviewerId)
|
|
|
|
|
|
.archivedAt(archivedAt)
|
2026-05-07 16:00:17 +08:00
|
|
|
|
.reviewerId(null)
|
|
|
|
|
|
.reviewerName("auto")
|
|
|
|
|
|
.reviewerComment("auto")
|
2026-05-06 19:07:45 +08:00
|
|
|
|
.build();
|
2026-04-27 10:27:57 +08:00
|
|
|
|
annotationResultHistoryMapper.insert(history);
|
|
|
|
|
|
|
|
|
|
|
|
int updated = annotationResultMapper.markArchived(
|
2026-05-06 19:07:45 +08:00
|
|
|
|
result.getId(),
|
|
|
|
|
|
result.getCompanyId(),
|
|
|
|
|
|
reviewerId,
|
|
|
|
|
|
reviewComment,
|
|
|
|
|
|
archivedAt);
|
2026-04-27 10:27:57 +08:00
|
|
|
|
if (updated == 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt);
|
|
|
|
|
|
}
|
2026-05-06 19:07:45 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从对象存储读取 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) : "";
|
|
|
|
|
|
}
|
2026-05-07 16:00:17 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 加载归档记录的文件内容
|
|
|
|
|
|
* @param currentUser 当前用户
|
|
|
|
|
|
* @param historyId 历史记录ID
|
|
|
|
|
|
* @return 文件内容响应
|
|
|
|
|
|
*/
|
|
|
|
|
|
public FileContentResponse loadFileContent(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);
|
|
|
|
|
|
|
|
|
|
|
|
String filePath = history.getQaContentFilePath();
|
|
|
|
|
|
if (filePath == null || filePath.isEmpty()) {
|
|
|
|
|
|
throw new BusinessException(ResultCode.ERROR, "文件路径为空");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
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 (Exception e) {
|
|
|
|
|
|
log.error("Failed to load file content, historyId={}, filePath={}", historyId, filePath, e);
|
|
|
|
|
|
throw new BusinessException(ResultCode.ERROR, "加载文件内容失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-06 19:07:45 +08:00
|
|
|
|
}
|