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:
@@ -213,12 +213,8 @@
|
|||||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||||
<version>4.4.3</version>
|
<version>4.4.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!--阿里云文字识别 OCR(身份证/营业执照识别)-->
|
<!-- 证件 OCR(身份证/营业执照识别)已改用百度云 REST 接口,不再依赖 aliyun-java-sdk-ocr:
|
||||||
<dependency>
|
理由见 ADR-0003《证件 OCR 改用百度云》,调用封装在 com.gxwebsoft.hjc.ocr.HjcBaiduOcrClient -->
|
||||||
<groupId>com.aliyun</groupId>
|
|
||||||
<artifactId>aliyun-java-sdk-ocr</artifactId>
|
|
||||||
<version>1.0.6</version>
|
|
||||||
</dependency>
|
|
||||||
<!--阿里支付 老版本 SDK-->
|
<!--阿里支付 老版本 SDK-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.alipay.sdk</groupId>
|
<groupId>com.alipay.sdk</groupId>
|
||||||
|
|||||||
@@ -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;
|
import com.gxwebsoft.hjc.dto.HjcOcrResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 汇吉采 证件 OCR 识别(阿里云文字识别 OCR,服务端调用)
|
* 汇吉采 证件 OCR 识别(百度云 OCR,服务端调用)
|
||||||
|
*
|
||||||
|
* <p>实现见 {@code HjcOcrServiceImpl};供应商细节封装在 {@code com.gxwebsoft.hjc.ocr} 包内。</p>
|
||||||
*/
|
*/
|
||||||
public interface HjcOcrService {
|
public interface HjcOcrService {
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
package com.gxwebsoft.hjc.service.impl;
|
package com.gxwebsoft.hjc.service.impl;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
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.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 com.gxwebsoft.hjc.service.HjcOcrService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
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>只做「业务映射」这一件事:材料类型路由 → 取图与前置校验 → 调百度云 → 中文 key 映射为
|
||||||
* 不阻断注册流程。</p>
|
* {@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
|
@Service
|
||||||
public class HjcOcrServiceImpl implements HjcOcrService {
|
public class HjcOcrServiceImpl implements HjcOcrService {
|
||||||
|
|
||||||
@@ -27,81 +39,165 @@ public class HjcOcrServiceImpl implements HjcOcrService {
|
|||||||
/** 材料类型:营业执照 */
|
/** 材料类型:营业执照 */
|
||||||
public static final String MATERIAL_LICENSE = "license";
|
public static final String MATERIAL_LICENSE = "license";
|
||||||
|
|
||||||
@Value("${aliyun.ocr.access-key-id:}")
|
/** 百度云对未识别字段返回的哨兵文本(location 全为 -1/0),按「未识别」处理 */
|
||||||
private String accessKeyId;
|
static final String UNRECOGNIZED_TEXT = "无";
|
||||||
|
|
||||||
@Value("${aliyun.ocr.access-key-secret:}")
|
/** 百度云 words_result key:身份证人像面 */
|
||||||
private String accessKeySecret;
|
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}")
|
/** 图片最短边下限(px) */
|
||||||
private String endpoint;
|
static final int MIN_SIDE_PX = 15;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private HjcBaiduOcrClient baiduOcrClient;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private HjcOcrImageFetcher imageFetcher;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public HjcOcrResult recognize(String materialType, String fileUrl) {
|
public HjcOcrResult recognize(String materialType, String fileUrl) {
|
||||||
if (StrUtil.isBlank(materialType) || StrUtil.isBlank(fileUrl)) {
|
if (StrUtil.isBlank(materialType) || StrUtil.isBlank(fileUrl)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (StrUtil.isBlank(accessKeyId) || StrUtil.isBlank(accessKeySecret)) {
|
boolean idCardFront = MATERIAL_IDCARD_FRONT.equals(materialType);
|
||||||
// OCR 未配置 AK(占位),不阻断注册
|
boolean license = MATERIAL_LICENSE.equals(materialType);
|
||||||
System.out.println("HjcOcr: aliyun.ocr 未配置 AccessKey,跳过识别");
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
IAcsClient client = createClient();
|
byte[] imageBytes = imageFetcher.fetch(fileUrl);
|
||||||
if (MATERIAL_IDCARD_FRONT.equals(materialType)) {
|
long maxBytes = idCardFront ? MAX_IMAGE_BYTES_IDCARD : MAX_IMAGE_BYTES_LICENSE;
|
||||||
return recognizeIdCard(client, fileUrl);
|
if (imageBytes.length > maxBytes) {
|
||||||
|
throw new HjcOcrException(HjcOcrException.ERROR_IMAGE_TOO_LARGE,
|
||||||
|
"图片超过接口上限,未调用百度云:bytes=" + imageBytes.length + " limit=" + maxBytes, null);
|
||||||
}
|
}
|
||||||
if (MATERIAL_LICENSE.equals(materialType)) {
|
if (!hasEnoughSideLength(imageBytes, MIN_SIDE_PX)) {
|
||||||
return recognizeBusinessLicense(client, fileUrl);
|
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) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
log.warn("HjcOcr: 识别异常 materialType={}", materialType, e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IAcsClient createClient() throws Exception {
|
/** 身份证人像面:回填经办人姓名 + 身份证号;一个都没识别到则返回 null(视为识别失败) */
|
||||||
DefaultProfile profile = DefaultProfile.getProfile(region, accessKeyId, accessKeySecret);
|
private HjcOcrResult toIdCardResult(HjcBaiduOcrClient.Result recognized) {
|
||||||
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();
|
|
||||||
HjcOcrResult result = new HjcOcrResult();
|
HjcOcrResult result = new HjcOcrResult();
|
||||||
result.setMaterialType(MATERIAL_IDCARD_FRONT);
|
result.setMaterialType(MATERIAL_IDCARD_FRONT);
|
||||||
result.setAgentName(front.getName());
|
result.setAgentName(text(recognized.getWords(), KEY_NAME));
|
||||||
result.setIdCardNo(front.getIDNumber());
|
result.setIdCardNo(text(recognized.getWords(), KEY_ID_NUMBER));
|
||||||
|
if (result.getAgentName() == null && result.getIdCardNo() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 营业执照:回填统一社会信用代码 + 企业地址;企业名称/法定代表人仅作展示提示 */
|
/**
|
||||||
private HjcOcrResult recognizeBusinessLicense(IAcsClient client, String fileUrl) throws Exception {
|
* 营业执照:回填统一社会信用代码 + 企业地址;企业名称与法定代表人仅作展示提示(不覆盖登录账号)。
|
||||||
RecognizeBusinessLicenseRequest request = new RecognizeBusinessLicenseRequest();
|
* 老版执照可能没有「社会信用代码」,缺失即不回填,由用户手填。
|
||||||
request.setImageURL(fileUrl);
|
*/
|
||||||
RecognizeBusinessLicenseResponse response = client.getAcsResponse(request);
|
private HjcOcrResult toLicenseResult(HjcBaiduOcrClient.Result recognized) {
|
||||||
if (response == null || response.getData() == null) {
|
Map<String, String> words = recognized.getWords();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
RecognizeBusinessLicenseResponse.Data data = response.getData();
|
|
||||||
HjcOcrResult result = new HjcOcrResult();
|
HjcOcrResult result = new HjcOcrResult();
|
||||||
result.setMaterialType(MATERIAL_LICENSE);
|
result.setMaterialType(MATERIAL_LICENSE);
|
||||||
result.setEnterpriseName(data.getName());
|
result.setEnterpriseName(text(words, KEY_COMPANY_NAME));
|
||||||
result.setCreditCode(data.getRegisterNumber());
|
result.setCreditCode(text(words, KEY_CREDIT_CODE));
|
||||||
result.setAddress(data.getAddress());
|
result.setAddress(text(words, KEY_ADDRESS));
|
||||||
result.setLegalPerson(data.getLegalPerson());
|
result.setLegalPerson(text(words, KEY_LEGAL_PERSON));
|
||||||
|
if (result.getEnterpriseName() == null && result.getCreditCode() == null
|
||||||
|
&& result.getAddress() == null && result.getLegalPerson() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ aliyun:
|
|||||||
access-key-id: LTAI5tEsyhW4GCKbds1qsopg
|
access-key-id: LTAI5tEsyhW4GCKbds1qsopg
|
||||||
access-key-secret: zltFlQrYVAoq2KMFDWgLa3GhkMNeyO
|
access-key-secret: zltFlQrYVAoq2KMFDWgLa3GhkMNeyO
|
||||||
endpoint: mt.cn-hangzhou.aliyuncs.com
|
endpoint: mt.cn-hangzhou.aliyuncs.com
|
||||||
# 阿里云文字识别 OCR(身份证/营业执照识别),AK 需具备 OCR 服务权限;未开通前为占位值
|
|
||||||
|
# 百度云 OCR(证件识别),生产环境的真实 api-key/secret-key 由部署时注入;留空则跳过识别
|
||||||
|
baidu:
|
||||||
ocr:
|
ocr:
|
||||||
access-key-id: ""
|
api-key: ""
|
||||||
access-key-secret: ""
|
secret-key: ""
|
||||||
region: cn-hangzhou
|
|
||||||
endpoint: ocr-api.cn-hangzhou.aliyuncs.com
|
|
||||||
|
|||||||
@@ -105,13 +105,14 @@ config:
|
|||||||
bucketDomain: https://oss.wsdns.cn
|
bucketDomain: https://oss.wsdns.cn
|
||||||
aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com
|
aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com
|
||||||
|
|
||||||
# 阿里云文字识别 OCR(身份证/营业执照识别),AK 需具备 OCR 服务权限;未开通前为占位值
|
# 百度云 OCR(证件识别:身份证人像面 / 营业执照)
|
||||||
aliyun:
|
# 在百度智能云「文字识别」创建应用,并勾选「身份证识别」「营业执照识别」两个接口,
|
||||||
|
# 再把应用的 API Key / Secret Key 填到这里;留空(占位)时跳过识别,不阻断注册提交。
|
||||||
|
# 操作步骤见 docs/百度OCR运维清单.md
|
||||||
|
baidu:
|
||||||
ocr:
|
ocr:
|
||||||
access-key-id: ""
|
api-key: oP61trkhmHuii354yY3wFkft
|
||||||
access-key-secret: ""
|
secret-key: p3BnWNzxbiUpwoZoQgaH9FtWGpg9mnYx
|
||||||
region: cn-hangzhou
|
|
||||||
endpoint: ocr-api.cn-hangzhou.aliyuncs.com
|
|
||||||
|
|
||||||
# 商城订单配置
|
# 商城订单配置
|
||||||
shop:
|
shop:
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package com.gxwebsoft.hjc.ocr;
|
||||||
|
|
||||||
|
import cn.hutool.http.HttpRequest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 百度云 OCR 客户端单测:覆盖 token 缓存/刷新、请求参数、字段抽取与错误码分支。
|
||||||
|
*
|
||||||
|
* <p>不联网、不消耗百度云额度:{@link HjcBaiduOcrClient#execute(HttpRequest)} 是唯一的 HTTP 出口,
|
||||||
|
* 打桩子类把它换成预设响应队列。</p>
|
||||||
|
*/
|
||||||
|
class HjcBaiduOcrClientTest {
|
||||||
|
|
||||||
|
private static final String TOKEN_OK =
|
||||||
|
"{\"access_token\":\"tk-1\",\"expires_in\":2592000,\"scope\":\"public\"}";
|
||||||
|
|
||||||
|
private static final String IDCARD_OK =
|
||||||
|
"{\"words_result\":{"
|
||||||
|
+ "\"姓名\":{\"location\":{\"left\":1,\"top\":2,\"width\":3,\"height\":4},\"words\":\"张三\"},"
|
||||||
|
+ "\"公民身份号码\":{\"words\":\"110101199003077777\"},"
|
||||||
|
+ "\"性别\":{\"words\":\"男\"}},"
|
||||||
|
+ "\"words_result_num\":3,\"log_id\":1234567890123456789,\"image_status\":\"normal\"}";
|
||||||
|
|
||||||
|
private static final String LICENSE_OK =
|
||||||
|
"{\"words_result\":{"
|
||||||
|
+ "\"单位名称\":{\"words\":\"深圳市某某科技有限公司\"},"
|
||||||
|
+ "\"社会信用代码\":{\"words\":\"91440300MA5XXXXXXX\"},"
|
||||||
|
+ "\"地址\":{\"words\":\"深圳市南山区某某路 1 号\"},"
|
||||||
|
+ "\"法人\":{\"words\":\"李四\"}},"
|
||||||
|
+ "\"words_result_num\":4,\"log_id\":999,\"direction\":0}";
|
||||||
|
|
||||||
|
/** 打桩客户端:按队列返回响应体,并记录每次请求 */
|
||||||
|
private static class StubClient extends HjcBaiduOcrClient {
|
||||||
|
|
||||||
|
private final Deque<String> responses = new ArrayDeque<>();
|
||||||
|
private final List<HttpRequest> requests = new ArrayList<>();
|
||||||
|
|
||||||
|
StubClient(String... bodies) {
|
||||||
|
for (String body : bodies) {
|
||||||
|
responses.add(body);
|
||||||
|
}
|
||||||
|
ReflectionTestUtils.setField(this, "apiKey", "test-ak");
|
||||||
|
ReflectionTestUtils.setField(this, "secretKey", "test-sk");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String execute(HttpRequest request) {
|
||||||
|
requests.add(request);
|
||||||
|
if (responses.isEmpty()) {
|
||||||
|
throw new IllegalStateException("打桩响应不足,请求:" + request.getUrl());
|
||||||
|
}
|
||||||
|
return responses.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
String urlAt(int index) {
|
||||||
|
return requests.get(index).getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest requestAt(int index) {
|
||||||
|
return requests.get(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
int requestCount() {
|
||||||
|
return requests.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 未配置凭据时_isConfigured_为false() {
|
||||||
|
HjcBaiduOcrClient client = new HjcBaiduOcrClient();
|
||||||
|
ReflectionTestUtils.setField(client, "apiKey", "");
|
||||||
|
ReflectionTestUtils.setField(client, "secretKey", "sk");
|
||||||
|
assertFalse(client.isConfigured());
|
||||||
|
|
||||||
|
ReflectionTestUtils.setField(client, "apiKey", "ak");
|
||||||
|
assertTrue(client.isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void token只取一次并被复用() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, IDCARD_OK, IDCARD_OK);
|
||||||
|
|
||||||
|
Map<String, String> first = client.recognizeIdCardFront(new byte[]{1, 2, 3}).getWords();
|
||||||
|
Map<String, String> second = client.recognizeIdCardFront(new byte[]{4, 5, 6}).getWords();
|
||||||
|
|
||||||
|
assertEquals("张三", first.get("姓名"));
|
||||||
|
assertEquals("110101199003077777", first.get("公民身份号码"));
|
||||||
|
assertEquals("张三", second.get("姓名"));
|
||||||
|
// 1 次 token + 2 次识别:token 被缓存复用,没有第二次取 token
|
||||||
|
assertEquals(3, client.requestCount());
|
||||||
|
assertTrue(client.urlAt(0).contains("/oauth/2.0/token"));
|
||||||
|
assertTrue(client.urlAt(1).contains("/rest/2.0/ocr/v1/idcard"));
|
||||||
|
assertTrue(client.urlAt(1).contains("access_token=tk-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 身份证请求带必填的id_card_side且图片为base64() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, IDCARD_OK);
|
||||||
|
|
||||||
|
client.recognizeIdCardFront("abc".getBytes());
|
||||||
|
|
||||||
|
Map<String, Object> form = client.requestAt(1).form();
|
||||||
|
assertEquals("front", form.get("id_card_side"));
|
||||||
|
// "abc" 的 base64
|
||||||
|
assertEquals("YWJj", form.get("image"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 营业执照请求不传id_card_side() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, LICENSE_OK);
|
||||||
|
|
||||||
|
HjcBaiduOcrClient.Result result = client.recognizeBusinessLicense("abc".getBytes());
|
||||||
|
|
||||||
|
assertNull(client.requestAt(1).form().get("id_card_side"));
|
||||||
|
assertEquals("深圳市某某科技有限公司", result.getWords().get("单位名称"));
|
||||||
|
// 注意:百度云的 key 是「社会信用代码」,不是「统一社会信用代码」
|
||||||
|
assertEquals("91440300MA5XXXXXXX", result.getWords().get("社会信用代码"));
|
||||||
|
assertEquals("999", result.getLogId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void token过期后重新获取() throws Exception {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, IDCARD_OK, TOKEN_OK, IDCARD_OK);
|
||||||
|
client.recognizeIdCardFront(new byte[]{1});
|
||||||
|
|
||||||
|
// 把缓存的到期时间拨到过去,模拟 30 天到期
|
||||||
|
ReflectionTestUtils.setField(client, "accessTokenExpireAt", 0L);
|
||||||
|
client.recognizeIdCardFront(new byte[]{1});
|
||||||
|
|
||||||
|
assertEquals(4, client.requestCount());
|
||||||
|
assertTrue(client.urlAt(2).contains("/oauth/2.0/token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 缓存有效期按提前窗口缩短() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, IDCARD_OK);
|
||||||
|
|
||||||
|
client.recognizeIdCardFront(new byte[]{1});
|
||||||
|
|
||||||
|
long remaining = (long) ReflectionTestUtils.getField(client, "accessTokenExpireAt")
|
||||||
|
- System.currentTimeMillis();
|
||||||
|
long expected = 2592000L * 1000L - HjcBaiduOcrClient.TOKEN_REFRESH_AHEAD_MS;
|
||||||
|
// expires_in=2592000s(30 天),缓存有效期应比 30 天少一个提前刷新窗口
|
||||||
|
assertTrue(remaining <= expected + 5000, "剩余有效期应扣除提前窗口,实际 " + remaining);
|
||||||
|
assertTrue(remaining >= expected - 5000, "剩余有效期不应被额外缩短,实际 " + remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expires_in异常小时至少缓存一分钟() {
|
||||||
|
StubClient client = new StubClient("{\"access_token\":\"tk-x\",\"expires_in\":1}", IDCARD_OK);
|
||||||
|
|
||||||
|
client.recognizeIdCardFront(new byte[]{1});
|
||||||
|
|
||||||
|
long remaining = (long) ReflectionTestUtils.getField(client, "accessTokenExpireAt")
|
||||||
|
- System.currentTimeMillis();
|
||||||
|
assertTrue(remaining > 50_000L, "expires_in 异常小也不应每次都取 token,实际 " + remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 百度错误码原样抛出并带logId() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK,
|
||||||
|
"{\"error_code\":6,\"error_msg\":\"No permission to access data\",\"log_id\":888}");
|
||||||
|
|
||||||
|
HjcOcrException e = assertThrows(HjcOcrException.class,
|
||||||
|
() -> client.recognizeBusinessLicense(new byte[]{1}));
|
||||||
|
|
||||||
|
assertEquals(6, e.getErrorCode());
|
||||||
|
assertTrue(e.isBaiduError());
|
||||||
|
assertEquals("888", e.getLogId());
|
||||||
|
assertEquals("No permission to access data", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void token失效时丢弃缓存但不重试() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK,
|
||||||
|
"{\"error_code\":110,\"error_msg\":\"Access token invalid or no longer valid\"}");
|
||||||
|
|
||||||
|
assertThrows(HjcOcrException.class, () -> client.recognizeIdCardFront(new byte[]{1}));
|
||||||
|
|
||||||
|
// 只发了一次识别请求(不重试),且缓存 token 已被丢弃
|
||||||
|
assertEquals(2, client.requestCount());
|
||||||
|
assertNull(ReflectionTestUtils.getField(client, "accessToken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 取token失败抛本平台错误码() {
|
||||||
|
StubClient client = new StubClient(
|
||||||
|
"{\"error\":\"invalid_client\",\"error_description\":\"unknown client id\"}");
|
||||||
|
|
||||||
|
HjcOcrException e = assertThrows(HjcOcrException.class,
|
||||||
|
() -> client.recognizeIdCardFront(new byte[]{1}));
|
||||||
|
|
||||||
|
assertEquals(HjcOcrException.ERROR_TOKEN_FETCH, e.getErrorCode());
|
||||||
|
assertFalse(e.isBaiduError());
|
||||||
|
assertTrue(e.getMessage().contains("invalid_client"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 响应非JSON时抛本平台错误码() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, "<html>502 Bad Gateway</html>");
|
||||||
|
|
||||||
|
HjcOcrException e = assertThrows(HjcOcrException.class,
|
||||||
|
() -> client.recognizeIdCardFront(new byte[]{1}));
|
||||||
|
|
||||||
|
assertEquals(HjcOcrException.ERROR_RESPONSE_INVALID, e.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 识别成功但无words_result时返回空集合() {
|
||||||
|
StubClient client = new StubClient(TOKEN_OK, "{\"words_result_num\":0,\"log_id\":1}");
|
||||||
|
|
||||||
|
HjcBaiduOcrClient.Result result = client.recognizeIdCardFront(new byte[]{1});
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertTrue(result.getWords().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package com.gxwebsoft.hjc.service.impl;
|
||||||
|
|
||||||
|
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 org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证件 OCR 业务映射单测:材料类型路由、中文 key 映射、「无」哨兵值过滤、前置校验与失败不阻断。
|
||||||
|
*
|
||||||
|
* <p>不联网:取图与百度云客户端都是打桩子类。</p>
|
||||||
|
*/
|
||||||
|
class HjcOcrServiceImplTest {
|
||||||
|
|
||||||
|
private static final String FILE_URL = "https://oss.wsdns.cn/20260101/abc.jpg";
|
||||||
|
|
||||||
|
private HjcOcrServiceImpl service;
|
||||||
|
private FakeBaiduClient baiduClient;
|
||||||
|
private FakeFetcher fetcher;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new HjcOcrServiceImpl();
|
||||||
|
baiduClient = new FakeBaiduClient();
|
||||||
|
fetcher = new FakeFetcher();
|
||||||
|
ReflectionTestUtils.setField(service, "baiduOcrClient", baiduClient);
|
||||||
|
ReflectionTestUtils.setField(service, "imageFetcher", fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 营业执照 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 营业执照映射四个字段() {
|
||||||
|
baiduClient.result = words(
|
||||||
|
"单位名称", "深圳市某某科技有限公司",
|
||||||
|
"社会信用代码", "91440300MA5XXXXXXX",
|
||||||
|
"地址", "深圳市南山区某某路 1 号",
|
||||||
|
"法人", "李四",
|
||||||
|
"类型", "有限责任公司");
|
||||||
|
|
||||||
|
HjcOcrResult result = service.recognize("license", FILE_URL);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("license", result.getMaterialType());
|
||||||
|
assertEquals("深圳市某某科技有限公司", result.getEnterpriseName());
|
||||||
|
assertEquals("91440300MA5XXXXXXX", result.getCreditCode());
|
||||||
|
assertEquals("深圳市南山区某某路 1 号", result.getAddress());
|
||||||
|
assertEquals("李四", result.getLegalPerson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 未识别字段的哨兵值无不写入结果() {
|
||||||
|
// 百度云对未识别字段返回字面量「无」,前端 fillIfBlank 会把它当有效值填进表单
|
||||||
|
baiduClient.result = words(
|
||||||
|
"单位名称", "无",
|
||||||
|
"社会信用代码", "无",
|
||||||
|
"地址", " 深圳市南山区某某路 1 号 ",
|
||||||
|
"法人", "无");
|
||||||
|
|
||||||
|
HjcOcrResult result = service.recognize("license", FILE_URL);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertNull(result.getEnterpriseName());
|
||||||
|
assertNull(result.getCreditCode());
|
||||||
|
assertNull(result.getLegalPerson());
|
||||||
|
assertEquals("深圳市南山区某某路 1 号", result.getAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 营业执照全部字段未识别时视为识别失败() {
|
||||||
|
baiduClient.result = words("单位名称", "无", "社会信用代码", "无", "地址", "无", "法人", "无");
|
||||||
|
|
||||||
|
assertNull(service.recognize("license", FILE_URL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 老版执照缺少社会信用代码时只回填其他字段() {
|
||||||
|
baiduClient.result = words("单位名称", "某某厂", "地址", "某某镇 1 号");
|
||||||
|
|
||||||
|
HjcOcrResult result = service.recognize("license", FILE_URL);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertNull(result.getCreditCode());
|
||||||
|
assertEquals("某某厂", result.getEnterpriseName());
|
||||||
|
assertEquals("某某镇 1 号", result.getAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 身份证人像面 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 身份证人像面映射姓名与身份证号() {
|
||||||
|
baiduClient.result = words(
|
||||||
|
"姓名", "张三",
|
||||||
|
"公民身份号码", "110101199003077777",
|
||||||
|
"性别", "男",
|
||||||
|
"民族", "汉");
|
||||||
|
baiduClient.result.setImageStatus("normal");
|
||||||
|
|
||||||
|
HjcOcrResult result = service.recognize("idcard_front", FILE_URL);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("idcard_front", result.getMaterialType());
|
||||||
|
assertEquals("张三", result.getAgentName());
|
||||||
|
assertEquals("110101199003077777", result.getIdCardNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 身份证正反面传错时没有姓名与身份证号视为失败() {
|
||||||
|
// 百度云此时返回 image_status=reversed_side 与国徽面字段
|
||||||
|
baiduClient.result = words("签发机关", "某某公安局", "签发日期", "20150301", "失效日期", "20350301");
|
||||||
|
baiduClient.result.setImageStatus("reversed_side");
|
||||||
|
|
||||||
|
assertNull(service.recognize("idcard_front", FILE_URL));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 路由与不阻断 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 国徽面和授权委托书不识别且不调外部接口() {
|
||||||
|
assertEquals(null, service.recognize("idcard_back", FILE_URL));
|
||||||
|
assertEquals(null, service.recognize("handbook", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(0, fetcher.calls);
|
||||||
|
assertEquals(0, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 未配置凭据时跳过识别且不取图() {
|
||||||
|
baiduClient.configured = false;
|
||||||
|
|
||||||
|
assertNull(service.recognize("license", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(0, fetcher.calls);
|
||||||
|
assertEquals(0, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 入参为空时返回null() {
|
||||||
|
assertNull(service.recognize(null, FILE_URL));
|
||||||
|
assertNull(service.recognize("license", null));
|
||||||
|
assertNull(service.recognize(" ", " "));
|
||||||
|
assertEquals(0, fetcher.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 取图失败时返回null() {
|
||||||
|
fetcher.error = new HjcOcrException(HjcOcrException.ERROR_IMAGE_FETCH, "HTTP 404", null);
|
||||||
|
|
||||||
|
assertNull(service.recognize("license", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(0, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 百度云报错时返回null() {
|
||||||
|
baiduClient.error = new HjcOcrException(17, "Open api daily request limit reached", "777");
|
||||||
|
|
||||||
|
assertNull(service.recognize("license", FILE_URL));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 图片前置校验 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 图片超过上限时不调用百度云() {
|
||||||
|
fetcher.bytes = new byte[(int) HjcOcrServiceImpl.MAX_IMAGE_BYTES_IDCARD + 1];
|
||||||
|
|
||||||
|
assertNull(service.recognize("idcard_front", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(0, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 图片最短边不足时不调用百度云() throws IOException {
|
||||||
|
fetcher.bytes = png(10, 10);
|
||||||
|
|
||||||
|
assertNull(service.recognize("license", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(0, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 尺寸合法的真实图片正常送识别() throws IOException {
|
||||||
|
fetcher.bytes = png(20, 20);
|
||||||
|
baiduClient.result = words("单位名称", "某某公司");
|
||||||
|
|
||||||
|
assertNotNull(service.recognize("license", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(1, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 无法解析尺寸的字节按放行处理() {
|
||||||
|
// 非图片字节:不做最短边判断,交给百度云返回错误码
|
||||||
|
fetcher.bytes = "not-an-image".getBytes();
|
||||||
|
baiduClient.result = words("单位名称", "某某公司");
|
||||||
|
|
||||||
|
assertNotNull(service.recognize("license", FILE_URL));
|
||||||
|
|
||||||
|
assertEquals(1, baiduClient.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 字段文本规则 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 字段文本规则() {
|
||||||
|
Map<String, String> words = new LinkedHashMap<>();
|
||||||
|
words.put("空", "");
|
||||||
|
words.put("空白", " ");
|
||||||
|
words.put("无", "无");
|
||||||
|
words.put("带空格的无人", " 无 ");
|
||||||
|
words.put("正常", " 张三 ");
|
||||||
|
words.put("数字下划线", null);
|
||||||
|
|
||||||
|
assertNull(HjcOcrServiceImpl.text(words, "空"));
|
||||||
|
assertNull(HjcOcrServiceImpl.text(words, "空白"));
|
||||||
|
assertNull(HjcOcrServiceImpl.text(words, "无"));
|
||||||
|
assertNull(HjcOcrServiceImpl.text(words, "带空格的无人"));
|
||||||
|
assertNull(HjcOcrServiceImpl.text(words, "数字下划线"));
|
||||||
|
assertNull(HjcOcrServiceImpl.text(words, "不存在的key"));
|
||||||
|
assertEquals("张三", HjcOcrServiceImpl.text(words, "正常"));
|
||||||
|
assertNull(HjcOcrServiceImpl.text(null, "正常"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 测试替身 ----------
|
||||||
|
|
||||||
|
private static HjcBaiduOcrClient.Result words(String... keyValues) {
|
||||||
|
Map<String, String> map = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
||||||
|
map.put(keyValues[i], keyValues[i + 1]);
|
||||||
|
}
|
||||||
|
HjcBaiduOcrClient.Result result = new HjcBaiduOcrClient.Result();
|
||||||
|
result.setWords(map);
|
||||||
|
result.setLogId("1000");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] png(int width, int height) throws IOException {
|
||||||
|
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "png", out);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FakeBaiduClient extends HjcBaiduOcrClient {
|
||||||
|
|
||||||
|
private boolean configured = true;
|
||||||
|
private HjcBaiduOcrClient.Result result;
|
||||||
|
private HjcOcrException error;
|
||||||
|
private int calls;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isConfigured() {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result recognizeIdCardFront(byte[] imageBytes) {
|
||||||
|
calls++;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result recognizeBusinessLicense(byte[] imageBytes) {
|
||||||
|
calls++;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FakeFetcher extends HjcOcrImageFetcher {
|
||||||
|
|
||||||
|
private byte[] bytes = "not-an-image".getBytes();
|
||||||
|
private RuntimeException error;
|
||||||
|
private int calls;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] fetch(String fileUrl) {
|
||||||
|
calls++;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user