标注结果和标注结果归档优化
This commit is contained in:
@@ -1,49 +1,58 @@
|
||||
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.fasterxml.jackson.core.type.TypeReference;
|
||||
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.enums.UserRole;
|
||||
import com.labelsys.backend.dto.common.PageResult;
|
||||
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.AnnotationResultResponse;
|
||||
import com.labelsys.backend.entity.AnnotationResult;
|
||||
import com.labelsys.backend.entity.AnnotationResultHistory;
|
||||
import com.labelsys.backend.entity.SourceResource;
|
||||
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.SourceResourceMapper;
|
||||
|
||||
import com.labelsys.backend.util.IdGenerator;
|
||||
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;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnnotationResultService {
|
||||
|
||||
private final AnnotationResultMapper annotationResultMapper;
|
||||
private final SourceResourceMapper sourceResourceMapper;
|
||||
private final DataPermissionService dataPermissionService;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final AnnotationResultMapper annotationResultMapper;
|
||||
private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
|
||||
private final SourceResourceMapper sourceResourceMapper;
|
||||
private final DataPermissionService dataPermissionService;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PageResult<AnnotationResultResponse> pageResults(LoginUser currentUser, AnnotationResultPageQuery query) {
|
||||
List<String> allowedRoles = dataPermissionService.getAllowedRoles(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(query.taskId() != null, AnnotationResult::getTaskId, query.taskId())
|
||||
.eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId())
|
||||
.eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview,
|
||||
query.requiresManualReview());
|
||||
query.requiresManualReview())
|
||||
.orderByDesc(AnnotationResult::getCreatedAt);
|
||||
|
||||
if (shouldFilterByUserId) {
|
||||
wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId());
|
||||
@@ -51,12 +60,11 @@ public class AnnotationResultService {
|
||||
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());
|
||||
Page<AnnotationResult> resultPage = annotationResultMapper.selectPage(page, wrapper);
|
||||
|
||||
List<AnnotationResultResponse> records = resultPage.getRecords().stream().map(this::toResponse)
|
||||
var records = resultPage.getRecords().stream()
|
||||
.map(this::toResponse)
|
||||
.filter(response -> query.runtimeStatus() == null
|
||||
|| query.runtimeStatus().equals(response.runtimeStatus()))
|
||||
.toList();
|
||||
@@ -72,14 +80,122 @@ public class AnnotationResultService {
|
||||
currentUser.companyId(), currentUser.userId());
|
||||
throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在");
|
||||
}
|
||||
assertResultPermission(currentUser, 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) {
|
||||
return new AnnotationResultResponse(result.getId(), result.getTaskId(), result.getResourceId(),
|
||||
deriveStatus(result), result.getRequiresManualReview(), result.getIsDeleted(),
|
||||
result.getQaContentStorageMode(), result.getReviewComment(), result.getReviewedAt(),
|
||||
result.getCreatedAt());
|
||||
return new AnnotationResultResponse(
|
||||
result.getId(),
|
||||
result.getTaskId(),
|
||||
result.getResourceId(),
|
||||
deriveStatus(result),
|
||||
result.getRequiresManualReview(),
|
||||
result.getIsDeleted(),
|
||||
result.getQaContentFilePath(),
|
||||
result.getDiffSummaryFilePath(),
|
||||
result.getReviewComment(),
|
||||
result.getReviewedAt(),
|
||||
result.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private AnnotationResultStatus deriveStatus(AnnotationResult result) {
|
||||
@@ -92,69 +208,130 @@ public class AnnotationResultService {
|
||||
return AnnotationResultStatus.AUTO_ARCHIVE_PENDING;
|
||||
}
|
||||
|
||||
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, "结果不存在");
|
||||
private QaContent loadQaContent(AnnotationResult result) {
|
||||
try {
|
||||
String filePath = result.getQaContentFilePath();
|
||||
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 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) {
|
||||
if (QaContentStorageMode.EXTERNAL.name().equals(result.getQaContentStorageMode())) {
|
||||
if (result.getQaContentFilePath() == null || result.getQaContentFilePath().isBlank()) {
|
||||
log.warn("External storage mode but file path is empty, resultId={}", result.getId());
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
String filePath = result.getQaContentFilePath();
|
||||
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 download external qa content, resultId={}, filePath={}",
|
||||
result.getId(), result.getQaContentFilePath(), e);
|
||||
throw new BusinessException(ResultCode.ERROR, "下载问答内容失败");
|
||||
}
|
||||
} else {
|
||||
return result.getQaContentJson() != null ? result.getQaContentJson() : "{}";
|
||||
private DiffContent loadDiffSummary(AnnotationResult result) {
|
||||
try {
|
||||
String filePath = result.getDiffSummaryFilePath();
|
||||
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 TypeReference<DiffContent>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load diff summary, resultId={}, filePath={}", result.getId(),
|
||||
result.getDiffSummaryFilePath(), e);
|
||||
throw new BusinessException(ResultCode.ERROR, "加载差异摘要失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void saveQaContent(AnnotationResult result, QaContent qaContent) {
|
||||
try {
|
||||
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) {
|
||||
if (filePath.startsWith("/")) {
|
||||
filePath = filePath.substring(1);
|
||||
}
|
||||
int firstSlash = filePath.indexOf("/");
|
||||
if (firstSlash > 0) {
|
||||
return filePath.substring(0, firstSlash);
|
||||
}
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST, "无效的文件路径格式");
|
||||
// 从文件路径中提取 bucket 名称
|
||||
// 例如:annotation-results/2/qa/801.json -> annotation-results
|
||||
int firstSlash = filePath.indexOf('/');
|
||||
return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath;
|
||||
}
|
||||
|
||||
private String extractObjectKey(String filePath) {
|
||||
if (filePath.startsWith("/")) {
|
||||
filePath = filePath.substring(1);
|
||||
// 从文件路径中提取 object key
|
||||
// 例如: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) {
|
||||
return filePath.substring(firstSlash + 1);
|
||||
|
||||
private record Metadata(String createdAt, String updatedAt) {
|
||||
}
|
||||
}
|
||||
|
||||
// 内部类: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, "无效的文件路径格式");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user