diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java index 2e2f2f5..e188f0f 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/car/RecognizeDrivingLicenseController.java @@ -1,43 +1,585 @@ package com.heyu.api.controller.car; -import com.heyu.api.baidu.handle.traffic.BVehicleLicenseHandle; -import com.heyu.api.baidu.request.traffic.BVehicleLicenseRequest; import com.heyu.api.controller.BaseController; -import com.heyu.api.controller.ocr.BaiduOcrResult; import com.heyu.api.data.annotation.EbAuthentication; import com.heyu.api.data.constants.ApiConstants; +import com.heyu.api.data.utils.MapUtils; import com.heyu.api.data.utils.R; import com.heyu.api.data.utils.StringUtils; import com.heyu.api.request.car.VehicleLicenseRequest; -import org.springframework.beans.factory.annotation.Autowired; +import com.heyu.api.resp.car.RecognizeDrivingLicenseBackResp; +import com.heyu.api.resp.car.RecognizeDrivingLicenseFaceResp; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +/** + * 行驶证识别控制器 + *

+ * 能力说明:对机动车行驶证主页(正页)副页电子行驶证进行结构化识别, + * 底层对接百度智能云文字识别「行驶证识别」能力,由本服务完成参数校验、请求转发与字段映射。 + *

+ * + *

百度官方文档

+ * + * + *

本服务接口

+ * + * + *

请求参数({@link VehicleLicenseRequest})

+ * + * + *

返回约定(重要)

+ * + * + * @author heyu + * @since 1.0.0 + * @see VehicleLicenseRequest + * @see RecognizeDrivingLicenseFaceResp + * @see RecognizeDrivingLicenseBackResp + */ +@Slf4j @RestController @RequestMapping("/driving/license") public class RecognizeDrivingLicenseController extends BaseController { - @Autowired - private BVehicleLicenseHandle bVehicleLicenseHandle; + /** 百度行驶证识别 API 路径 */ + private static final String VEHICLE_LICENSE_URI = "/rest/2.0/ocr/v1/vehicle_license"; + private static final String SIDE_DEFAULT_HINT = "未传(将按正页处理)"; + + /** + * 行驶证识别 + * + * @param request 行驶证识别请求 + * @return 正页返回 {@link RecognizeDrivingLicenseFaceResp};副页返回 {@link RecognizeDrivingLicenseBackResp} + */ @EbAuthentication(tencent = ApiConstants.TENCENT_AUTH) @PostMapping("/recognize") public R recognize(@RequestBody VehicleLicenseRequest request) { - return BaiduOcrResult.raw(bVehicleLicenseHandle.handle(toBaiduRequest(request))); + long start = System.currentTimeMillis(); + RecognizeContext ctx = null; + try { + String validateError = validateRequest(request); + if (validateError != null) { + log.info("行驶证识别:参数检查没通过,直接返回错误(还没调识别、不扣费)。{} 返回给客户:{}", + buildInputLogContext(request), abbreviate(validateError, 120)); + return R.error(validateError); + } + + ctx = new RecognizeContext( + resolveVehicleLicenseSide(request), + ImageMode.of(request), + buildInputLogContext(request)); + + String content = buildRequestContent(request, ctx.side); + if (isBlank(content)) { + log.error("行驶证识别:组装请求失败,请求里没带有效图片。识别{},{}。{}", + sideDesc(ctx.side), ctx.imageMode.desc, ctx.inputLog); + return okResult(ctx.side, formatHint( + "报文组装异常", + "识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别", + "请核对 imageBase64 是否已 urlencode、imageUrl 是否非空字符串", + "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageMode.name() + ), null); + } + + Map platformResult = callPlatform(content, ctx); + if (platformResult == null) { + return okResult(ctx.side, formatHint( + "服务无回执", + "识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执", + "建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻与 side 并联系技术支持", + "目标页面=" + sideLabel(ctx.side) + ",影像模式=" + ctx.imageMode.name() + ), null); + } + + Object data = ApiConstants.back.equals(ctx.side) + ? buildBackResp(platformResult) + : buildFaceResp(platformResult); + String hint = resolvePlatformHint(platformResult, ctx, data); + + logRecognizeResult(ctx, platformResult, data, hint, start); + return okResult(ctx.side, hint, data); + + } catch (Exception e) { + long cost = System.currentTimeMillis() - start; + String side = ctx != null ? ctx.side + : (request != null ? resolveVehicleLicenseSide(request) : ApiConstants.front); + String mode = ctx != null ? ctx.imageMode.desc + : (request != null ? ImageMode.of(request).desc : "未知"); + log.error("行驶证识别:程序运行出错,耗时 {} ms。识别{},{}。客户传的:{}。异常:{} - {}", + cost, sideDesc(side), mode, buildInputLogContext(request), + e.getClass().getSimpleName(), + e.getMessage() != null ? e.getMessage() : "无具体说明", e); + return okResult(side, formatHint( + "运行时故障", + "服务端在处理识别流程时抛出未预期异常,识别结果不可用", + "请勿重复高频重试;请保存 traceId、异常发生时间及 side,由技术支持结合堆栈进一步定位", + "异常类型=" + e.getClass().getSimpleName() + + (e.getMessage() != null ? ",摘要=" + e.getMessage() : "") + ), null); + } } - private BVehicleLicenseRequest toBaiduRequest(VehicleLicenseRequest request) { - BVehicleLicenseRequest bRequest = new BVehicleLicenseRequest(); + private Map callPlatform(String content, RecognizeContext ctx) { + int len = content.length(); + log.info("行驶证识别:开始调用平台识别。识别{},{},请求大小约 {} 字节。{}", + sideDesc(ctx.side), ctx.imageMode.desc, len, ctx.inputLog); + Map result = requestBaidu(VEHICLE_LICENSE_URI, content); + if (result == null) { + log.error("行驶证识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。" + + "识别{},{},请求约 {} 字节。{}", + sideDesc(ctx.side), ctx.imageMode.desc, len, ctx.inputLog); + } + return result; + } + + private String resolvePlatformHint(Map platformResult, RecognizeContext ctx, Object data) { + Object errorCodeObj = platformResult.get("error_code"); + if (errorCodeObj != null) { + String errorCode = String.valueOf(errorCodeObj); + Object errorMsgObj = platformResult.get("error_msg"); + String errorMsg = errorMsgObj != null ? errorMsgObj.toString() : "平台未返回文字描述"; + String category = PlatformError.categoryOf(errorCode); + log.error("行驶证识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。识别{},{}。客户传的:{}。{}", + category, errorCode, errorMsg, + sideDesc(ctx.side), ctx.imageMode.desc, + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + category, + PlatformError.reasonOf(errorCode), + PlatformError.suggestionOf(errorCode, sideLabel(ctx.side)), + "错误码=" + errorCode + ",错误描述=" + errorMsg + ",目标页面=" + sideLabel(ctx.side) + ); + } + if (isWordsResultEmpty(platformResult)) { + log.info("行驶证识别:平台返回了,但没有识别结果数据(可能不是行驶证或 side 传错)。识别{},{}。客户传的:{}。{}", + sideDesc(ctx.side), ctx.imageMode.desc, + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "结构化结果缺失", + "平台回执未包含可解析的识别结果集合,无法进入字段映射环节", + "优先排查:① 上传内容是否为行驶证对应页面;② side 是否与实物一致(正页 front、副页 back);" + + "③ 使用 URL 时确保图片链接可公网访问且无防盗链", + "目标页面=" + sideLabel(ctx.side) + ",解析状态=结果集为空" + ); + } + if (isAllStringFieldsBlank(data)) { + log.info("行驶证识别:平台有返回,但号牌、所有人等字段一个都没识别出来(可能传错正/副页或图片不清晰)。" + + "识别{},{}。客户传的:{}。平台回执:{}", + sideDesc(ctx.side), ctx.imageMode.desc, + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "字段映射为空", + "平台回执已通过结构校验,但号牌号码、所有人、车辆类型等结构化字段均未命中", + ApiConstants.back.equals(ctx.side) + ? "副页识别请确认 side=back,画面包含检验记录、核定载人数、外廓尺寸等区域" + : "正页识别请确认 side=front,画面须包含完整行驶证主页;电子行驶证需保证截图清晰", + "目标页面=" + sideLabel(ctx.side) + ",已映射字段数=0" + ); + } + return null; + } + + private void logRecognizeResult(RecognizeContext ctx, Map platformResult, + Object data, String hint, long start) { + long cost = System.currentTimeMillis() - start; + int mapped = countNonBlankStringFields(data); + if (StringUtils.isNotBlank(hint)) { + log.info("行驶证识别:处理结束(接口仍返回成功,但带了提示信息)。耗时 {} ms,识别出 {} 个字段。" + + "识别{},{}。客户传的:{}。平台回执:{}。给客户的提示:{}", + cost, mapped, sideDesc(ctx.side), ctx.imageMode.desc, + ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120)); + } else { + log.info("行驶证识别:识别成功。耗时 {} ms,共识别出 {} 个字段。识别{},{}。客户传的:{}。平台回执:{}", + cost, mapped, sideDesc(ctx.side), ctx.imageMode.desc, + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + } + } + + private String validateRequest(VehicleLicenseRequest request) { if (request == null) { - return bRequest; + log.info("行驶证识别:没收到任何请求参数,直接拒绝,未调用识别、不扣费"); + return formatHint( + "入参绑定失败", + "控制器未接收到可绑定的请求对象,所有业务字段均为空", + "请确认使用 POST 提交;字段名与接口文档一致(imageBase64 / imageUrl / side)", + "绑定结果=VehicleLicenseRequest 为 null" + ); } - bRequest.setImageBase64(request.getImageBase64()); - bRequest.setImageUrl(request.getImageUrl()); - if (StringUtils.isNotBlank(request.getSide())) { - bRequest.setVehicleLicenseSide(request.getSide()); + if (isBlank(request.getImageBase64()) && isBlank(request.getImageUrl())) { + log.info("行驶证识别:没传图片(imageBase64 和 imageUrl 都为空),直接拒绝,未调用识别、不扣费。{}", + buildInputLogContext(request)); + return formatHint( + "影像源缺失", + "imageBase64 与 imageUrl 均未提供,识别引擎没有可处理的图像输入", + "任选其一:① imageBase64 传 jpg/jpeg/png/bmp 的 base64(urlencode 后≤4M);" + + "② imageUrl 传可公网直连的地址(≤1024 字符,关闭防盗链)", + "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 (resolveVehicleLicenseSide(request) == null) { + log.info("行驶证识别:side 参数写错了(只支持 front 正页、back 副页),直接拒绝,未调用识别、不扣费。{}", + buildInputLogContext(request)); + return formatHint( + "页面标识无效", + "参数 side 的取值无法映射到行驶证主页或副页识别模式", + "行驶证主页(含电子行驶证主页)请传 front;副页请传 back", + "当前 side=" + request.getSide() + ",合法取值=front|back" + ); + } + return null; + } + + private String resolveVehicleLicenseSide(VehicleLicenseRequest request) { + if (request == null || isBlank(request.getSide())) { + return ApiConstants.front; + } + String side = request.getSide().trim(); + if (ApiConstants.front.equals(side) || ApiConstants.face.equals(side)) { + return ApiConstants.front; + } + if (ApiConstants.back.equals(side)) { + return ApiConstants.back; + } + return null; + } + + private String sideLabel(String side) { + return ApiConstants.back.equals(side) ? "副页(back)" : "正页(front)"; + } + + private String sideDesc(String side) { + return ApiConstants.back.equals(side) ? "行驶证副页" : "行驶证正页"; + } + + private String buildRequestContent(VehicleLicenseRequest request, String vehicleLicenseSide) { + 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()); + } + sb.append("&detect_direction=false"); + sb.append("&vehicle_license_side=").append(vehicleLicenseSide); + sb.append("&unified=false"); + sb.append("&quality_warn=false"); + sb.append("&risk_warn=false"); + return sb.toString(); + } + + private R okResult(String vehicleLicenseSide, String hint, Object data) { + if (data == null) { + data = ApiConstants.back.equals(vehicleLicenseSide) + ? new RecognizeDrivingLicenseBackResp() + : new RecognizeDrivingLicenseFaceResp(); + } + R result = StringUtils.isNotBlank(hint) ? R.ok(hint) : R.ok(); + return result.setData(data); + } + + private RecognizeDrivingLicenseFaceResp buildFaceResp(Map data) { + RecognizeDrivingLicenseFaceResp resp = new RecognizeDrivingLicenseFaceResp(); + resp.setPlateNumber(getWords(data, "号牌号码")); + resp.setVehicleType(getWords(data, "车辆类型")); + resp.setOwner(getWords(data, "所有人")); + resp.setAddress(getWords(data, "住址")); + resp.setUseCharacter(getWords(data, "使用性质")); + resp.setModel(getWords(data, "品牌型号")); + resp.setVin(getWords(data, "车辆识别代号")); + resp.setEngineNumber(getWords(data, "发动机号码")); + resp.setRegisterDate(firstNonBlank(getWords(data, "注册日期"), getWords(data, "注册登记日期"))); + resp.setIssueDate(getWords(data, "发证日期")); + resp.setIssueAuthority(firstNonBlank(getWords(data, "发证单位"), getWords(data, "发证机关"))); + return resp; + } + + private RecognizeDrivingLicenseBackResp buildBackResp(Map data) { + RecognizeDrivingLicenseBackResp resp = new RecognizeDrivingLicenseBackResp(); + resp.setPlateNumber(getWords(data, "号牌号码")); + resp.setFileNumber(getWords(data, "档案编号")); + resp.setApprovedPassengerCapacity(getWords(data, "核定载人数")); + resp.setGrossMass(getWords(data, "总质量")); + resp.setUnladenMass(getWords(data, "整备质量")); + resp.setApprovedLoad(getWords(data, "核定载质量")); + resp.setOverallDimension(getWords(data, "外廓尺寸")); + resp.setTractionMass(getWords(data, "准牵引总质量")); + resp.setInspectionRecord(getWords(data, "检验记录")); + resp.setEnergyType(firstNonBlank(getWords(data, "能源种类"), getWords(data, "燃油类型"))); + resp.setRemark(getWords(data, "备注")); + resp.setCertificateNumber(getWords(data, "证芯编号")); + return resp; + } + + private String getWords(Map data, String field) { + return MapUtils.getByExpr(data, "words_result." + field + ".words"); + } + + private String firstNonBlank(String first, String second) { + return StringUtils.isNotBlank(first) ? first : second; + } + + @SuppressWarnings("unchecked") + private boolean isWordsResultEmpty(Map platformResult) { + Object wordsResult = platformResult.get("words_result"); + if (wordsResult == null) { + return true; + } + if (wordsResult instanceof Map) { + return ((Map) wordsResult).isEmpty(); + } + return false; + } + + private static int countNonBlankStringFields(Object data) { + if (data == null) { + return 0; + } + int count = 0; + for (Field f : data.getClass().getDeclaredFields()) { + if (!String.class.equals(f.getType())) { + continue; + } + try { + f.setAccessible(true); + Object v = f.get(data); + if (v instanceof String && StringUtils.isNotBlank((String) v)) { + count++; + } + } catch (IllegalAccessException ignored) { + // ignore + } + } + return count; + } + + private static boolean isAllStringFieldsBlank(Object data) { + return countNonBlankStringFields(data) == 0; + } + + private String formatHint(String category, String reason, String suggestion, String detail) { + StringBuilder sb = new StringBuilder(); + sb.append("【行驶证识别·").append(category).append("】").append(reason); + if (StringUtils.isNotBlank(detail)) { + sb.append("|定位信息:").append(detail); + } + sb.append("|处置指引:").append(suggestion); + return sb.toString(); + } + + private String abbreviate(String text, int maxLen) { + if (text == null || text.length() <= maxLen) { + return text; + } + return text.substring(0, maxLen) + "..."; + } + + private String buildInputLogContext(VehicleLicenseRequest request) { + if (request == null) { + return "未收到请求体"; + } + String sideParam = isBlank(request.getSide()) ? "未传(默认按正页)" : request.getSide().trim(); + String resolvedSide = resolveVehicleLicenseSide(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())); + } + + private String buildPlatformReceiptSummary(Map platformResult) { + if (platformResult == null) { + return "平台无任何回执"; + } + Object errorCode = platformResult.get("error_code"); + Object errorMsg = platformResult.get("error_msg"); + Object logId = platformResult.get("log_id"); + if (errorCode != null) { + return String.format("平台返回失败,错误码 %s,说明:%s,流水号 %s", + errorCode, + errorMsg != null ? errorMsg : "无", + logId != null ? logId : "无"); + } + Object wordsResultNum = platformResult.get("words_result_num"); + Object wordsResult = platformResult.get("words_result"); + int fieldCount = wordsResult instanceof Map ? ((Map) wordsResult).size() : 0; + return String.format("平台返回成功,识别结果约 %s 项、结构化字段 %d 个,流水号 %s", + wordsResultNum != null ? wordsResultNum : "未知", + fieldCount, + logId != null ? logId : "无"); + } + + private int textLength(String value) { + return value == null ? 0 : value.length(); + } + + private static class RecognizeContext { + final String side; + final ImageMode imageMode; + final String inputLog; + + RecognizeContext(String side, ImageMode imageMode, String inputLog) { + this.side = side; + this.imageMode = imageMode; + 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(VehicleLicenseRequest 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; + } + } + + private enum PlatformError { + QPS_DAILY_LIMIT("17", "日配额耗尽", + "本接口当日累计调用次数已触达平台日配额上限,本次识别请求被拒绝", + "请安排次日再试,或联系运营提升日调用配额"), + QPS_RATE_LIMIT("18", "并发限流", + "单位时间请求过于密集,已触发平台 QPS 限流,请拉长调用间隔后重试", + "请在客户端增加限流/退避策略,避免瞬时并发过高"), + QPS_TOTAL_EXHAUSTED("19", "总配额耗尽", + "平台侧总调用配额已耗尽,需运营侧扩容或次日再试", + "请联系运营确认平台总配额与续费状态"), + PARAM_FORMAT("100", "平台业务拒绝", + "提交的表单字段组合不符合行驶证识别接口契约,请对照文档核对字段名与取值", + null), + AUTH_INVALID("110", "鉴权失效", + "平台鉴权凭证校验未通过,属服务端配置异常,需运维刷新令牌", + "属服务端鉴权配置问题,需技术支持检查平台密钥与令牌缓存"), + AUTH_EXPIRED("111", "鉴权失效", + "平台鉴权凭证已超过有效期,属服务端配置异常,需运维重新签发", + "属服务端令牌过期,需技术支持重新获取并刷新缓存"), + PARAM_ILLEGAL("216100", "影像不合规", "存在非法或与接口不匹配的参数项", null), + IMAGE_MISSING("216101", "影像不合规", "影像入参缺失:未提供可用的 image 或 url 字段", null), + PARAM_TOO_LONG("216103", "影像不合规", "单个参数字段长度超限(常见于 url 过长或 base64 体积过大)", null), + APP_NOT_ENABLED("216110", "影像不合规", + "当前应用未开通行驶证识别能力或授权范围不包含本接口", null), + IMAGE_EMPTY("216200", "影像不合规", + "解码后的图片数据为空,上传环节可能未完成或内容损坏", + "请重新上传图片,确认 base64 未截断、未混入换行或非法字符"), + IMAGE_FORMAT("216201", "影像不合规", + "图片编码格式不在允许列表,仅支持 jpg、jpeg、png、bmp", + "请将图片转为 jpg/jpeg/png/bmp 之一后重新编码上传"), + IMAGE_SIZE("216202", "影像不合规", + "像素尺寸或编码后体积不满足规范(边长 15~4096px,编码后≤4M)", + "请压缩或裁剪图片,保证最长边≤4096px、最短边≥15px,且 urlencode 后≤4M"), + IMAGE_BLUR("216203", "影像不合规", + "引擎无法从当前画面中稳定提取行驶证文本,多见于模糊、过曝或非证件照", + null), + LAYOUT_FAIL("216630", "影像不合规", + "证件版面识别失败,建议重拍并保证四角完整、无遮挡", + "请平铺证件拍摄,避免手指遮挡关键字段;副页识别务必传 side=back"), + ENGINE_INTERNAL("282000", "引擎内部错误", + "平台识别引擎内部异常,属短暂性故障,可间隔数秒后重试", + "短暂性故障,建议 5~10 秒后单次重试,不宜连续轰炸接口"); + + private static final String IMAGE_PREFIX = "2162"; + private static final String DEFAULT_CATEGORY = "平台业务拒绝"; + private static final String IMAGE_CATEGORY = "影像不合规"; + private static final String DEFAULT_REASON = "平台返回了未在本地维护的错误码,需结合错误描述人工判读"; + private static final String DEFAULT_SUGGESTION = + "请依据错误描述调整入参或影像后重试;持续失败请附带 traceId 联系技术支持"; + + private static final Map BY_CODE; + + static { + Map m = new HashMap<>(); + for (PlatformError e : EnumSet.allOf(PlatformError.class)) { + m.put(e.code, e); + } + BY_CODE = Collections.unmodifiableMap(m); + } + + final String code; + final String category; + final String reason; + final String suggestion; + + PlatformError(String code, String category, String reason, String suggestion) { + this.code = code; + this.category = category; + this.reason = reason; + this.suggestion = suggestion; + } + + static String categoryOf(String code) { + PlatformError e = BY_CODE.get(code); + if (e != null) { + return e.category; + } + if (code != null && code.startsWith(IMAGE_PREFIX)) { + return IMAGE_CATEGORY; + } + return DEFAULT_CATEGORY; + } + + static String reasonOf(String code) { + PlatformError e = BY_CODE.get(code); + return e != null ? e.reason : DEFAULT_REASON; + } + + static String suggestionOf(String code, String sideLabel) { + PlatformError e = BY_CODE.get(code); + if (e == IMAGE_BLUR) { + return "请在自然光下重拍,确保证面占画面主体且文字可辨;side=" + sideLabel; + } + if (e != null && e.suggestion != null) { + return e.suggestion; + } + return DEFAULT_SUGGESTION; } - return bRequest; } } diff --git a/api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java b/api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java new file mode 100644 index 0000000..049746c --- /dev/null +++ b/api-web/api-interface/src/test/java/com/api/test/ImageToBase64.java @@ -0,0 +1,79 @@ +package com.api.test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; + +/** + * 图片转 Base64 工具(测试用)。 + *

+ * 用于本地构造驾驶证识别等接口需要的 imageBase64 入参。 + * 支持本地文件路径与 HTTP(S) 远程 URL 两种来源。 + */ +public class ImageToBase64 { + + /** 读 URL 时的连接超时(毫秒) */ + private static final int CONNECT_TIMEOUT_MS = 5_000; + /** 读 URL 时的读取超时(毫秒) */ + private static final int READ_TIMEOUT_MS = 15_000; + + /** + * 将图片转为 Base64 字符串(不带换行、不带 data: 前缀)。 + * + * @param source 本地文件绝对/相对路径,或 http(s):// 开头的远程地址 + * @return Base64 字符串 + */ + public static String toBase64(String source) throws IOException { + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("source 不能为空"); + } + byte[] bytes = source.startsWith("http://") || source.startsWith("https://") + ? readFromUrl(source) + : readFromFile(source); + return Base64.getEncoder().encodeToString(bytes); + } + + private static byte[] readFromFile(String path) throws IOException { + Path p = Paths.get(path); + if (!Files.exists(p)) { + throw new IOException("文件不存在: " + path); + } + return Files.readAllBytes(p); + } + + private static byte[] readFromUrl(String url) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestMethod("GET"); + try (InputStream in = conn.getInputStream()) { + int status = conn.getResponseCode(); + if (status / 100 != 2) { + throw new IOException("下载图片失败,HTTP " + status + ",url=" + url); + } + byte[] buf = new byte[8192]; + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + conn.disconnect(); + } + } + + public static void main(String[] args) throws IOException { + String source = args.length > 0 + ? args[0] + : "/Users/quyixiao/gita/eb-service-api/api-web/api-interface/src/test/resources/aaa.jpg"; + String base64 = toBase64(source); + System.out.println("长度: " + base64.length()); + System.out.println(base64); + } +} diff --git a/api-web/api-interface/src/test/resources/aaa.jpg b/api-web/api-interface/src/test/resources/aaa.jpg new file mode 100644 index 0000000..f7b1800 Binary files /dev/null and b/api-web/api-interface/src/test/resources/aaa.jpg differ