身份证核验接口 Java 版完整接入:签名工具类的三个细节与官方 Demo 的四个改进
最近在给公司的实名认证服务重写 Java 接入层,过程中把官方 Demo、网上流传的工具类挨个过了一遍,发现能跑和能上生产之间差的东西还不少:编码、超时、null 传播、密钥管理,每一项都是真实翻车点。整理成这篇,从签名工具类写到完整调用,供同样要接这类接口的朋友参考。
背景:这是个什么接口
身份证二要素核验:提交「姓名 + 身份证号」,服务端对接权威数据源实时返回两者是否一致。常见的使用场景是企业业务里的注册实名、开户核身、支付绑卡前的身份校验——都是用户已授权的业务流程,不是查询工具,这点先说清楚。
开通方式:服务商官网提交企业试用申请 → 人工资质审核(一般 1 个工作日)→ 发放 mall_id(商户 ID)与 appkey(签名密钥)。鉴权方式是 MD5 参数签名:
sign = md5( mall_id + realname + idcard + tm + appkey )
字段值按文档顺序直接拼接、无分隔符,MD5 取小写十六进制。appkey 只参与签名,不随请求传输。
一、签名工具类:三个容易翻车的细节
很多博客里的 MD5 工具类"能跑",但放在生产环境有三个隐患。先看我最终保留的版本:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public final class SignUtil {
/** 32 位小写 MD5(核验接口要求小写十六进制)。 */
public static String md5Lower(String text) {
try {
byte[] bytes = MessageDigest.getInstance("MD5")
.digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(32);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("MD5 not available", e);
}
}
/** 13 位毫秒时间戳,防重放。签名与请求必须使用同一个值。 */
public static String tm() {
return String.valueOf(System.currentTimeMillis());
}
}
三个设计决策,每个都对应一种真实翻车:
1. StandardCharsets.UTF_8,不要用 "UTF-8" 字符串重载。
除了免受检异常,更重要的是编码语义明确。中文姓名参与签名时,getBytes() 不显式指定编码的代码,在 Windows 开发机(默认 GBK)上本地测试一切正常,上 Linux 生产立刻签名错误——这种"本地好使上线就挂"的问题,排查起来非常浪费时间。
2. 小写 hex。
Character.forDigit(..., 16) 输出天然小写。很多老代码继承自大写 hexDigits 数组写法,遇上对签名做字符串比较的服务端就是 1104。另外,MD5 失败时抛异常而不是返回 null——见过返回 null 的版本,调用方拿 null 拼签名,最终报错的是远端"签名不合法",排查方向直接被带偏到沟里。
3. tm() 独立成方法。
时间戳必须 13 位毫秒,而且签名和表单里必须是同一个值。把它封装成方法并在调用处一次生成、两处复用,从结构上杜绝"签名的 tm 和请求的 tm 差了几毫秒"这种玄学问题。
二、完整客户端:对照官方 Demo 的四个改进
JDK 11+ 零依赖实现(Java 8 把 HttpClient 换成 HttpURLConnection 即可):
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
public class IdcardVerifyClient {
private static final String VERIFY_URL = "https://api2.nztapi.com/v2/id-server";
private final String mallId;
private final String appKey;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public IdcardVerifyClient(String mallId, String appKey) {
this.mallId = mallId;
this.appKey = appKey;
}
public String verify(String realname, String idcard) throws IOException, InterruptedException {
realname = realname.trim();
idcard = idcard.trim().toLowerCase(); // 末位 X 必须小写
String tm = SignUtil.tm();
String sign = SignUtil.md5Lower(mallId + realname + idcard + tm + appKey);
Map<String, String> form = new LinkedHashMap<>();
form.put("mall_id", mallId);
form.put("realname", realname);
form.put("idcard", idcard);
form.put("tm", tm);
form.put("sign", sign);
String body = form.entrySet().stream()
.map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)
+ "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
HttpRequest req = HttpRequest.newBuilder(URI.create(VERIFY_URL))
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new IOException("HTTP " + resp.statusCode() + ": " + resp.body());
}
return resp.body();
}
public static void main(String[] args) throws Exception {
String mallId = System.getenv("NZT_MALL_ID"); // 凭证走环境变量
String appKey = System.getenv("NZT_APP_KEY");
if (mallId == null || appKey == null) {
System.err.println("请先配置环境变量 NZT_MALL_ID / NZT_APP_KEY");
return;
}
System.out.println(new IdcardVerifyClient(mallId, appKey)
.verify("张三", "11010119900101123X"));
// 成功返回: {"data":{"code":"1000","message":"一致"},"status":"2001"}
}
}
服务商官方附的 Java Demo 能跑,但拿去生产前建议对照改掉四处:
① 字符串 replace 做中文 URL 编码。 Demo 里是先拼好完整 query 串,再 param.replace(realname, URLEncoder.encode(realname, "UTF-8"))。问题:如果姓名里出现 &、=、%,或者姓名是另一个参数的子串,replace 会替换错位置。正确姿势是像上面那样构造 Map 后统一对每个值独立编码。
② URL 和 body 两套拼接。 Demo 的 URL 里带 ? 结尾同时又 POST 表单,两处拼参数早晚不一致。只用表单 body,单一事实来源。
③ 密钥硬编码成常量。 Demo 里 private final static String appkey="你的appkey",照抄进项目就是密钥进 Git 历史——.gitignore 挡不住已经 commit 的东西。环境变量或配置中心注入。
④ 连接无超时。 Demo 的 URLConnection 没设任何超时,服务端偶发慢查询时你的线程池会被成批拖死。HttpClient/HttpURLConnection 都要显式设 connect/read 超时。
三、错误码速查与 1104 排查清单
返回 JSON 两层结构:data.code 业务结果,status 服务状态。
| data.code | 含义 | 计费 | 动作 |
|---|---|---|---|
| 1000 | 一致 | 是 | 放行 |
| 1001 | 不一致 | 是 | 拒绝或转人工 |
| 1002 | 库中无此号 | 否 | 核对输入 |
| 1101 | 商户 ID 不合法 | 否 | 查 mall_id |
| 1103 | 编码不合法 | 否 | 身份证末位 X 转小写 |
| 1104 | 签名不合法 | 否 | 见下 |
| 1106 | 余额不足 | 否 | 充值 |
| 1107 | tm 不合法 | 否 | 13 位毫秒 |
| 1108 | 参数异常 | 否 | 参数无误则确认该产品已对账户开通 |
| 1109 | 账户被暂停 | 否 | 联系服务商 |
1104 按命中率从高到低查:
- 拼接顺序与文档不符——特别注意不同接口的签名串不一样:银行卡三/四要素的签名串不含身份证号字段,手机三要素签名串不含手机号。别凭感觉把全部参数拼进去;
- tm 是 10 位秒级,或签名与表单各生成了一个 tm(差几毫秒都不行);
- MD5 结果大写;
- 中文编码不是 UTF-8(Windows 默认 GBK 的开发机必现,见上文第一节);
- appkey 带了首尾空格(从聊天工具/文档复制凭证的经典事故,
trim()一下)。
四、上生产前的四条
- 凭证走环境变量或配置中心,本地凭证文件全部
.gitignore; - 前置格式校验:18 位身份证正则、11 位手机号,格式不对直接拒——无效调用也可能计费;
- 超时 + 谨慎重试:10-15 秒读超时;只在网络异常时重试一次,业务失败(1001)重试等于双倍计费;
- 审计留痕:记录授权依据、请求参数摘要与返回码。注意不要落身份证号明文,需要时存哈希或掩码。
相关资源
- 四语言零依赖示例(Python/Node/PHP/Go)+ OpenAPI 3.1 规格 + Postman 集合:github.com/awayaz1987-byte/nztapi-api-docs
- 接入文档:www.nztapi.com/developers/common
最后照例提醒:这类接口仅限用户已授权的业务场景(注册实名、开户、绑卡等),禁止用于批量查询他人身份信息;接入方自行承担《个人信息保护法》《数据安全法》项下的数据处理义务。
Posted in: Java, 后端实践. 标签:Java, MD5, API 签名, 实名认证, HttpClient

浙公网安备 33010602011771号