diff --git a/api-web/api-interface/src/main/java/com/heyu/api/controller/card/BusinessLicenseRecognizeController.java b/api-web/api-interface/src/main/java/com/heyu/api/controller/card/BusinessLicenseRecognizeController.java index fcf49e7..224aa75 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/controller/card/BusinessLicenseRecognizeController.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/controller/card/BusinessLicenseRecognizeController.java @@ -1,36 +1,395 @@ package com.heyu.api.controller.card; -import com.heyu.api.baidu.handle.certificate.BBusinessLicenseHandle; -import com.heyu.api.baidu.request.certificate.BBusinessLicenseRequest; -import com.heyu.api.controller.BaseController; +import com.heyu.api.controller.AbstractRecognizeController; +import com.heyu.api.controller.BaiduOcrError; import com.heyu.api.data.annotation.CacheResult; +import com.heyu.api.data.annotation.EbAuthentication; import com.heyu.api.data.annotation.NotIntercept; -import com.heyu.api.data.utils.ApiR; +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.R; +import com.heyu.api.data.utils.StringUtils; import com.heyu.api.request.card.BusinessLicenseRecognizeRequest; +import com.heyu.api.resp.card.BusinessLicenseRecognizeResp; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +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.util.Map; +/** + * 营业执照识别控制器 + *

+ * 接口背景:用于商户入驻、企业资质核验、开票信息录入等场景,将营业执照照片自动识别为结构化字段, + * 替代人工录入。底层对接百度智能云文字识别「营业执照识别」能力,由本服务完成参数校验、请求转发与字段映射。 + *

+ *

+ * 支持识别:单位名称、统一社会信用代码、法人、公司类型、组成形式、注册资本、实收资本、经营范围、 + * 成立日期、核准日期、有效期、登记机关、税务登记号、证件编号、公司地址等核心字段。 + *

+ * + *

百度官方文档

+ * + * + *

本服务接口

+ * + * + *

请求参数({@link BusinessLicenseRecognizeRequest})

+ * + * + *

响应结构({@link BusinessLicenseRecognizeResp})

+ * + *

日期统一格式化为 {@code yyyyMMdd}(如 20250108);"长期"统一返回 {@code 29991231};百度未识别字段("无")统一返回 null。

+ * + *

返回约定(重要)

+ * + * + * @author heyu + * @since 1.0.0 + * @see BusinessLicenseRecognizeRequest + * @see BusinessLicenseRecognizeResp + */ @Slf4j @RestController @RequestMapping("/business/license") @NotIntercept -public class BusinessLicenseRecognizeController extends BaseController { +public class BusinessLicenseRecognizeController extends AbstractRecognizeController { - @Autowired - private BBusinessLicenseHandle bBusinessLicenseHandle; + /** 百度营业执照识别 API 路径 */ + private static final String BUSINESS_LICENSE_URI = "/rest/2.0/ocr/v1/business_license"; - @RequestMapping("/recognize") + /** 营业执照识别无 side 概念,统一占位以复用父类上下文 */ + private static final String SIDE_PLACEHOLDER = "business_license"; + + /** 百度对未识别字段返回的占位值,需在映射时清洗为 null */ + private static final String BAIDU_EMPTY = "无"; + + /** "长期"有效期的统一表达 */ + private static final String LONG_TERM_DATE = "29991231"; + + /** + * 营业执照识别 + * + * @param request 营业执照识别请求 + * @return {@link BusinessLicenseRecognizeResp} + */ + @EbAuthentication(tencent = ApiConstants.TENCENT_AUTH) @CacheResult - public R recognize(BusinessLicenseRecognizeRequest request) { - BBusinessLicenseRequest businessLicenseRequest = new BBusinessLicenseRequest(); - businessLicenseRequest.setImageUrl(request.getImageUrl()); - businessLicenseRequest.setImageBase64(request.getImageBase64()); - ApiR bR = bBusinessLicenseHandle.handle(businessLicenseRequest); - return com.heyu.api.controller.ocr.BaiduOcrResult.raw(bR); + @PostMapping("/recognize") + public R recognize(@RequestBody BusinessLicenseRecognizeRequest request) { + long start = System.currentTimeMillis(); + RecognizeContext ctx = null; + try { + // ---------- 步骤①:参数校验(不调百度、不扣费) ---------- + String validateError = validateRequest(request); + if (validateError != null) { + ResolvedImageInput validateImage = request != null + ? ImageInputUtils.resolve(request.getImageUrlOrBase64()) : null; + log.info("营业执照识别:参数检查没通过,接口仍返回成功并附带提示(还没调识别、不扣费)。{} 返回给客户:{}", + buildInputLogContext(request, validateImage), + abbreviate(validateError, 120)); + return okResult(SIDE_PLACEHOLDER, validateError, null); + } + + // ---------- 步骤②:解析影像入参 & 构建上下文 ---------- + ResolvedImageInput imageInput = ImageInputUtils.resolve(request.getImageUrlOrBase64()); + ctx = new RecognizeContext( + SIDE_PLACEHOLDER, + imageInput, + buildInputLogContext(request, imageInput)); + + // ---------- 步骤③:组装百度 API 请求体 ---------- + String content = buildRequestContent(imageInput); + if (isBlank(content)) { + log.error("营业执照识别:组装请求失败,请求里没带有效图片。{}。{}", + ctx.imageInput.getType().getDesc(), ctx.inputLog); + return okResult(SIDE_PLACEHOLDER, formatHint( + "报文组装异常", + "识别请求体在序列化后未包含任何影像载荷,平台侧无法受理本次识别", + "请核对 imageUrlOrBase64 是否非空且为有效 Base64 或 HTTP(S) 链接", + "影像模式=" + ctx.imageInput.getType().name() + ), null); + } + + // ---------- 步骤④:调用百度平台识别 ---------- + Map platformResult = callPlatform(content, ctx); + if (platformResult == null) { + return okResult(SIDE_PLACEHOLDER, formatHint( + "服务无回执", + "识别指令已下发,但在约定时间内未收到平台可解析的 JSON 回执", + "建议间隔 3~5 秒重试;若连续失败,请记录 traceId、调用时刻并联系技术支持排查链路", + "影像模式=" + ctx.imageInput.getType().name() + ), null); + } + + // ---------- 步骤⑤:解析平台结果 → 构建营业执照响应 ---------- + BusinessLicenseRecognizeResp data = buildResp(platformResult); + String hint = resolvePlatformHint(platformResult, ctx, data); + + // ---------- 步骤⑥:日志记录 & 返回 ---------- + logRecognizeResult(ctx, platformResult, data, hint, start); + return okResult(SIDE_PLACEHOLDER, hint, data); + + } catch (Exception e) { + long cost = System.currentTimeMillis() - start; + 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, mode, buildInputLogContext(request, fallbackImage), + e.getClass().getSimpleName(), + e.getMessage() != null ? e.getMessage() : "无具体说明", e); + return okResult(SIDE_PLACEHOLDER, formatHint( + "运行时故障", + "服务端在处理识别流程时抛出未预期异常,识别结果不可用", + "请勿重复高频重试;请保存 traceId、异常发生时间,由技术支持结合堆栈进一步定位", + "异常类型=" + e.getClass().getSimpleName() + + (e.getMessage() != null ? ",摘要=" + e.getMessage() : "") + ), null); + } + } + + /** + * 子类钩子:营业执照识别只有一种响应类型,无 side 区分。 + */ + @Override + protected Object defaultEmptyResp(String side) { + return new BusinessLicenseRecognizeResp(); + } + + // ===================== 流程拆分方法 ===================== + + private Map callPlatform(String content, RecognizeContext ctx) { + int len = content.length(); + log.info("营业执照识别:开始调用平台识别。{},请求大小约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); + Map result = requestBaidu(BUSINESS_LICENSE_URI, content); + if (result == null) { + log.error("营业执照识别:平台没有返回结果(可能网络超时、鉴权失败或响应无法解析)。{},请求约 {} 字节。{}", + ctx.imageInput.getType().getDesc(), len, ctx.inputLog); + } + return result; + } + + /** + * 解析平台返回结果,判断是否需要向客户返回提示信息。 + *

判断顺序:

+ *
    + *
  1. error_code 存在 → 平台业务拒绝
  2. + *
  3. words_result 缺失或为空 → 平台未识别出营业执照
  4. + *
  5. 响应对象全部 String 字段为空 → 字段映射失败
  6. + *
  7. 以上均不命中 → 返回 null(识别正常)
  8. + *
+ */ + private String resolvePlatformHint(Map platformResult, RecognizeContext ctx, + BusinessLicenseRecognizeResp 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 = BaiduOcrError.categoryOf(errorCode); + log.error("营业执照识别:平台拒绝了本次识别。[{}] 错误码 {},原因:{}。{}。客户传的:{}。{}", + category, errorCode, errorMsg, + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + category, + BaiduOcrError.reasonOf(errorCode), + BaiduOcrError.suggestionOf(errorCode, "营业执照"), + "错误码=" + errorCode + ",错误描述=" + errorMsg + ); + } + if (isWordsResultEmpty(platformResult)) { + log.info("营业执照识别:平台返回了,但 words_result 为空(可能不是营业执照、画面残缺或非证件照)。{}。客户传的:{}。{}", + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "结构化结果缺失", + "平台回执未包含可解析的营业执照识别结果,无法进入字段映射环节", + "优先排查:① 画面是否为完整营业执照(含单位名称/信用代码等核心信息);② 是否模糊 / 反光 / 严重倾斜;" + + "③ 使用 URL 时确保平台抓取节点可访问且无 403/302 拦截", + "解析状态=结果对象为空" + ); + } + if (isAllStringFieldsBlank(data)) { + log.info("营业执照识别:平台有返回,但单位名称/信用代码/法人等字段一个都没识别出来。{}。客户传的:{}。平台回执:{}", + ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + return formatHint( + "字段映射为空", + "平台回执已通过结构校验,但单位名称、统一社会信用代码、法人等结构化字段均未命中", + "请确认上传图片为正面完整的营业执照(含国徽与正文);推荐:平铺拍摄、避开反光、保证四角与文字清晰", + "已映射字段数=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, ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult), abbreviate(hint, 120)); + } else { + log.info("营业执照识别:识别成功。耗时 {} ms,共识别出 {} 个字段。{}。客户传的:{}。平台回执:{}", + cost, mapped, ctx.imageInput.getType().getDesc(), + ctx.inputLog, buildPlatformReceiptSummary(platformResult)); + } + } + + // ===================== 校验 ===================== + + private String validateRequest(BusinessLicenseRecognizeRequest request) { + if (request == null) { + log.info("营业执照识别:没收到任何请求参数(表单未绑定成功),直接拒绝,未调用识别、不扣费"); + return formatHint( + "入参绑定失败", + "控制器未接收到可绑定的请求对象,所有业务字段均为空", + "请确认使用 POST 提交;Content-Type 为 application/json;字段名为 imageUrlOrBase64", + "绑定结果=BusinessLicenseRecognizeRequest 为 null" + ); + } + ImageInputUtils.ValidationResult imageValidation = + ImageInputUtils.validate(request.getImageUrlOrBase64()); + if (!imageValidation.isValid()) { + log.info("营业执照识别:imageUrlOrBase64 校验未通过({}),未调用识别、不扣费。原因:{}。{}", + imageValidation.getCategory(), + imageValidation.getReason(), + buildInputLogContext(request, null)); + return formatHint( + imageValidation.getCategory(), + imageValidation.getReason(), + imageValidation.getSuggestion(), + imageValidation.getDetail() + ); + } + return null; + } + + // ===================== 请求 / 响应构造 ===================== + + private String buildRequestContent(ResolvedImageInput imageInput) { + StringBuilder sb = new StringBuilder(); + 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("&accuracy=normal"); + sb.append("&risk_warn=false"); + return sb.toString(); + } + + /** + * 将百度返回的 words_result 映射为对外响应对象。 + *

映射规则:

+ *
    + *
  • 百度返回的占位值"无" → null(保持响应整洁)
  • + *
  • 日期字段统一格式化为 yyyyMMdd;"长期" → 29991231
  • + *
+ */ + private BusinessLicenseRecognizeResp buildResp(Map data) { + BusinessLicenseRecognizeResp resp = new BusinessLicenseRecognizeResp(); + resp.setCompanyName(clean(data, "单位名称")); + resp.setRegisterNumber(clean(data, "社会信用代码")); + resp.setCompanyLegalPerson(clean(data, "法人")); + resp.setCompanyType(clean(data, "类型")); + resp.setCompositionType(clean(data, "组成形式")); + resp.setRegisterCapital(clean(data, "注册资本")); + resp.setRealCapital(clean(data, "实收资本")); + resp.setBusiness(clean(data, "经营范围")); + resp.setCompanyAddress(clean(data, "地址")); + resp.setRegisterOrgan(clean(data, "登记机关")); + resp.setTaxRegisterCode(clean(data, "税务登记号")); + resp.setCertificateNumber(clean(data, "证件编号")); + resp.setRegisterDate(normalizeDate(clean(data, "成立日期"))); + resp.setApprovalDate(normalizeDate(clean(data, "核准日期"))); + resp.setValidDateStart(normalizeDate(clean(data, "有效期起始日期"))); + resp.setValidDateEnd(normalizeDate(clean(data, "有效期"))); + return resp; + } + + /** + * 从 words_result 取字段并清洗百度占位值"无"。 + */ + private String clean(Map data, String field) { + String value = getWords(data, field); + if (StringUtils.isBlank(value) || BAIDU_EMPTY.equals(value.trim())) { + return null; + } + return value.trim(); + } + + /** + * 将"2025年01月08日""2025-01-08"等格式统一为 yyyyMMdd;含"长期"返回 29991231;无法解析则原样返回。 + */ + private String normalizeDate(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + if (raw.contains("长期")) { + return LONG_TERM_DATE; + } + String digits = raw.replaceAll("[^0-9]", ""); + if (digits.length() == 8) { + return digits; + } + return raw; + } + + // ===================== 私有工具 ===================== + + 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 buildInputLogContext(BusinessLicenseRecognizeRequest request, ResolvedImageInput imageInput) { + if (request == null) { + return "未收到请求体"; + } + String modeDesc = imageInput != null ? imageInput.getType().getDesc() : "影像未解析"; + int rawLen = imageInput != null + ? textLength(imageInput.getRawValue()) + : textLength(request.getImageUrlOrBase64()); + return String.format("%s,影像原始长度 %d 字符", modeDesc, rawLen); } } diff --git a/api-web/api-interface/src/main/java/com/heyu/api/request/card/BusinessLicenseRecognizeRequest.java b/api-web/api-interface/src/main/java/com/heyu/api/request/card/BusinessLicenseRecognizeRequest.java index 78d51a4..22485e3 100644 --- a/api-web/api-interface/src/main/java/com/heyu/api/request/card/BusinessLicenseRecognizeRequest.java +++ b/api-web/api-interface/src/main/java/com/heyu/api/request/card/BusinessLicenseRecognizeRequest.java @@ -1,25 +1,26 @@ package com.heyu.api.request.card; - import com.heyu.api.data.dto.BaseReq; import lombok.Data; +/** + * 营业执照识别请求参数。 + * + * 对外提供简洁接口,支持识别营业执照的单位名称、统一社会信用代码、法人、注册资本、经营范围、 + * 成立/核准日期、有效期、登记机关等字段。 + */ @Data public class BusinessLicenseRecognizeRequest extends BaseReq { - - /** - * 营业执照图片的url + * 影像入参(URL 或 Base64 二合一): + *
    + *
  • HTTP/HTTPS 图片链接(≤1024 字符,未 urlencode 时由服务端自动编码)
  • + *
  • 图片 Base64 字符串(支持 jpg/jpeg/png/bmp;可带 data:image/...;base64, 前缀; + * 编码后 urlencode 前建议≤4M,未 urlencode 时由服务端自动编码)
  • + *
+ * 服务端根据内容自动识别为链接或 Base64。 */ - private String imageUrl; - - /** - * 营业执照base 64 编码 - */ - private String imageBase64; - - - + private String imageUrlOrBase64; }