This commit is contained in:
2026-08-07 14:46:55 +08:00
parent 830575cc92
commit b0ea1a0c5c
109 changed files with 5774 additions and 339 deletions
@@ -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) {}
}
+2
View File
@@ -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
@@ -0,0 +1,67 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.PriceBookFileService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalPriceBookFileController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalPriceBookFileControllerTest {
@MockBean PriceBookFileService service;
@Resource InternalPriceBookFileController controller;
@Test
void uploadsWithAuthenticatedOperatorAndBusinessId() {
var file = new MockMultipartFile("file", "price.xlsx", "application/octet-stream", new byte[]{1});
var metadata = metadata();
when(service.upload(41L, 7L, file)).thenReturn(metadata);
var response = controller.upload("internal-token", 7L, 41L, file);
assertThat(response.data()).isEqualTo(metadata);
verify(service).upload(41L, 7L, file);
}
@Test
void rejectsInvalidToken() {
assertThatThrownBy(() -> controller.metadata("bad-token", 7L, 41L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
}
@Test
void rejectsMissingOperatorOrBusinessOwnership() {
assertThatThrownBy(() -> controller.metadata("internal-token", 0L, 41L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("必须大于0");
assertThatThrownBy(() -> controller.metadata("internal-token", 7L, 0L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("必须大于0");
}
@Test
void downloadsAsAttachmentWithoutCaching() {
PriceBookFileService.FileMetadata metadata = metadata();
when(service.download(41L, 99L)).thenReturn(
new PriceBookFileService.Download(metadata, new byte[]{1, 2, 3}));
var response = controller.download("internal-token", 7L, 41L, 99L);
assertThat(response.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
assertThat(response.getHeaders().getContentDisposition().isAttachment()).isTrue();
}
private static PriceBookFileService.FileMetadata metadata() {
return new PriceBookFileService.FileMetadata(99L, 41L, "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "hash",
PriceBookFileService.BUSINESS_TYPE);
}
}
@@ -0,0 +1,79 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PriceBookFileServiceTest {
@Mock PriceBookFileStorage storage;
@Mock FileObjectMapper files;
@InjectMocks PriceBookFileService service;
@Test
void persistsPlatformOwnedPriceBookMetadata() {
var file = new MockMultipartFile("file", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE,
new byte[]{1});
when(storage.store(41L, file)).thenReturn(new PriceBookFileStorage.Stored(
"price-books/0/41/a.xlsx", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE,
1L, "hash"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
FileObjectEntity object = invocation.getArgument(0);
object.setId(99L);
return 1;
});
PriceBookFileService.FileMetadata result = service.upload(41L, 7L, file);
assertThat(result.fileId()).isEqualTo(99L);
assertThat(result.businessId()).isEqualTo(41L);
assertThat(result.businessType()).isEqualTo(PriceBookFileService.BUSINESS_TYPE);
verify(files).insert(any(FileObjectEntity.class));
}
@Test
void rejectsDownloadWhenBusinessOwnershipDoesNotMatch() {
when(files.selectOne(any(Wrapper.class))).thenReturn(null);
assertThatThrownBy(() -> service.download(42L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
@Test
void downloadsOnlyOwnedPriceBookObject() {
FileObjectEntity object = object(42L, 99L);
when(files.selectOne(any(Wrapper.class))).thenReturn(object);
when(storage.read("price-books/0/42/a.xlsx")).thenReturn(new byte[]{1, 2});
PriceBookFileService.Download result = service.download(42L, 99L);
assertThat(result.content()).containsExactly(1, 2);
assertThat(result.metadata().businessId()).isEqualTo(42L);
}
private static FileObjectEntity object(long businessId, long fileId) {
FileObjectEntity object = new FileObjectEntity();
object.setId(fileId);
object.setTenantId(0L);
object.setBusinessId(Long.toString(businessId));
object.setBusinessType(PriceBookFileService.BUSINESS_TYPE);
object.setOriginalName("price.xlsx");
object.setObjectKey("price-books/0/" + businessId + "/a.xlsx");
object.setContentType(PriceBookFileStorage.XLSX_CONTENT_TYPE);
object.setSizeBytes(2L);
object.setSha256("hash");
return object;
}
}
@@ -0,0 +1,97 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PriceBookFileStorageTest {
private static final String XLSX = PriceBookFileStorage.XLSX_CONTENT_TYPE;
@TempDir Path root;
private PriceBookFileStorage storage;
@BeforeEach
void setUp() {
storage = new PriceBookFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
ReflectionTestUtils.setField(storage, "maxSizeBytes", 1024L * 1024);
storage.initializeRoot();
}
@Test
void storesValidPackageEvenWhenTableMetadataIsMalformed() throws IOException {
Map<String, byte[]> entries = requiredEntries();
entries.put("xl/tables/table1.xml", "<table ref=\"5:31\">".getBytes());
var file = new MockMultipartFile("file", "2026-price.xlsx", XLSX, zip(entries));
PriceBookFileStorage.Stored stored = storage.store(101L, file);
assertThat(stored.originalName()).isEqualTo("2026-price.xlsx");
assertThat(stored.objectKey()).matches("price-books/0/101/[0-9a-f-]{36}\\.xlsx");
assertThat(stored.sha256()).hasSize(64);
assertThat(Files.isRegularFile(root.resolve(stored.objectKey()))).isTrue();
}
@Test
void rejectsFakeXlsxExtension() {
var file = new MockMultipartFile("file", "fake.xlsx", XLSX, "not-a-zip".getBytes());
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("有效XLSX");
}
@Test
void rejectsOversizedFile() throws IOException {
ReflectionTestUtils.setField(storage, "maxSizeBytes", 8L);
var file = new MockMultipartFile("file", "large.xlsx", XLSX, zip(requiredEntries()));
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("超过允许大小");
}
@Test
void rejectsTraversalFileName() throws IOException {
var file = new MockMultipartFile("file", "../price.xlsx", XLSX, zip(requiredEntries()));
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("文件名不安全");
}
@Test
void rejectsWrongMimeType() throws IOException {
var file = new MockMultipartFile("file", "price.xlsx", "text/plain", zip(requiredEntries()));
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("类型不正确");
}
private static Map<String, byte[]> requiredEntries() {
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put("[Content_Types].xml", "<Types/>".getBytes());
entries.put("xl/workbook.xml", "<workbook/>".getBytes());
entries.put("xl/_rels/workbook.xml.rels", "<Relationships/>".getBytes());
return entries;
}
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(entry.getKey()));
zip.write(entry.getValue());
zip.closeEntry();
}
}
return bytes.toByteArray();
}
}