接口文档:身份证识别、 + * 营业执照识别。 + * 调用方式为「先取 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错误码有两类,便于日志排查:
+ *本异常只在服务端流转用于打日志,不向接口调用方暴露细节: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百度云对未识别字段不返回空值,而是返回「无」——不过滤就会被前端 + * {@code fillIfBlank} 当成有效值填进表单。
+ */ + static String text(Map不联网、不消耗百度云额度:{@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不联网:取图与百度云客户端都是打桩子类。
+ */ +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