Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
This commit is contained in:
2026-08-07 19:49:09 +08:00
192 changed files with 10729 additions and 391 deletions
@@ -1,16 +1,22 @@
package com.baiyue.integration.dto.ocr;
import java.math.BigDecimal;
/**
* 百度 OCR 返回的文字候选及其识别框。
*/
public record OcrCandidate(
String text,
Double confidence,
BigDecimal confidence,
Integer left,
Integer top,
Integer width,
Integer height
) {
public OcrCandidate {
confidence = OcrConfidence.normalize(confidence);
}
public long area() {
if (width == null || height == null || width <= 0 || height <= 0) {
return 0;
@@ -0,0 +1,27 @@
package com.baiyue.integration.dto.ocr;
import java.math.BigDecimal;
import java.math.RoundingMode;
/** Canonical confidence representation shared by OCR response models. */
public final class OcrConfidence {
private static final int SCALE = 5;
private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(SCALE);
private static final BigDecimal ONE = BigDecimal.ONE.setScale(SCALE);
private OcrConfidence() {
}
public static BigDecimal normalize(BigDecimal value) {
if (value == null) {
return null;
}
if (value.compareTo(BigDecimal.ZERO) < 0) {
return ZERO;
}
if (value.compareTo(BigDecimal.ONE) > 0) {
return ONE;
}
return value.setScale(SCALE, RoundingMode.HALF_UP);
}
}
@@ -12,10 +12,15 @@ public record WeightOcrImageResult(
String rawResponseFile,
Status status,
BigDecimal weight,
Double confidence,
BigDecimal confidence,
List<OcrCandidate> candidates,
String ocrRawText,
String error
) {
public WeightOcrImageResult {
confidence = OcrConfidence.normalize(confidence);
}
public enum Status {
/** 已成功识别到有效重量。 */
RECOGNIZED,
@@ -12,6 +12,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
@@ -163,7 +164,7 @@ public class BaiduMeterClient {
JsonNode location = item.path("location");
candidates.add(new OcrCandidate(
item.path("words").asText(),
nullableDouble(probability.get("average")),
nullableDecimal(probability.get("average")),
nullableInt(location.get("left")),
nullableInt(location.get("top")),
nullableInt(location.get("width")),
@@ -195,8 +196,8 @@ public class BaiduMeterClient {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static Double nullableDouble(JsonNode node) {
return node == null || !node.isNumber() ? null : node.doubleValue();
private static BigDecimal nullableDecimal(JsonNode node) {
return node == null || !node.isNumber() ? null : node.decimalValue();
}
private static Integer nullableInt(JsonNode node) {
@@ -25,7 +25,8 @@ public class WeightCandidateSelector {
.flatMap(Optional::stream)
.max(Comparator
.comparingLong((SelectedWeight selected) -> selected.candidate().area())
.thenComparing(selected -> selected.confidence() == null ? -1 : selected.confidence()));
.thenComparing(selected -> selected.confidence() == null
? BigDecimal.valueOf(-1) : selected.confidence()));
}
private Optional<SelectedWeight> toSelectedWeight(OcrCandidate candidate) {
@@ -55,6 +56,6 @@ public class WeightCandidateSelector {
.replace('-', '-');
}
public record SelectedWeight(BigDecimal weight, Double confidence, OcrCandidate candidate) {
public record SelectedWeight(BigDecimal weight, BigDecimal confidence, OcrCandidate candidate) {
}
}
@@ -34,6 +34,7 @@ import java.util.stream.Stream;
@Service
public class WeightOcrBatchService {
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp");
private static final int RAW_TEXT_MAX_LENGTH = 1000;
@Resource private IntegrationProperties properties;
@Resource private WeightOcrStoragePolicy storagePolicy;
@@ -113,7 +114,7 @@ public class WeightOcrBatchService {
return new WeightOcrImageResult(
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
Status.RECOGNIZED, selected.weight(), selected.confidence(),
response.candidates(), null);
response.candidates(), summarizeCandidates(response.candidates()), null);
}
private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped,
@@ -121,6 +122,7 @@ public class WeightOcrBatchService {
return new WeightOcrImageResult(
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
Status.NO_WEIGHT_FOUND, null, null, response.candidates(),
summarizeCandidates(response.candidates()),
"百度响应中没有符合重量格式的候选值");
}
@@ -129,7 +131,33 @@ public class WeightOcrBatchService {
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);
Status.FAILED, null, null, List.of(), null, error);
}
private String summarizeCandidates(List<com.baiyue.integration.dto.ocr.OcrCandidate> candidates) {
if (candidates == null || candidates.isEmpty()) {
return null;
}
StringBuilder summary = new StringBuilder();
for (var candidate : candidates) {
if (candidate == null || candidate.text() == null) {
continue;
}
String text = candidate.text().replaceAll("[\\p{Cc}\\p{Cf}]+", " ").strip();
if (text.isBlank()) {
continue;
}
int separatorLength = summary.isEmpty() ? 0 : 1;
int remaining = RAW_TEXT_MAX_LENGTH - summary.length() - separatorLength;
if (remaining <= 0) {
break;
}
if (separatorLength > 0) {
summary.append('\n');
}
summary.append(text, 0, Math.min(text.length(), remaining));
}
return summary.isEmpty() ? null : summary.toString();
}
private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths,
@@ -56,7 +56,9 @@ public class WeightOcrSingleImageService {
if (batch.results() == null || batch.results().size() != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "OCR单图识别结果数量异常");
}
return batch.results().get(0);
WeightOcrImageResult result = batch.results().get(0);
return new WeightOcrImageResult(null, null, null, result.status(), result.weight(),
result.confidence(), java.util.List.of(), result.ocrRawText(), result.error());
} finally {
deleteRequestDirectory(requestDirectory);
}
@@ -4,6 +4,7 @@ import com.baiyue.integration.dto.ocr.OcrCandidate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -15,21 +16,31 @@ class WeightCandidateSelectorTest {
@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));
new OcrCandidate("10:38", new BigDecimal("0.99"), 10, 10, 40, 10),
new OcrCandidate(" 12,35 ", new BigDecimal("0.97"), 20, 30, 120, 60),
new OcrCandidate("29", new BigDecimal("0.99"), 160, 10, 20, 10),
new OcrCandidate("kg", new BigDecimal("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);
assertThat(selected.confidence()).isEqualByComparingTo("0.97000");
}
@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();
new OcrCandidate("kg", new BigDecimal("0.99"), 0, 0, 20, 10),
new OcrCandidate("10:38", new BigDecimal("0.99"), 0, 0, 20, 10)))).isEmpty();
}
@Test
void canonicalizesConfidenceToRangeAndFiveDecimalPlaces() {
assertThat(new OcrCandidate("1", new BigDecimal("0.5097191334"), 0, 0, 1, 1)
.confidence()).isEqualByComparingTo("0.50972");
assertThat(new OcrCandidate("1", new BigDecimal("1.2"), 0, 0, 1, 1)
.confidence()).isEqualByComparingTo("1.00000");
assertThat(new OcrCandidate("1", new BigDecimal("-0.1"), 0, 0, 1, 1)
.confidence()).isEqualByComparingTo("0.00000");
}
}
@@ -19,6 +19,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.math.BigDecimal;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -56,13 +57,17 @@ class WeightOcrBatchServiceTest {
when(properties.baiduOcr()).thenReturn(properties(inputRoot, outputRoot).baiduOcr());
when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse(
"{\"words_result\":[]}",
List.of(new OcrCandidate("12.35", 0.98, 10, 10, 80, 30))));
List.of(
new OcrCandidate("12.35", new BigDecimal("0.5097191334"), 10, 10, 80, 30),
new OcrCandidate("KG\u0000", new BigDecimal("0.98"), 10, 50, 30, 10))));
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);
assertThat(result.results().get(0).confidence()).isEqualByComparingTo("0.50972");
assertThat(result.results().get(0).ocrRawText()).isEqualTo("12.35\nKG");
Path batchOutput = outputRoot.resolve(result.outputDirectory());
assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile();
assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile();
@@ -0,0 +1,58 @@
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.WeightOcrBatchResult;
import com.baiyue.integration.dto.ocr.WeightOcrImageResult;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.util.unit.DataSize;
import java.math.BigDecimal;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(WeightOcrSingleImageService.class)
class WeightOcrSingleImageServiceTest {
@TempDir Path tempDirectory;
@MockBean IntegrationProperties properties;
@MockBean WeightOcrBatchService batches;
@Resource WeightOcrSingleImageService service;
@Test
void returnsSafeContractWithoutTemporaryPathsOrCandidateDetails() throws Exception {
var config = new IntegrationProperties.BaiduOcr(true, "key", "secret",
URI.create("https://example.com/token"), URI.create("https://example.com/meter"),
Duration.ofSeconds(1), Duration.ofSeconds(1), tempDirectory, tempDirectory.resolve("out"),
DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0);
when(properties.baiduOcr()).thenReturn(config);
var imageResult = new WeightOcrImageResult("weight.jpg", "cropped/weight.jpg", "raw/weight.json",
WeightOcrImageResult.Status.RECOGNIZED, new BigDecimal("285.5"),
new BigDecimal("0.5097191334"),
List.of(new OcrCandidate("285.5", new BigDecimal("0.5097191334"), 0, 0, 10, 10)),
"285.5", null);
when(batches.recognize(any())).thenReturn(new WeightOcrBatchResult("batch", Instant.now(),
Instant.now(), "input", "output", 1, 1, 0, 0, List.of(imageResult)));
var result = service.recognize(new MockMultipartFile(
"file", "weight.jpg", "image/jpeg", new byte[]{1, 2, 3}));
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
assertThat(result.ocrRawText()).isEqualTo("285.5");
assertThat(result.sourceImage()).isNull();
assertThat(result.croppedImage()).isNull();
assertThat(result.rawResponseFile()).isNull();
assertThat(result.candidates()).isEmpty();
}
}