资源软删除判断,bbxo查询返回临时签名链接

This commit is contained in:
wh
2026-05-09 17:57:38 +08:00
parent f01c991390
commit e1c1628e29
13 changed files with 212 additions and 31 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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失败");
}
}
}

View File

@@ -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, "资源已有标注结果,无法删除");
}
}
}