资源软删除判断,bbxo查询返回临时签名链接
This commit is contained in:
@@ -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<BboxCoordinateResponse> bboxes,
|
||||
@Schema(description = "备注", example = "车辆检测标注") String remark,
|
||||
@Schema(description = "创建人名称", example = "张审核") String creatorName,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<QaContent>() {
|
||||
});
|
||||
} 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<DiffContent>() {
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AnnotationTaskResource>()
|
||||
.eq(AnnotationTaskResource::getResourceId, resourceId));
|
||||
|
||||
// 删除标注结果记录(包括已删除的)
|
||||
annotationResultMapper.delete(new LambdaQueryWrapper<AnnotationResult>()
|
||||
.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<ImageBboxResponse.BboxCoordinateResponse> 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<Long> taskIds = annotationTaskResourceMapper.selectList(
|
||||
new LambdaQueryWrapper<AnnotationTaskResource>()
|
||||
.eq(AnnotationTaskResource::getResourceId, resourceId))
|
||||
.stream()
|
||||
.map(AnnotationTaskResource::getTaskId)
|
||||
.toList();
|
||||
|
||||
if (!taskIds.isEmpty()) {
|
||||
Long activeTaskCount = annotationTaskMapper.selectCount(
|
||||
new LambdaQueryWrapper<AnnotationTask>()
|
||||
.in(AnnotationTask::getId, taskIds)
|
||||
.notIn(AnnotationTask::getTaskStatus, "COMPLETED", "FAILED"));
|
||||
if (activeTaskCount > 0) {
|
||||
throw new BusinessException(ResultCode.FORBIDDEN, "资源已被标注任务引用,无法删除");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查资源是否存在标注历史记录(只要存在就不能删除)
|
||||
Long historyCount = annotationResultHistoryMapper.selectCount(
|
||||
new LambdaQueryWrapper<AnnotationResultHistory>()
|
||||
.eq(AnnotationResultHistory::getResourceId, resourceId));
|
||||
if (historyCount > 0) {
|
||||
throw new BusinessException(ResultCode.FORBIDDEN, "资源存在标注历史记录,无法删除");
|
||||
}
|
||||
|
||||
// 3. 检查资源是否有未被软删除的标注结果
|
||||
Long activeResultCount = annotationResultMapper.selectCount(
|
||||
new LambdaQueryWrapper<AnnotationResult>()
|
||||
.eq(AnnotationResult::getResourceId, resourceId)
|
||||
.eq(AnnotationResult::getIsDeleted, false));
|
||||
if (activeResultCount > 0) {
|
||||
throw new BusinessException(ResultCode.FORBIDDEN, "资源已有标注结果,无法删除");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user