From b6e2a376e602d686c7cd1d8a27e28522775cc26f Mon Sep 17 00:00:00 2001
From: quyixiao <2621048238@qq.com>
Date: Sat, 6 Jun 2026 00:59:55 +0800
Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=AE=E6=94=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../RecognizeDrivingLicenseController.java | 572 +++++++++++++++++-
.../test/java/com/api/test/ImageToBase64.java | 79 +++
.../api-interface/src/test/resources/aaa.jpg | Bin 0 -> 232085 bytes
3 files changed, 636 insertions(+), 15 deletions(-)
create mode 100644 api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java
create mode 100644 api-web/api-interface/src/test/resources/aaa.jpg
diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java
index 2e2f2f5..e188f0f 100644
--- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java
+++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java
@@ -1,43 +1,585 @@
package com.heyu.api.controller.car;
-import com.heyu.api.baidu.handle.traffic.BVehicleLicenseHandle;
-import com.heyu.api.baidu.request.traffic.BVehicleLicenseRequest;
import com.heyu.api.controller.BaseController;
-import com.heyu.api.controller.ocr.BaiduOcrResult;
import com.heyu.api.data.annotation.EbAuthentication;
import com.heyu.api.data.constants.ApiConstants;
+import com.heyu.api.data.utils.MapUtils;
import com.heyu.api.data.utils.R;
import com.heyu.api.data.utils.StringUtils;
import com.heyu.api.request.car.VehicleLicenseRequest;
-import org.springframework.beans.factory.annotation.Autowired;
+import com.heyu.api.resp.car.RecognizeDrivingLicenseBackResp;
+import com.heyu.api.resp.car.RecognizeDrivingLicenseFaceResp;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 行驶证识别控制器
+ *
+ * 能力说明:对机动车行驶证主页(正页)、副页及电子行驶证进行结构化识别,
+ * 底层对接百度智能云文字识别「行驶证识别」能力,由本服务完成参数校验、请求转发与字段映射。
+ *
+ *
+ * 百度官方文档
+ *
+ * - 产品文档:行驶证识别
+ * - 接口地址:{@code POST https://aip.baidubce.com/rest/2.0/ocr/v1/vehicle_license}
+ *
+ *
+ * 本服务接口
+ *
+ * - 路径:{@code POST /driving/license/recognize}
+ * - Content-Type:{@code application/x-www-form-urlencoded} 或 {@code application/json}({@code @RequestBody})
+ * - 鉴权:{@link EbAuthentication}(Tencent 鉴权头,见项目网关配置)
+ *
+ *
+ * 请求参数({@link VehicleLicenseRequest})
+ *
+ * - imageBase64 / imageUrl:二选一,图片 jpg/jpeg/png/bmp,编码后≤4M
+ * - side:{@code front} 主页(默认),{@code back} 副页;映射百度 {@code vehicle_license_side}
+ *
+ *
+ * 返回约定(重要)
+ *
+ * - 入参校验失败:{@code R.error()},未调用百度、不计费
+ * - 已调用百度识别链路:一律 {@code R.ok()};异常说明写入 {@code msg}(对外不暴露「百度」字样)
+ *
+ *
+ * @author heyu
+ * @since 1.0.0
+ * @see VehicleLicenseRequest
+ * @see RecognizeDrivingLicenseFaceResp
+ * @see RecognizeDrivingLicenseBackResp
+ */
+@Slf4j
@RestController
@RequestMapping("/driving/license")
public class RecognizeDrivingLicenseController extends BaseController {
- @Autowired
- private BVehicleLicenseHandle bVehicleLicenseHandle;
+ /** 百度行驶证识别 API 路径 */
+ private static final String VEHICLE_LICENSE_URI = "/rest/2.0/ocr/v1/vehicle_license";
+ private static final String SIDE_DEFAULT_HINT = "未传(将按正页处理)";
+
+ /**
+ * 行驶证识别
+ *
+ * @param request 行驶证识别请求
+ * @return 正页返回 {@link RecognizeDrivingLicenseFaceResp};副页返回 {@link RecognizeDrivingLicenseBackResp}
+ */
@EbAuthentication(tencent = ApiConstants.TENCENT_AUTH)
@PostMapping("/recognize")
public R recognize(@RequestBody VehicleLicenseRequest request) {
- return BaiduOcrResult.raw(bVehicleLicenseHandle.handle(toBaiduRequest(request)));
+ long start = System.currentTimeMillis();
+ RecognizeContext ctx = null;
+ try {
+ String validateError = validateRequest(request);
+ if (validateError != null) {
+ log.info("行驶证识别:参数检查没通过,直接返回错误(还没调识别、不扣费)。{} 返回给客户:{}",
+ buildInputLogContext(request), abbreviate(validateError, 120));
+ return R.error(validateError);
+ }
+
+ ctx = new RecognizeContext(
+ resolveVehicleLicenseSide(request),
+ ImageMode.of(request),
+ buildInputLogContext(request));
+
+ String content = buildRequestContent(request, ctx.side);
+ if (isBlank(content)) {
+ log.error("行驶证识别:组装请求失败,请求里没带有效图片。识别{},{}。{}",
+ sideDesc(ctx.side), ctx.imageMode.desc, ctx.inputLog);
+ return okResult(ctx.side, formatHint(
+ "报文组装异常",
+ "识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别",
+ "请核对 imageBase64 是否已 urlencode、imageUrl 是否非空字符串",
+ "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageMode.name()
+ ), null);
+ }
+
+ Map platformResult = callPlatform(content, ctx);
+ if (platformResult == null) {
+ return okResult(ctx.side, formatHint(
+ "服务无回执",
+ "识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执",
+ "建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻与 side 并联系技术支持",
+ "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageMode.name()
+ ), null);
+ }
+
+ Object data = ApiConstants.back.equals(ctx.side)
+ ? buildBackResp(platformResult)
+ : buildFaceResp(platformResult);
+ String hint = resolvePlatformHint(platformResult, ctx, data);
+
+ logRecognizeResult(ctx, platformResult, data, hint, start);
+ return okResult(ctx.side, hint, data);
+
+ } catch (Exception e) {
+ long cost = System.currentTimeMillis() - start;
+ String side = ctx != null ? ctx.side
+ : (request != null ? resolveVehicleLicenseSide(request) : ApiConstants.front);
+ String mode = ctx != null ? ctx.imageMode.desc
+ : (request != null ? ImageMode.of(request).desc : "未知");
+ log.error("行驶证识别:程序运行出错,耗时 {} ms。识别{},{}。客户传的:{}。异常:{} - {}",
+ cost, sideDesc(side), mode, buildInputLogContext(request),
+ e.getClass().getSimpleName(),
+ e.getMessage() != null ? e.getMessage() : "无具体说明", e);
+ return okResult(side, formatHint(
+ "运行时故障",
+ "服务端在处理识别流程时抛出未预期异常,识别结果不可用",
+ "请勿重复高频重试;请保存 traceId、异常发生时间及 side,由技术支持结合堆栈进一步定位",
+ "异常类型=" + e.getClass().getSimpleName()
+ + (e.getMessage() != null ? ",摘要=" + e.getMessage() : "")
+ ), null);
+ }
}
- private BVehicleLicenseRequest toBaiduRequest(VehicleLicenseRequest request) {
- BVehicleLicenseRequest bRequest = new BVehicleLicenseRequest();
+ private Map callPlatform(String content, RecognizeContext ctx) {
+ int len = content.length();
+ log.info("行驶证识别:开始调用平台识别。识别{},{},请求大小约 {} 字节。{}",
+ sideDesc(ctx.side), ctx.imageMode.desc, len, ctx.inputLog);
+ Map result = requestBaidu(VEHICLE_LICENSE_URI, content);
+ if (result == null) {
+ log.error("行驶证识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。"
+ + "识别{},{},请求约 {} 字节。{}",
+ sideDesc(ctx.side), ctx.imageMode.desc, len, ctx.inputLog);
+ }
+ return result;
+ }
+
+ private String resolvePlatformHint(Map platformResult, RecognizeContext ctx, Object data) {
+ Object errorCodeObj = platformResult.get("error_code");
+ if (errorCodeObj != null) {
+ String errorCode = String.valueOf(errorCodeObj);
+ Object errorMsgObj = platformResult.get("error_msg");
+ String errorMsg = errorMsgObj != null ? errorMsgObj.toString() : "平台未返回文字描述";
+ String category = PlatformError.categoryOf(errorCode);
+ log.error("行驶证识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。识别{},{}。客户传的:{}。{}",
+ category, errorCode, errorMsg,
+ sideDesc(ctx.side), ctx.imageMode.desc,
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ return formatHint(
+ category,
+ PlatformError.reasonOf(errorCode),
+ PlatformError.suggestionOf(errorCode, sideLabel(ctx.side)),
+ "错误码=" + errorCode + ",错误描述=" + errorMsg + ",目标页面=" + sideLabel(ctx.side)
+ );
+ }
+ if (isWordsResultEmpty(platformResult)) {
+ log.info("行驶证识别:平台返回了,但没有识别结果数据(可能不是行驶证或 side 传错)。识别{},{}。客户传的:{}。{}",
+ sideDesc(ctx.side), ctx.imageMode.desc,
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ return formatHint(
+ "结构化结果缺失",
+ "平台回执未包含可解析的识别结果集合,无法进入字段映射环节",
+ "优先排查:① 上传内容是否为行驶证对应页面;② side 是否与实物一致(正页 front、副页 back);"
+ + "③ 使用 URL 时确保图片链接可公网访问且无防盗链",
+ "目标页面=" + sideLabel(ctx.side) + ",解析状态=结果集为空"
+ );
+ }
+ if (isAllStringFieldsBlank(data)) {
+ log.info("行驶证识别:平台有返回,但号牌、所有人等字段一个都没识别出来(可能传错正/副页或图片不清晰)。"
+ + "识别{},{}。客户传的:{}。平台回执:{}",
+ sideDesc(ctx.side), ctx.imageMode.desc,
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ return formatHint(
+ "字段映射为空",
+ "平台回执已通过结构校验,但号牌号码、所有人、车辆类型等结构化字段均未命中",
+ ApiConstants.back.equals(ctx.side)
+ ? "副页识别请确认 side=back,画面包含检验记录、核定载人数、外廓尺寸等区域"
+ : "正页识别请确认 side=front,画面须包含完整行驶证主页;电子行驶证需保证截图清晰",
+ "目标页面=" + sideLabel(ctx.side) + ",已映射字段数=0"
+ );
+ }
+ return null;
+ }
+
+ private void logRecognizeResult(RecognizeContext ctx, Map platformResult,
+ Object data, String hint, long start) {
+ long cost = System.currentTimeMillis() - start;
+ int mapped = countNonBlankStringFields(data);
+ if (StringUtils.isNotBlank(hint)) {
+ log.info("行驶证识别:处理结束(接口仍返回成功,但带了提示信息)。耗时 {} ms,识别出 {} 个字段。"
+ + "识别{},{}。客户传的:{}。平台回执:{}。给客户的提示:{}",
+ cost, mapped, sideDesc(ctx.side), ctx.imageMode.desc,
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120));
+ } else {
+ log.info("行驶证识别:识别成功。耗时 {} ms,共识别出 {} 个字段。识别{},{}。客户传的:{}。平台回执:{}",
+ cost, mapped, sideDesc(ctx.side), ctx.imageMode.desc,
+ ctx.inputLog, buildPlatformReceiptSummary(platformResult));
+ }
+ }
+
+ private String validateRequest(VehicleLicenseRequest request) {
if (request == null) {
- return bRequest;
+ log.info("行驶证识别:没收到任何请求参数,直接拒绝,未调用识别、不扣费");
+ return formatHint(
+ "入参绑定失败",
+ "控制器未接收到可绑定的请求对象,所有业务字段均为空",
+ "请确认使用 POST 提交;字段名与接口文档一致(imageBase64 / imageUrl / side)",
+ "绑定结果=VehicleLicenseRequest 为 null"
+ );
}
- bRequest.setImageBase64(request.getImageBase64());
- bRequest.setImageUrl(request.getImageUrl());
- if (StringUtils.isNotBlank(request.getSide())) {
- bRequest.setVehicleLicenseSide(request.getSide());
+ if (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl())) {
+ log.info("行驶证识别:没传图片(imageBase64 和 imageUrl 都为空),直接拒绝,未调用识别、不扣费。{}",
+ buildInputLogContext(request));
+ return formatHint(
+ "影像源缺失",
+ "imageBase64 与 imageUrl 均未提供,识别引擎没有可处理的图像输入",
+ "任选其一:① imageBase64 传 jpg/jpeg/png/bmp 的 base64(urlencode 后≤4M);"
+ + "② imageUrl 传可公网直连的地址(≤1024 字符,关闭防盗链)",
+ "side=" + (isBlank(request.getSide()) ? SIDE_DEFAULT_HINT : request.getSide().trim())
+ );
+ }
+ if (StringUtils.isNotBlank(request.getImageBase64()) && StringUtils.isNotBlank(request.getImageUrl())) {
+ log.info("行驶证识别:客户同时传了 Base64 和图片链接,按规则只用 Base64,忽略链接。{}",
+ buildInputLogContext(request));
+ }
+ if (resolveVehicleLicenseSide(request) == null) {
+ log.info("行驶证识别:side 参数写错了(只支持 front 正页、back 副页),直接拒绝,未调用识别、不扣费。{}",
+ buildInputLogContext(request));
+ return formatHint(
+ "页面标识无效",
+ "参数 side 的取值无法映射到行驶证主页或副页识别模式",
+ "行驶证主页(含电子行驶证主页)请传 front;副页请传 back",
+ "当前 side=" + request.getSide() + ",合法取值=front|back"
+ );
+ }
+ return null;
+ }
+
+ private String resolveVehicleLicenseSide(VehicleLicenseRequest request) {
+ if (request == null || isBlank(request.getSide())) {
+ return ApiConstants.front;
+ }
+ String side = request.getSide().trim();
+ if (ApiConstants.front.equals(side) || ApiConstants.face.equals(side)) {
+ return ApiConstants.front;
+ }
+ if (ApiConstants.back.equals(side)) {
+ return ApiConstants.back;
+ }
+ return null;
+ }
+
+ private String sideLabel(String side) {
+ return ApiConstants.back.equals(side) ? "副页(back)" : "正页(front)";
+ }
+
+ private String sideDesc(String side) {
+ return ApiConstants.back.equals(side) ? "行驶证副页" : "行驶证正页";
+ }
+
+ private String buildRequestContent(VehicleLicenseRequest request, String vehicleLicenseSide) {
+ StringBuilder sb = new StringBuilder();
+ if (StringUtils.isNotBlank(request.getImageBase64())) {
+ sb.append("&image=").append(request.getImageBase64());
+ } else if (StringUtils.isNotBlank(request.getImageUrl())) {
+ sb.append("&url=").append(request.getImageUrl());
+ }
+ sb.append("&detect_direction=false");
+ sb.append("&vehicle_license_side=").append(vehicleLicenseSide);
+ sb.append("&unified=false");
+ sb.append("&quality_warn=false");
+ sb.append("&risk_warn=false");
+ return sb.toString();
+ }
+
+ private R okResult(String vehicleLicenseSide, String hint, Object data) {
+ if (data == null) {
+ data = ApiConstants.back.equals(vehicleLicenseSide)
+ ? new RecognizeDrivingLicenseBackResp()
+ : new RecognizeDrivingLicenseFaceResp();
+ }
+ R result = StringUtils.isNotBlank(hint) ? R.ok(hint) : R.ok();
+ return result.setData(data);
+ }
+
+ private RecognizeDrivingLicenseFaceResp buildFaceResp(Map data) {
+ RecognizeDrivingLicenseFaceResp resp = new RecognizeDrivingLicenseFaceResp();
+ resp.setPlateNumber(getWords(data, "号牌号码"));
+ resp.setVehicleType(getWords(data, "车辆类型"));
+ resp.setOwner(getWords(data, "所有人"));
+ resp.setAddress(getWords(data, "住址"));
+ resp.setUseCharacter(getWords(data, "使用性质"));
+ resp.setModel(getWords(data, "品牌型号"));
+ resp.setVin(getWords(data, "车辆识别代号"));
+ resp.setEngineNumber(getWords(data, "发动机号码"));
+ resp.setRegisterDate(firstNonBlank(getWords(data, "注册日期"), getWords(data, "注册登记日期")));
+ resp.setIssueDate(getWords(data, "发证日期"));
+ resp.setIssueAuthority(firstNonBlank(getWords(data, "发证单位"), getWords(data, "发证机关")));
+ return resp;
+ }
+
+ private RecognizeDrivingLicenseBackResp buildBackResp(Map data) {
+ RecognizeDrivingLicenseBackResp resp = new RecognizeDrivingLicenseBackResp();
+ resp.setPlateNumber(getWords(data, "号牌号码"));
+ resp.setFileNumber(getWords(data, "档案编号"));
+ resp.setApprovedPassengerCapacity(getWords(data, "核定载人数"));
+ resp.setGrossMass(getWords(data, "总质量"));
+ resp.setUnladenMass(getWords(data, "整备质量"));
+ resp.setApprovedLoad(getWords(data, "核定载质量"));
+ resp.setOverallDimension(getWords(data, "外廓尺寸"));
+ resp.setTractionMass(getWords(data, "准牵引总质量"));
+ resp.setInspectionRecord(getWords(data, "检验记录"));
+ resp.setEnergyType(firstNonBlank(getWords(data, "能源种类"), getWords(data, "燃油类型")));
+ resp.setRemark(getWords(data, "备注"));
+ resp.setCertificateNumber(getWords(data, "证芯编号"));
+ return resp;
+ }
+
+ private String getWords(Map data, String field) {
+ return MapUtils.getByExpr(data, "words_result." + field + ".words");
+ }
+
+ private String firstNonBlank(String first, String second) {
+ return StringUtils.isNotBlank(first) ? first : second;
+ }
+
+ @SuppressWarnings("unchecked")
+ private boolean isWordsResultEmpty(Map platformResult) {
+ Object wordsResult = platformResult.get("words_result");
+ if (wordsResult == null) {
+ return true;
+ }
+ if (wordsResult instanceof Map) {
+ return ((Map) wordsResult).isEmpty();
+ }
+ return false;
+ }
+
+ private static int countNonBlankStringFields(Object data) {
+ if (data == null) {
+ return 0;
+ }
+ int count = 0;
+ for (Field f : data.getClass().getDeclaredFields()) {
+ if (!String.class.equals(f.getType())) {
+ continue;
+ }
+ try {
+ f.setAccessible(true);
+ Object v = f.get(data);
+ if (v instanceof String && StringUtils.isNotBlank((String) v)) {
+ count++;
+ }
+ } catch (IllegalAccessException ignored) {
+ // ignore
+ }
+ }
+ return count;
+ }
+
+ private static boolean isAllStringFieldsBlank(Object data) {
+ return countNonBlankStringFields(data) == 0;
+ }
+
+ private String formatHint(String category, String reason, String suggestion, String detail) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("【行驶证识别·").append(category).append("】").append(reason);
+ if (StringUtils.isNotBlank(detail)) {
+ sb.append("|定位信息:").append(detail);
+ }
+ sb.append("|处置指引:").append(suggestion);
+ return sb.toString();
+ }
+
+ private String abbreviate(String text, int maxLen) {
+ if (text == null || text.length() <= maxLen) {
+ return text;
+ }
+ return text.substring(0, maxLen) + "...";
+ }
+
+ private String buildInputLogContext(VehicleLicenseRequest request) {
+ if (request == null) {
+ return "未收到请求体";
+ }
+ String sideParam = isBlank(request.getSide()) ? "未传(默认按正页)" : request.getSide().trim();
+ String resolvedSide = resolveVehicleLicenseSide(request);
+ String targetPage = resolvedSide == null ? "side 无法识别" : sideDesc(resolvedSide);
+ ImageMode mode = ImageMode.of(request);
+ return String.format("识别%s,side 参数=%s,%s,Base64 长度 %d 字符,图片链接长度 %d 字符",
+ targetPage, sideParam, mode.desc,
+ textLength(request.getImageBase64()), textLength(request.getImageUrl()));
+ }
+
+ private String buildPlatformReceiptSummary(Map platformResult) {
+ if (platformResult == null) {
+ return "平台无任何回执";
+ }
+ Object errorCode = platformResult.get("error_code");
+ Object errorMsg = platformResult.get("error_msg");
+ Object logId = platformResult.get("log_id");
+ if (errorCode != null) {
+ return String.format("平台返回失败,错误码 %s,说明:%s,流水号 %s",
+ errorCode,
+ errorMsg != null ? errorMsg : "无",
+ logId != null ? logId : "无");
+ }
+ Object wordsResultNum = platformResult.get("words_result_num");
+ Object wordsResult = platformResult.get("words_result");
+ int fieldCount = wordsResult instanceof Map ? ((Map, ?>) wordsResult).size() : 0;
+ return String.format("平台返回成功,识别结果约 %s 项、结构化字段 %d 个,流水号 %s",
+ wordsResultNum != null ? wordsResultNum : "未知",
+ fieldCount,
+ logId != null ? logId : "无");
+ }
+
+ private int textLength(String value) {
+ return value == null ? 0 : value.length();
+ }
+
+ private static class RecognizeContext {
+ final String side;
+ final ImageMode imageMode;
+ final String inputLog;
+
+ RecognizeContext(String side, ImageMode imageMode, String inputLog) {
+ this.side = side;
+ this.imageMode = imageMode;
+ this.inputLog = inputLog;
+ }
+ }
+
+ private enum ImageMode {
+ BASE64("使用 Base64 编码图片"),
+ URL("使用图片链接"),
+ BASE64_WITH_URL_IGNORED("同时传了 Base64 和链接,实际使用 Base64"),
+ NONE("未传图片"),
+ UNKNOWN("图片提交方式未知");
+
+ final String desc;
+
+ ImageMode(String desc) {
+ this.desc = desc;
+ }
+
+ static ImageMode of(VehicleLicenseRequest req) {
+ if (req == null) {
+ return UNKNOWN;
+ }
+ boolean hasBase64 = StringUtils.isNotBlank(req.getImageBase64());
+ boolean hasUrl = StringUtils.isNotBlank(req.getImageUrl());
+ if (hasBase64 && hasUrl) {
+ return BASE64_WITH_URL_IGNORED;
+ }
+ if (hasBase64) {
+ return BASE64;
+ }
+ if (hasUrl) {
+ return URL;
+ }
+ return NONE;
+ }
+ }
+
+ private enum PlatformError {
+ QPS_DAILY_LIMIT("17", "日配额耗尽",
+ "本接口当日累计调用次数已触达平台日配额上限,本次识别请求被拒绝",
+ "请安排次日再试,或联系运营提升日调用配额"),
+ QPS_RATE_LIMIT("18", "并发限流",
+ "单位时间请求过于密集,已触发平台 QPS 限流,请拉长调用间隔后重试",
+ "请在客户端增加限流/退避策略,避免瞬时并发过高"),
+ QPS_TOTAL_EXHAUSTED("19", "总配额耗尽",
+ "平台侧总调用配额已耗尽,需运营侧扩容或次日再试",
+ "请联系运营确认平台总配额与续费状态"),
+ PARAM_FORMAT("100", "平台业务拒绝",
+ "提交的表单字段组合不符合行驶证识别接口契约,请对照文档核对字段名与取值",
+ null),
+ AUTH_INVALID("110", "鉴权失效",
+ "平台鉴权凭证校验未通过,属服务端配置异常,需运维刷新令牌",
+ "属服务端鉴权配置问题,需技术支持检查平台密钥与令牌缓存"),
+ AUTH_EXPIRED("111", "鉴权失效",
+ "平台鉴权凭证已超过有效期,属服务端配置异常,需运维重新签发",
+ "属服务端令牌过期,需技术支持重新获取并刷新缓存"),
+ PARAM_ILLEGAL("216100", "影像不合规", "存在非法或与接口不匹配的参数项", null),
+ IMAGE_MISSING("216101", "影像不合规", "影像入参缺失:未提供可用的 image 或 url 字段", null),
+ PARAM_TOO_LONG("216103", "影像不合规", "单个参数字段长度超限(常见于 url 过长或 base64 体积过大)", null),
+ APP_NOT_ENABLED("216110", "影像不合规",
+ "当前应用未开通行驶证识别能力或授权范围不包含本接口", null),
+ IMAGE_EMPTY("216200", "影像不合规",
+ "解码后的图片数据为空,上传环节可能未完成或内容损坏",
+ "请重新上传图片,确认 base64 未截断、未混入换行或非法字符"),
+ IMAGE_FORMAT("216201", "影像不合规",
+ "图片编码格式不在允许列表,仅支持 jpg、jpeg、png、bmp",
+ "请将图片转为 jpg/jpeg/png/bmp 之一后重新编码上传"),
+ IMAGE_SIZE("216202", "影像不合规",
+ "像素尺寸或编码后体积不满足规范(边长 15~4096px,编码后≤4M)",
+ "请压缩或裁剪图片,保证最长边≤4096px、最短边≥15px,且 urlencode 后≤4M"),
+ IMAGE_BLUR("216203", "影像不合规",
+ "引擎无法从当前画面中稳定提取行驶证文本,多见于模糊、过曝或非证件照",
+ null),
+ LAYOUT_FAIL("216630", "影像不合规",
+ "证件版面识别失败,建议重拍并保证四角完整、无遮挡",
+ "请平铺证件拍摄,避免手指遮挡关键字段;副页识别务必传 side=back"),
+ ENGINE_INTERNAL("282000", "引擎内部错误",
+ "平台识别引擎内部异常,属短暂性故障,可间隔数秒后重试",
+ "短暂性故障,建议 5~10 秒后单次重试,不宜连续轰炸接口");
+
+ private static final String IMAGE_PREFIX = "2162";
+ private static final String DEFAULT_CATEGORY = "平台业务拒绝";
+ private static final String IMAGE_CATEGORY = "影像不合规";
+ private static final String DEFAULT_REASON = "平台返回了未在本地维护的错误码,需结合错误描述人工判读";
+ private static final String DEFAULT_SUGGESTION =
+ "请依据错误描述调整入参或影像后重试;持续失败请附带 traceId 联系技术支持";
+
+ private static final Map BY_CODE;
+
+ static {
+ Map m = new HashMap<>();
+ for (PlatformError e : EnumSet.allOf(PlatformError.class)) {
+ m.put(e.code, e);
+ }
+ BY_CODE = Collections.unmodifiableMap(m);
+ }
+
+ final String code;
+ final String category;
+ final String reason;
+ final String suggestion;
+
+ PlatformError(String code, String category, String reason, String suggestion) {
+ this.code = code;
+ this.category = category;
+ this.reason = reason;
+ this.suggestion = suggestion;
+ }
+
+ static String categoryOf(String code) {
+ PlatformError e = BY_CODE.get(code);
+ if (e != null) {
+ return e.category;
+ }
+ if (code != null && code.startsWith(IMAGE_PREFIX)) {
+ return IMAGE_CATEGORY;
+ }
+ return DEFAULT_CATEGORY;
+ }
+
+ static String reasonOf(String code) {
+ PlatformError e = BY_CODE.get(code);
+ return e != null ? e.reason : DEFAULT_REASON;
+ }
+
+ static String suggestionOf(String code, String sideLabel) {
+ PlatformError e = BY_CODE.get(code);
+ if (e == IMAGE_BLUR) {
+ return "请在自然光下重拍,确保证面占画面主体且文字可辨;side=" + sideLabel;
+ }
+ if (e != null && e.suggestion != null) {
+ return e.suggestion;
+ }
+ return DEFAULT_SUGGESTION;
}
- return bRequest;
}
}
diff --git a/api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java b/api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java
new file mode 100644
index 0000000..049746c
--- /dev/null
+++ b/api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java
@@ -0,0 +1,79 @@
+package com.api.test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Base64;
+
+/**
+ * 图片转 Base64 工具(测试用)。
+ *
+ * 用于本地构造驾驶证识别等接口需要的 imageBase64 入参。
+ * 支持本地文件路径与 HTTP(S) 远程 URL 两种来源。
+ */
+public class ImageToBase64 {
+
+ /** 读 URL 时的连接超时(毫秒) */
+ private static final int CONNECT_TIMEOUT_MS = 5_000;
+ /** 读 URL 时的读取超时(毫秒) */
+ private static final int READ_TIMEOUT_MS = 15_000;
+
+ /**
+ * 将图片转为 Base64 字符串(不带换行、不带 data: 前缀)。
+ *
+ * @param source 本地文件绝对/相对路径,或 http(s):// 开头的远程地址
+ * @return Base64 字符串
+ */
+ public static String toBase64(String source) throws IOException {
+ if (source == null || source.trim().isEmpty()) {
+ throw new IllegalArgumentException("source 不能为空");
+ }
+ byte[] bytes = source.startsWith("http://") || source.startsWith("https://")
+ ? readFromUrl(source)
+ : readFromFile(source);
+ return Base64.getEncoder().encodeToString(bytes);
+ }
+
+ private static byte[] readFromFile(String path) throws IOException {
+ Path p = Paths.get(path);
+ if (!Files.exists(p)) {
+ throw new IOException("文件不存在: " + path);
+ }
+ return Files.readAllBytes(p);
+ }
+
+ private static byte[] readFromUrl(String url) throws IOException {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
+ conn.setReadTimeout(READ_TIMEOUT_MS);
+ conn.setRequestMethod("GET");
+ try (InputStream in = conn.getInputStream()) {
+ int status = conn.getResponseCode();
+ if (status / 100 != 2) {
+ throw new IOException("下载图片失败,HTTP " + status + ",url=" + url);
+ }
+ byte[] buf = new byte[8192];
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ int n;
+ while ((n = in.read(buf)) > 0) {
+ out.write(buf, 0, n);
+ }
+ return out.toByteArray();
+ } finally {
+ conn.disconnect();
+ }
+ }
+
+ public static void main(String[] args) throws IOException {
+ String source = args.length > 0
+ ? args[0]
+ : "/Users/quyixiao/gita/eb-service-api/api-web/api-interface/src/test/resources/aaa.jpg";
+ String base64 = toBase64(source);
+ System.out.println("长度: " + base64.length());
+ System.out.println(base64);
+ }
+}
diff --git a/api-web/api-interface/src/test/resources/aaa.jpg b/api-web/api-interface/src/test/resources/aaa.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..f7b18009d5ee293ef5d2b01bca4d32f4da780337
GIT binary patch
literal 232085
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