feat(ocr): 集成百度智能云身份证OCR识别服务

- 替换原有失效的身份证OCR服务为百度智能云OCR,支持身份证正面信息识别
- 身份证号、姓名、住址识别成功后回填至订单和用户信息,驱动支付流程状态
- 新增后端配置项管理百度OCR的API密钥,保证密钥仅服务端可见
- 调用失败时仅日志警告,不阻断业务,前端可手动填写身份证号兜底
- 新增数据库字段 id_code、real_name、address 支持身份证识别数据存储
- 更新确认收货接口参数,新增身份证号字段支持前端手动输入
- 更新相关代码逻辑,使用百度OCR接口实现最佳努力模式的身份证识别及回填
- 修改配置文件调整默认激活环境和数据库连接信息,支持开发与生产环境分离
This commit is contained in:
2026-08-23 11:12:41 +08:00
parent db8ab7ffad
commit 5c4ccec4f7
9 changed files with 248 additions and 56 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<groupId>com.gxwebsoft</groupId> <groupId>com.gxwebsoft</groupId>
<artifactId>yxw-api</artifactId> <artifactId>yxw-api</artifactId>
<version>1.5.2</version> <version>1.0</version>
<name>yxw-api</name> <name>yxw-api</name>
<description>WebSoftApi project for Spring Boot</description> <description>WebSoftApi project for Spring Boot</description>
@@ -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;
}
@@ -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<String, String> body = new LinkedMultiValueMap<>();
body.add("id_card_side", "front");
body.add("image", base64Image);
HttpEntity<MultiValueMap<String, String>> 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;
}
}
}
@@ -25,8 +25,8 @@ import com.gxwebsoft.common.core.utils.ImageUtil;
import com.gxwebsoft.common.core.web.*; import com.gxwebsoft.common.core.web.*;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.oa.service.IdcardService; import com.gxwebsoft.common.core.utils.BaiduIdCardResult;
import com.gxwebsoft.oa.vo.IdcardRespVO; import com.gxwebsoft.common.core.utils.BaiduOcrService;
import com.gxwebsoft.shop.entity.Order; import com.gxwebsoft.shop.entity.Order;
import com.gxwebsoft.shop.entity.OrderPay; import com.gxwebsoft.shop.entity.OrderPay;
import com.gxwebsoft.shop.entity.OrderRefund; import com.gxwebsoft.shop.entity.OrderRefund;
@@ -80,7 +80,7 @@ public class OpenEquipmentController extends BaseController {
private OrderPayService orderPayService; private OrderPayService orderPayService;
@Resource @Resource
private IdcardService idcardService; private BaiduOcrService baiduOcrService;
@Resource @Resource
private RestTemplate restTemplate; 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]=安装照片 * orderSourceData 约定顺序:[0]=身份证正面、[1]=身份证反面、[2]=人车合照、[3]=车子照片、[4]=安装照片
* *
* @param orderSourceData 前端传入的图片地址 JSON 数组字符串 * @param orderSourceData 前端传入的图片地址 JSON 数组字符串
* @param order 当前订单(校验通过时回填实名信息) * @param order 当前订单(识别成功时回填实名信息)
* @return 校验失败返回 ApiResult(fail),校验通过返回 null
*/
/**
* 身份证 OCR 真实性校验(当前为「尽力而为」模式:OCR 调不通/未通过时仅记录日志,不阻断业务)。
* 等阿里云市场接口/AppCode 恢复正常后,本方法会在识别成功时自动回填实名信息,无需额外改动。
* orderSourceData 约定顺序:[0]=身份证正面、[1]=身份证反面、[2]=人车合照、[3]=车子照片、[4]=安装照片
*
* @param orderSourceData 前端传入的图片地址 JSON 数组字符串
* @param order 当前订单(校验通过时回填实名信息)
* @return 始终返回 null(放行业务);OCR 结果仅用于尽力回填 * @return 始终返回 null(放行业务);OCR 结果仅用于尽力回填
*/ */
private ApiResult<?> verifyIdCard(String orderSourceData, Order order) { private ApiResult<?> verifyIdCard(String orderSourceData, Order order) {
@@ -472,42 +465,39 @@ public class OpenEquipmentController extends BaseController {
return null; return null;
} }
// 3. 调第三方 OCR 识别(best-effort异常/失败仅记录日志,放行业务) // 3. 调百度身份证 OCR 识别(best-effort:失败仅记录日志,放行业务;但成功会回填实名/idCode
try { try {
String front = ImageUtil.ImageBase64(images.get(0) + "?x-oss-process=image/resize,w_750/quality,Q_80"); 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");
IdcardRespVO verify = idcardService.verify(front, back); BaiduIdCardResult ocr = baiduOcrService.recognizeFront(front);
if (verify == null) { if (ocr != null && StringUtils.hasText(ocr.getIdCardNo())) {
log.warn("身份证OCR识别返回为空,跳过校验,业务继续"); order.setIdCode(ocr.getIdCardNo());
return null; if (StringUtils.hasText(ocr.getName())) {
} order.setRealName(ocr.getName());
if (!"FP00000".equals(verify.getCode())) { }
log.warn("身份证OCR识别未通过 code={}, message={},跳过校验,业务继续", verify.getCode(), verify.getMessage()); if (StringUtils.hasText(ocr.getAddress())) {
return null; order.setAddress(ocr.getAddress());
} }
// 成功:回填实名信息 User user = userService.getById(order.getUserId());
if (StringUtils.hasText(verify.getName()) && StringUtils.hasText(verify.getIdCardNo())) { if (user != null) {
order.setRealName(verify.getName()); user.setIdCard(ocr.getIdCardNo());
order.setIdCode(verify.getIdCardNo()); if (StringUtils.hasText(ocr.getName())) {
order.setAddress(verify.getAddress()); user.setRealName(ocr.getName());
}
User user = userService.getById(order.getUserId()); if (StringUtils.hasText(ocr.getAddress())) {
if (user != null) { user.setAddress(ocr.getAddress());
user.setRealName(verify.getName()); }
user.setIdCard(verify.getIdCardNo()); userService.updateUser(user);
user.setAddress(verify.getAddress()); }
userService.updateUser(user); log.info("百度OCR识别成功并回填实名 idCardNo={}", ocr.getIdCardNo());
} } else {
log.info("身份证OCR识别成功并回填实名 name={}, idCardNo={}", verify.getName(), verify.getIdCardNo()); log.warn("百度OCR识别到身份证号,业务继续(可由前端手动填写兜底)");
} else { }
log.warn("身份证OCR识别成功但字段缺失,跳过回填,业务继续"); } catch (Exception e) {
} log.error("百度OCR识别调用异常,跳过校验,业务继续", e);
} catch (Exception e) { }
log.error("身份证OCR识别调用异常,跳过校验,业务继续", e); return null;
}
return null;
} }
/*@ApiOperation("确认收货") /*@ApiOperation("确认收货")
@@ -40,4 +40,7 @@ public class OrderReceiptParam implements Serializable {
@ApiModelProperty(value = "家庭地址") @ApiModelProperty(value = "家庭地址")
@NotBlank(message = "家庭地址不能为空!") @NotBlank(message = "家庭地址不能为空!")
private String homeAddress; private String homeAddress;
@ApiModelProperty(value = "身份证号(前端手动填写兜底;校验通过时优先使用百度OCR识别结果)")
private String idCode;
} }
+3 -3
View File
@@ -3,9 +3,9 @@
# 数据源配置 # 数据源配置
spring: spring:
datasource: datasource:
url: jdbc:mysql://47.119.165.234:3308/open_ws?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 url: jdbc:mysql://8.134.55.105:13306/yunxinwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
username: open_ws username: yunxinwei
password: DzAmFiZfPJ6ZGApm password: T4xEYiwTdEmS2ZED
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource type: com.alibaba.druid.pool.DruidDataSource
+2 -2
View File
@@ -3,7 +3,7 @@
# 数据源配置 # 数据源配置
spring: spring:
datasource: 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 username: yunxinwei
password: T4xEYiwTdEmS2ZED password: T4xEYiwTdEmS2ZED
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@@ -30,7 +30,7 @@ socketio:
# 框架配置 # 框架配置
config: 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 # server-url: https://127.0.0.1:9090/api
upload-path: /www/wwwroot/file.ws/ upload-path: /www/wwwroot/file.ws/
+7 -1
View File
@@ -7,7 +7,7 @@ socketio:
# 多环境配置 # 多环境配置
spring: spring:
profiles: profiles:
active: prod active: dev
# json时间格式设置 # json时间格式设置
jackson: jackson:
time-zone: GMT+8 time-zone: GMT+8
@@ -103,6 +103,12 @@ config:
# smsKey: ${SMS_ACCESS_KEY:LTAI5tBWM9dSmEAoQFhNqxqJ} # smsKey: ${SMS_ACCESS_KEY:LTAI5tBWM9dSmEAoQFhNqxqJ}
# smsSecret: ${SMS_SECRET:Dr0BqiKl7eaL1NNKoCd12qKsbgjnum} # smsSecret: ${SMS_SECRET:Dr0BqiKl7eaL1NNKoCd12qKsbgjnum}
# 百度智能云身份证OCR(确认收货实名识别,密钥仅服务端使用)
baidu:
ocr:
api-key: PbpC2NPq86MYoyhuNphc72fo
secret-key: dFpDFlQ7EQComD2vFAUA8Exj2FkZnkHb
# 日志配置 # 日志配置
#logging: #logging:
# level: # level:
@@ -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;