二期内容提交
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@FeignClient(name = "integration-service", contextId = "measurementWeightOcrClient",
|
||||
url = "${baiyue.clients.integration.url:}", path = "/internal/integrations/weight-ocr")
|
||||
public interface WeightOcrClient {
|
||||
@PostMapping(value = "/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
ApiResponse<Result> recognize(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestPart("file") MultipartFile file);
|
||||
|
||||
record Result(String sourceImage, String croppedImage, String rawResponseFile,
|
||||
String status, BigDecimal weight, Double confidence,
|
||||
java.util.List<?> candidates, String error) {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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.MeasurementImageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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;
|
||||
|
||||
/** 平台入库测量图片上传、OCR 和受控读取入口。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/files/measurements")
|
||||
@Tag(name = "平台入库测量图片")
|
||||
public class PlatformMeasurementImageController {
|
||||
private final MeasurementImageService images;
|
||||
|
||||
public PlatformMeasurementImageController(MeasurementImageService images) {
|
||||
this.images = images;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
|
||||
@Operation(summary = "上传称重图片并执行 OCR",
|
||||
description = "图片先持久化再识别;OCR 失败时保留文件,允许重试或人工录入。")
|
||||
public ApiResponse<MeasurementImageService.UploadResult> upload(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
long operatorId = requirePlatform(user);
|
||||
requirePositive(tenantId, inboundId);
|
||||
return ApiResponse.success(images.upload(tenantId, inboundId, operatorId, file));
|
||||
}
|
||||
|
||||
@PostMapping("/{tenantId}/{inboundId}/{fileId}/recognize")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
|
||||
@Operation(summary = "重试称重图片 OCR")
|
||||
public ApiResponse<MeasurementImageService.UploadResult> retryRecognition(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long fileId) {
|
||||
requirePlatform(user);
|
||||
requirePositive(tenantId, inboundId, fileId);
|
||||
return ApiResponse.success(images.retryRecognition(tenantId, inboundId, fileId));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
|
||||
@Operation(summary = "查看入库单称重图片")
|
||||
public ResponseEntity<byte[]> download(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long fileId) {
|
||||
requirePlatform(user);
|
||||
requirePositive(tenantId, inboundId, fileId);
|
||||
var image = images.download(tenantId, inboundId, 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 long requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|
||||
|| user.userId() == null || user.userId() <= 0) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
|
||||
}
|
||||
return user.userId();
|
||||
}
|
||||
|
||||
private void requirePositive(long... ids) {
|
||||
for (long id : ids) {
|
||||
if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.file.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("file_object")
|
||||
public class FileObjectEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String businessType;
|
||||
private String businessId;
|
||||
private String originalName;
|
||||
private String objectKey;
|
||||
private String storageProvider;
|
||||
private String bucketName;
|
||||
private String contentType;
|
||||
private Long sizeBytes;
|
||||
private String sha256;
|
||||
private String status;
|
||||
private Long createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private Long updatedBy;
|
||||
private LocalDateTime updatedAt;
|
||||
private Boolean deleted;
|
||||
private Integer version;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.file.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface FileObjectMapper extends BaseMapper<FileObjectEntity> {
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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.client.WeightOcrClient;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class MeasurementImageService {
|
||||
private final MeasurementImageStorage storage;
|
||||
private final FileObjectMapper files;
|
||||
private final WeightOcrClient ocr;
|
||||
private final String internalToken;
|
||||
|
||||
public MeasurementImageService(MeasurementImageStorage storage, FileObjectMapper files,
|
||||
WeightOcrClient ocr,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.storage = storage;
|
||||
this.files = files;
|
||||
this.ocr = ocr;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
|
||||
var stored = storage.store(tenantId, inboundId, file);
|
||||
FileObjectEntity object = new FileObjectEntity();
|
||||
object.setTenantId(tenantId);
|
||||
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
|
||||
object.setBusinessId(Long.toString(inboundId));
|
||||
object.setOriginalName(stored.originalName());
|
||||
object.setObjectKey(stored.objectKey());
|
||||
object.setStorageProvider("LOCAL");
|
||||
object.setBucketName("measurement-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);
|
||||
files.insert(object);
|
||||
|
||||
return recognize(object);
|
||||
}
|
||||
|
||||
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
|
||||
return recognize(requireObject(tenantId, inboundId, fileId));
|
||||
}
|
||||
|
||||
public Download download(long tenantId, long inboundId, long fileId) {
|
||||
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
|
||||
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
|
||||
}
|
||||
|
||||
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
|
||||
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
|
||||
.eq(FileObjectEntity::getTenantId, tenantId)
|
||||
.eq(FileObjectEntity::getId, fileId)
|
||||
.eq(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE")
|
||||
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
|
||||
.eq(FileObjectEntity::getDeleted, false));
|
||||
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
|
||||
return object;
|
||||
}
|
||||
|
||||
private UploadResult recognize(FileObjectEntity object) {
|
||||
byte[] content = storage.read(object.getObjectKey());
|
||||
MultipartFile image = new StoredImageMultipartFile(
|
||||
object.getOriginalName(), object.getContentType(), content);
|
||||
try {
|
||||
var response = ocr.recognize(internalToken, image);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
return failed(object, response == null ? "OCR服务无响应" : response.message());
|
||||
}
|
||||
var result = response.data();
|
||||
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
|
||||
object.getSizeBytes(), result.status(), result.weight(), "KG", result.confidence(), result.error());
|
||||
} catch (FeignException exception) {
|
||||
return failed(object, "OCR服务不可用,可稍后重试或人工录入");
|
||||
}
|
||||
}
|
||||
|
||||
private UploadResult failed(FileObjectEntity object, String message) {
|
||||
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
|
||||
object.getSizeBytes(), "FAILED", null, null, null, message);
|
||||
}
|
||||
|
||||
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
|
||||
String recognitionStatus, BigDecimal weightValue, String weightUnit,
|
||||
Double confidence, String error) {}
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
|
||||
private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes)
|
||||
implements MultipartFile {
|
||||
@Override public String getName() { return "file"; }
|
||||
@Override public String getOriginalFilename() { return originalFilename; }
|
||||
@Override public String getContentType() { return contentType; }
|
||||
@Override public boolean isEmpty() { return bytes.length == 0; }
|
||||
@Override public long getSize() { return bytes.length; }
|
||||
@Override public byte[] getBytes() { return bytes.clone(); }
|
||||
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
||||
@Override public void transferTo(java.io.File destination) throws IOException {
|
||||
java.nio.file.Files.write(destination.toPath(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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 MeasurementImageStorage {
|
||||
private static final long MAX_SIZE = 5L * 1024 * 1024;
|
||||
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "image/bmp");
|
||||
private final Path root;
|
||||
|
||||
public MeasurementImageStorage(@Value("${baiyue.file.storage-root}") Path root) {
|
||||
this.root = root.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public Stored store(long tenantId, long inboundId, 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、BMP");
|
||||
verifyImage(file);
|
||||
String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg");
|
||||
String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + 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, target, 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, "称重图片读取失败"); }
|
||||
}
|
||||
|
||||
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 "weight-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, Path path, String contentType, long size, String sha256,
|
||||
String originalName) {}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ baiyue:
|
||||
clients:
|
||||
iam:
|
||||
url: ${IAM_SERVICE_URL:}
|
||||
integration:
|
||||
url: ${INTEGRATION_SERVICE_URL:}
|
||||
file:
|
||||
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.service.MeasurementImageService;
|
||||
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.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
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;
|
||||
|
||||
class PlatformMeasurementImageControllerContractTest {
|
||||
private final MeasurementImageService images = mock(MeasurementImageService.class);
|
||||
private final PlatformMeasurementImageController controller =
|
||||
new PlatformMeasurementImageController(images);
|
||||
|
||||
@Test
|
||||
void 暴露平台测量图片multipart入口并校验重量维护权限() throws Exception {
|
||||
RequestMapping base = PlatformMeasurementImageController.class.getAnnotation(RequestMapping.class);
|
||||
assertThat(base.value()).containsExactly("/api/platform/v1/files/measurements");
|
||||
Method method = PlatformMeasurementImageController.class.getDeclaredMethod("upload",
|
||||
AuthenticatedUser.class, long.class, long.class, org.springframework.web.multipart.MultipartFile.class);
|
||||
assertThat(method.getAnnotation(PostMapping.class).consumes())
|
||||
.containsExactly(MediaType.MULTIPART_FORM_DATA_VALUE);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:weight:edit')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 商户身份不能调用平台上传接口() {
|
||||
var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1});
|
||||
var merchant = new AuthenticatedUser(2L, 10001L, 10001L, 2L,
|
||||
"merchant", "MERCHANT", "WEB", Set.of());
|
||||
|
||||
assertThatThrownBy(() -> controller.upload(merchant, 10001L, 88L, file))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 下载始终同时使用租户入库单和文件ID隔离() {
|
||||
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L,
|
||||
"admin", "PLATFORM", "WEB", Set.of());
|
||||
org.mockito.Mockito.when(images.download(10001L, 88L, 99L)).thenReturn(
|
||||
new MeasurementImageService.Download("weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2}));
|
||||
|
||||
controller.download(platform, 10001L, 88L, 99L);
|
||||
|
||||
verify(images).download(10001L, 88L, 99L);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user