refactor(file): isolate truck attachment storage
This commit is contained in:
@@ -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){}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user