暂存提交

This commit is contained in:
2026-07-31 14:57:09 +08:00
parent 7a19e2f096
commit f8804fbc52
53 changed files with 3771 additions and 0 deletions
@@ -0,0 +1,41 @@
package com.baiyue.integration.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
import com.baiyue.integration.dto.ocr.WeightOcrBatchResult;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import com.baiyue.integration.service.ocr.WeightOcrBatchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
/**
* 供平台内部服务调用的称重图片 OCR 接口。
*/
@Tag(name = "重量 OCR", description = "受内部服务令牌保护的称重图片批量识别能力")
@RestController
@RequestMapping("/internal/integrations/weight-ocr")
public class WeightOcrController {
@Resource
private WeightOcrBatchService batchService;
@Resource
private InternalServiceTokenGuard tokenGuard;
@Operation(summary = "批量识别称重图片")
@PostMapping("/batches")
public ApiResponse<WeightOcrBatchResult> recognize(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WeightOcrBatchRequest request) throws IOException {
tokenGuard.verify(token);
return ApiResponse.success(batchService.recognize(request));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.integration.dto.ocr;
/**
* 百度 OCR 返回的文字候选及其识别框。
*/
public record OcrCandidate(
String text,
Double confidence,
Integer left,
Integer top,
Integer width,
Integer height
) {
public long area() {
if (width == null || height == null || width <= 0 || height <= 0) {
return 0;
}
return (long) width * height;
}
}
@@ -0,0 +1,32 @@
package com.baiyue.integration.dto.ocr;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
/**
* 重量 OCR 批次请求。目录必须位于服务配置的允许根目录内。
*/
public record WeightOcrBatchRequest(
@NotBlank(message = "输入目录不能为空") String inputDirectory,
String outputDirectory,
@DecimalMin(value = "0.0", message = "纵向裁剪起始比例不能小于 0")
@DecimalMax(value = "0.95", message = "纵向裁剪起始比例不能大于 0.95")
Double cropStartRatio,
@DecimalMin(value = "0.0", message = "横向裁剪比例不能小于 0")
@DecimalMax(value = "0.49", message = "横向裁剪比例不能大于 0.49")
Double horizontalTrimRatio,
Boolean recursive
) {
public double effectiveCropStartRatio() {
return cropStartRatio == null ? 0.5 : cropStartRatio;
}
public double effectiveHorizontalTrimRatio() {
return horizontalTrimRatio == null ? 0.25 : horizontalTrimRatio;
}
public boolean effectiveRecursive() {
return Boolean.TRUE.equals(recursive);
}
}
@@ -0,0 +1,21 @@
package com.baiyue.integration.dto.ocr;
import java.time.Instant;
import java.util.List;
/**
* 重量 OCR 批次汇总结果。
*/
public record WeightOcrBatchResult(
String batchId,
Instant startedAt,
Instant finishedAt,
String inputDirectory,
String outputDirectory,
int total,
int recognized,
int noWeightFound,
int failed,
List<WeightOcrImageResult> results
) {
}
@@ -0,0 +1,24 @@
package com.baiyue.integration.dto.ocr;
import java.math.BigDecimal;
import java.util.List;
/**
* 单张称重图片识别结果。路径均为相对于受控根目录的相对路径。
*/
public record WeightOcrImageResult(
String sourceImage,
String croppedImage,
String rawResponseFile,
Status status,
BigDecimal weight,
Double confidence,
List<OcrCandidate> candidates,
String error
) {
public enum Status {
RECOGNIZED,
NO_WEIGHT_FOUND,
FAILED
}
}
@@ -0,0 +1,220 @@
package com.baiyue.integration.provider.ocr;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ocr.OcrCandidate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* 百度仪器仪表盘读数识别客户端,负责令牌缓存、限速和受控重试。
*/
@Component
public class BaiduMeterClient {
private static final int MAX_FORM_BYTES = 4 * 1024 * 1024;
private static final int QPS_LIMIT_ERROR_CODE = 18;
private static final long INITIAL_RETRY_DELAY_MILLIS = 1_000;
private final IntegrationProperties.BaiduOcr config;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final Object requestRateLock = new Object();
private volatile CachedToken cachedToken;
private long nextRequestAtMillis;
public BaiduMeterClient(IntegrationProperties properties, ObjectMapper objectMapper) {
this.config = properties.baiduOcr();
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(config == null ? java.time.Duration.ofSeconds(10)
: config.effectiveConnectTimeout())
.build();
}
public void validateReady() {
if (config == null || !config.enabled()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 能力未启用");
}
if (!config.configured()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "百度 OCR 密钥或接口地址未配置");
}
}
public MeterResponse recognize(Path image) throws IOException, InterruptedException {
validateReady();
long imageBytes = Files.size(image);
if (imageBytes <= 0 || imageBytes > config.effectiveMaxImageSize().toBytes()) {
throw new IOException("裁剪图片超过允许大小: " + image.getFileName());
}
String base64 = Base64.getEncoder().encodeToString(Files.readAllBytes(image));
String form = "image=" + encode(base64) + "&probability=true&poly_location=true";
if (form.getBytes(StandardCharsets.UTF_8).length > MAX_FORM_BYTES) {
throw new IOException("裁剪图片编码后超过百度接口 4MB 限制: " + image.getFileName());
}
for (int retry = 0; ; retry++) {
waitForRequestSlot();
try {
return sendRecognitionRequest(form);
} catch (BaiduApiException exception) {
if (exception.errorCode() != QPS_LIMIT_ERROR_CODE
|| retry >= config.effectiveMaxQpsRetries()) {
throw exception;
}
Thread.sleep(INITIAL_RETRY_DELAY_MILLIS << retry);
}
}
}
private MeterResponse sendRecognitionRequest(String form) throws IOException, InterruptedException {
String separator = config.meterUrl().toString().contains("?") ? "&" : "?";
URI uri = URI.create(config.meterUrl() + separator + "access_token=" + encode(accessToken()));
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(config.effectiveRequestTimeout())
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
ensureSuccessfulHttpStatus(response.statusCode());
JsonNode root = objectMapper.readTree(response.body());
ensureSuccessfulBaiduResponse(root);
return new MeterResponse(response.body(), parseCandidates(root.path("words_result")));
}
private void waitForRequestSlot() throws InterruptedException {
synchronized (requestRateLock) {
long waitMillis = nextRequestAtMillis - System.currentTimeMillis();
if (waitMillis > 0) {
Thread.sleep(waitMillis);
}
nextRequestAtMillis = System.currentTimeMillis()
+ config.effectiveMinRequestInterval().toMillis();
}
}
private String accessToken() throws IOException, InterruptedException {
CachedToken current = cachedToken;
if (usable(current)) {
return current.value();
}
synchronized (this) {
current = cachedToken;
if (usable(current)) {
return current.value();
}
String form = "grant_type=client_credentials"
+ "&client_id=" + encode(config.apiKey())
+ "&client_secret=" + encode(config.secretKey());
HttpRequest request = HttpRequest.newBuilder(config.tokenUrl())
.timeout(config.effectiveRequestTimeout())
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
ensureSuccessfulHttpStatus(response.statusCode());
JsonNode root = objectMapper.readTree(response.body());
String token = root.path("access_token").asText();
if (token.isBlank()) {
throw new IOException("百度 OAuth 获取 access_token 失败: " + safeError(root));
}
long expiresIn = Math.max(120, root.path("expires_in").asLong(2_592_000));
cachedToken = new CachedToken(token, Instant.now().plusSeconds(expiresIn));
return token;
}
}
private boolean usable(CachedToken token) {
return token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(60));
}
private List<OcrCandidate> parseCandidates(JsonNode wordsResult) {
List<OcrCandidate> candidates = new ArrayList<>();
if (!wordsResult.isArray()) {
return candidates;
}
for (JsonNode item : wordsResult) {
JsonNode probability = item.path("probability");
JsonNode location = item.path("location");
candidates.add(new OcrCandidate(
item.path("words").asText(),
nullableDouble(probability.get("average")),
nullableInt(location.get("left")),
nullableInt(location.get("top")),
nullableInt(location.get("width")),
nullableInt(location.get("height"))
));
}
return List.copyOf(candidates);
}
private void ensureSuccessfulHttpStatus(int statusCode) throws IOException {
if (statusCode < 200 || statusCode >= 300) {
throw new IOException("百度接口 HTTP 状态异常: " + statusCode);
}
}
private void ensureSuccessfulBaiduResponse(JsonNode root) throws IOException {
if (root.has("error_code")) {
int errorCode = root.path("error_code").asInt(-1);
throw new BaiduApiException(errorCode, "百度 OCR 调用失败: " + safeError(root));
}
}
private String safeError(JsonNode root) {
return root.path("error_code").asText("unknown") + " - "
+ root.path("error_msg").asText("unknown error");
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static Double nullableDouble(JsonNode node) {
return node == null || !node.isNumber() ? null : node.doubleValue();
}
private static Integer nullableInt(JsonNode node) {
return node == null || !node.isNumber() ? null : node.intValue();
}
public record MeterResponse(String rawJson, List<OcrCandidate> candidates) {
}
private record CachedToken(String value, Instant expiresAt) {
}
private static final class BaiduApiException extends IOException {
private final int errorCode;
private BaiduApiException(int errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
private int errorCode() {
return errorCode;
}
}
}
@@ -0,0 +1,81 @@
package com.baiyue.integration.service.ocr;
import org.springframework.stereotype.Component;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Iterator;
/**
* 裁剪称重图片的下方中央区域,并统一输出为 JPEG。
*/
@Component
public class ImageCropper {
private static final float JPEG_QUALITY = 0.90f;
public void cropBottomCenter(Path source, Path target, double startRatio,
double horizontalTrimRatio, long maxImagePixels) throws IOException {
validateRatios(startRatio, horizontalTrimRatio);
BufferedImage sourceImage = ImageIO.read(source.toFile());
if (sourceImage == null) {
throw new IOException("无法解析图片: " + source.getFileName());
}
long pixels = (long) sourceImage.getWidth() * sourceImage.getHeight();
if (pixels <= 0 || pixels > maxImagePixels) {
throw new IOException("图片像素超过允许上限: " + source.getFileName());
}
int startY = (int) Math.floor(sourceImage.getHeight() * startRatio);
int startX = (int) Math.floor(sourceImage.getWidth() * horizontalTrimRatio);
int croppedWidth = sourceImage.getWidth() - startX * 2;
int croppedHeight = sourceImage.getHeight() - startY;
if (croppedWidth <= 0 || croppedHeight <= 0) {
throw new IOException("图片裁剪区域无效: " + source.getFileName());
}
BufferedImage cropped = new BufferedImage(croppedWidth, croppedHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = cropped.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, croppedWidth, croppedHeight);
graphics.drawImage(sourceImage, 0, 0, croppedWidth, croppedHeight,
startX, startY, startX + croppedWidth, startY + croppedHeight, null);
} finally {
graphics.dispose();
}
Files.createDirectories(target.getParent());
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
if (!writers.hasNext()) {
throw new IOException("当前 Java 环境不支持 JPEG 编码");
}
ImageWriter writer = writers.next();
try (ImageOutputStream output = ImageIO.createImageOutputStream(target.toFile())) {
writer.setOutput(output);
ImageWriteParam parameters = writer.getDefaultWriteParam();
parameters.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
parameters.setCompressionQuality(JPEG_QUALITY);
writer.write(null, new IIOImage(cropped, null, null), parameters);
} finally {
writer.dispose();
}
}
private void validateRatios(double startRatio, double horizontalTrimRatio) {
if (startRatio < 0 || startRatio >= 1) {
throw new IllegalArgumentException("cropStartRatio 必须在 [0, 1) 范围内");
}
if (horizontalTrimRatio < 0 || horizontalTrimRatio >= 0.5) {
throw new IllegalArgumentException("horizontalTrimRatio 必须在 [0, 0.5) 范围内");
}
}
}
@@ -0,0 +1,60 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.integration.dto.ocr.OcrCandidate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* 从 OCR 候选内容中选择最可能的重量数值。
*/
@Component
public class WeightCandidateSelector {
private static final Pattern WEIGHT_PATTERN = Pattern.compile("^[+-]?\\d{1,8}(?:\\.\\d{1,4})?$");
public Optional<SelectedWeight> select(List<OcrCandidate> candidates) {
if (candidates == null || candidates.isEmpty()) {
return Optional.empty();
}
return candidates.stream()
.map(this::toSelectedWeight)
.flatMap(Optional::stream)
.max(Comparator
.comparingLong((SelectedWeight selected) -> selected.candidate().area())
.thenComparing(selected -> selected.confidence() == null ? -1 : selected.confidence()));
}
private Optional<SelectedWeight> toSelectedWeight(OcrCandidate candidate) {
if (candidate == null) {
return Optional.empty();
}
String normalized = normalize(candidate.text());
if (!WEIGHT_PATTERN.matcher(normalized).matches()) {
return Optional.empty();
}
try {
return Optional.of(new SelectedWeight(
new BigDecimal(normalized), candidate.confidence(), candidate));
} catch (NumberFormatException ignored) {
return Optional.empty();
}
}
private String normalize(String value) {
if (value == null) {
return "";
}
return value.strip()
.replace(" ", "")
.replace(',', '.')
.replace('+', '+')
.replace('-', '-');
}
public record SelectedWeight(BigDecimal weight, Double confidence, OcrCandidate candidate) {
}
}
@@ -0,0 +1,207 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
import com.baiyue.integration.dto.ocr.WeightOcrBatchResult;
import com.baiyue.integration.dto.ocr.WeightOcrImageResult;
import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status;
import com.baiyue.integration.provider.ocr.BaiduMeterClient;
import com.baiyue.integration.service.ocr.WeightOcrStoragePolicy.BatchPaths;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Semaphore;
import java.util.stream.Stream;
/**
* 编排称重图片批量裁剪、百度 OCR 调用和结果落盘。
*/
@Service
public class WeightOcrBatchService {
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp");
private final IntegrationProperties properties;
private final WeightOcrStoragePolicy storagePolicy;
private final ImageCropper imageCropper;
private final BaiduMeterClient baiduMeterClient;
private final WeightCandidateSelector candidateSelector;
private final ObjectMapper objectMapper;
private final Semaphore batchSlot = new Semaphore(1);
public WeightOcrBatchService(IntegrationProperties properties,
WeightOcrStoragePolicy storagePolicy,
ImageCropper imageCropper,
BaiduMeterClient baiduMeterClient,
WeightCandidateSelector candidateSelector,
ObjectMapper objectMapper) {
this.properties = properties;
this.storagePolicy = storagePolicy;
this.imageCropper = imageCropper;
this.baiduMeterClient = baiduMeterClient;
this.candidateSelector = candidateSelector;
this.objectMapper = objectMapper;
}
public WeightOcrBatchResult recognize(WeightOcrBatchRequest request) throws IOException {
baiduMeterClient.validateReady();
if (!batchSlot.tryAcquire()) {
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "已有重量 OCR 批次正在执行");
}
try {
return doRecognize(request);
} finally {
batchSlot.release();
}
}
private WeightOcrBatchResult doRecognize(WeightOcrBatchRequest request) throws IOException {
String batchId = UUID.randomUUID().toString();
Instant startedAt = Instant.now();
BatchPaths paths = storagePolicy.resolve(
request.inputDirectory(), request.outputDirectory(), batchId);
List<Path> images = findImages(paths.inputDirectory(), paths.outputDirectory(),
request.effectiveRecursive(), config().effectiveMaxBatchSize());
if (images.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "输入目录中没有支持的图片");
}
List<WeightOcrImageResult> results = new ArrayList<>(images.size());
for (Path image : images) {
results.add(recognizeOne(paths, image, request));
}
WeightOcrBatchResult result = buildResult(batchId, startedAt, paths, results);
objectMapper.writerWithDefaultPrettyPrinter()
.writeValue(paths.outputDirectory().resolve("batch-results.json").toFile(), result);
return result;
}
private WeightOcrImageResult recognizeOne(BatchPaths paths, Path source,
WeightOcrBatchRequest request) {
Path relative = paths.inputDirectory().relativize(source);
Path cropped = paths.outputDirectory().resolve("cropped")
.resolve(replaceExtension(relative, ".jpg"));
Path rawResponse = paths.outputDirectory().resolve("raw")
.resolve(replaceExtension(relative, ".json"));
try {
long sourceBytes = Files.size(source);
if (sourceBytes <= 0 || sourceBytes > config().effectiveMaxImageSize().toBytes()) {
throw new IOException("原始图片超过允许大小");
}
imageCropper.cropBottomCenter(source, cropped,
request.effectiveCropStartRatio(), request.effectiveHorizontalTrimRatio(),
config().effectiveMaxImagePixels());
BaiduMeterClient.MeterResponse response = baiduMeterClient.recognize(cropped);
Files.createDirectories(rawResponse.getParent());
Files.writeString(rawResponse, response.rawJson(), StandardCharsets.UTF_8);
return candidateSelector.select(response.candidates())
.map(selected -> success(relative, paths, cropped, rawResponse,
response, selected))
.orElseGet(() -> noWeight(relative, paths, cropped, rawResponse, response));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return failed(relative, paths, cropped, rawResponse, "调用百度 OCR 时线程被中断");
} catch (Exception exception) {
return failed(relative, paths, cropped, rawResponse, safeMessage(exception));
}
}
private WeightOcrImageResult success(Path source, BatchPaths paths, Path cropped,
Path rawResponse, BaiduMeterClient.MeterResponse response,
WeightCandidateSelector.SelectedWeight selected) {
return new WeightOcrImageResult(
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
Status.RECOGNIZED, selected.weight(), selected.confidence(),
response.candidates(), null);
}
private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped,
Path rawResponse, BaiduMeterClient.MeterResponse response) {
return new WeightOcrImageResult(
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
Status.NO_WEIGHT_FOUND, null, null, response.candidates(),
"百度响应中没有符合重量格式的候选值");
}
private WeightOcrImageResult failed(Path source, BatchPaths paths, Path cropped,
Path rawResponse, String error) {
return new WeightOcrImageResult(
source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null,
Files.exists(rawResponse) ? outputRelative(paths, rawResponse) : null,
Status.FAILED, null, null, List.of(), error);
}
private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths,
List<WeightOcrImageResult> results) {
return new WeightOcrBatchResult(
batchId, startedAt, Instant.now(), paths.inputDisplayPath(), paths.outputDisplayPath(),
results.size(), count(results, Status.RECOGNIZED),
count(results, Status.NO_WEIGHT_FOUND), count(results, Status.FAILED),
List.copyOf(results));
}
private int count(List<WeightOcrImageResult> results, Status status) {
return (int) results.stream().filter(result -> result.status() == status).count();
}
private List<Path> findImages(Path input, Path output, boolean recursive, int maxBatchSize)
throws IOException {
int maxDepth = recursive ? Integer.MAX_VALUE : 1;
List<Path> images;
try (Stream<Path> paths = Files.walk(input, maxDepth)) {
images = paths.filter(Files::isRegularFile)
.filter(path -> !path.startsWith(output))
.filter(this::isSupportedImage)
.sorted(Comparator.comparing(Path::toString))
.limit((long) maxBatchSize + 1)
.toList();
}
if (images.size() > maxBatchSize) {
throw new BizException(ResultCode.BAD_REQUEST,
"单批图片数量不能超过 " + maxBatchSize + " 张");
}
return images;
}
private boolean isSupportedImage(Path path) {
String name = path.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot >= 0 && dot < name.length() - 1
&& SUPPORTED_EXTENSIONS.contains(name.substring(dot + 1).toLowerCase(Locale.ROOT));
}
private Path replaceExtension(Path relative, String extension) {
String name = relative.getFileName().toString();
int dot = name.lastIndexOf('.');
String baseName = dot > 0 ? name.substring(0, dot) : name;
return relative.getParent() == null
? Path.of(baseName + extension)
: relative.getParent().resolve(baseName + extension);
}
private String outputRelative(BatchPaths paths, Path path) {
return paths.outputDirectory().relativize(path).toString();
}
private String safeMessage(Exception exception) {
String message = exception.getMessage();
return message == null || message.isBlank() ? "OCR 识别失败" : message;
}
private IntegrationProperties.BaiduOcr config() {
return properties.baiduOcr();
}
}
@@ -0,0 +1,89 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.integration.config.IntegrationProperties;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 将 OCR 文件访问限制在配置的输入、输出根目录内。
*/
@Component
public class WeightOcrStoragePolicy {
private final IntegrationProperties properties;
public WeightOcrStoragePolicy(IntegrationProperties properties) {
this.properties = properties;
}
public BatchPaths resolve(String inputValue, String outputValue, String batchId) throws IOException {
IntegrationProperties.BaiduOcr config = requireConfig();
Path inputRoot = prepareRoot(config.inputRoot(), "OCR 输入根目录");
Path outputRoot = prepareRoot(config.outputRoot(), "OCR 输出根目录");
Path input = resolveRequested(inputRoot, inputValue).toRealPath();
requireInside(inputRoot, input, "输入目录超出允许范围");
if (!Files.isDirectory(input) || !Files.isReadable(input)) {
throw new BizException(ResultCode.BAD_REQUEST, "输入目录不存在或不可读");
}
String outputName = outputValue == null || outputValue.isBlank() ? batchId : outputValue;
Path output = resolveRequested(outputRoot, outputName);
requireInside(outputRoot, output, "输出目录超出允许范围");
Files.createDirectories(output);
output = output.toRealPath();
requireInside(outputRoot, output, "输出目录超出允许范围");
return new BatchPaths(inputRoot, outputRoot, input, output);
}
private IntegrationProperties.BaiduOcr requireConfig() {
IntegrationProperties.BaiduOcr config = properties.baiduOcr();
if (config == null || config.inputRoot() == null || config.outputRoot() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 存储目录未配置");
}
return config;
}
private Path prepareRoot(Path configured, String name) throws IOException {
Path root = configured.toAbsolutePath().normalize();
Files.createDirectories(root);
if (!Files.isDirectory(root) || !Files.isReadable(root) || !Files.isWritable(root)) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, name + "不可用");
}
return root.toRealPath();
}
private Path resolveRequested(Path root, String value) {
try {
Path requested = Path.of(value);
return (requested.isAbsolute() ? requested : root.resolve(requested)).normalize();
} catch (RuntimeException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "目录格式无效");
}
}
private void requireInside(Path root, Path path, String message) {
if (!path.startsWith(root)) {
throw new BizException(ResultCode.FORBIDDEN, message);
}
}
public record BatchPaths(Path inputRoot, Path outputRoot, Path inputDirectory, Path outputDirectory) {
public String inputDisplayPath() {
return display(inputRoot, inputDirectory);
}
public String outputDisplayPath() {
return display(outputRoot, outputDirectory);
}
private String display(Path root, Path path) {
String relative = root.relativize(path).toString();
return relative.isBlank() ? "." : relative;
}
}
}
@@ -0,0 +1,52 @@
package com.baiyue.integration.service.ocr;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ImageCropperTest {
@TempDir
Path tempDirectory;
@Test
void cropsConfiguredBottomCenterRegion() throws Exception {
Path source = createImage();
Path target = tempDirectory.resolve("cropped/result.jpg");
new ImageCropper().cropBottomCenter(source, target, 0.5, 0.25, 10_000);
BufferedImage cropped = ImageIO.read(target.toFile());
assertThat(cropped.getWidth()).isEqualTo(50);
assertThat(cropped.getHeight()).isEqualTo(40);
assertThat(new Color(cropped.getRGB(10, 10))).isEqualTo(Color.BLACK);
}
@Test
void rejectsImageOverPixelLimit() throws Exception {
Path source = createImage();
assertThatThrownBy(() -> new ImageCropper()
.cropBottomCenter(source, tempDirectory.resolve("result.jpg"), 0.5, 0.25, 100))
.hasMessageContaining("像素超过");
}
private Path createImage() throws Exception {
BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, 100, 40);
graphics.setColor(Color.BLACK);
graphics.fillRect(0, 40, 100, 40);
graphics.dispose();
Path source = tempDirectory.resolve("source.png");
ImageIO.write(image, "png", source.toFile());
return source;
}
}
@@ -0,0 +1,33 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.integration.dto.ocr.OcrCandidate;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class WeightCandidateSelectorTest {
private final WeightCandidateSelector selector = new WeightCandidateSelector();
@Test
void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() {
List<OcrCandidate> candidates = List.of(
new OcrCandidate("10:38", 0.99, 10, 10, 40, 10),
new OcrCandidate(" 12,35 ", 0.97, 20, 30, 120, 60),
new OcrCandidate("29", 0.99, 160, 10, 20, 10),
new OcrCandidate("kg", 0.99, 150, 70, 30, 15));
WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow();
assertThat(selected.weight()).isEqualByComparingTo("12.35");
assertThat(selected.confidence()).isEqualTo(0.97);
}
@Test
void rejectsNonNumericCandidates() {
assertThat(selector.select(List.of(
new OcrCandidate("kg", 0.99, 0, 0, 20, 10),
new OcrCandidate("10:38", 0.99, 0, 0, 20, 10)))).isEmpty();
}
}
@@ -0,0 +1,73 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ocr.OcrCandidate;
import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status;
import com.baiyue.integration.provider.ocr.BaiduMeterClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.util.unit.DataSize;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class WeightOcrBatchServiceTest {
@TempDir
Path tempDirectory;
@Test
void completesBatchAndWritesArtifactsWithoutCallingRealProvider() throws Exception {
Path inputRoot = Files.createDirectories(tempDirectory.resolve("input"));
Path outputRoot = tempDirectory.resolve("output");
Path batchInput = Files.createDirectories(inputRoot.resolve("batch-a"));
BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
image.setRGB(x, y, Color.BLACK.getRGB());
}
}
ImageIO.write(image, "png", batchInput.resolve("weight.png").toFile());
IntegrationProperties properties = properties(inputRoot, outputRoot);
BaiduMeterClient client = mock(BaiduMeterClient.class);
when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse(
"{\"words_result\":[]}",
List.of(new OcrCandidate("12.35", 0.98, 10, 10, 80, 30))));
ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
WeightOcrBatchService service = new WeightOcrBatchService(
properties, new WeightOcrStoragePolicy(properties), new ImageCropper(), client,
new WeightCandidateSelector(), objectMapper);
var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false));
assertThat(result.total()).isEqualTo(1);
assertThat(result.recognized()).isEqualTo(1);
assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED);
Path batchOutput = outputRoot.resolve(result.outputDirectory());
assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile();
assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile();
assertThat(batchOutput.resolve("batch-results.json")).isRegularFile();
}
private IntegrationProperties properties(Path inputRoot, Path outputRoot) {
IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr(
true, "key", "secret", URI.create("https://example.com/token"),
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
50, Duration.ZERO, 0);
return new IntegrationProperties(baidu, null);
}
}
@@ -0,0 +1,57 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.integration.config.IntegrationProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.util.unit.DataSize;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WeightOcrStoragePolicyTest {
@TempDir
Path tempDirectory;
@Test
void resolvesDirectoriesInsideConfiguredRoots() throws Exception {
Path input = Files.createDirectories(tempDirectory.resolve("input/batch-a"));
Path outputRoot = tempDirectory.resolve("output");
WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(input.getParent(), outputRoot));
WeightOcrStoragePolicy.BatchPaths paths = policy.resolve("batch-a", null, "batch-id");
assertThat(paths.inputDirectory()).isEqualTo(input.toRealPath());
assertThat(paths.outputDirectory()).isEqualTo(outputRoot.resolve("batch-id").toRealPath());
}
@Test
void rejectsInputAndOutputOutsideConfiguredRoots() throws Exception {
Path inputRoot = Files.createDirectories(tempDirectory.resolve("input"));
Path outputRoot = tempDirectory.resolve("output");
Path outside = Files.createDirectories(tempDirectory.resolve("outside"));
WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(inputRoot, outputRoot));
assertThatThrownBy(() -> policy.resolve(outside.toString(), null, "batch-id"))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
assertThatThrownBy(() -> policy.resolve(".", "../outside", "batch-id"))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
}
private IntegrationProperties properties(Path inputRoot, Path outputRoot) {
IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr(
true, "key", "secret", URI.create("https://example.com/token"),
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
50, Duration.ZERO, 0);
return new IntegrationProperties(baidu, null);
}
}