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;