refactor(file): isolate truck attachment storage

This commit is contained in:
2026-08-08 20:45:10 +08:00
parent 6b0781d120
commit aab8e37175
2 changed files with 104 additions and 7 deletions
@@ -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 Set<String>TYPES=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<String, String> 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) {}
}
@@ -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<FileObjectEntity>().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){}
}