优化
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
|
||||
|
||||
+67
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user