提交修改

This commit is contained in:
quyixiao 2026-06-06 01:39:07 +08:00
parent 4dd5a1cb18
commit 9ff21b0d3a
2 changed files with 288 additions and 33 deletions

View File

@ -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) {

View File

@ -45,9 +45,9 @@ import java.util.Map;
*
* <h3>返回约定重要</h3>
* <ul>
* <li>入参校验失败{@code R.error()}<strong>未调用百度不计费</strong></li>
* <li>已调用百度识别链路一律 {@code R.ok()}若图片/平台/识别异常将说明写入 {@code msg}
* <li>一律 {@code R.ok()}入参校验失败平台异常识别结果为空等情况将说明写入 {@code msg}
* {@code data} 可能为空或字段不全对外不暴露百度字样日志内可排查</li>
* <li>入参校验失败时<strong>未调用百度不计费</strong></li>
* </ul>
*
* @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) {