This commit is contained in:
2026-08-07 18:44:51 +08:00
parent 1a76602872
commit 37fec1e012
24 changed files with 601 additions and 24 deletions
@@ -0,0 +1,48 @@
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.ProductImageService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/file/v1/product-images")
public class InternalProductImageController {
@Resource private ProductImageService images;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@GetMapping("/{tenantId}/{fileId}")
public ApiResponse<ProductImageService.UploadResult> validate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户和文件ID必须大于0");
}
return ApiResponse.success(images.validate(tenantId, fileId));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,62 @@
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.common.security.AuthenticatedUser;
import com.baiyue.file.service.ProductImageService;
import jakarta.annotation.Resource;
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.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.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;
@RestController
@RequestMapping("/api/merchant/v1/files/product-images")
public class MerchantProductImageController {
@Resource private ProductImageService images;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit')")
public ApiResponse<ProductImageService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
long tenantId = merchantTenant(user);
return ApiResponse.success(images.upload(tenantId, user.userId(), file));
}
@GetMapping("/{fileId}")
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
public ResponseEntity<byte[]> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long fileId) {
var image = images.download(merchantTenant(user), fileId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(image.fileName(), StandardCharsets.UTF_8).build().toString())
.body(image.content());
}
private static long merchantTenant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
}
return user.tenantId();
}
}
@@ -87,11 +87,12 @@ public class MeasurementImageService {
if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片");
}
object.setDeleted(true);
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now());
files.updateById(object);
if (files.updateById(object) != 1 || files.deleteById(object.getId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "测量图片已被删除,请刷新后重试");
}
storage.delete(object.getObjectKey());
}
@@ -101,6 +102,7 @@ public class MeasurementImageService {
.eq(FileObjectEntity::getId, fileId)
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
return object;
@@ -0,0 +1,76 @@
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;
@Service
public class ProductImageService {
public static final String BUSINESS_TYPE = "ORDER_PRODUCT_IMAGE";
@Resource private ProductImageStorage storage;
@Resource private FileObjectMapper files;
@Transactional
public UploadResult upload(long tenantId, long operatorId, MultipartFile file) {
var stored = storage.store(tenantId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType(BUSINESS_TYPE);
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("product-images");
object.setContentType(stored.contentType());
object.setSizeBytes(stored.size());
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 {
files.insert(object);
} catch (RuntimeException exception) {
storage.delete(stored.objectKey());
throw exception;
}
return metadata(object);
}
public UploadResult validate(long tenantId, long fileId) {
return metadata(requireObject(tenantId, fileId));
}
public Download download(long tenantId, long fileId) {
FileObjectEntity object = requireObject(tenantId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
private FileObjectEntity requireObject(long tenantId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE)
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在");
return object;
}
private static UploadResult metadata(FileObjectEntity object) {
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes());
}
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -0,0 +1,107 @@
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 javax.imageio.ImageIO;
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;
@Component
public class ProductImageStorage {
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png");
@Value("${baiyue.file.storage-root}") private Path configuredRoot;
private Path root;
@PostConstruct
void initializeRoot() {
root = configuredRoot.toAbsolutePath().normalize();
}
public Stored store(long tenantId, MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("产品图片不能为空");
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);
String extension = "image/png".equals(type) ? ".png" : ".jpg";
String objectKey = "product-images/" + tenantId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.createDirectories(target.getParent());
file.transferTo(target);
return new Stored(objectKey, type, file.getSize(), sha256(target), safeName(file));
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片保存失败");
}
}
public byte[] read(String objectKey) {
Path path = root.resolve(objectKey).normalize();
if (!path.startsWith(root) || !Files.isRegularFile(path)) {
throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在");
}
try {
return Files.readAllBytes(path);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片读取失败");
}
}
public void delete(String objectKey) {
Path path = root.resolve(objectKey).normalize();
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片清理失败");
}
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
} catch (IOException exception) {
throw bad("上传内容不是有效图片");
}
}
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 String safeName(MultipartFile file) {
String name = file.getOriginalFilename();
if (name == null || name.isBlank()) return "product-image";
return Path.of(name).getFileName().toString();
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
public record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {}
}
@@ -24,6 +24,9 @@ import java.math.BigDecimal;
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.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -85,7 +88,7 @@ class MeasurementImageServiceEvidenceTest {
LambdaQueryWrapper<FileObjectEntity> query = invocation.getArgument(0);
query.getSqlSegment();
assertThat(query.getParamNameValuePairs().values())
.contains(11L, 41L, "21", false);
.contains(11L, 41L, "21", "ACTIVE", false);
return null;
});
@@ -116,4 +119,47 @@ class MeasurementImageServiceEvidenceTest {
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
assertThat(result.ocrRawText()).isEqualTo("285.5\nKG");
}
@Test
void deletesMeasurementEvidenceThroughTheConfiguredLogicalDelete() {
FileObjectEntity object = measurementEvidence();
when(files.selectOne(any())).thenReturn(object);
when(files.updateById(object)).thenReturn(1);
when(files.deleteById(41L)).thenReturn(1);
service.deleteMeasurementEvidence(11L, 21L, 41L, 31L);
assertThat(object.getStatus()).isEqualTo("DELETED");
assertThat(object.getDeleted()).isFalse();
var ordered = inOrder(files, storage);
ordered.verify(files).updateById(object);
ordered.verify(files).deleteById(41L);
ordered.verify(storage).delete(object.getObjectKey());
}
@Test
void doesNotDeleteStoredContentWhenLogicalDeleteLosesTheRace() {
FileObjectEntity object = measurementEvidence();
when(files.selectOne(any())).thenReturn(object);
when(files.updateById(object)).thenReturn(1);
when(files.deleteById(41L)).thenReturn(0);
assertThatThrownBy(() -> service.deleteMeasurementEvidence(11L, 21L, 41L, 31L))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("已被删除");
verify(storage, never()).delete(any());
}
private FileObjectEntity measurementEvidence() {
FileObjectEntity object = new FileObjectEntity();
object.setId(41L);
object.setTenantId(11L);
object.setBusinessType("INBOUND_MEASUREMENT_IMAGE");
object.setBusinessId("21");
object.setObjectKey("measurements/11/21/evidence.png");
object.setStatus("ACTIVE");
object.setDeleted(false);
return object;
}
}