From 5c4ccec4f7d90aa1890882b65a6c647d214ef93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Sun, 23 Aug 2026 11:12:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(ocr):=20=E9=9B=86=E6=88=90=E7=99=BE?= =?UTF-8?q?=E5=BA=A6=E6=99=BA=E8=83=BD=E4=BA=91=E8=BA=AB=E4=BB=BD=E8=AF=81?= =?UTF-8?q?OCR=E8=AF=86=E5=88=AB=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换原有失效的身份证OCR服务为百度智能云OCR,支持身份证正面信息识别 - 身份证号、姓名、住址识别成功后回填至订单和用户信息,驱动支付流程状态 - 新增后端配置项管理百度OCR的API密钥,保证密钥仅服务端可见 - 调用失败时仅日志警告,不阻断业务,前端可手动填写身份证号兜底 - 新增数据库字段 id_code、real_name、address 支持身份证识别数据存储 - 更新确认收货接口参数,新增身份证号字段支持前端手动输入 - 更新相关代码逻辑,使用百度OCR接口实现最佳努力模式的身份证识别及回填 - 修改配置文件调整默认激活环境和数据库连接信息,支持开发与生产环境分离 --- pom.xml | 2 +- .../common/core/utils/BaiduIdCardResult.java | 20 +++ .../common/core/utils/BaiduOcrService.java | 133 ++++++++++++++++++ .../controller/OpenEquipmentController.java | 86 +++++------ .../shop/param/OrderReceiptParam.java | 3 + src/main/resources/application-dev.yml | 6 +- src/main/resources/application-prod.yml | 6 +- src/main/resources/application.yml | 8 +- 数据库脚本/alter_shop_order_idcard.sql | 40 ++++++ 9 files changed, 248 insertions(+), 56 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/BaiduIdCardResult.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/BaiduOcrService.java create mode 100644 数据库脚本/alter_shop_order_idcard.sql diff --git a/pom.xml b/pom.xml index b90c9d5..abe51c0 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.gxwebsoft yxw-api - 1.5.2 + 1.0 yxw-api WebSoftApi project for Spring Boot diff --git a/src/main/java/com/gxwebsoft/common/core/utils/BaiduIdCardResult.java b/src/main/java/com/gxwebsoft/common/core/utils/BaiduIdCardResult.java new file mode 100644 index 0000000..df686ab --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/BaiduIdCardResult.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.common.core.utils; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 百度身份证 OCR 识别结果(仅取前端面所需字段) + */ +@Data +public class BaiduIdCardResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 公民身份号码 */ + private String idCardNo; + /** 姓名 */ + private String name; + /** 住址 */ + private String address; +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/BaiduOcrService.java b/src/main/java/com/gxwebsoft/common/core/utils/BaiduOcrService.java new file mode 100644 index 0000000..68f2034 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/BaiduOcrService.java @@ -0,0 +1,133 @@ +package com.gxwebsoft.common.core.utils; + +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.Resource; +import java.util.regex.Pattern; + +/** + * 百度智能云「身份证识别」服务(替换原已失效的第三方 OCR)。 + * 密钥在服务端配置,不暴露给前端;access_token 进程内缓存。 + */ +@Slf4j +@Service +public class BaiduOcrService { + + @Value("${baidu.ocr.api-key:}") + private String apiKey; + + @Value("${baidu.ocr.secret-key:}") + private String secretKey; + + @Resource + private RestTemplate restTemplate; + + private static final String TOKEN_URL = + "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s"; + private static final String OCR_URL = + "https://aip.baidubce.com/rest/2.0/ocr/v1/idcard?access_token=%s"; + + private static final Pattern ID_CARD_PATTERN = Pattern.compile("^\\d{17}[\\dXx]$"); + + /** 进程内 token 缓存 */ + private String cachedToken; + private long tokenExpireTime; + + /** + * 识别身份证正面,提取 身份证号/姓名/住址。 + * + * @param base64Image 图片 base64(不含 data uri 前缀) + * @return 识别结果;调用失败/未识别到身份证号时返回 null + */ + public BaiduIdCardResult recognizeFront(String base64Image) { + if (base64Image == null || base64Image.isEmpty()) { + return null; + } + String token = getToken(); + if (token == null) { + return null; + } + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("id_card_side", "front"); + body.add("image", base64Image); + HttpEntity> entity = new HttpEntity<>(body, headers); + + String resp = restTemplate.postForObject(String.format(OCR_URL, token), entity, String.class); + JSONObject json = JSONObject.parseObject(resp); + if (json == null) { + log.warn("百度OCR返回为空"); + return null; + } + if (json.containsKey("error_code")) { + log.warn("百度OCR错误 error_code={}, error_msg={}", json.getString("error_code"), json.getString("error_msg")); + return null; + } + JSONObject words = json.getJSONObject("words_result"); + if (words == null) { + return null; + } + BaiduIdCardResult result = new BaiduIdCardResult(); + JSONObject idNode = words.getJSONObject("公民身份号码"); + if (idNode != null && idNode.getString("words") != null) { + result.setIdCardNo(idNode.getString("words").trim()); + } + JSONObject nameNode = words.getJSONObject("姓名"); + if (nameNode != null && nameNode.getString("words") != null) { + result.setName(nameNode.getString("words").trim()); + } + JSONObject addrNode = words.getJSONObject("住址"); + if (addrNode != null && addrNode.getString("words") != null) { + result.setAddress(addrNode.getString("words").trim()); + } + // 身份证号不合法时视为未识别 + if (result.getIdCardNo() == null || !ID_CARD_PATTERN.matcher(result.getIdCardNo()).matches()) { + log.warn("百度OCR未识别到合法身份证号 idCardNo={}", result.getIdCardNo()); + return null; + } + return result; + } catch (Exception e) { + log.error("百度OCR调用异常", e); + return null; + } + } + + private String getToken() { + long now = System.currentTimeMillis(); + if (cachedToken != null && now < tokenExpireTime) { + return cachedToken; + } + if (apiKey == null || apiKey.isEmpty() || secretKey == null || secretKey.isEmpty()) { + log.error("百度OCR未配置 api-key/secret-key,无法获取 token"); + return null; + } + try { + String url = String.format(TOKEN_URL, apiKey, secretKey); + String resp = restTemplate.getForObject(url, String.class); + JSONObject json = JSONObject.parseObject(resp); + if (json == null || json.getString("access_token") == null) { + log.error("获取百度OCR token失败 resp={}", resp); + return null; + } + cachedToken = json.getString("access_token"); + long expiresIn = json.getLongValue("expires_in"); + // 提前 5 分钟过期,避免临界失效 + tokenExpireTime = now + (expiresIn - 300) * 1000L; + return cachedToken; + } catch (Exception e) { + log.error("获取百度OCR token异常", e); + return null; + } + } +} diff --git a/src/main/java/com/gxwebsoft/open/controller/OpenEquipmentController.java b/src/main/java/com/gxwebsoft/open/controller/OpenEquipmentController.java index 58f438d..8b2e22e 100644 --- a/src/main/java/com/gxwebsoft/open/controller/OpenEquipmentController.java +++ b/src/main/java/com/gxwebsoft/open/controller/OpenEquipmentController.java @@ -25,8 +25,8 @@ import com.gxwebsoft.common.core.utils.ImageUtil; import com.gxwebsoft.common.core.web.*; import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.service.UserService; -import com.gxwebsoft.oa.service.IdcardService; -import com.gxwebsoft.oa.vo.IdcardRespVO; +import com.gxwebsoft.common.core.utils.BaiduIdCardResult; +import com.gxwebsoft.common.core.utils.BaiduOcrService; import com.gxwebsoft.shop.entity.Order; import com.gxwebsoft.shop.entity.OrderPay; import com.gxwebsoft.shop.entity.OrderRefund; @@ -80,7 +80,7 @@ public class OpenEquipmentController extends BaseController { private OrderPayService orderPayService; @Resource - private IdcardService idcardService; + private BaiduOcrService baiduOcrService; @Resource private RestTemplate restTemplate; @@ -439,20 +439,13 @@ public class OpenEquipmentController extends BaseController { } /** - * 身份证 OCR 真实性校验,并把识别到的实名信息回填到 order / user。 + * 身份证 OCR 真实性校验(「尽力而为」模式:OCR 调不通/未识别到身份证号时仅记录日志,不阻断业务)。 + * 使用百度智能云身份证识别,识别成功时把 身份证号/姓名/住址 回填到 order / user, + * 其中 order.idCode 用于驱动前端「确认身份 → 去支付」的状态流转。 * orderSourceData 约定顺序:[0]=身份证正面、[1]=身份证反面、[2]=人车合照、[3]=车子照片、[4]=安装照片 * * @param orderSourceData 前端传入的图片地址 JSON 数组字符串 - * @param order 当前订单(校验通过时回填实名信息) - * @return 校验失败返回 ApiResult(fail),校验通过返回 null - */ - /** - * 身份证 OCR 真实性校验(当前为「尽力而为」模式:OCR 调不通/未通过时仅记录日志,不阻断业务)。 - * 等阿里云市场接口/AppCode 恢复正常后,本方法会在识别成功时自动回填实名信息,无需额外改动。 - * orderSourceData 约定顺序:[0]=身份证正面、[1]=身份证反面、[2]=人车合照、[3]=车子照片、[4]=安装照片 - * - * @param orderSourceData 前端传入的图片地址 JSON 数组字符串 - * @param order 当前订单(校验通过时回填实名信息) + * @param order 当前订单(识别成功时回填实名信息) * @return 始终返回 null(放行业务);OCR 结果仅用于尽力回填 */ private ApiResult verifyIdCard(String orderSourceData, Order order) { @@ -472,42 +465,39 @@ public class OpenEquipmentController extends BaseController { return null; } - // 3. 调第三方 OCR 识别(best-effort:异常/失败均仅记录日志,放行业务) - try { - String front = ImageUtil.ImageBase64(images.get(0) + "?x-oss-process=image/resize,w_750/quality,Q_80"); - String back = ImageUtil.ImageBase64(images.get(1) + "?x-oss-process=image/resize,w_750/quality,Q_80"); + // 3. 调百度身份证 OCR 识别(best-effort:失败仅记录日志,放行业务;但成功会回填实名/idCode) + try { + String front = ImageUtil.ImageBase64(images.get(0) + "?x-oss-process=image/resize,w_750/quality,Q_80"); - IdcardRespVO verify = idcardService.verify(front, back); - if (verify == null) { - log.warn("身份证OCR识别返回为空,跳过校验,业务继续"); - return null; - } - if (!"FP00000".equals(verify.getCode())) { - log.warn("身份证OCR识别未通过 code={}, message={},跳过校验,业务继续", verify.getCode(), verify.getMessage()); - return null; - } + BaiduIdCardResult ocr = baiduOcrService.recognizeFront(front); + if (ocr != null && StringUtils.hasText(ocr.getIdCardNo())) { + order.setIdCode(ocr.getIdCardNo()); + if (StringUtils.hasText(ocr.getName())) { + order.setRealName(ocr.getName()); + } + if (StringUtils.hasText(ocr.getAddress())) { + order.setAddress(ocr.getAddress()); + } - // 成功:回填实名信息 - if (StringUtils.hasText(verify.getName()) && StringUtils.hasText(verify.getIdCardNo())) { - order.setRealName(verify.getName()); - order.setIdCode(verify.getIdCardNo()); - order.setAddress(verify.getAddress()); - - User user = userService.getById(order.getUserId()); - if (user != null) { - user.setRealName(verify.getName()); - user.setIdCard(verify.getIdCardNo()); - user.setAddress(verify.getAddress()); - userService.updateUser(user); - } - log.info("身份证OCR识别成功并回填实名 name={}, idCardNo={}", verify.getName(), verify.getIdCardNo()); - } else { - log.warn("身份证OCR识别成功但字段缺失,跳过回填,业务继续"); - } - } catch (Exception e) { - log.error("身份证OCR识别调用异常,跳过校验,业务继续", e); - } - return null; + User user = userService.getById(order.getUserId()); + if (user != null) { + user.setIdCard(ocr.getIdCardNo()); + if (StringUtils.hasText(ocr.getName())) { + user.setRealName(ocr.getName()); + } + if (StringUtils.hasText(ocr.getAddress())) { + user.setAddress(ocr.getAddress()); + } + userService.updateUser(user); + } + log.info("百度OCR识别成功并回填实名 idCardNo={}", ocr.getIdCardNo()); + } else { + log.warn("百度OCR未识别到身份证号,业务继续(可由前端手动填写兜底)"); + } + } catch (Exception e) { + log.error("百度OCR识别调用异常,跳过校验,业务继续", e); + } + return null; } /*@ApiOperation("确认收货") diff --git a/src/main/java/com/gxwebsoft/shop/param/OrderReceiptParam.java b/src/main/java/com/gxwebsoft/shop/param/OrderReceiptParam.java index 79ab4f9..d0ab9e8 100644 --- a/src/main/java/com/gxwebsoft/shop/param/OrderReceiptParam.java +++ b/src/main/java/com/gxwebsoft/shop/param/OrderReceiptParam.java @@ -40,4 +40,7 @@ public class OrderReceiptParam implements Serializable { @ApiModelProperty(value = "家庭地址") @NotBlank(message = "家庭地址不能为空!") private String homeAddress; + + @ApiModelProperty(value = "身份证号(前端手动填写兜底;校验通过时优先使用百度OCR识别结果)") + private String idCode; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 4b46b37..84478d1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,9 +3,9 @@ # 数据源配置 spring: datasource: - url: jdbc:mysql://47.119.165.234:3308/open_ws?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 - username: open_ws - password: DzAmFiZfPJ6ZGApm + url: jdbc:mysql://8.134.55.105:13306/yunxinwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 + username: yunxinwei + password: T4xEYiwTdEmS2ZED driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4c95037..ac75ed0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,7 +3,7 @@ # 数据源配置 spring: datasource: - url: jdbc:mysql://1Panel-mysql-LCaM:3306/yunxinwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 + url: jdbc:mysql://1Panel-mysql-XsWW:3306/yunxinwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 username: yunxinwei password: T4xEYiwTdEmS2ZED driver-class-name: com.mysql.cj.jdbc.Driver @@ -30,7 +30,7 @@ socketio: # 框架配置 config: # 生产环境接口 - server-url: https://yxw.wsdns.cn/api + server-url: https://yxw-api.websoft.top/api # server-url: https://127.0.0.1:9090/api upload-path: /www/wwwroot/file.ws/ @@ -47,4 +47,4 @@ config: cron: alert: 0 30 9 * * ? profit: 0 0/2 * * * ? - timeoutOrder: 1 0 0 * * ? \ No newline at end of file + timeoutOrder: 1 0 0 * * ? diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a6100de..77b9d6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,7 @@ socketio: # 多环境配置 spring: profiles: - active: prod + active: dev # json时间格式设置 jackson: time-zone: GMT+8 @@ -103,6 +103,12 @@ config: # smsKey: ${SMS_ACCESS_KEY:LTAI5tBWM9dSmEAoQFhNqxqJ} # smsSecret: ${SMS_SECRET:Dr0BqiKl7eaL1NNKoCd12qKsbgjnum} +# 百度智能云身份证OCR(确认收货实名识别,密钥仅服务端使用) +baidu: + ocr: + api-key: PbpC2NPq86MYoyhuNphc72fo + secret-key: dFpDFlQ7EQComD2vFAUA8Exj2FkZnkHb + # 日志配置 #logging: # level: diff --git a/数据库脚本/alter_shop_order_idcard.sql b/数据库脚本/alter_shop_order_idcard.sql new file mode 100644 index 0000000..de9f05f --- /dev/null +++ b/数据库脚本/alter_shop_order_idcard.sql @@ -0,0 +1,40 @@ +-- 修复「确认收货」卡在"确认身份":shop_order 缺少 id_code / address 两列。 +-- real_name 已存在(real_name 重复报错 1060 已经证实),无需再加。 +-- 后端 OCR 识别到的身份证号无法落库(MyBatis-Plus updateById 会因列不存在而报错)。 +-- 部署后端新代码【前】必须先在 yunxinwei 库执行本脚本。 +-- 本脚本幂等安全:基于 INFORMATION_SCHEMA 判断列是否存在再决定是否执行 ALTER; +-- 即使重复跑也不会报 1060 Duplicate column。 + +-- 1) 先看一眼现状,便于核对 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'shop_order' + AND COLUMN_NAME IN ('id_code', 'real_name', 'address', 'comments', 'receipt_phone') +ORDER BY COLUMN_NAME; + +-- 2) 条件加列:id_code +SET @sql_id_code = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'shop_order' AND COLUMN_NAME = 'id_code') = 0, + 'ALTER TABLE `shop_order` ADD COLUMN `id_code` varchar(18) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT ''身份证号'' AFTER `comments`', + 'SELECT ''id_code 已存在,跳过'' AS info' +); +PREPARE stmt FROM @sql_id_code; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 3) 条件加列:address +SET @sql_address = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'shop_order' AND COLUMN_NAME = 'address') = 0, + 'ALTER TABLE `shop_order` ADD COLUMN `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT ''详细地址'' AFTER `real_name`', + 'SELECT ''address 已存在,跳过'' AS info' +); +PREPARE stmt FROM @sql_address; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 4) 验证最终结果 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'shop_order' + AND COLUMN_NAME IN ('id_code', 'real_name', 'address') +ORDER BY COLUMN_NAME;