提交修改

This commit is contained in:
quyixiao 2026-06-06 01:18:24 +08:00
parent b6e2a376e6
commit 4dd5a1cb18
3 changed files with 267 additions and 88 deletions

View File

@ -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) 图片链接
* <p>规则 {@code http://} {@code https://} 开头含已 urlencode 的形式视为链接否则视为 Base64</p>
*
* @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;
}
}
}

View File

@ -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<String, Object> 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<String, Object> 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 的 base64urlencode 后≤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 格式
* <ul>
* <li>优先使用 Base64image 参数备选 URLurl 参数</li>
* <li>Base64 使用 image 参数HTTP(S) 链接使用 url 参数均已表单 urlencode</li>
* <li>始终携带固定的百度参数detect_directiondriving_license_sideunified_valid_period </li>
* </ul>
*
* @param request 客户入参
* @param imageInput 解析后的影像入参
* @param drivingLicenseSide 标准化后的 sidefront / 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("识别%sside 参数=%s%sBase64 长度 %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("识别%sside 参数=%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;
}
}
/**
* 平台错误码元数据
* <p>

View File

@ -11,17 +11,15 @@ import lombok.Data;
public class DriverLicenseRecognizeRequest {
/**
* 图像数据base64编码后进行urlencode要求base64编码和urlencode后大小不超过4M
* 支持jpg/jpeg/png/bmp格式
* 和url二选一
* 影像入参二选一能力已合并为单一字段
* <ul>
* <li>HTTP/HTTPS 图片链接长度建议1024 字符 urlencode 时由服务端自动编码</li>
* <li>图片 Base64 字符串支持 jpg/jpeg/png/bmp可带 data:image/...;base64, 前缀
* 编码后 urlencode 前建议4M urlencode 时由服务端自动编码</li>
* </ul>
* 服务端根据内容自动识别为链接或 Base64
*/
private String imageBase64;
/**
* 图片完整URLURL长度不超过1024字节
* 和imageBase64二选一
*/
private String imageUrl;
private String imageUrlOrBase64;
/**
* face识别驾驶证正页电子驾驶证正页默认