二期内容提交

This commit is contained in:
2026-08-04 10:07:45 +08:00
parent b2b4ff429d
commit 57b54a5236
184 changed files with 13195 additions and 5 deletions
@@ -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) {}
}
+2
View File
@@ -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