diff --git a/pom.xml b/pom.xml index b3d2e72..ee9b689 100644 --- a/pom.xml +++ b/pom.xml @@ -213,12 +213,8 @@ aliyun-java-sdk-core 4.4.3 - - - com.aliyun - aliyun-java-sdk-ocr - 1.0.6 - + com.alipay.sdk diff --git a/src/main/java/com/gxwebsoft/hjc/ocr/HjcBaiduOcrClient.java b/src/main/java/com/gxwebsoft/hjc/ocr/HjcBaiduOcrClient.java new file mode 100644 index 0000000..ff24625 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/ocr/HjcBaiduOcrClient.java @@ -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 客户端(身份证识别 / 营业执照识别) + * + *

接口文档:身份证识别、 + * 营业执照识别。 + * 调用方式为「先取 access_token,再以表单 POST 传 base64 图片」,不引入百度官方 Java SDK, + * 只用项目已有的 hutool-http + fastjson。

+ * + *

token 缓存:access_token 有效期 30 天且跨接口共用,按应用(api-key/secret-key)维度缓存。 + * 本项目为单实例部署,故只在进程内缓存({@code volatile} + 到期时间戳 + 双重检查刷新), + * 不落 Redis,避免引入一层与 OCR 无关的失败模式。若将来改多实例部署,只需改造 + * {@link #currentAccessToken()} 与 {@link #refreshAccessToken()}。

+ * + *

失败语义:任何失败都抛 {@link HjcOcrException},携带百度云 {@code error_code}/{@code log_id} + * 或本平台侧错误码,由调用方决定日志与返回。不重试:QPS/额度类错误重试只会更糟。

+ * + *

本类不打印任何识别内容(姓名、身份证号属个人信息),只记录字段名与错误码。

+ */ +@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 调用并返回响应体文本。 + * + *

该方法是唯一的 HTTP 出口,测试中可覆盖以注入打桩响应,从而在不联网、不依赖百度额度的情况下 + * 覆盖 token 缓存与各错误码分支。

+ */ + 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 extractWords(JSONObject json) { + Map 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 words = new LinkedHashMap<>(); + + /** 百度云 log_id,用于向百度云排查 */ + private String logId; + + /** 身份证接口返回:normal / reversed_side / blurred / non_idcard 等 */ + private String imageStatus; + + public Map getWords() { + return words; + } + + public void setWords(Map 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; + } + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/ocr/HjcOcrException.java b/src/main/java/com/gxwebsoft/hjc/ocr/HjcOcrException.java new file mode 100644 index 0000000..0d33989 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/ocr/HjcOcrException.java @@ -0,0 +1,58 @@ +package com.gxwebsoft.hjc.ocr; + +/** + * 汇吉采 证件 OCR 失败异常 + * + *

错误码有两类,便于日志排查:

+ *
    + *
  • 正数:百度云返回的 {@code error_code}(如 6 未开通接口、17/18/19 额度与 QPS 超限、 + * 110/111 token 失效、216201/216202 图片格式与大小错误、282102/282112/282114 识别与 URL 抓取失败)。
  • + *
  • 负数:本平台侧的错误码,见本类常量(取图失败、token 获取失败、响应无法解析、图片不合规、 + * 未识别到字段)。
  • + *
+ * + *

本异常只在服务端流转用于打日志,不向接口调用方暴露细节:Controller 仍统一返回 + * 「识别失败或该类型无需识别,请手动填写」。

+ */ +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; + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/ocr/HjcOcrImageFetcher.java b/src/main/java/com/gxwebsoft/hjc/ocr/HjcOcrImageFetcher.java new file mode 100644 index 0000000..32d219f --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/ocr/HjcOcrImageFetcher.java @@ -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 方式上传。 + * + *

为什么由我方取图,而不是把 URL 交给百度云回源抓取:URL 模式依赖「第三方能抓到我方文件地址」, + * 这条链路不受本平台控制,且百度云对 URL 模式还有 {@code 282112}(URL 下载超时,含图片 >3M、防盗链) + * 这类难以自查的失败;改成我方取图后,「取图失败」变成可判断、可打日志的本地事件。

+ * + *

只认 HTTP 200 + {@code image/*}:证件地址若指向 404/HTML(例如文件服务未正确映射路径), + * 直接失败而不是把错误页当图片送去识别,避免白耗百度云额度(身份证接口失败也计费)。

+ */ +@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); + } + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcOcrService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcOcrService.java index e7bfd43..c822465 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/HjcOcrService.java +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcOcrService.java @@ -3,7 +3,9 @@ package com.gxwebsoft.hjc.service; import com.gxwebsoft.hjc.dto.HjcOcrResult; /** - * 汇吉采 证件 OCR 识别(阿里云文字识别 OCR,服务端调用) + * 汇吉采 证件 OCR 识别(百度云 OCR,服务端调用) + * + *

实现见 {@code HjcOcrServiceImpl};供应商细节封装在 {@code com.gxwebsoft.hjc.ocr} 包内。

*/ public interface HjcOcrService { diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImpl.java index 373e1ff..e43e631 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImpl.java +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImpl.java @@ -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) * - *

AK 需具备 OCR 服务权限;未配置(占位值为空)时直接返回 null,由前端提示「识别失败,请手动填写」, - * 不阻断注册流程。

+ *

只做「业务映射」这一件事:材料类型路由 → 取图与前置校验 → 调百度云 → 中文 key 映射为 + * {@link HjcOcrResult} 字段 → 过滤未识别哨兵值。HTTP 细节在 {@link HjcBaiduOcrClient}, + * 取图在 {@link HjcOcrImageFetcher},两层都可独立打桩测试。

+ * + *

不阻断原则:未配置凭据、材料类型无需识别、图片不合规、百度云返回错误码——一律返回 {@code null}, + * 由 Controller 统一回「识别失败或该类型无需识别,请手动填写」,注册流程照常可提交。

+ * + *

日志:只记录材料类型、图片字节数、识别到的字段名、错误码与 log_id, + * 不记录识别出的值(姓名、身份证号属个人信息)。

*/ +@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,按百度云接口页。 + * + *

百度云「调用方式」页另称全接口 4M,与接口页冲突;此处按较宽松的接口页取值, + * 不因一次未经验证的文档冲突就拦掉前端 5M 上限的既有照片。实测若证实为 4M, + * 再决定砍前端上限或加服务端压缩。

+ */ + 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 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 后等于「无」一律视为未识别。 + * + *

百度云对未识别字段不返回空值,而是返回「无」——不过滤就会被前端 + * {@code fillIfBlank} 当成有效值填进表单。

+ */ + static String text(Map 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 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; + } + } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1073da1..f02256e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -77,9 +77,9 @@ aliyun: access-key-id: LTAI5tEsyhW4GCKbds1qsopg access-key-secret: zltFlQrYVAoq2KMFDWgLa3GhkMNeyO endpoint: mt.cn-hangzhou.aliyuncs.com - # 阿里云文字识别 OCR(身份证/营业执照识别),AK 需具备 OCR 服务权限;未开通前为占位值 + +# 百度云 OCR(证件识别),生产环境的真实 api-key/secret-key 由部署时注入;留空则跳过识别 +baidu: ocr: - access-key-id: "" - access-key-secret: "" - region: cn-hangzhou - endpoint: ocr-api.cn-hangzhou.aliyuncs.com + api-key: "" + secret-key: "" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 57246f8..5e35e29 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -105,13 +105,14 @@ config: bucketDomain: https://oss.wsdns.cn aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com -# 阿里云文字识别 OCR(身份证/营业执照识别),AK 需具备 OCR 服务权限;未开通前为占位值 -aliyun: +# 百度云 OCR(证件识别:身份证人像面 / 营业执照) +# 在百度智能云「文字识别」创建应用,并勾选「身份证识别」「营业执照识别」两个接口, +# 再把应用的 API Key / Secret Key 填到这里;留空(占位)时跳过识别,不阻断注册提交。 +# 操作步骤见 docs/百度OCR运维清单.md +baidu: ocr: - access-key-id: "" - access-key-secret: "" - region: cn-hangzhou - endpoint: ocr-api.cn-hangzhou.aliyuncs.com + api-key: oP61trkhmHuii354yY3wFkft + secret-key: p3BnWNzxbiUpwoZoQgaH9FtWGpg9mnYx # 商城订单配置 shop: diff --git a/src/test/java/com/gxwebsoft/hjc/ocr/HjcBaiduOcrClientTest.java b/src/test/java/com/gxwebsoft/hjc/ocr/HjcBaiduOcrClientTest.java new file mode 100644 index 0000000..8bdf61d --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/ocr/HjcBaiduOcrClientTest.java @@ -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 缓存/刷新、请求参数、字段抽取与错误码分支。 + * + *

不联网、不消耗百度云额度:{@link HjcBaiduOcrClient#execute(HttpRequest)} 是唯一的 HTTP 出口, + * 打桩子类把它换成预设响应队列。

+ */ +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 responses = new ArrayDeque<>(); + private final List 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 first = client.recognizeIdCardFront(new byte[]{1, 2, 3}).getWords(); + Map 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 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, "502 Bad Gateway"); + + 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()); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImplTest.java b/src/test/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImplTest.java new file mode 100644 index 0000000..4f2412b --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/service/impl/HjcOcrServiceImplTest.java @@ -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 映射、「无」哨兵值过滤、前置校验与失败不阻断。 + * + *

不联网:取图与百度云客户端都是打桩子类。

+ */ +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 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 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; + } + } +}