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