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 index a453c13..32498c2 100644 --- 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 @@ -13,6 +13,16 @@ import java.util.regex.Pattern; public final class ImageInputUtils { private static final Pattern PERCENT_ENCODED = Pattern.compile("%[0-9A-Fa-f]{2}"); + private static final Pattern BASE64_CHAR_PATTERN = Pattern.compile("^[A-Za-z0-9+/]*={0,2}$"); + /** 百度 OCR 建议:图片 URL 长度不超过 1024 字节 */ + private static final int MAX_URL_LENGTH = 1024; + /** 百度 OCR 建议:Base64 urlencode 前建议不超过 4M */ + private static final int MAX_BASE64_LENGTH = 4 * 1024 * 1024; + private static final Pattern BASE64_CHARS = Pattern.compile("^[A-Za-z0-9+/=]+$"); + /** 百度 url 参数建议上限 */ + private static final int MAX_URL_LENGTH = 1024; + /** 百度 image 参数 urlencode 前建议上限(字符数) */ + private static final int MAX_BASE64_LENGTH = 4 * 1024 * 1024; private ImageInputUtils() { } @@ -87,6 +97,260 @@ public final class ImageInputUtils { return ImageInputType.BASE64; } + /** + * 影像入参校验结果(含失败原因明细) + */ + public static final class ValidationResult { + private final boolean valid; + private final ResolvedImageInput resolved; + private final String category; + private final String reason; + private final String suggestion; + private final String detail; + + private ValidationResult(boolean valid, ResolvedImageInput resolved, + String category, String reason, String suggestion, String detail) { + this.valid = valid; + this.resolved = resolved; + this.category = category; + this.reason = reason; + this.suggestion = suggestion; + this.detail = detail; + } + + public static ValidationResult ok(ResolvedImageInput resolved) { + return new ValidationResult(true, resolved, null, null, null, null); + } + + public static ValidationResult fail(String category, String reason, String suggestion, String detail) { + return new ValidationResult(false, null, category, reason, suggestion, detail); + } + + public boolean isValid() { + return valid; + } + + public ResolvedImageInput getResolved() { + return resolved; + } + + public String getCategory() { + return category; + } + + public String getReason() { + return reason; + } + + public String getSuggestion() { + return suggestion; + } + + public String getDetail() { + return detail; + } + } + + /** + * 校验并解析影像入参;失败时返回具体原因(协议错误、Base64 非法、超长等)。 + * + * @param input 用户传入的 imageUrlOrBase64 + * @return 校验结果;入参为空时 valid=false + */ + public static ValidationResult validate(String input) { + if (StringUtils.isBlank(input)) { + return ValidationResult.fail( + "影像源缺失", + "imageUrlOrBase64 未提供或仅包含空白字符", + "请传 HTTP/HTTPS 图片链接,或 jpg/jpeg/png/bmp 的 Base64 字符串", + "入参长度=0" + ); + } + String trimmed = input.trim(); + String protocolHint = diagnoseUnsupportedProtocol(trimmed); + if (protocolHint != null) { + return ValidationResult.fail( + "链接协议无效", + protocolHint, + "图片链接须以 http:// 或 https:// 开头,且可被公网访问", + "入参前缀=" + abbreviate(trimmed, 48) + ); + } + String pathHint = diagnoseLocalPath(trimmed); + if (pathHint != null) { + return ValidationResult.fail( + "影像格式无效", + pathHint, + "请改为 HTTP/HTTPS 图片链接,或将图片转为 Base64 后传入", + "入参前缀=" + abbreviate(trimmed, 48) + ); + } + + ImageInputType type = detectType(trimmed); + if (type == ImageInputType.URL) { + return validateUrl(trimmed); + } + return validateBase64(trimmed); + } + + private static ValidationResult validateUrl(String trimmed) { + String rawUrl = normalizeHttpUrl(trimmed); + if (!isHttpUrl(rawUrl)) { + String decodeIssue = diagnoseUrlDecodeFailure(trimmed); + return ValidationResult.fail( + "链接解析失败", + decodeIssue != null ? decodeIssue : "链接经 urldecode 后仍不是有效的 HTTP(S) 地址", + "请确认链接以 http:// 或 https:// 开头;若已 urlencode,请检查 % 编码是否完整、无截断", + "入参长度=" + trimmed.length() + " 字符" + ); + } + if (rawUrl.length() > MAX_URL_LENGTH) { + return ValidationResult.fail( + "链接过长", + "图片链接长度为 " + rawUrl.length() + " 字符,超过上限 " + MAX_URL_LENGTH + " 字符", + "请缩短 URL 或使用 Base64 传图;检查是否误把查询参数或冗余路径拼入链接", + "链接前缀=" + abbreviate(rawUrl, 64) + ); + } + return ValidationResult.ok(new ResolvedImageInput( + ImageInputType.URL, rawUrl, encodeForFormBody(trimmed, rawUrl))); + } + + private static ValidationResult validateBase64(String trimmed) { + if (trimmed.regionMatches(true, 0, "data:", 0, 5) && trimmed.indexOf("base64,") < 0) { + return ValidationResult.fail( + "Base64 前缀无效", + "data: 前缀格式不正确,缺少 base64, 段", + "请使用 data:image/jpeg;base64,{数据} 格式,或直接传纯 Base64 字符串", + "入参前缀=" + abbreviate(trimmed, 48) + ); + } + String missingSchemeHint = diagnoseMissingUrlScheme(trimmed); + if (missingSchemeHint != null) { + return ValidationResult.fail( + "链接缺少协议头", + missingSchemeHint, + "若以链接传图,请补全为 https:// 或 http:// 开头", + "入参前缀=" + abbreviate(trimmed, 48) + ); + } + + String rawBase64 = stripDataUriPrefix(trimmed); + String normalized = rawBase64.replaceAll("\\s+", ""); + if (normalized.length() < 4) { + return ValidationResult.fail( + "Base64 内容过短", + "剥离前缀后 Base64 仅 " + normalized.length() + " 字符,不足以构成有效图片", + "请确认图片已完整编码,未截断", + "原始入参长度=" + trimmed.length() + " 字符" + ); + } + if (normalized.length() > MAX_BASE64_LENGTH) { + return ValidationResult.fail( + "Base64 体积过大", + "Base64 字符串长度 " + normalized.length() + " 字符,超过建议上限 " + + MAX_BASE64_LENGTH + " 字符(约 4M)", + "请压缩图片或降低分辨率后重新编码;支持 jpg/jpeg/png/bmp", + "编码后预估体积≈" + (normalized.length() / 1024) + " KB" + ); + } + if (!BASE64_CHAR_PATTERN.matcher(normalized).matches()) { + return ValidationResult.fail( + "Base64 字符非法", + "内容包含非 Base64 合法字符(仅允许 A-Z、a-z、0-9、+、/、=)", + "请检查是否误传了普通文本、JSON 字段名或文件路径;链接须以 http:// 或 https:// 开头", + "非法片段示例=" + abbreviate(extractIllegalBase64Snippet(normalized), 32) + ); + } + if (!isValidBase64(normalized)) { + return ValidationResult.fail( + "Base64 解码失败", + "字符串符合 Base64 字符集,但解码失败,可能存在截断、填充位(=)错误或编码不完整", + "请重新生成 Base64,确保完整复制、无换行截断;或改用 HTTP(S) 图片链接", + "Base64 长度=" + normalized.length() + " 字符,末尾=" + abbreviateTail(normalized, 8) + ); + } + return ValidationResult.ok(new ResolvedImageInput( + ImageInputType.BASE64, normalized, encodeForFormBody(trimmed, normalized))); + } + + private static String diagnoseUnsupportedProtocol(String trimmed) { + String lower = trimmed.toLowerCase(); + if (lower.startsWith("ftp://")) { + return "检测到 ftp:// 协议,本接口仅支持 HTTP/HTTPS 图片链接"; + } + if (lower.startsWith("file://")) { + return "检测到 file:// 本地文件协议,不支持直接读取本地文件"; + } + return null; + } + + private static String diagnoseLocalPath(String trimmed) { + if (trimmed.startsWith("/") && !trimmed.startsWith("//")) { + return "检测到以 / 开头的本地绝对路径,无法作为网络图片链接使用"; + } + if (trimmed.matches("^[A-Za-z]:\\\\.*")) { + return "检测到 Windows 本地路径(如 C:\\...),无法作为网络图片链接使用"; + } + return null; + } + + private static String diagnoseMissingUrlScheme(String trimmed) { + String lower = trimmed.toLowerCase(); + if (lower.startsWith("www.")) { + return "链接以 www. 开头但缺少协议头,无法识别为 HTTP(S) 地址"; + } + if (trimmed.startsWith("//")) { + return "链接以 // 开头但缺少协议头(如 https:),无法识别为完整 URL"; + } + if (lower.startsWith("http:/") && !lower.startsWith("http://")) { + return "链接协议写法错误,应为 http:// 而非 http:/"; + } + if (lower.startsWith("https:/") && !lower.startsWith("https://")) { + return "链接协议写法错误,应为 https:// 而非 https:/"; + } + return null; + } + + private static String diagnoseUrlDecodeFailure(String trimmed) { + if (!PERCENT_ENCODED.matcher(trimmed).find()) { + return null; + } + try { + URLDecoder.decode(trimmed, StandardCharsets.UTF_8.name()); + return "链接含有 % 编码,但解码后不是 http:// 或 https:// 开头"; + } catch (IllegalArgumentException | UnsupportedEncodingException ex) { + return "链接含有无效的 % 编码序列,urldecode 失败:" + ex.getMessage(); + } + } + + private static String extractIllegalBase64Snippet(String normalized) { + for (int i = 0; i < normalized.length(); i++) { + char c = normalized.charAt(i); + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '+' || c == '/' || c == '=')) { + int start = Math.max(0, i - 4); + int end = Math.min(normalized.length(), i + 5); + return normalized.substring(start, end); + } + } + return normalized; + } + + private static String abbreviate(String text, int maxLen) { + if (text == null || text.length() <= maxLen) { + return text; + } + return text.substring(0, maxLen) + "..."; + } + + private static String abbreviateTail(String text, int tailLen) { + if (text == null || text.length() <= tailLen) { + return text; + } + return "..." + text.substring(text.length() - tailLen); + } + /** * 解析影像入参:识别类型、剥离 data: 前缀,并在未 urlencode 时自动编码为表单可用值。 * @@ -94,17 +358,8 @@ public final class ImageInputUtils { * @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)); + ValidationResult result = validate(input); + return result.isValid() ? result.getResolved() : null; } private static String normalizeHttpUrl(String input) { 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 59e7d9a..7b54157 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 @@ -45,9 +45,9 @@ import java.util.Map; * *

返回约定(重要)

* * * @author heyu @@ -82,10 +82,16 @@ public class RecognizeDriverLicenseController extends BaseController { // ---------- 步骤①:参数校验(不调百度、不扣费) ---------- String validateError = validateRequest(request); if (validateError != null) { - log.info("驾驶证识别:参数检查没通过,直接返回错误(还没调识别、不扣费)。{} 返回给客户:{}", - buildInputLogContext(request, ImageInputUtils.resolve(request.getImageUrlOrBase64())), + String side = request != null ? resolveDrivingLicenseSide(request) : null; + if (side == null) { + side = ApiConstants.front; + } + ResolvedImageInput validateImage = request != null + ? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null; + log.info("驾驶证识别:参数检查没通过,接口仍返回成功并附带提示(还没调识别、不扣费)。{} 返回给客户:{}", + buildInputLogContext(request, validateImage), abbreviate(validateError, 120)); - return R.error(validateError); + return okResult(side, validateError, null); } // ---------- 步骤②:解析影像入参 & 构建上下文 ---------- @@ -276,25 +282,19 @@ public class RecognizeDriverLicenseController extends BaseController { "绑定结果=DriverLicenseRecognizeRequest 为 null" ); } - if (isBlank(request.getImageUrlOrBase64())) { - log.info("驾驶证识别:没传图片(imageUrlOrBase64 为空),直接拒绝,未调用识别、不扣费。{}", + ImageInputUtils.ValidationResult imageValidation = + ImageInputUtils.validate(request.getImageUrlOrBase64()); + if (!imageValidation.isValid()) { + log.info("驾驶证识别:imageUrlOrBase64 校验未通过({}),未调用识别、不扣费。原因:{}。{}", + imageValidation.getCategory(), + imageValidation.getReason(), buildInputLogContext(request, null)); return formatHint( - "影像源缺失", - "imageUrlOrBase64 未提供,识别引擎没有可处理的图像输入", - "请传 HTTP/HTTPS 图片链接,或 jpg/jpeg/png/bmp 的 Base64 字符串(可带 data:image 前缀);" - + "未 urlencode 时由服务端自动编码", - "side=" + (isBlank(request.getSide()) ? SIDE_DEFAULT_HINT : request.getSide().trim()) - ); - } - 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()) + imageValidation.getCategory(), + imageValidation.getReason(), + imageValidation.getSuggestion(), + imageValidation.getDetail() + ",side=" + + (isBlank(request.getSide()) ? SIDE_DEFAULT_HINT : request.getSide().trim()) ); } if (resolveDrivingLicenseSide(request) == null) {