feat(hjc): 证件OCR改用百度云(身份证/营业执照识别)

- 新增 com.gxwebsoft.hjc.ocr:HjcBaiduOcrClient(access_token 进程内缓存+提前刷新、
  两个接口调用、error_code/log_id 解析)、HjcOcrImageFetcher(取回 fileUrl 字节,
  要求 HTTP 200 且 Content-Type 为 image/*)、HjcOcrException(正数=百度云错误码,负数=本方错误码)
- HjcOcrServiceImpl 重写:材料类型路由(国徽面/授权委托书提前返回)、中文 key 映射、
  未识别哨兵值「无」过滤、图片大小与最短边前置校验(不合规不调百度云,身份证失败也计费)、
  失败按 error_code 打结构化日志(不记录识别出的值)
- pom 移除 aliyun-java-sdk-ocr;配置 aliyun.ocr.* 改为 baidu.ocr.api-key/secret-key(占位空值)
- 新增打桩单测 28 个(token 缓存与刷新、字段映射、「无」过滤、错误码、超限、正反面传错)

背景:阿里云 OCR 的 AK 一直是空占位,从未开通、链路从未跑通;甲方要求改用百度云。
接口契约不变(POST /api/hjc/ocr/recognize,前端零改动)。
This commit is contained in:
2026-09-10 14:47:00 +08:00
parent 08138f1f04
commit 1e2ec82c18
10 changed files with 1093 additions and 78 deletions
@@ -0,0 +1,267 @@
package com.gxwebsoft.hjc.ocr;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 百度云 OCR REST 客户端(身份证识别 / 营业执照识别)
*
* <p>接口文档:<a href="https://ai.baidu.com/ai-doc/OCR/rk3h7xzck">身份证识别</a>、
* <a href="https://ai.baidu.com/ai-doc/OCR/sk3h7y3zs">营业执照识别</a>。
* 调用方式为「先取 access_token,再以表单 POST 传 base64 图片」,不引入百度官方 Java SDK,
* 只用项目已有的 hutool-http + fastjson。</p>
*
* <p><b>token 缓存</b>access_token 有效期 30 天且<b>跨接口共用</b>,按应用(api-key/secret-key)维度缓存。
* 本项目为单实例部署,故只在进程内缓存({@code volatile} + 到期时间戳 + 双重检查刷新),
* 不落 Redis,避免引入一层与 OCR 无关的失败模式。若将来改多实例部署,只需改造
* {@link #currentAccessToken()} 与 {@link #refreshAccessToken()}。</p>
*
* <p><b>失败语义</b>:任何失败都抛 {@link HjcOcrException},携带百度云 {@code error_code}/{@code log_id}
* 或本平台侧错误码,由调用方决定日志与返回。不重试:QPS/额度类错误重试只会更糟。</p>
*
* <p>本类不打印任何识别内容(姓名、身份证号属个人信息),只记录字段名与错误码。</p>
*/
@Component
public class HjcBaiduOcrClient {
/** 获取 access_tokenPOST,参数 grant_type/client_id/client_secret */
private static final String TOKEN_URL = "https://aip.baidubce.com/oauth/2.0/token";
/** 身份证识别:id_card_side 必填,front=人像面 */
private static final String IDCARD_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/idcard";
/** 营业执照识别 */
private static final String BUSINESS_LICENSE_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/business_license";
/** 连接超时:百度云为公网服务,2s 足够 */
static final int CONNECT_TIMEOUT_MS = 2000;
/** 读取超时:base64 图片上行 + 识别,给 8s */
static final int READ_TIMEOUT_MS = 8000;
/** 提前刷新窗口:缓存的到期时间 = 拿到 token 的时刻 + (expires_in - 本窗口),即有效期还剩不到 1 小时就重新取 */
static final long TOKEN_REFRESH_AHEAD_MS = 60L * 60L * 1000L;
/** token 有效期兜底(百度云返回 expires_in 缺失时按 30 天算) */
static final long DEFAULT_TOKEN_TTL_MS = 30L * 24L * 60L * 60L * 1000L;
/** 刷新后至少缓存 1 分钟,防止 expires_in 异常小导致每次都请求 token */
static final long MIN_TOKEN_TTL_MS = 60L * 1000L;
/** 身份证人像面:id_card_side=front */
static final String ID_CARD_SIDE_FRONT = "front";
@Value("${baidu.ocr.api-key:}")
private String apiKey;
@Value("${baidu.ocr.secret-key:}")
private String secretKey;
/** 缓存的 access_token */
private volatile String accessToken;
/** 缓存到期时间(epoch millis */
private volatile long accessTokenExpireAt;
/**
* 凭据是否已配置:未配置则调用方跳过识别(不阻断注册)
*/
public boolean isConfigured() {
return StrUtil.isNotBlank(apiKey) && StrUtil.isNotBlank(secretKey);
}
/**
* 身份证人像面识别
*
* @param imageBytes 图片原始字节(内部做 base64 + urlencode
* @return 识别结果({@code words_result} 的中文 key → 文本)
*/
public Result recognizeIdCardFront(byte[] imageBytes) {
return recognize(IDCARD_URL, imageBytes, "id_card_side", ID_CARD_SIDE_FRONT);
}
/**
* 营业执照识别
*
* @param imageBytes 图片原始字节(内部做 base64 + urlencode
* @return 识别结果({@code words_result} 的中文 key → 文本)
*/
public Result recognizeBusinessLicense(byte[] imageBytes) {
return recognize(BUSINESS_LICENSE_URL, imageBytes, null, null);
}
private Result recognize(String apiUrl, byte[] imageBytes, String extraName, String extraValue) {
String token = currentAccessToken();
HttpRequest request = HttpRequest.post(apiUrl + "?access_token=" + token)
.setConnectionTimeout(CONNECT_TIMEOUT_MS)
.timeout(READ_TIMEOUT_MS)
.form("image", Base64.getEncoder().encodeToString(imageBytes));
if (extraName != null) {
request.form(extraName, extraValue);
}
JSONObject json = parseJson(execute(request), "OCR 识别");
int errorCode = json.getIntValue("error_code");
if (errorCode != 0) {
String errorMsg = json.getString("error_msg");
String logId = json.getString("log_id");
if (isTokenInvalid(errorCode)) {
// 不是重试:只是让下一次调用重新取 token,避免继续用失效 token
invalidateAccessToken();
}
throw new HjcOcrException(errorCode, errorMsg, logId);
}
Result result = new Result();
result.setLogId(json.getString("log_id"));
// 身份证接口专属:normal / reversed_side(正反面传错)/ blurred / non_idcard 等
result.setImageStatus(json.getString("image_status"));
result.setWords(extractWords(json));
return result;
}
/**
* 取可用的 access_token:命中缓存直接返回,否则同步刷新(双重检查,避免并发重复取 token)
*/
private String currentAccessToken() {
String cached = this.accessToken;
if (cached != null && System.currentTimeMillis() < this.accessTokenExpireAt) {
return cached;
}
synchronized (this) {
if (this.accessToken != null && System.currentTimeMillis() < this.accessTokenExpireAt) {
return this.accessToken;
}
refreshAccessToken();
return this.accessToken;
}
}
private void refreshAccessToken() {
HttpRequest request = HttpRequest.post(TOKEN_URL)
.setConnectionTimeout(CONNECT_TIMEOUT_MS)
.timeout(READ_TIMEOUT_MS)
.form("grant_type", "client_credentials")
.form("client_id", apiKey)
.form("client_secret", secretKey);
JSONObject json = parseJson(execute(request), "获取 access_token");
String token = json.getString("access_token");
if (StrUtil.isBlank(token)) {
// 失败响应形如 {"error":"invalid_client","error_description":"unknown client id"}
throw new HjcOcrException(HjcOcrException.ERROR_TOKEN_FETCH,
"获取 access_token 失败:" + describeClientError(json), null);
}
long expiresInSeconds = json.getLongValue("expires_in");
long ttlMillis = expiresInSeconds > 0 ? expiresInSeconds * 1000L : DEFAULT_TOKEN_TTL_MS;
this.accessToken = token;
this.accessTokenExpireAt = System.currentTimeMillis()
+ Math.max(ttlMillis - TOKEN_REFRESH_AHEAD_MS, MIN_TOKEN_TTL_MS);
}
private void invalidateAccessToken() {
this.accessToken = null;
this.accessTokenExpireAt = 0L;
}
/**
* 执行一次 HTTP 调用并返回响应体文本。
*
* <p>该方法是唯一的 HTTP 出口,测试中可覆盖以注入打桩响应,从而在不联网、不依赖百度额度的情况下
* 覆盖 token 缓存与各错误码分支。</p>
*/
protected String execute(HttpRequest request) {
try (HttpResponse response = request.execute()) {
String body = response.body();
if (StrUtil.isBlank(body)) {
throw new HjcOcrException(HjcOcrException.ERROR_RESPONSE_INVALID,
"百度云 OCR 响应为空,HTTP " + response.getStatus(), null);
}
return body;
} catch (HjcOcrException e) {
throw e;
} catch (Exception e) {
throw new HjcOcrException(HjcOcrException.ERROR_RESPONSE_INVALID,
"调用百度云 OCR 失败:" + e.getMessage(), null);
}
}
private static JSONObject parseJson(String body, String action) {
try {
return JSONObject.parseObject(body);
} catch (Exception e) {
throw new HjcOcrException(HjcOcrException.ERROR_RESPONSE_INVALID,
action + " 响应不是合法 JSON" + brief(body), null);
}
}
/** {@code words_result} 形如 {"姓名":{"location":{...},"words":"张三"}} */
private static Map<String, String> extractWords(JSONObject json) {
Map<String, String> words = new LinkedHashMap<>();
JSONObject wordsResult = json.getJSONObject("words_result");
if (wordsResult == null) {
return words;
}
for (String key : wordsResult.keySet()) {
JSONObject item = wordsResult.getJSONObject(key);
words.put(key, item == null ? null : item.getString("words"));
}
return words;
}
/** 100/110/111token 无效或过期,需丢弃缓存 */
private static boolean isTokenInvalid(int errorCode) {
return errorCode == 100 || errorCode == 110 || errorCode == 111;
}
private static String describeClientError(JSONObject json) {
String error = json.getString("error");
String description = json.getString("error_description");
return StrUtil.isBlank(error) ? brief(json.toJSONString()) : error + " / " + description;
}
private static String brief(String text) {
if (text == null) {
return null;
}
return text.length() <= 200 ? text : text.substring(0, 200) + "...";
}
/**
* 百度云 OCR 识别结果
*/
public static class Result {
/** {@code words_result}:中文 key → 文本(未识别字段的文本为「无」) */
private Map<String, String> words = new LinkedHashMap<>();
/** 百度云 log_id,用于向百度云排查 */
private String logId;
/** 身份证接口返回:normal / reversed_side / blurred / non_idcard 等 */
private String imageStatus;
public Map<String, String> getWords() {
return words;
}
public void setWords(Map<String, String> words) {
this.words = words;
}
public String getLogId() {
return logId;
}
public void setLogId(String logId) {
this.logId = logId;
}
public String getImageStatus() {
return imageStatus;
}
public void setImageStatus(String imageStatus) {
this.imageStatus = imageStatus;
}
}
}
@@ -0,0 +1,58 @@
package com.gxwebsoft.hjc.ocr;
/**
* 汇吉采 证件 OCR 失败异常
*
* <p>错误码有两类,便于日志排查:</p>
* <ul>
* <li><b>正数</b>:百度云返回的 {@code error_code}(如 6 未开通接口、17/18/19 额度与 QPS 超限、
* 110/111 token 失效、216201/216202 图片格式与大小错误、282102/282112/282114 识别与 URL 抓取失败)。</li>
* <li><b>负数</b>:本平台侧的错误码,见本类常量(取图失败、token 获取失败、响应无法解析、图片不合规、
* 未识别到字段)。</li>
* </ul>
*
* <p>本异常只在服务端流转用于打日志,<b>不向接口调用方暴露细节</b>Controller 仍统一返回
* 「识别失败或该类型无需识别,请手动填写」。</p>
*/
public class HjcOcrException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** 本平台侧:取回证件图片失败(我方下载 fileUrl 失败) */
public static final int ERROR_IMAGE_FETCH = -1;
/** 本平台侧:获取 access_token 失败 */
public static final int ERROR_TOKEN_FETCH = -2;
/** 本平台侧:百度云响应不是合法 JSON */
public static final int ERROR_RESPONSE_INVALID = -3;
/** 本平台侧:图片超过接口大小上限,未调用百度云 */
public static final int ERROR_IMAGE_TOO_LARGE = -4;
/** 本平台侧:图片最短边不足,未调用百度云 */
public static final int ERROR_IMAGE_TOO_SMALL = -5;
/** 本平台侧:调用成功但一个字段都没识别到(常见于身份证正反面传错) */
public static final int ERROR_NO_FIELD = -6;
/** 错误码:正数为百度云 error_code,负数为本平台侧错误码 */
private final int errorCode;
/** 百度云 log_id,便于向百度云排查;本平台侧错误时为 null */
private final String logId;
public HjcOcrException(int errorCode, String message, String logId) {
super(message);
this.errorCode = errorCode;
this.logId = logId;
}
public int getErrorCode() {
return errorCode;
}
public String getLogId() {
return logId;
}
/** 是否百度云返回的错误(正数) */
public boolean isBaiduError() {
return errorCode > 0;
}
}
@@ -0,0 +1,60 @@
package com.gxwebsoft.hjc.ocr;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import org.springframework.stereotype.Component;
/**
* 证件图片取回器:把前端上传后返回的 {@code fileUrl} 取成字节,供百度云 OCR 以 base64 方式上传。
*
* <p><b>为什么由我方取图,而不是把 URL 交给百度云回源抓取</b>:URL 模式依赖「第三方能抓到我方文件地址」,
* 这条链路不受本平台控制,且百度云对 URL 模式还有 {@code 282112}URL 下载超时,含图片 >3M、防盗链)
* 这类难以自查的失败;改成我方取图后,「取图失败」变成可判断、可打日志的本地事件。</p>
*
* <p>只认 HTTP 200 + {@code image/*}:证件地址若指向 404/HTML(例如文件服务未正确映射路径),
* 直接失败而不是把错误页当图片送去识别,避免白耗百度云额度(身份证接口失败也计费)。</p>
*/
@Component
public class HjcOcrImageFetcher {
/** 连接超时 */
static final int CONNECT_TIMEOUT_MS = 2000;
/** 读取超时:与百度云调用保持同一档 */
static final int READ_TIMEOUT_MS = 8000;
/**
* 取回证件图片字节
*
* @param fileUrl 已上传证件的可访问地址(由 {@code /api/hjc/auth/upload} 或 {@code /api/file/upload} 返回)
* @return 图片原始字节
* @throws HjcOcrException 取图失败(地址不可达、非 200、内容不是图片)
*/
public byte[] fetch(String fileUrl) {
try (HttpResponse response = HttpRequest.get(fileUrl)
.setConnectionTimeout(CONNECT_TIMEOUT_MS)
.timeout(READ_TIMEOUT_MS)
.execute()) {
if (response.getStatus() != 200) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"取回证件图片失败:HTTP " + response.getStatus() + " url=" + fileUrl, null);
}
String contentType = response.header("Content-Type");
if (StrUtil.isNotBlank(contentType) && !contentType.toLowerCase().startsWith("image/")) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"证件地址返回的不是图片:Content-Type=" + contentType + " url=" + fileUrl, null);
}
byte[] bytes = response.bodyBytes();
if (bytes == null || bytes.length == 0) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"证件地址未取到内容:url=" + fileUrl, null);
}
return bytes;
} catch (HjcOcrException e) {
throw e;
} catch (Exception e) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH,
"取回证件图片失败:" + e.getMessage() + " url=" + fileUrl, null);
}
}
}
@@ -3,7 +3,9 @@ package com.gxwebsoft.hjc.service;
import com.gxwebsoft.hjc.dto.HjcOcrResult;
/**
* 汇吉采 证件 OCR 识别(阿里云文字识别 OCR,服务端调用)
* 汇吉采 证件 OCR 识别(百度云 OCR,服务端调用)
*
* <p>实现见 {@code HjcOcrServiceImpl};供应商细节封装在 {@code com.gxwebsoft.hjc.ocr} 包内。</p>
*/
public interface HjcOcrService {
@@ -1,24 +1,36 @@
package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.ocr.model.v20191230.RecognizeBusinessLicenseRequest;
import com.aliyuncs.ocr.model.v20191230.RecognizeBusinessLicenseResponse;
import com.aliyuncs.ocr.model.v20191230.RecognizeIdentityCardRequest;
import com.aliyuncs.ocr.model.v20191230.RecognizeIdentityCardResponse;
import com.aliyuncs.profile.DefaultProfile;
import com.gxwebsoft.hjc.dto.HjcOcrResult;
import com.gxwebsoft.hjc.ocr.HjcBaiduOcrClient;
import com.gxwebsoft.hjc.ocr.HjcOcrException;
import com.gxwebsoft.hjc.ocr.HjcOcrImageFetcher;
import com.gxwebsoft.hjc.service.HjcOcrService;
import org.springframework.beans.factory.annotation.Value;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.ByteArrayInputStream;
import java.util.Iterator;
import java.util.Map;
/**
* 汇吉采 证件 OCR 识别实现(阿里云文字识别 OCR
* 汇吉采 证件 OCR 识别实现(百度云 OCR
*
* <p>AK 需具备 OCR 服务权限;未配置(占位值为空)时直接返回 null,由前端提示「识别失败,请手动填写」,
* 不阻断注册流程。</p>
* <p>只做「业务映射」这一件事:材料类型路由 → 取图与前置校验 → 调百度云 → 中文 key 映射为
* {@link HjcOcrResult} 字段 → 过滤未识别哨兵值。HTTP 细节在 {@link HjcBaiduOcrClient}
* 取图在 {@link HjcOcrImageFetcher},两层都可独立打桩测试。</p>
*
* <p><b>不阻断原则</b>:未配置凭据、材料类型无需识别、图片不合规、百度云返回错误码——一律返回 {@code null}
* 由 Controller 统一回「识别失败或该类型无需识别,请手动填写」,注册流程照常可提交。</p>
*
* <p><b>日志</b>:只记录材料类型、图片字节数、识别到的字段名、错误码与 log_id,
* <b>不记录识别出的值</b>(姓名、身份证号属个人信息)。</p>
*/
@Slf4j
@Service
public class HjcOcrServiceImpl implements HjcOcrService {
@@ -27,81 +39,165 @@ public class HjcOcrServiceImpl implements HjcOcrService {
/** 材料类型:营业执照 */
public static final String MATERIAL_LICENSE = "license";
@Value("${aliyun.ocr.access-key-id:}")
private String accessKeyId;
/** 百度云对未识别字段返回的哨兵文本(location 全为 -1/0),按「未识别」处理 */
static final String UNRECOGNIZED_TEXT = "";
@Value("${aliyun.ocr.access-key-secret:}")
private String accessKeySecret;
/** 百度云 words_result key:身份证人像面 */
static final String KEY_NAME = "姓名";
/** 身份证号(百度云为「公民身份号码」,不是「身份证号」) */
static final String KEY_ID_NUMBER = "公民身份号码";
/** 营业执照 words_result key:企业名称 */
static final String KEY_COMPANY_NAME = "单位名称";
/** 统一社会信用代码(百度云的 key 是「社会信用代码」,取「统一社会信用代码」会静默取空) */
static final String KEY_CREDIT_CODE = "社会信用代码";
/** 企业地址 */
static final String KEY_ADDRESS = "地址";
/** 法定代表人 */
static final String KEY_LEGAL_PERSON = "法人";
@Value("${aliyun.ocr.region:cn-hangzhou}")
private String region;
/**
* 图片大小上限(原始字节):身份证 8M / 营业执照 10M,按百度云接口页。
*
* <p>百度云「调用方式」页另称全接口 4M,与接口页冲突;此处按较宽松的接口页取值,
* 不因一次未经验证的文档冲突就拦掉前端 5M 上限的既有照片。实测若证实为 4M,
* 再决定砍前端上限或加服务端压缩。</p>
*/
static final long MAX_IMAGE_BYTES_IDCARD = 8L * 1024 * 1024;
static final long MAX_IMAGE_BYTES_LICENSE = 10L * 1024 * 1024;
@Value("${aliyun.ocr.endpoint:ocr-api.cn-hangzhou.aliyuncs.com}")
private String endpoint;
/** 图片最短边下限(px */
static final int MIN_SIDE_PX = 15;
@Resource
private HjcBaiduOcrClient baiduOcrClient;
@Resource
private HjcOcrImageFetcher imageFetcher;
@Override
public HjcOcrResult recognize(String materialType, String fileUrl) {
if (StrUtil.isBlank(materialType) || StrUtil.isBlank(fileUrl)) {
return null;
}
if (StrUtil.isBlank(accessKeyId) || StrUtil.isBlank(accessKeySecret)) {
// OCR 未配置 AK(占位),不阻断注册
System.out.println("HjcOcr: aliyun.ocr 未配置 AccessKey,跳过识别");
boolean idCardFront = MATERIAL_IDCARD_FRONT.equals(materialType);
boolean license = MATERIAL_LICENSE.equals(materialType);
if (!idCardFront && !license) {
// idcard_back(身份证国徽面)与 handbook(授权委托书)只上传不识别
log.info("HjcOcr: 材料类型 {} 无需识别,跳过", materialType);
return null;
}
if (!baiduOcrClient.isConfigured()) {
// 占位未填:不算错误,注册流程不受影响
log.warn("HjcOcr: baidu.ocr.api-key/secret-key 未配置,跳过识别 materialType={}", materialType);
return null;
}
try {
IAcsClient client = createClient();
if (MATERIAL_IDCARD_FRONT.equals(materialType)) {
return recognizeIdCard(client, fileUrl);
byte[] imageBytes = imageFetcher.fetch(fileUrl);
long maxBytes = idCardFront ? MAX_IMAGE_BYTES_IDCARD : MAX_IMAGE_BYTES_LICENSE;
if (imageBytes.length > maxBytes) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_TOO_LARGE,
"图片超过接口上限,未调用百度云:bytes=" + imageBytes.length + " limit=" + maxBytes, null);
}
if (MATERIAL_LICENSE.equals(materialType)) {
return recognizeBusinessLicense(client, fileUrl);
if (!hasEnoughSideLength(imageBytes, MIN_SIDE_PX)) {
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_TOO_SMALL,
"图片最短边小于 " + MIN_SIDE_PX + "px,未调用百度云", null);
}
HjcBaiduOcrClient.Result recognized = idCardFront
? baiduOcrClient.recognizeIdCardFront(imageBytes)
: baiduOcrClient.recognizeBusinessLicense(imageBytes);
HjcOcrResult result = idCardFront ? toIdCardResult(recognized) : toLicenseResult(recognized);
if (result == null) {
// 常见于身份证正反面传错(image_status=reversed_side)或照片质量差
throw new HjcOcrException(HjcOcrException.ERROR_NO_FIELD,
"未识别到任何字段,imageStatus=" + recognized.getImageStatus(), recognized.getLogId());
}
log.info("HjcOcr: 识别完成 materialType={} bytes={} words={} imageStatus={} logId={}",
materialType, imageBytes.length, recognized.getWords().keySet(),
recognized.getImageStatus(), recognized.getLogId());
return result;
} catch (HjcOcrException e) {
log.warn("HjcOcr: 识别失败 materialType={} errorCode={} fromBaidu={} logId={} msg={}",
materialType, e.getErrorCode(), e.isBaiduError(), e.getLogId(), e.getMessage());
} catch (Exception e) {
e.printStackTrace();
log.warn("HjcOcr: 识别异常 materialType={}", materialType, e);
}
return null;
}
private IAcsClient createClient() throws Exception {
DefaultProfile profile = DefaultProfile.getProfile(region, accessKeyId, accessKeySecret);
DefaultProfile.addEndpoint(region, region, "ocr", endpoint);
return new DefaultAcsClient(profile);
}
/** 身份证人像面:回填经办人姓名 + 身份证号 */
private HjcOcrResult recognizeIdCard(IAcsClient client, String fileUrl) throws Exception {
RecognizeIdentityCardRequest request = new RecognizeIdentityCardRequest();
request.setImageURL(fileUrl);
request.setSide("face");
RecognizeIdentityCardResponse response = client.getAcsResponse(request);
if (response == null || response.getData() == null
|| response.getData().getFrontResult() == null) {
return null;
}
RecognizeIdentityCardResponse.Data.FrontResult front = response.getData().getFrontResult();
/** 身份证人像面:回填经办人姓名 + 身份证号;一个都没识别到则返回 null(视为识别失败) */
private HjcOcrResult toIdCardResult(HjcBaiduOcrClient.Result recognized) {
HjcOcrResult result = new HjcOcrResult();
result.setMaterialType(MATERIAL_IDCARD_FRONT);
result.setAgentName(front.getName());
result.setIdCardNo(front.getIDNumber());
result.setAgentName(text(recognized.getWords(), KEY_NAME));
result.setIdCardNo(text(recognized.getWords(), KEY_ID_NUMBER));
if (result.getAgentName() == null && result.getIdCardNo() == null) {
return null;
}
return result;
}
/** 营业执照:回填统一社会信用代码 + 企业地址;企业名称/法定代表人仅作展示提示 */
private HjcOcrResult recognizeBusinessLicense(IAcsClient client, String fileUrl) throws Exception {
RecognizeBusinessLicenseRequest request = new RecognizeBusinessLicenseRequest();
request.setImageURL(fileUrl);
RecognizeBusinessLicenseResponse response = client.getAcsResponse(request);
if (response == null || response.getData() == null) {
return null;
}
RecognizeBusinessLicenseResponse.Data data = response.getData();
/**
* 营业执照:回填统一社会信用代码 + 企业地址;企业名称与法定代表人仅作展示提示(不覆盖登录账号)。
* 老版执照可能没有「社会信用代码」,缺失即不回填,由用户手填。
*/
private HjcOcrResult toLicenseResult(HjcBaiduOcrClient.Result recognized) {
Map<String, String> words = recognized.getWords();
HjcOcrResult result = new HjcOcrResult();
result.setMaterialType(MATERIAL_LICENSE);
result.setEnterpriseName(data.getName());
result.setCreditCode(data.getRegisterNumber());
result.setAddress(data.getAddress());
result.setLegalPerson(data.getLegalPerson());
result.setEnterpriseName(text(words, KEY_COMPANY_NAME));
result.setCreditCode(text(words, KEY_CREDIT_CODE));
result.setAddress(text(words, KEY_ADDRESS));
result.setLegalPerson(text(words, KEY_LEGAL_PERSON));
if (result.getEnterpriseName() == null && result.getCreditCode() == null
&& result.getAddress() == null && result.getLegalPerson() == null) {
return null;
}
return result;
}
/**
* 取字段文本:{@code null}、空串、trim 后等于「无」一律视为未识别。
*
* <p>百度云对未识别字段不返回空值,而是返回「无」——不过滤就会被前端
* {@code fillIfBlank} 当成有效值填进表单。</p>
*/
static String text(Map<String, String> words, String key) {
if (words == null) {
return null;
}
String value = words.get(key);
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty() || UNRECOGNIZED_TEXT.equals(trimmed)) {
return null;
}
return trimmed;
}
/**
* 最短边是否达标:只读图片元数据(不解码整图),读不出尺寸时放行交给百度云判断
*/
private static boolean hasEnoughSideLength(byte[] imageBytes, int minSide) {
try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(imageBytes))) {
if (input == null) {
return true;
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) {
return true;
}
ImageReader reader = readers.next();
try {
reader.setInput(input);
return reader.getWidth(0) >= minSide && reader.getHeight(0) >= minSide;
} finally {
reader.dispose();
}
} catch (Exception e) {
log.debug("HjcOcr: 读取图片尺寸失败,跳过最短边校验", e);
return true;
}
}
}