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:
@@ -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_token:POST,参数 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/111:token 无效或过期,需丢弃缓存 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user