优化
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user