✨ 身份证OCR改接ShenFenZheng,回填有效期与长期有效

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 11:47:20 +08:00
parent fc1dcb84cc
commit e93d50c27b
7 changed files with 393 additions and 31 deletions
@@ -418,6 +418,9 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
driver.setDrivingLicenseBack(trimToNull(driver.getDrivingLicenseBack()));
driver.setQualificationFront(trimToNull(driver.getQualificationFront()));
driver.setQualificationBack(trimToNull(driver.getQualificationBack()));
if (driver.getIdCardLongTerm() == null) {
driver.setIdCardLongTerm(DEFAULT_FALSE);
}
if (driver.getDrivingLicenseLongTerm() == null) {
driver.setDrivingLicenseLongTerm(DEFAULT_FALSE);
}
@@ -38,9 +38,22 @@ public class IDCardVO implements Serializable {
*/
@JsonFormat(pattern = DateUtil.PATTERN_DATE, timezone = "GMT+8")
private Date birthday;
/**
* 住址
*/
private String address;
/**
* 有效期起
*/
@JsonFormat(pattern = DateUtil.PATTERN_DATE, timezone = "GMT+8")
private Date validDateStart;
/**
* 有效期止
*/
@JsonFormat(pattern = DateUtil.PATTERN_DATE, timezone = "GMT+8")
private Date validDateEnd;
/**
* 长期有效:1-是,0-否
*/
private Integer longTerm;
}
@@ -1,6 +1,9 @@
package org.springblade.thirdparty.ocr.service.impl;
import cn.hutool.core.lang.TypeReference;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -13,10 +16,10 @@ import org.springblade.thirdparty.ocr.constant.OCRImageType;
import org.springblade.thirdparty.ocr.convert.AttachmentIndexConvert;
import org.springblade.thirdparty.ocr.exception.OCRException;
import org.springblade.thirdparty.ocr.feign.IOCRClient;
import org.springblade.thirdparty.ocr.feign.IOCROtherClient;
import org.springblade.thirdparty.ocr.pojo.dto.*;
import org.springblade.thirdparty.ocr.pojo.vo.*;
import org.springblade.thirdparty.ocr.service.IOCRService;
import org.springblade.thirdparty.ocr.support.ShenFenZhengIdCardSupport;
import org.springblade.thirdparty.ocr.util.ConvertUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -43,10 +46,12 @@ public class OCRServiceImpl implements IOCRService {
private static final BigDecimal WEIGHT_LIMIT = BigDecimal.valueOf(200);
private final IOCRClient client;
private final IOCROtherClient otherClient;
@Value("${thirdParty.ocr.baseOtherUrl}")
private String ocrBaseOtherUrl;
/**
* 身份证 OCR 服务地址,Nacos:thirdParty.ocr.idCardBaseUrl
*/
@Value("${thirdParty.ocr.idCardBaseUrl:${thirdParty.ocr.baseOtherUrl}}")
private String idCardBaseUrl;
@Override
public TransportCertificateVO recognitionTransportCertificate(CertificateBatchRecognitionDTO param) {
@@ -339,45 +344,79 @@ public class OCRServiceImpl implements IOCRService {
if (CollectionUtils.isEmpty(urls)) {
throw new OCRException("图像链接不能为空");
}
if (StringUtil.isBlank(idCardBaseUrl)) {
throw new OCRException("身份证OCR服务地址未配置(thirdParty.ocr.idCardBaseUrl)");
}
IDCardVO result = new IDCardVO();
try {
for (String url : urls) {
OCRCardBankDetRecDTO param = new OCRCardBankDetRecDTO(url);
// param.setDataType("IDCard-Main");
log.info("调用ocr证件定位+结构化信息提取接口,请求地址:{},请求参数:{}",
getCardBankDetRecUrl(), JSONUtil.toJsonStr(param));
OCRResultVO<OCRBigCommonCardVO> resultVO = otherClient.cardBankDetRec(param);
if (resultVO != null && OCRConstant.SUCCESS_CODE.equals(resultVO.getCode())) {
// 成功
OCRBigCommonCardVO card = resultVO.getResult();
convertIDCard(result, card);
if (StringUtil.isBlank(url)) {
continue;
}
log.error("调用ocr接口证件定位+结构化信息提取异常响应参数:{}", JSONUtil.toJsonStr(resultVO));
String message = getMessage(resultVO);
throw new OCRException("调用ocr接口证件定位+结构化信息提取异常 " + message);
ShenFenZhengIdCardSupport.merge(result, recognizeIdCardByShenFenZheng(url.trim()));
}
} catch (OCRException e) {
throw e;
} catch (Exception e) {
log.error("调用ocr接口证件定位+结构化信息提取异常", e);
throw new OCRException("调用ocr接口证件定位+结构化信息提取异常");
log.error("调用身份证OCR接口异常", e);
throw new OCRException("调用身份证OCR接口异常");
}
return result;
}
private String getCardBankDetRecUrl() {
return ocrBaseOtherUrl.replaceAll("/+$", "") + "/CardBankDetRec";
/**
* 下载图片后以 formData.image_file 调用 ShenFenZheng
*
* @param imageUrl 可访问的图片地址
* @return 单张识别结果
*/
private IDCardVO recognizeIdCardByShenFenZheng(String imageUrl) {
String requestUrl = idCardBaseUrl.replaceAll("/+$", "") + ShenFenZhengIdCardSupport.PATH;
byte[] imageBytes;
try {
imageBytes = HttpUtil.downloadBytes(imageUrl);
} catch (Exception exception) {
log.error("下载身份证图片失败,url={}", imageUrl, exception);
throw new OCRException("下载身份证图片失败");
}
if (imageBytes == null || imageBytes.length == 0) {
throw new OCRException("身份证图片内容为空");
}
String fileName = resolveImageFileName(imageUrl);
log.info("调用身份证OCR接口,请求地址:{},文件名:{},大小:{}", requestUrl, fileName, imageBytes.length);
try (HttpResponse response = HttpRequest.post(requestUrl)
.form(ShenFenZhengIdCardSupport.FILE_FIELD, imageBytes, fileName)
.timeout(60000)
.execute()) {
String body = response.body();
log.info("调用身份证OCR接口响应,status={},body={}", response.getStatus(), body);
if (!response.isOk()) {
throw new OCRException("调用身份证OCR接口失败,HTTP状态码:" + response.getStatus());
}
if (!ShenFenZhengIdCardSupport.isSuccess(body)) {
throw new OCRException("身份证OCR识别失败");
}
return ShenFenZhengIdCardSupport.parse(body);
} catch (OCRException exception) {
throw exception;
} catch (Exception exception) {
log.error("调用身份证OCR接口异常,url={}", requestUrl, exception);
throw new OCRException("调用身份证OCR接口异常");
}
}
/**
* 转换身份证
* @param result
* @param card
* @return
*/
private void convertIDCard(IDCardVO result, OCRBigCommonCardVO card) {
convert(result, card, ConvertUtil::getIDCardSetter, null);
private String resolveImageFileName(String imageUrl) {
String path = imageUrl;
int queryIndex = path.indexOf('?');
if (queryIndex >= 0) {
path = path.substring(0, queryIndex);
}
int slashIndex = path.lastIndexOf('/');
String name = slashIndex >= 0 ? path.substring(slashIndex + 1) : path;
if (StringUtil.isBlank(name) || !name.contains(".")) {
return "id-card.jpg";
}
return name;
}
/**
@@ -0,0 +1,301 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.thirdparty.ocr.support;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.thirdparty.ocr.pojo.vo.IDCardVO;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 身份证 OCR(ShenFenZheng)响应解析
* <p>
* 第三方返回形如:
* {@code result."0" = [[{"label":"身份证_姓名"}], [{"result":"张三"}], {"r":[...]}]},
* 这里抽成「标签 → 文本」后再回填 {@link IDCardVO}。
*
* @author Chill
*/
public final class ShenFenZhengIdCardSupport {
/**
* 接口路径
*/
public static final String PATH = "/ShenFenZheng";
/**
* formData 文件字段名
*/
public static final String FILE_FIELD = "image_file";
private static final String LONG_TERM_TEXT = "长期";
private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})");
private static final Map<String, BiConsumer<IDCardVO, String>> FIELD_SETTERS = initFieldSetters();
private ShenFenZhengIdCardSupport() {
}
/**
* 解析第三方原始 JSON 为身份证 VO
*
* @param responseBody 响应原文
* @return 识别结果;结构异常时返回空对象
*/
public static IDCardVO parse(String responseBody) {
IDCardVO result = new IDCardVO();
if (StringUtil.isBlank(responseBody)) {
return result;
}
JSONObject root = JSONUtil.parseObj(responseBody);
Map<String, String> fields = readLabelValues(root.getJSONObject("result"));
for (Map.Entry<String, String> entry : fields.entrySet()) {
BiConsumer<IDCardVO, String> setter = FIELD_SETTERS.get(normalizeLabel(entry.getKey()));
if (setter != null && StringUtil.isNotBlank(entry.getValue())) {
setter.accept(result, entry.getValue().trim());
}
}
return result;
}
/**
* 判断第三方是否识别成功
*
* @param responseBody 响应原文
* @return true 表示成功
*/
public static boolean isSuccess(String responseBody) {
if (StringUtil.isBlank(responseBody)) {
return false;
}
JSONObject root = JSONUtil.parseObj(responseBody);
Boolean status = root.getBool("status");
if (Boolean.TRUE.equals(status)) {
return true;
}
// 个别版本只给 identifyCode,1001 视为成功
Integer identifyCode = root.getInt("identifyCode");
return identifyCode != null && identifyCode == 1001;
}
/**
* 合并多张图的识别结果:后写的非空值覆盖先写的,方便正反面各识别一次后汇总
*
* @param target 汇总目标
* @param source 本次识别结果
*/
public static void merge(IDCardVO target, IDCardVO source) {
if (target == null || source == null) {
return;
}
if (StringUtil.isNotBlank(source.getName())) {
target.setName(source.getName());
}
if (StringUtil.isNotBlank(source.getIdNo())) {
target.setIdNo(source.getIdNo());
}
if (StringUtil.isNotBlank(source.getGender())) {
target.setGender(source.getGender());
}
if (StringUtil.isNotBlank(source.getNation())) {
target.setNation(source.getNation());
}
if (source.getBirthday() != null) {
target.setBirthday(source.getBirthday());
}
if (StringUtil.isNotBlank(source.getAddress())) {
target.setAddress(source.getAddress());
}
if (source.getValidDateStart() != null) {
target.setValidDateStart(source.getValidDateStart());
}
if (source.getValidDateEnd() != null) {
target.setValidDateEnd(source.getValidDateEnd());
}
if (source.getLongTerm() != null) {
target.setLongTerm(source.getLongTerm());
}
}
private static Map<String, String> readLabelValues(JSONObject result) {
Map<String, String> fields = new LinkedHashMap<>();
if (result == null || result.isEmpty()) {
return fields;
}
for (String key : result.keySet()) {
JSONArray item = result.getJSONArray(key);
if (item == null || item.size() < 2) {
continue;
}
String label = readNestedText(item.get(0), "label");
String value = readNestedText(item.get(1), "result");
if (StringUtil.isNotBlank(label) && StringUtil.isNotBlank(value)) {
fields.put(label, value);
}
}
return fields;
}
private static String readNestedText(Object node, String fieldName) {
if (node instanceof JSONArray array) {
if (array.isEmpty()) {
return null;
}
Object first = array.get(0);
if (first instanceof JSONObject object) {
return object.getStr(fieldName);
}
return null;
}
if (node instanceof JSONObject object) {
return object.getStr(fieldName);
}
return null;
}
private static String normalizeLabel(String label) {
return StringUtil.isBlank(label) ? "" : label.trim();
}
private static Map<String, BiConsumer<IDCardVO, String>> initFieldSetters() {
Map<String, BiConsumer<IDCardVO, String>> setters = new LinkedHashMap<>();
setters.put("身份证_姓名", IDCardVO::setName);
setters.put("姓名", IDCardVO::setName);
setters.put("身份证_公民身份号码", IDCardVO::setIdNo);
setters.put("公民身份号码", IDCardVO::setIdNo);
setters.put("公民身份证号码", IDCardVO::setIdNo);
setters.put("身份证_性别", IDCardVO::setGender);
setters.put("性别", IDCardVO::setGender);
setters.put("身份证_民族", (card, value) -> card.setNation(normalizeNation(value)));
setters.put("民族", (card, value) -> card.setNation(normalizeNation(value)));
setters.put("身份证_出生", (card, value) -> card.setBirthday(parseDate(value)));
setters.put("出生", (card, value) -> card.setBirthday(parseDate(value)));
setters.put("身份证_住址", IDCardVO::setAddress);
setters.put("住址", IDCardVO::setAddress);
setters.put("身份证_有效期限", ShenFenZhengIdCardSupport::applyValidity);
setters.put("有效期限", ShenFenZhengIdCardSupport::applyValidity);
setters.put("身份证_签发日期", (card, value) -> card.setValidDateStart(parseDate(value)));
setters.put("签发日期", (card, value) -> card.setValidDateStart(parseDate(value)));
setters.put("身份证_失效日期", ShenFenZhengIdCardSupport::applyEndDate);
setters.put("失效日期", ShenFenZhengIdCardSupport::applyEndDate);
return setters;
}
private static String normalizeNation(String value) {
String nation = value == null ? "" : value.trim();
if (nation.isEmpty()) {
return nation;
}
return nation.endsWith("族") ? nation : nation + "族";
}
private static void applyValidity(IDCardVO card, String value) {
if (StringUtil.isBlank(value)) {
return;
}
if (value.contains(LONG_TERM_TEXT)) {
card.setLongTerm(1);
card.setValidDateEnd(null);
Date start = parseFirstDate(value);
if (start != null) {
card.setValidDateStart(start);
}
return;
}
card.setLongTerm(0);
Date start = parseFirstDate(value);
Date end = parseLastDate(value);
if (start != null) {
card.setValidDateStart(start);
}
if (end != null) {
card.setValidDateEnd(end);
}
}
private static void applyEndDate(IDCardVO card, String value) {
if (StringUtil.isBlank(value)) {
return;
}
if (value.contains(LONG_TERM_TEXT)) {
card.setLongTerm(1);
card.setValidDateEnd(null);
return;
}
Date end = parseDate(value);
if (end != null) {
card.setLongTerm(0);
card.setValidDateEnd(end);
}
}
private static Date parseDate(String text) {
return parseFirstDate(text);
}
private static Date parseFirstDate(String text) {
Matcher matcher = DATE_PATTERN.matcher(text);
if (!matcher.find()) {
return null;
}
return toDate(matcher.group(1), matcher.group(2), matcher.group(3));
}
private static Date parseLastDate(String text) {
Matcher matcher = DATE_PATTERN.matcher(text);
String year = null;
String month = null;
String day = null;
while (matcher.find()) {
year = matcher.group(1);
month = matcher.group(2);
day = matcher.group(3);
}
if (year == null) {
return null;
}
return toDate(year, month, day);
}
private static Date toDate(String year, String month, String day) {
try {
String formatted = String.format("%s-%02d-%02d", year, Integer.parseInt(month), Integer.parseInt(day));
return DateUtil.parse(formatted, DateUtil.PATTERN_DATE);
} catch (Exception ignored) {
return null;
}
}
}
+3 -1
View File
@@ -92,8 +92,10 @@ thirdParty:
ocr:
# OCR磅单、车牌识别接口地址
baseUrl: http://127.0.0.1:8080
# OCR证件识别接口地址
# OCR证件识别接口地址(银行卡等通用证件)
baseOtherUrl: http://127.0.0.1:8080
# 身份证 OCR 服务地址(POST /ShenFenZheng,formData 字段 image_file)
idCardBaseUrl: http://124.71.22.123:8500
wps:
# WPS开放接口地址
baseUrl: http://127.0.0.1:8080
+3 -1
View File
@@ -54,8 +54,10 @@ thirdParty:
ocr:
# OCR磅单、车牌识别接口地址
baseUrl: http://127.0.0.1:8080
# OCR证件识别接口地址
# OCR证件识别接口地址(银行卡等通用证件)
baseOtherUrl: http://127.0.0.1:8080
# 身份证 OCR 服务地址(POST /ShenFenZheng,formData 字段 image_file)
idCardBaseUrl: http://124.71.22.123:8500
wps:
# WPS开放接口地址
baseUrl: http://127.0.0.1:8080
+3 -1
View File
@@ -3,8 +3,10 @@ thirdParty:
ocr:
# OCR磅单、车牌识别接口地址
baseUrl: ${OCR_BASE_URL:http://127.0.0.1:8080}
# OCR证件识别接口地址
# OCR证件识别接口地址(银行卡等通用证件)
baseOtherUrl: ${OCR_BASE_OTHER_URL:http://127.0.0.1:8080}
# 身份证 OCR 服务地址(POST /ShenFenZheng,formData 字段 image_file)
idCardBaseUrl: ${OCR_ID_CARD_BASE_URL:http://124.71.22.123:8500}
wps:
# WPS开放接口地址
baseUrl: ${WPS_BASE_URL:http://127.0.0.1:8080}