From 4dd5a1cb186b595ff4dddfa7b0b07f5a901cf407 Mon Sep 17 00:00:00 2001 From: quyixiao <2621048238@qq.com> Date: Sat, 6 Jun 2026 01:18:24 +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 --- .../heyu/api/data/utils/ImageInputUtils.java | 195 ++++++++++++++++++ .../car/RecognizeDriverLicenseController.java | 142 ++++++------- .../car/DriverLicenseRecognizeRequest.java | 18 +- 3 files changed, 267 insertions(+), 88 deletions(-) create mode 100644 api-mapper/src/main/java/com/heyu/api/data/utils/ImageInputUtils.java diff --git a/api-mapper/src/main/java/com/heyu/api/data/utils/ImageInputUtils.java b/api-mapper/src/main/java/com/heyu/api/data/utils/ImageInputUtils.java new file mode 100644 index 0000000..a453c13 --- /dev/null +++ b/api-mapper/src/main/java/com/heyu/api/data/utils/ImageInputUtils.java @@ -0,0 +1,195 @@ +package com.heyu.api.data.utils; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.regex.Pattern; + +/** + * 影像入参解析工具:自动识别 Base64 与 HTTP(S) 图片链接,并输出可供表单提交使用的编码值。 + */ +public final class ImageInputUtils { + + private static final Pattern PERCENT_ENCODED = Pattern.compile("%[0-9A-Fa-f]{2}"); + + private ImageInputUtils() { + } + + /** + * 影像入参类型 + */ + public enum ImageInputType { + /** Base64 编码图片(可带 data:image 前缀) */ + BASE64("使用 Base64 编码图片"), + /** HTTP/HTTPS 图片链接 */ + URL("使用图片链接"); + + private final String desc; + + ImageInputType(String desc) { + this.desc = desc; + } + + public String getDesc() { + return desc; + } + } + + /** + * 解析后的影像入参 + */ + public static final class ResolvedImageInput { + private final ImageInputType type; + /** 去除 data: 前缀、URL 解码后的原始值(日志用,不含表单编码) */ + private final String rawValue; + /** 可直接拼接到 application/x-www-form-urlencoded 请求体的值 */ + private final String formValue; + + ResolvedImageInput(ImageInputType type, String rawValue, String formValue) { + this.type = type; + this.rawValue = rawValue; + this.formValue = formValue; + } + + public ImageInputType getType() { + return type; + } + + public String getRawValue() { + return rawValue; + } + + public String getFormValue() { + return formValue; + } + } + + /** + * 判断字符串是 Base64 影像还是 HTTP(S) 图片链接。 + *

规则:以 {@code http://} 或 {@code https://} 开头(含已 urlencode 的形式)视为链接,否则视为 Base64。

+ * + * @param input 用户传入的影像字符串 + * @return 入参类型;空串返回 {@code null} + */ + public static ImageInputType detectType(String input) { + if (StringUtils.isBlank(input)) { + return null; + } + String trimmed = input.trim(); + if (trimmed.regionMatches(true, 0, "data:", 0, 5)) { + return ImageInputType.BASE64; + } + if (isHttpUrl(trimmed) || isHttpUrl(tryUrlDecode(trimmed))) { + return ImageInputType.URL; + } + return ImageInputType.BASE64; + } + + /** + * 解析影像入参:识别类型、剥离 data: 前缀,并在未 urlencode 时自动编码为表单可用值。 + * + * @param input 用户传入的影像字符串 + * @return 解析结果;入参为空时返回 {@code null} + */ + public static ResolvedImageInput resolve(String input) { + if (StringUtils.isBlank(input)) { + return null; + } + String trimmed = input.trim(); + ImageInputType type = detectType(trimmed); + if (type == ImageInputType.URL) { + String rawUrl = normalizeHttpUrl(trimmed); + return new ResolvedImageInput(type, rawUrl, encodeForFormBody(trimmed, rawUrl)); + } + String rawBase64 = stripDataUriPrefix(trimmed); + return new ResolvedImageInput(type, rawBase64, encodeForFormBody(trimmed, rawBase64)); + } + + private static String normalizeHttpUrl(String input) { + if (isHttpUrl(input)) { + return input; + } + String decoded = tryUrlDecode(input); + return isHttpUrl(decoded) ? decoded : input; + } + + private static boolean isHttpUrl(String value) { + if (StringUtils.isBlank(value)) { + return false; + } + return value.regionMatches(true, 0, "http://", 0, 7) + || value.regionMatches(true, 0, "https://", 0, 8); + } + + private static String stripDataUriPrefix(String input) { + if (!input.regionMatches(true, 0, "data:", 0, 5)) { + return input; + } + int base64Idx = input.indexOf("base64,"); + if (base64Idx >= 0) { + return input.substring(base64Idx + "base64,".length()); + } + return input; + } + + /** + * 若入参尚未做表单 urlencode,则进行编码;已编码则原样返回。 + */ + private static String encodeForFormBody(String original, String rawValue) { + if (appearsFormUrlEncoded(original, rawValue)) { + return original.trim(); + } + try { + return URLEncoder.encode(rawValue, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + return rawValue; + } + } + + /** + * 判断是否已做过表单 urlencode(避免二次编码)。 + */ + private static boolean appearsFormUrlEncoded(String original, String rawValue) { + String trimmed = original.trim(); + if (!PERCENT_ENCODED.matcher(trimmed).find()) { + return false; + } + if (trimmed.equals(rawValue)) { + return false; + } + try { + String decoded = URLDecoder.decode(trimmed, StandardCharsets.UTF_8.name()); + return decoded.equals(rawValue); + } catch (IllegalArgumentException | UnsupportedEncodingException ex) { + return false; + } + } + + private static String tryUrlDecode(String value) { + if (StringUtils.isBlank(value) || !PERCENT_ENCODED.matcher(value).find()) { + return value; + } + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (IllegalArgumentException | UnsupportedEncodingException ex) { + return value; + } + } + + /** + * 粗略校验 Base64 字符串是否可解码(仅用于可选校验,非强制)。 + */ + public static boolean isValidBase64(String base64) { + if (StringUtils.isBlank(base64)) { + return false; + } + try { + Base64.getDecoder().decode(base64); + return true; + } catch (IllegalArgumentException ex) { + return false; + } + } +} diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicenseController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicenseController.java index 366c6bb..59e7d9a 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicenseController.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDriverLicenseController.java @@ -3,6 +3,8 @@ package com.heyu.api.controller.car; import com.heyu.api.controller.BaseController; import com.heyu.api.data.annotation.EbAuthentication; import com.heyu.api.data.constants.ApiConstants; +import com.heyu.api.data.utils.ImageInputUtils; +import com.heyu.api.data.utils.ImageInputUtils.ResolvedImageInput; import com.heyu.api.data.utils.MapUtils; import com.heyu.api.data.utils.R; import com.heyu.api.data.utils.StringUtils; @@ -81,27 +83,29 @@ public class RecognizeDriverLicenseController extends BaseController { String validateError = validateRequest(request); if (validateError != null) { log.info("驾驶证识别:参数检查没通过,直接返回错误(还没调识别、不扣费)。{} 返回给客户:{}", - buildInputLogContext(request), abbreviate(validateError, 120)); + buildInputLogContext(request, ImageInputUtils.resolve(request.getImageUrlOrBase64())), + abbreviate(validateError, 120)); return R.error(validateError); } - // ---------- 步骤②:构建上下文(side、图片模式、入参摘要) ---------- + // ---------- 步骤②:解析影像入参 & 构建上下文 ---------- + ResolvedImageInput imageInput = ImageInputUtils.resolve(request.getImageUrlOrBase64()); ctx = new RecognizeContext( resolveDrivingLicenseSide(request), - ImageMode.of(request), - buildInputLogContext(request)); + imageInput, + buildInputLogContext(request, imageInput)); // ---------- 步骤③:组装百度 API 请求体 ---------- - String content = buildRequestContent(request, ctx.side); + String content = buildRequestContent(imageInput, ctx.side); if (isBlank(content)) { log.error("驾驶证识别:组装请求失败,请求里没带有效图片。识别{},{}。{}", - sideDesc(ctx.side), ctx.imageMode.desc, ctx.inputLog); + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), ctx.inputLog); return okResult(ctx.side, formatHint( "报文组装异常", "识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别", - "请核对 imageBase64 是否已 urlencode、imageUrl 是否非空字符串;" + "请核对 imageUrlOrBase64 是否非空且为有效 Base64 或 HTTP(S) 链接;" + "请求头须为 application/x-www-form-urlencoded", - "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageMode.name() + "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageInput.getType().name() ), null); } @@ -112,7 +116,7 @@ public class RecognizeDriverLicenseController extends BaseController { "服务无回执", "识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执", "建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻与 side 并联系技术支持排查链路", - "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageMode.name() + "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageInput.getType().name() ), null); } @@ -130,10 +134,12 @@ public class RecognizeDriverLicenseController extends BaseController { long cost = System.currentTimeMillis() - start; String side = ctx != null ? ctx.side : (request != null ? resolveDrivingLicenseSide(request) : ApiConstants.front); - String mode = ctx != null ? ctx.imageMode.desc - : (request != null ? ImageMode.of(request).desc : "未知"); + ResolvedImageInput fallbackImage = request != null + ? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null; + String mode = ctx != null ? ctx.imageInput.getType().getDesc() + : (fallbackImage != null ? fallbackImage.getType().getDesc() : "未知"); log.error("驾驶证识别:程序运行出错,耗时 {} ms。识别{},{}。客户传的:{}。异常:{} - {}", - cost, sideDesc(side), mode, buildInputLogContext(request), + cost, sideDesc(side), mode, buildInputLogContext(request, fallbackImage), e.getClass().getSimpleName(), e.getMessage() != null ? e.getMessage() : "无具体说明", e); return okResult(side, formatHint( @@ -159,12 +165,12 @@ public class RecognizeDriverLicenseController extends BaseController { private Map callPlatform(String content, RecognizeContext ctx) { int len = content.length(); log.info("驾驶证识别:开始调用平台识别。识别{},{},请求大小约 {} 字节。{}", - sideDesc(ctx.side), ctx.imageMode.desc, len, ctx.inputLog); + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), len, ctx.inputLog); Map result = requestBaidu(DRIVING_LICENSE_URI, content); if (result == null) { log.error("驾驶证识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。" + "识别{},{},请求约 {} 字节。{}", - sideDesc(ctx.side), ctx.imageMode.desc, len, ctx.inputLog); + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), len, ctx.inputLog); } return result; } @@ -193,7 +199,7 @@ public class RecognizeDriverLicenseController extends BaseController { String category = PlatformError.categoryOf(errorCode); log.error("驾驶证识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。识别{},{}。客户传的:{}。{}", category, errorCode, errorMsg, - sideDesc(ctx.side), ctx.imageMode.desc, + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), ctx.inputLog, buildPlatformReceiptSummary(platformResult)); return formatHint( category, @@ -204,7 +210,7 @@ public class RecognizeDriverLicenseController extends BaseController { } if (isWordsResultEmpty(platformResult)) { log.info("驾驶证识别:平台返回了,但没有识别结果数据(可能不是驾驶证或 side 传错)。识别{},{}。客户传的:{}。{}", - sideDesc(ctx.side), ctx.imageMode.desc, + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), ctx.inputLog, buildPlatformReceiptSummary(platformResult)); return formatHint( "结构化结果缺失", @@ -217,7 +223,7 @@ public class RecognizeDriverLicenseController extends BaseController { if (isAllStringFieldsBlank(data)) { log.info("驾驶证识别:平台有返回,但证号、姓名等字段一个都没识别出来(可能传错正/副页或图片不清晰)。" + "识别{},{}。客户传的:{}。平台回执:{}", - sideDesc(ctx.side), ctx.imageMode.desc, + sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), ctx.inputLog, buildPlatformReceiptSummary(platformResult)); return formatHint( "字段映射为空", @@ -248,11 +254,11 @@ public class RecognizeDriverLicenseController extends BaseController { if (StringUtils.isNotBlank(hint)) { log.info("驾驶证识别:处理结束(接口仍返回成功,但带了提示信息)。耗时 {} ms,识别出 {} 个字段。" + "识别{},{}。客户传的:{}。平台回执:{}。给客户的提示:{}", - cost, mapped, sideDesc(ctx.side), ctx.imageMode.desc, + cost, mapped, sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120)); } else { log.info("驾驶证识别:识别成功。耗时 {} ms,共识别出 {} 个字段。识别{},{}。客户传的:{}。平台回执:{}", - cost, mapped, sideDesc(ctx.side), ctx.imageMode.desc, + cost, mapped, sideDesc(ctx.side), ctx.imageInput.getType().getDesc(), ctx.inputLog, buildPlatformReceiptSummary(platformResult)); } } @@ -265,29 +271,35 @@ public class RecognizeDriverLicenseController extends BaseController { return formatHint( "入参绑定失败", "控制器未接收到可绑定的表单对象,所有业务字段均为空", - "请确认使用 POST 提交;Content-Type 为 application/x-www-form-urlencoded;" - + "字段名与接口文档一致(imageBase64 / imageUrl / side)", + "请确认使用 POST 提交;Content-Type 为 application/json 或 application/x-www-form-urlencoded;" + + "字段名与接口文档一致(imageUrlOrBase64 / side)", "绑定结果=DriverLicenseRecognizeRequest 为 null" ); } - if (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl())) { - log.info("驾驶证识别:没传图片(imageBase64 和 imageUrl 都为空),直接拒绝,未调用识别、不扣费。{}", - buildInputLogContext(request)); + if (isBlank(request.getImageUrlOrBase64())) { + log.info("驾驶证识别:没传图片(imageUrlOrBase64 为空),直接拒绝,未调用识别、不扣费。{}", + buildInputLogContext(request, null)); return formatHint( "影像源缺失", - "imageBase64 与 imageUrl 均未提供,识别引擎没有可处理的图像输入", - "任选其一:① imageBase64 传 jpg/jpeg/png/bmp 的 base64(urlencode 后≤4M);" - + "② imageUrl 传可公网直连的 HTTPS/HTTP 地址(≤1024 字符,关闭防盗链)", + "imageUrlOrBase64 未提供,识别引擎没有可处理的图像输入", + "请传 HTTP/HTTPS 图片链接,或 jpg/jpeg/png/bmp 的 Base64 字符串(可带 data:image 前缀);" + + "未 urlencode 时由服务端自动编码", "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 (ImageInputUtils.resolve(request.getImageUrlOrBase64()) == null) { + log.info("驾驶证识别:imageUrlOrBase64 无法解析为有效影像入参,直接拒绝,未调用识别、不扣费。{}", + buildInputLogContext(request, null)); + return formatHint( + "影像入参无效", + "imageUrlOrBase64 内容为空或无法识别为 Base64 / HTTP(S) 链接", + "链接须以 http:// 或 https:// 开头;Base64 须为有效编码字符串", + "imageUrlOrBase64 长度=" + textLength(request.getImageUrlOrBase64()) + ); } if (resolveDrivingLicenseSide(request) == null) { log.info("驾驶证识别:side 参数写错了(只支持 face/front 正页、back 副页),直接拒绝,未调用识别、不扣费。{}", - buildInputLogContext(request)); + buildInputLogContext(request, ImageInputUtils.resolve(request.getImageUrlOrBase64()))); return formatHint( "页面标识无效", "参数 side 的取值无法映射到驾驶证正页或副页识别模式", @@ -350,20 +362,23 @@ public class RecognizeDriverLicenseController extends BaseController { /** * 构造调用百度驾驶证的 POST 请求体(application/x-www-form-urlencoded 格式)。 * * - * @param request 客户入参 + * @param imageInput 解析后的影像入参 * @param drivingLicenseSide 标准化后的 side(front / back) - * @return 请求体字符串;若既无 Base64 也无 URL 则返回空字符串 + * @return 请求体字符串;imageInput 为空时返回空字符串 */ - private String buildRequestContent(DriverLicenseRecognizeRequest request, String drivingLicenseSide) { + private String buildRequestContent(ResolvedImageInput imageInput, String drivingLicenseSide) { 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()); + if (imageInput == null || StringUtils.isBlank(imageInput.getFormValue())) { + return sb.toString(); + } + if (ImageInputUtils.ImageInputType.URL == imageInput.getType()) { + sb.append("&url=").append(imageInput.getFormValue()); + } else { + sb.append("&image=").append(imageInput.getFormValue()); } sb.append("&detect_direction=false"); sb.append("&driving_license_side=").append(drivingLicenseSide); @@ -552,18 +567,18 @@ public class RecognizeDriverLicenseController extends BaseController { return text.substring(0, maxLen) + "..."; } - /** 入参日志说明(不打印 base64 正文,只说长度) */ - private String buildInputLogContext(DriverLicenseRecognizeRequest request) { + /** 入参日志说明(不打印影像正文,只说类型与长度) */ + private String buildInputLogContext(DriverLicenseRecognizeRequest request, ResolvedImageInput imageInput) { if (request == null) { return "未收到请求体"; } String sideParam = isBlank(request.getSide()) ? "未传(默认按正页)" : request.getSide().trim(); String resolvedSide = resolveDrivingLicenseSide(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())); + String modeDesc = imageInput != null ? imageInput.getType().getDesc() : "影像未解析"; + int rawLen = imageInput != null ? textLength(imageInput.getRawValue()) : textLength(request.getImageUrlOrBase64()); + return String.format("识别%s,side 参数=%s,%s,影像原始长度 %d 字符", + targetPage, sideParam, modeDesc, rawLen); } /** 平台回执日志说明(便于失败排查) */ @@ -603,49 +618,20 @@ public class RecognizeDriverLicenseController extends BaseController { // ===================== 内部类型 ===================== /** - * 单次识别请求的上下文,集中保存 side / imageMode / 入参日志摘要,避免在主流程中反复解析。 + * 单次识别请求的上下文,集中保存 side / 影像入参 / 入参日志摘要,避免在主流程中反复解析。 */ private static class RecognizeContext { final String side; - final ImageMode imageMode; + final ResolvedImageInput imageInput; final String inputLog; - RecognizeContext(String side, ImageMode imageMode, String inputLog) { + RecognizeContext(String side, ResolvedImageInput imageInput, String inputLog) { this.side = side; - this.imageMode = imageMode; + this.imageInput = imageInput; 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(DriverLicenseRecognizeRequest 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; - } - } - /** * 平台错误码元数据。 *

diff --git a/api-web/api-interface/src/main/java/com/heyu/api/request/car/DriverLicenseRecognizeRequest.java b/api-web/api-interface/src/main/java/com/heyu/api/request/car/DriverLicenseRecognizeRequest.java index a857e42..7f73b4e 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/request/car/DriverLicenseRecognizeRequest.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/request/car/DriverLicenseRecognizeRequest.java @@ -11,17 +11,15 @@ import lombok.Data; public class DriverLicenseRecognizeRequest { /** - * 图像数据,base64编码后进行urlencode,要求base64编码和urlencode后大小不超过4M - * 支持jpg/jpeg/png/bmp格式 - * 和url二选一 + * 影像入参(二选一能力已合并为单一字段): + *

+ * 服务端根据内容自动识别为链接或 Base64。 */ - private String imageBase64; - - /** - * 图片完整URL,URL长度不超过1024字节 - * 和imageBase64二选一 - */ - private String imageUrl; + private String imageUrlOrBase64; /** * face:识别驾驶证正页、电子驾驶证正页(默认)