diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index fa3a1c80..c4ea6169 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -418,6 +418,9 @@ public class DriverServiceImpl extends BaseServiceImpl 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); } diff --git a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java index 0098b2b9..85f8cae5 100644 --- a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java +++ b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/pojo/vo/IDCardVO.java @@ -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; } diff --git a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java index e4853d28..5303dabb 100644 --- a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java +++ b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/service/impl/OCRServiceImpl.java @@ -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 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; } /** diff --git a/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/support/ShenFenZhengIdCardSupport.java b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/support/ShenFenZhengIdCardSupport.java new file mode 100644 index 00000000..73e69d45 --- /dev/null +++ b/blade-third-party-api/blade-ocr-api/src/main/java/org/springblade/thirdparty/ocr/support/ShenFenZhengIdCardSupport.java @@ -0,0 +1,301 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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)响应解析 + *

+ * 第三方返回形如: + * {@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> 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 fields = readLabelValues(root.getJSONObject("result")); + for (Map.Entry entry : fields.entrySet()) { + BiConsumer 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 readLabelValues(JSONObject result) { + Map 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> initFieldSetters() { + Map> 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; + } + } +} diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index 7607c247..c316ea70 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -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 diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index a444dde6..a1caecb8 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -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 diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index cda8991b..b2218363 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -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}