资源软删除判断,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;
@@ -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;
@@ -44,6 +51,8 @@ import java.util.List;
public class SourceResourceService {
private final SourceResourceMapper sourceResourceMapper;
private final AnnotationResultMapper annotationResultMapper;
private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
private final AnnotationTaskResourceMapper annotationTaskResourceMapper;
private final SysUserMapper sysUserMapper;
private final DataPermissionService dataPermissionService;
@@ -51,6 +60,7 @@ public class SourceResourceService {
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, "资源已有标注结果,无法删除");
}
}
}

View File

@@ -11,14 +11,16 @@
<result column="creator_role" property="creatorRole"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
<result column="deleted" property="deleted"/>
<result column="deleted_at" property="deletedAt"/>
</resultMap>
<select id="selectByResourceId" resultMap="ImageBboxAnnotationResultMap">
SELECT id, company_id, resource_id, bbox_json, remark, creator_id, creator_role, created_at, updated_at
FROM image_bbox_annotation WHERE resource_id = #{resourceId}
SELECT id, company_id, resource_id, bbox_json, remark, creator_id, creator_role, created_at, updated_at, deleted, deleted_at
FROM image_bbox_annotation WHERE resource_id = #{resourceId} AND deleted = false
</select>
<delete id="deleteByResourceId">
DELETE FROM image_bbox_annotation WHERE resource_id = #{resourceId}
</delete>
<update id="deleteByResourceId">
UPDATE image_bbox_annotation SET deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE resource_id = #{resourceId}
</update>
</mapper>

View File

@@ -15,19 +15,22 @@
<result column="remark" property="remark" />
<result column="created_at" property="createdAt" />
<result column="updated_at" property="updatedAt" />
<result column="deleted" property="deleted" />
<result column="deleted_at" property="deletedAt" />
</resultMap>
<sql id="SourceResourceColumns"> id, company_id, creator_id, creator_role, resource_name,
resource_type, bucket_name, file_path, file_size, storage_provider, remark,
created_at, updated_at </sql>
created_at, updated_at, deleted, deleted_at </sql>
<select id="selectByCompanyIdAndIds" resultMap="SourceResourceResultMap"> select <include
refid="SourceResourceColumns" /> from source_resource where company_id = #{companyId}
and id in <foreach collection="resourceIds" item="resourceId" open="(" separator=","
close=")"> #{resourceId} </foreach>
and deleted = false
</select>
<select id="selectByCompanyIdAndResourceName" resultMap="SourceResourceResultMap"> SELECT <include
refid="SourceResourceColumns" /> FROM source_resource WHERE company_id = #{companyId}
AND resource_name = #{resourceName} LIMIT 1 </select>
AND resource_name = #{resourceName} AND deleted = false LIMIT 1 </select>
</mapper>

View File

@@ -138,6 +138,8 @@ CREATE TABLE IF NOT EXISTS source_resource
remark VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP,
CONSTRAINT fk_source_resource_company FOREIGN KEY (company_id) REFERENCES sys_company (id),
CONSTRAINT fk_source_resource_creator FOREIGN KEY (creator_id) REFERENCES sys_user (id)
);
@@ -169,6 +171,8 @@ CREATE TABLE IF NOT EXISTS image_bbox_annotation
creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP,
CONSTRAINT fk_image_bbox_annotation_company FOREIGN KEY (company_id) REFERENCES sys_company (id),
CONSTRAINT fk_image_bbox_annotation_resource FOREIGN KEY (resource_id) REFERENCES source_resource (id),
CONSTRAINT fk_image_bbox_annotation_creator FOREIGN KEY (creator_id) REFERENCES sys_user (id)
@@ -184,6 +188,8 @@ COMMENT ON COLUMN image_bbox_annotation.creator_id IS '创建人用户ID。';
COMMENT ON COLUMN image_bbox_annotation.creator_role IS '创建人数据权限角色,默认 EMPLOYEE。';
COMMENT ON COLUMN image_bbox_annotation.created_at IS '创建时间。';
COMMENT ON COLUMN image_bbox_annotation.updated_at IS '更新时间。';
COMMENT ON COLUMN image_bbox_annotation.deleted IS '软删除标记,默认 FALSE。';
COMMENT ON COLUMN image_bbox_annotation.deleted_at IS '软删除时间。';
-- 修改 annotation_task 表,删除模型和提示词相关字段
CREATE TABLE IF NOT EXISTS annotation_task
@@ -193,7 +199,7 @@ CREATE TABLE IF NOT EXISTS annotation_task
creator_id BIGINT NOT NULL,
creator_role VARCHAR(32) NOT NULL DEFAULT 'EMPLOYEE',
task_name VARCHAR(255) NOT NULL,
industry_type VARCHAR(32) NOT NULL DEFAULT 'transport',
industry_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL',
task_type VARCHAR(32) NOT NULL DEFAULT 'EXTRACT_QA',
task_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,