✨ 小程序证件 OCR 薄适配层,含类型映射

小程序按既有契约 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
This commit is contained in:
2026-10-02 16:31:42 +08:00
parent 5747975152
commit 2edbacf838
4 changed files with 317 additions and 0 deletions
@@ -0,0 +1,53 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
}
@@ -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<BaiduOcrResultVO> 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()));
}
}
@@ -0,0 +1,139 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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 证件类型
* <p>
* ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由:
* <table border="1">
* <caption>命名对照</caption>
* <tr><th>证件</th><th>小程序传</th><th>后端 OCR 用</th></tr>
* <tr><td>驾驶证</td><td>{@code driver}</td><td>{@code driving_license}</td></tr>
* <tr><td>行驶证</td><td>{@code vehicle}</td><td>{@code vehicle_license}</td></tr>
* </table>
* 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之,
* 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。
* 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。
* <p>
* 采用白名单而非「透传 + 兜底为通用识别」还有一个原因:
* 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。
*
* @author Chill
*/
public final class OcrTypeMapping {
/**
* 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝
*/
private static final Map<String, BaiduOcrType> 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<String, String> 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);
}
/**
* 规范化正反面参数
* <p>
* 小程序目前不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。
*
* @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);
}
}
@@ -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 证件类型的映射测试。
* <p>
* 两侧用的是两套命名,且**都不太直观**:
* 小程序把「行驶证」叫 {@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"));
}
}