From aab8e37175b7c525000503e98ac1467311b94daa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 8 Aug 2026 20:45:10 +0800 Subject: [PATCH] refactor(file): isolate truck attachment storage --- .../file/service/BusinessFileStorage.java | 109 +++++++++++++++++- .../service/InternalBusinessFileService.java | 2 +- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 4595c4cd0..6048a07e0 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -1,8 +1,105 @@ package com.baiyue.file.service; -import com.baiyue.common.core.api.ResultCode;import com.baiyue.common.core.exception.BizException;import jakarta.annotation.PostConstruct;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import org.springframework.web.multipart.MultipartFile;import java.nio.file.*;import java.security.*;import java.util.*; -@Component public class BusinessFileStorage{ - private static final long MAX=10L*1024*1024;private static final SetTYPES=Set.of("text/plain","application/pdf","image/jpeg","image/png");@Value("${baiyue.file.storage-root}")Path configured;Path root;@PostConstruct void init(){root=configured.toAbsolutePath().normalize();} - Stored storeLoading(long tenantId,long businessId,MultipartFile file){return store(tenantId,Long.toString(businessId),file);} - Stored store(long tenantId,String businessId,MultipartFile file){if(file==null||file.isEmpty())throw bad("附件不能为空");if(file.getSize()>MAX)throw bad("附件不能超过10MB");String type=file.getContentType()==null?"":file.getContentType().toLowerCase(Locale.ROOT);if(!TYPES.contains(type))throw bad("附件仅支持TXT、PDF、JPG、PNG");String name=file.getOriginalFilename()==null?"attachment":Path.of(file.getOriginalFilename()).getFileName().toString();String suffix=name.contains(".")?name.substring(name.lastIndexOf('.')):"";String key="business-files/"+tenantId+"/"+businessId+"/"+UUID.randomUUID()+suffix;Path target=root.resolve(key).normalize();if(!target.startsWith(root))throw new BizException(ResultCode.FORBIDDEN,"文件路径越界");try{Files.createDirectories(target.getParent());file.transferTo(target);byte[]hash=MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(target));return new Stored(key,type,file.getSize(),HexFormat.of().formatHex(hash),name);}catch(Exception e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"附件保存失败");}} - record Stored(String objectKey,String contentType,long size,String sha256,String originalName){}private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Component +public class BusinessFileStorage { + private static final long MAX_SIZE = 10L * 1024 * 1024; + private static final Map TYPES = Map.of( + "text/plain", ".txt", "application/pdf", ".pdf", + "image/jpeg", ".jpg", "image/png", ".png"); + + @Value("${baiyue.file.storage-root}") + private Path configuredRoot; + private Path root; + + @PostConstruct + void initializeRoot() { + root = configuredRoot.toAbsolutePath().normalize(); + } + + Stored storeLoading(long tenantId, long businessId, MultipartFile file) { + if (file == null || file.isEmpty()) throw bad("附件不能为空"); + if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB"); + String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + String extension = TYPES.get(type); + if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); + verifyContent(file, type); + String name = safeName(file); + String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension; + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, type, file.getSize(), sha256(target), name); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "附件保存失败"); + } + } + + private void verifyContent(MultipartFile file, String type) { + try (InputStream input = file.getInputStream()) { + byte[] prefix = input.readNBytes(4096); + if ("application/pdf".equals(type)) { + if (prefix.length < 5 || !new String(prefix, 0, 5, StandardCharsets.US_ASCII).equals("%PDF-")) { + throw bad("上传内容不是有效PDF"); + } + } else if ("text/plain".equals(type)) { + for (byte value : prefix) if (value == 0) throw bad("文本附件包含非法二进制内容"); + } else if (prefix.length < 8 || !validImageSignature(prefix, type)) { + throw bad("上传内容不是有效图片"); + } + } catch (IOException exception) { + throw bad("附件内容读取失败"); + } + } + + private static boolean validImageSignature(byte[] bytes, String type) { + if ("image/png".equals(type)) { + return (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47; + } + return (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8; + } + + private static String safeName(MultipartFile file) { + String name = file.getOriginalFilename(); + return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString(); + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {} } diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 9007738a3..9aa1c2665 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -14,7 +14,7 @@ import java.time.LocalDateTime; public class InternalBusinessFileService { private static final String TRUCK_ATTACHMENT = "TRUCK_APPOINTMENT_ATTACHMENT"; @Resource FileObjectMapper files; @Resource BusinessFileStorage storage; - public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!TRUCK_ATTACHMENT.equals(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long appointmentId;try{appointmentId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"预约ID格式无效");}if(appointmentId<=0)throw new BizException(ResultCode.BAD_REQUEST,"预约ID必须有效");var stored=storage.storeLoading(tenantId,appointmentId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(TRUCK_ATTACHMENT);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("loading-evidence");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return new Metadata(value.getId(),tenantId,TRUCK_ATTACHMENT,businessId,value.getOriginalName(),value.getSha256());} + public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!TRUCK_ATTACHMENT.equals(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long appointmentId;try{appointmentId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"预约ID格式无效");}if(appointmentId<=0)throw new BizException(ResultCode.BAD_REQUEST,"预约ID必须有效");var stored=storage.storeLoading(tenantId,appointmentId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(TRUCK_ATTACHMENT);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("business-files");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return new Metadata(value.getId(),tenantId,TRUCK_ATTACHMENT,businessId,value.getOriginalName(),value.getSha256());} public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=files.selectOne(new LambdaQueryWrapper().eq(FileObjectEntity::getTenantId,tenantId).eq(FileObjectEntity::getBusinessType,businessType).eq(FileObjectEntity::getBusinessId,businessId).eq(FileObjectEntity::getId,fileId).eq(FileObjectEntity::getStatus,"ACTIVE").eq(FileObjectEntity::getDeleted,false));if(value==null)throw new BizException(ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配");return new Metadata(value.getId(),value.getTenantId(),value.getBusinessType(),value.getBusinessId(),value.getOriginalName(),value.getSha256());} public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256){} }