AIGC标识 身份证核验接口 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 按命中率从高到低查:

  1. 拼接顺序与文档不符——特别注意不同接口的签名串不一样:银行卡三/四要素的签名串不含身份证号字段,手机三要素签名串不含手机号。别凭感觉把全部参数拼进去;
  2. tm 是 10 位秒级,或签名与表单各生成了一个 tm(差几毫秒都不行);
  3. MD5 结果大写
  4. 中文编码不是 UTF-8(Windows 默认 GBK 的开发机必现,见上文第一节);
  5. appkey 带了首尾空格(从聊天工具/文档复制凭证的经典事故,trim() 一下)。

四、上生产前的四条

  1. 凭证走环境变量或配置中心,本地凭证文件全部 .gitignore
  2. 前置格式校验:18 位身份证正则、11 位手机号,格式不对直接拒——无效调用也可能计费;
  3. 超时 + 谨慎重试:10-15 秒读超时;只在网络异常时重试一次,业务失败(1001)重试等于双倍计费;
  4. 审计留痕:记录授权依据、请求参数摘要与返回码。注意不要落身份证号明文,需要时存哈希或掩码。

相关资源

最后照例提醒:这类接口仅限用户已授权的业务场景(注册实名、开户、绑卡等),禁止用于批量查询他人身份信息;接入方自行承担《个人信息保护法》《数据安全法》项下的数据处理义务。


Posted in: Java, 后端实践. 标签:Java, MD5, API 签名, 实名认证, HttpClient

posted @ 2026-09-21 11:07  awayaz  阅读(10)  评论(0)    收藏  举报