This commit is contained in:
2026-08-07 19:23:40 +08:00
parent 37fec1e012
commit c698290106
39 changed files with 1897 additions and 13 deletions
@@ -8,6 +8,8 @@ import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
@@ -15,6 +17,7 @@ import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Iterator;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@@ -36,7 +39,7 @@ public class ProductImageStorage {
if (file.getSize() > MAX_SIZE) throw bad("产品图片不能超过5MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(type)) throw bad("产品图片仅支持JPG、PNG");
verifyImage(file);
verifyImage(file, type);
String extension = "image/png".equals(type) ? ".png" : ".jpg";
String objectKey = "product-images/" + tenantId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
@@ -72,9 +75,23 @@ public class ProductImageStorage {
}
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
private void verifyImage(MultipartFile file, String declaredType) {
try (InputStream input = file.getInputStream();
ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
if (imageInput == null) throw bad("上传内容不是有效图片");
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInput);
if (!readers.hasNext()) throw bad("上传内容不是有效图片");
ImageReader reader = readers.next();
try {
String format = reader.getFormatName().toLowerCase(Locale.ROOT);
boolean matches = "image/png".equals(declaredType) ? "png".equals(format)
: "jpeg".equals(format) || "jpg".equals(format);
if (!matches) throw bad("图片内容与文件类型不一致");
reader.setInput(imageInput, true, true);
reader.read(0);
} finally {
reader.dispose();
}
} catch (IOException exception) {
throw bad("上传内容不是有效图片");
}
@@ -96,7 +113,11 @@ public class ProductImageStorage {
private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename();
if (name == null || name.isBlank()) return "product-image";
return Path.of(name).getFileName().toString();
String normalized = name.replace('\\', '/');
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1)
.replaceAll("[\\p{Cntrl}]", "").trim();
if (leaf.isEmpty()) return "product-image";
return leaf.length() <= 255 ? leaf : leaf.substring(leaf.length() - 255);
}
private static BizException bad(String message) {
@@ -0,0 +1,83 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.ProductImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProductImageControllerContractTest {
@Test
void merchantRouteUsesMultipartAndOrderPermissions() throws Exception {
RequestMapping mapping = MerchantProductImageController.class.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).contains("/api/merchant/v1/files/product-images");
var upload = MerchantProductImageController.class.getDeclaredMethod(
"upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class);
assertThat(upload.getAnnotation(PostMapping.class).consumes())
.contains(MediaType.MULTIPART_FORM_DATA_VALUE);
assertThat(upload.getAnnotation(PreAuthorize.class).value())
.contains("merchant:order:create").contains("merchant:order:edit");
}
@Test
void merchantUploadAndDownloadAlwaysUseAuthenticatedCustomer() {
ProductImageService service = mock(ProductImageService.class);
MerchantProductImageController controller = new MerchantProductImageController();
ReflectionTestUtils.setField(controller, "images", service);
var user = merchant(10001L);
var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1});
var uploaded = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L);
when(service.upload(10001L, 2L, file)).thenReturn(uploaded);
when(service.download(10001L, 31L)).thenReturn(
new ProductImageService.Download("product.png", "image/png", new byte[] {1}));
assertThat(controller.upload(user, file).data()).isEqualTo(uploaded);
assertThat(controller.download(user, 31L).getBody()).containsExactly(1);
verify(service).upload(10001L, 2L, file);
verify(service).download(10001L, 31L);
}
@Test
void internalValidationRequiresSharedTokenAndForwardsCustomerScope() {
ProductImageService service = mock(ProductImageService.class);
InternalProductImageController controller = new InternalProductImageController();
ReflectionTestUtils.setField(controller, "images", service);
ReflectionTestUtils.setField(controller, "internalToken", "token");
controller.initializeToken();
var metadata = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L);
when(service.validate(10001L, 31L)).thenReturn(metadata);
assertThat(controller.validate("token", 10001L, 31L).data()).isEqualTo(metadata);
assertThatThrownBy(() -> controller.validate("wrong", 10001L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
verify(service).validate(10001L, 31L);
}
@Test
void rejectsPlatformOrMalformedIdentityBeforeFileAccess() {
ProductImageService service = mock(ProductImageService.class);
MerchantProductImageController controller = new MerchantProductImageController();
ReflectionTestUtils.setField(controller, "images", service);
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of());
var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1});
assertThatThrownBy(() -> controller.upload(platform, file)).isInstanceOf(BizException.class);
}
private static AuthenticatedUser merchant(long tenantId) {
return new AuthenticatedUser(2L, tenantId, tenantId, 2L, "merchant", "MERCHANT", "WEB", Set.of());
}
}
@@ -0,0 +1,62 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
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 javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ProductImageStorageTest {
@TempDir Path root;
@Test
void storesValidatedImageUnderCustomerDirectoryAndReadsItBack() throws Exception {
ProductImageStorage storage = storage();
byte[] content = png();
var stored = storage.store(10001L,
new MockMultipartFile("file", "../product.png", "image/png", content));
assertThat(stored.objectKey()).startsWith("product-images/10001/").endsWith(".png");
assertThat(stored.originalName()).isEqualTo("product.png");
assertThat(storage.read(stored.objectKey())).isEqualTo(content);
}
@Test
void rejectsEmptyOversizedForgedAndMismatchedFiles() throws Exception {
ProductImageStorage storage = storage();
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "empty.png", "image/png", new byte[0])))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "large.png", "image/png", new byte[5 * 1024 * 1024 + 1])))
.isInstanceOf(BizException.class).hasMessageContaining("5MB");
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "fake.jpg", "image/jpeg", "not-an-image".getBytes())))
.isInstanceOf(BizException.class).hasMessageContaining("有效图片");
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "mismatch.jpg", "image/jpeg", png())))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
private ProductImageStorage storage() {
ProductImageStorage storage = new ProductImageStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
private static byte[] png() throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB), "png", output);
return output.toByteArray();
}
}