From 2edbacf838ca5bf85e56be24070ea63676212544 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Fri, 2 Oct 2026 16:31:42 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E8=AF=81=E4=BB=B6=20OCR=20=E8=96=84=E9=80=82=E9=85=8D=E5=B1=82?= =?UTF-8?q?=EF=BC=8C=E5=90=AB=E7=B1=BB=E5=9E=8B=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小程序按既有契约 POST {imageUrl, type, side?} 到 /driver/auth/ocr, 后端转调已有的百度 OCR 能力(recognizeUrl)。 映射的必要性在于两侧命名对「行驶证」和「驾驶证」的用词正好相反: 驾驶证 —— 小程序传 driver,后端 OCR 用 driving_license 行驶证 —— 小程序传 vehicle,后端 OCR 用 vehicle_license 把入参直接透传下游,调用方不会收到任何错误,只会拿到另一种证件的字段, 问题要到人工核对时才发现。故用显式白名单逐一对应。 采用白名单而非「透传 + 兜底为通用识别」,是因为从业资格证没有对应的百度接口: 静默降级成通用文字识别会让前端以为识别成功,实际拿到一堆通用文本。 该类型明确报错并提示手动填写。 正反面:小程序目前不传 side,按 front 处理(正页是常用场景)。 10 个映射用例覆盖五个小程序类型、后端既有命名、大小写/分隔符变体、 未知类型、空值、不支持类型与 side 归一化。 全模块 58 个测试通过:mvn -o test -pl blade-service/blade-transport -am --- .../pojo/dto/OcrRecognizeRequest.java | 53 +++++++ .../controller/DriverAuthController.java | 20 +++ .../transport/support/OcrTypeMapping.java | 139 ++++++++++++++++++ .../transport/support/OcrTypeMappingTest.java | 105 +++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java new file mode 100644 index 00000000..0e1aa212 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java @@ -0,0 +1,53 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 证件 OCR 识别请求(小程序) + * + * @author Chill + */ +@Data +@Schema(description = "证件OCR识别请求") +public class OcrRecognizeRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "已上传图片的地址,必须是百度可访问的HTTP或HTTPS地址") + private String imageUrl; + @Schema(description = "证件类型:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证") + private String type; + @Schema(description = "正副面:front或back,默认front") + private String side; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java index 708e02ab..9d8fb576 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java @@ -31,10 +31,15 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.tool.api.R; +import org.springblade.transport.ocr.constant.BaiduOcrType; +import org.springblade.transport.ocr.service.IBaiduOcrService; +import org.springblade.transport.pojo.dto.OcrRecognizeRequest; import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.vo.BaiduOcrResultVO; import org.springblade.transport.pojo.vo.TransportCertRecordVO; import org.springblade.transport.service.IDriverCertService; +import org.springblade.transport.support.OcrTypeMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -61,6 +66,7 @@ import java.util.List; public class DriverAuthController extends BladeController { private final IDriverCertService driverCertService; + private final IBaiduOcrService baiduOcrService; // ------------------------------------------------------------ 个人认证 @@ -118,4 +124,18 @@ public class DriverAuthController extends BladeController { return R.data(driverCertService.getRecord(id)); } + // ------------------------------------------------------------ OCR + + @PostMapping("/ocr") + @ApiOperationSupport(order = 8) + @Operation(summary = "证件OCR识别", + description = "按小程序既有契约收 {imageUrl, type, side?},转调百度OCR。" + + "type 用小程序命名:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证") + public R ocr(@RequestBody OcrRecognizeRequest request) { + // 两侧对「驾驶证 / 行驶证」的用词正好相反,故必须经显式映射,不能把入参直接透传下游 + BaiduOcrType ocrType = OcrTypeMapping.toBaiduType(request.getType()); + String side = OcrTypeMapping.normalizeSide(request.getSide()); + return R.data(baiduOcrService.recognizeUrl(ocrType, side, request.getImageUrl())); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java new file mode 100644 index 00000000..61bf18f3 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java @@ -0,0 +1,139 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.Locale; +import java.util.Map; + +/** + * 证件类型映射:小程序命名 → 百度 OCR 证件类型 + *

+ * ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由: + * + * + * + * + * + *
命名对照
证件小程序传后端 OCR 用
驾驶证{@code driver}{@code driving_license}
行驶证{@code vehicle}{@code vehicle_license}
+ * 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之, + * 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。 + * 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。 + *

+ * 采用白名单而非「透传 + 兜底为通用识别」还有一个原因: + * 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。 + * + * @author Chill + */ +public final class OcrTypeMapping { + + /** + * 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝 + */ + private static final Map TYPE_ALIASES = Map.ofEntries( + // 身份证 + Map.entry("IDCARD", BaiduOcrType.ID_CARD), + Map.entry("ID_CARD", BaiduOcrType.ID_CARD), + // 驾驶证:小程序叫 driver + Map.entry("DRIVER", BaiduOcrType.DRIVING_LICENSE), + Map.entry("DRIVING_LICENSE", BaiduOcrType.DRIVING_LICENSE), + // 行驶证:小程序叫 vehicle + Map.entry("VEHICLE", BaiduOcrType.VEHICLE_LICENSE), + Map.entry("VEHICLE_LICENSE", BaiduOcrType.VEHICLE_LICENSE), + // 道路运输证 + Map.entry("ROAD_TRANSPORT", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE), + Map.entry("ROAD_TRANSPORT_CERTIFICATE", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE) + ); + + /** + * 明确知道「没有对应 OCR 能力」的类型,单独给出可读提示 + */ + private static final Map KNOWN_UNSUPPORTED = Map.of( + "QUALIFICATION", "从业资格证" + ); + + private static final String SIDE_FRONT = "front"; + private static final String SIDE_BACK = "back"; + + private OcrTypeMapping() { + } + + /** + * 把调用方传来的证件类型转成百度 OCR 证件类型 + * + * @param type 证件类型:小程序传 idcard / driver / vehicle / road_transport / qualification, + * 也接受后端既有命名 id_card / driving_license / vehicle_license / road_transport_certificate + * @return 百度 OCR 证件类型 + * @throws IllegalArgumentException 类型为空、未知,或该证件不支持 OCR + */ + public static BaiduOcrType toBaiduType(String type) { + String normalized = normalize(type); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("OCR证件类型不能为空"); + } + BaiduOcrType baiduOcrType = TYPE_ALIASES.get(normalized); + if (baiduOcrType != null) { + return baiduOcrType; + } + String unsupportedName = KNOWN_UNSUPPORTED.get(normalized); + if (unsupportedName != null) { + // 不降级为通用识别:否则前端会拿到一堆通用文本却以为识别成功 + throw new IllegalArgumentException(unsupportedName + "暂不支持自动识别,请手动填写"); + } + throw new IllegalArgumentException("不支持的OCR证件类型:" + type); + } + + /** + * 规范化正反面参数 + *

+ * 小程序目前不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。 + * + * @param side 正反面:front / back + * @return 规范化结果,恒为 front 或 back + */ + public static String normalizeSide(String side) { + // 归一化结果是大写,故此处比对 "BACK" + return "BACK".equals(normalize(side)) ? SIDE_BACK : SIDE_FRONT; + } + + /** + * 归一化:去首尾空白、去分隔符差异、统一大写 + * + * @param value 原始值 + * @return 归一化结果;空值返回空串 + */ + private static String normalize(String value) { + if (value == null) { + return ""; + } + return value.trim() + .replace('-', '_') + .replace(' ', '_') + .toUpperCase(Locale.ROOT); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java new file mode 100644 index 00000000..c88a8057 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java @@ -0,0 +1,105 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 小程序证件类型 → 百度 OCR 证件类型的映射测试。 + *

+ * 两侧用的是两套命名,且**都不太直观**: + * 小程序把「行驶证」叫 {@code vehicle}、把「驾驶证」叫 {@code driver} + * (按业务直觉命名),而后端 OCR 沿用百度接口的 {@code vehicle_license} + * (行驶证)与 {@code driving_license}(驾驶证)。 + * 这两个词一一对应关系相反,映射写错会让「行驶证」被当成「驾驶证」识别, + * 结果是司机传了行驶证却拿到一堆驾驶证字段——而且不会报错。 + * 故此处逐一锁住。 + */ +class OcrTypeMappingTest { + + @Test + @DisplayName("身份证:小程序 idcard → 后端 ID_CARD") + void idCard() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("idcard")); + } + + @Test + @DisplayName("驾驶证:小程序 driver → 后端 DRIVING_LICENSE(不是 vehicle_license)") + void drivingLicense() { + assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driver")); + } + + @Test + @DisplayName("行驶证:小程序 vehicle → 后端 VEHICLE_LICENSE(不是 driving_license)") + void vehicleLicense() { + assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle")); + } + + @Test + @DisplayName("道路运输证:小程序 road_transport → 后端 ROAD_TRANSPORT_CERTIFICATE") + void roadTransport() { + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("road_transport")); + } + + @Test + @DisplayName("从业资格证:后端无对应 OCR 能力,必须明确报错而不是退化成通用识别") + void qualificationIsNotSilentlyDowngraded() { + // 百度没有「从业资格证」专用接口。若这里悄悄退化成 GENERAL, + // 前端会拿到一堆通用文本却以为识别成功了,故要求明确失败。 + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> OcrTypeMapping.toBaiduType("qualification")); + assertTrue(ex.getMessage().contains("从业资格证"), + "错误信息要指明是哪个证件类型不支持,便于前端提示用户手动填写"); + } + + @Test + @DisplayName("后端既有命名也接受(前端或其它调用方可能直接传后端名)") + void acceptsBackendNames() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id_card")); + assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driving_license")); + assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle_license")); + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("road_transport_certificate")); + } + + @Test + @DisplayName("大小写与分隔符不敏感") + void caseAndSeparatorInsensitive() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("IDCard")); + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType(" ID_CARD ")); + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id-card")); + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("Road-Transport")); + } + + @Test + @DisplayName("未知类型:明确报错,不静默降级为通用识别") + void unknownTypeFails() { + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("truck")); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("general")); + } + + @Test + @DisplayName("空值:明确报错") + void emptyTypeFails() { + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(null)); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("")); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(" ")); + } + + @Test + @DisplayName("正反面:小程序不传 side 时按正面处理") + void defaultSide() { + assertEquals("front", OcrTypeMapping.normalizeSide(null)); + assertEquals("front", OcrTypeMapping.normalizeSide("")); + assertEquals("front", OcrTypeMapping.normalizeSide("front")); + assertEquals("back", OcrTypeMapping.normalizeSide("back")); + assertEquals("back", OcrTypeMapping.normalizeSide("BACK")); + } + +}