提交修改
This commit is contained in:
parent
4dd5a1cb18
commit
9ff21b0d3a
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user