优化
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.service.PriceBookFileService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Internal source-file API used only by Firstleg price-book workflows. */
|
||||
@RestController
|
||||
@RequestMapping("/internal/file/v1/price-books")
|
||||
public class InternalPriceBookFileController {
|
||||
@Resource
|
||||
private PriceBookFileService service;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
private byte[] expectedToken;
|
||||
|
||||
@PostConstruct
|
||||
void initializeToken() {
|
||||
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<PriceBookFileService.FileMetadata> upload(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long businessId,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
requireAccess(token, operatorId, businessId, null);
|
||||
return ApiResponse.success(service.upload(businessId, operatorId, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{businessId}/{fileId}/metadata")
|
||||
public ApiResponse<PriceBookFileService.FileMetadata> metadata(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long businessId,
|
||||
@PathVariable long fileId) {
|
||||
requireAccess(token, operatorId, businessId, fileId);
|
||||
return ApiResponse.success(service.metadata(businessId, fileId));
|
||||
}
|
||||
|
||||
@GetMapping("/{businessId}/{fileId}/content")
|
||||
public ResponseEntity<byte[]> download(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long businessId,
|
||||
@PathVariable long fileId) {
|
||||
requireAccess(token, operatorId, businessId, fileId);
|
||||
PriceBookFileService.Download download = service.download(businessId, fileId);
|
||||
PriceBookFileService.FileMetadata metadata = download.metadata();
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(metadata.contentType()))
|
||||
.contentLength(download.content().length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(metadata.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
private void requireAccess(String token, long operatorId, long businessId, Long fileId) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
if (operatorId <= 0 || businessId <= 0 || (fileId != null && fileId <= 0)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "操作主体、价格版本业务和文件ID必须大于0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Persists platform price-book source files and enforces exact business ownership. */
|
||||
@Service
|
||||
public class PriceBookFileService {
|
||||
public static final long PLATFORM_SCOPE = 0L;
|
||||
public static final String BUSINESS_TYPE = "FBA_PRICE_BOOK_SOURCE";
|
||||
|
||||
@Resource
|
||||
private PriceBookFileStorage storage;
|
||||
@Resource
|
||||
private FileObjectMapper files;
|
||||
|
||||
@Transactional
|
||||
public FileMetadata upload(long businessId, long operatorId, MultipartFile file) {
|
||||
PriceBookFileStorage.Stored stored = storage.store(businessId, file);
|
||||
FileObjectEntity object = new FileObjectEntity();
|
||||
object.setTenantId(PLATFORM_SCOPE);
|
||||
object.setBusinessType(BUSINESS_TYPE);
|
||||
object.setBusinessId(Long.toString(businessId));
|
||||
object.setOriginalName(stored.originalName());
|
||||
object.setObjectKey(stored.objectKey());
|
||||
object.setStorageProvider("LOCAL");
|
||||
object.setBucketName("price-books");
|
||||
object.setContentType(stored.contentType());
|
||||
object.setSizeBytes(stored.sizeBytes());
|
||||
object.setSha256(stored.sha256());
|
||||
object.setStatus("ACTIVE");
|
||||
object.setCreatedBy(operatorId);
|
||||
object.setUpdatedBy(operatorId);
|
||||
object.setCreatedAt(LocalDateTime.now());
|
||||
object.setUpdatedAt(LocalDateTime.now());
|
||||
object.setDeleted(false);
|
||||
object.setVersion(0);
|
||||
try {
|
||||
if (files.insert(object) != 1) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "价格表文件元数据保存失败");
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
storage.delete(stored.objectKey());
|
||||
throw exception;
|
||||
}
|
||||
return metadata(object);
|
||||
}
|
||||
|
||||
public FileMetadata metadata(long businessId, long fileId) {
|
||||
return metadata(requireObject(businessId, fileId));
|
||||
}
|
||||
|
||||
public Download download(long businessId, long fileId) {
|
||||
FileObjectEntity object = requireObject(businessId, fileId);
|
||||
return new Download(metadata(object), storage.read(object.getObjectKey()));
|
||||
}
|
||||
|
||||
private FileObjectEntity requireObject(long businessId, long fileId) {
|
||||
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
|
||||
.eq(FileObjectEntity::getTenantId, PLATFORM_SCOPE)
|
||||
.eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE)
|
||||
.eq(FileObjectEntity::getBusinessId, Long.toString(businessId))
|
||||
.eq(FileObjectEntity::getId, fileId)
|
||||
.eq(FileObjectEntity::getStatus, "ACTIVE")
|
||||
.eq(FileObjectEntity::getDeleted, false));
|
||||
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
|
||||
return object;
|
||||
}
|
||||
|
||||
private static FileMetadata metadata(FileObjectEntity object) {
|
||||
return new FileMetadata(object.getId(), Long.parseLong(object.getBusinessId()),
|
||||
object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getSha256(),
|
||||
object.getBusinessType());
|
||||
}
|
||||
|
||||
public record FileMetadata(Long fileId, Long businessId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String businessType) {}
|
||||
public record Download(FileMetadata metadata, byte[] content) {}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
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.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/** Stores original price-book XLSX files without interpreting workbook table metadata. */
|
||||
@Component
|
||||
public class PriceBookFileStorage {
|
||||
static final String XLSX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final Set<String> ACCEPTED_CONTENT_TYPES = Set.of(
|
||||
XLSX_CONTENT_TYPE, "application/octet-stream", "application/zip");
|
||||
private static final Set<String> REQUIRED_ENTRIES = Set.of(
|
||||
"[Content_Types].xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels");
|
||||
private static final int MAX_ZIP_ENTRIES = 20_000;
|
||||
private static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024;
|
||||
|
||||
@Value("${baiyue.file.storage-root}")
|
||||
private Path configuredRoot;
|
||||
@Value("${baiyue.file.price-book.max-size-bytes:10485760}")
|
||||
private long maxSizeBytes;
|
||||
private Path root;
|
||||
|
||||
@PostConstruct
|
||||
void initializeRoot() {
|
||||
root = configuredRoot.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public Stored store(long businessId, MultipartFile file) {
|
||||
validateFile(file);
|
||||
String originalName = safeName(file);
|
||||
verifyXlsxPackage(file);
|
||||
String objectKey = "price-books/0/" + businessId + "/" + UUID.randomUUID() + ".xlsx";
|
||||
Path target = resolve(objectKey);
|
||||
try {
|
||||
Files.createDirectories(target.getParent());
|
||||
file.transferTo(target);
|
||||
return new Stored(objectKey, originalName, XLSX_CONTENT_TYPE, Files.size(target), sha256(target));
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] read(String objectKey) {
|
||||
Path target = resolve(objectKey);
|
||||
if (!Files.isRegularFile(target)) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
|
||||
}
|
||||
try {
|
||||
return Files.readAllBytes(target);
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败");
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(String objectKey) {
|
||||
Path target = resolve(objectKey);
|
||||
try {
|
||||
Files.deleteIfExists(target);
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件清理失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw bad("价格表文件不能为空");
|
||||
if (maxSizeBytes <= 0 || file.getSize() > maxSizeBytes) {
|
||||
throw bad("价格表文件超过允许大小");
|
||||
}
|
||||
String name = file.getOriginalFilename();
|
||||
if (name == null || name.isBlank() || name.length() > 200
|
||||
|| name.indexOf('\0') >= 0 || name.contains("/") || name.contains("\\")) {
|
||||
throw bad("价格表文件名不安全");
|
||||
}
|
||||
if (!name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
|
||||
throw bad("价格表仅支持XLSX文件");
|
||||
}
|
||||
String contentType = file.getContentType() == null
|
||||
? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
if (!ACCEPTED_CONTENT_TYPES.contains(contentType)) {
|
||||
throw bad("价格表文件类型不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyXlsxPackage(MultipartFile file) {
|
||||
Set<String> found = new java.util.HashSet<>();
|
||||
int entries = 0;
|
||||
long expandedBytes = 0;
|
||||
try (InputStream raw = new BufferedInputStream(file.getInputStream())) {
|
||||
raw.mark(4);
|
||||
if (raw.read() != 'P' || raw.read() != 'K' || raw.read() != 3 || raw.read() != 4) {
|
||||
throw bad("上传内容不是有效XLSX文件");
|
||||
}
|
||||
raw.reset();
|
||||
try (ZipInputStream zip = new ZipInputStream(raw)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) {
|
||||
if (++entries > MAX_ZIP_ENTRIES || unsafeEntry(entry.getName())) {
|
||||
throw bad("XLSX压缩包结构不安全");
|
||||
}
|
||||
if (REQUIRED_ENTRIES.contains(entry.getName())) found.add(entry.getName());
|
||||
for (int count; (count = zip.read(buffer)) >= 0;) {
|
||||
expandedBytes += count;
|
||||
if (expandedBytes > MAX_EXPANDED_BYTES) {
|
||||
throw bad("XLSX解压内容超过安全限制");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (IOException exception) {
|
||||
throw bad("上传内容不是有效XLSX文件");
|
||||
}
|
||||
if (!found.containsAll(REQUIRED_ENTRIES)) throw bad("上传内容缺少XLSX必要结构");
|
||||
}
|
||||
|
||||
private Path resolve(String objectKey) {
|
||||
if (objectKey == null || !objectKey.matches("price-books/0/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "价格表文件路径无效");
|
||||
}
|
||||
Path target = root.resolve(objectKey).normalize();
|
||||
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
|
||||
return target;
|
||||
}
|
||||
|
||||
private static boolean unsafeEntry(String name) {
|
||||
return name == null || name.startsWith("/") || name.startsWith("\\")
|
||||
|| name.contains("../") || name.contains("..\\") || name.indexOf('\0') >= 0;
|
||||
}
|
||||
|
||||
private static String safeName(MultipartFile file) {
|
||||
return file.getOriginalFilename().trim();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public record Stored(String objectKey, String originalName, String contentType,
|
||||
long sizeBytes, String sha256) {}
|
||||
}
|
||||
@@ -55,6 +55,8 @@ baiyue:
|
||||
url: ${INTEGRATION_SERVICE_URL:}
|
||||
file:
|
||||
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
|
||||
price-book:
|
||||
max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760}
|
||||
export:
|
||||
retention-days: ${FILE_EXPORT_RETENTION_DAYS:7}
|
||||
legacy-worker-enabled: false
|
||||
|
||||
Reference in New Issue
Block a user