腾讯云《意愿核身移动 H5》 快速完成身份验证接入

发布于:2025-09-05 ⋅ 阅读:(19) ⋅ 点赞:(0)

简介

本文将带你快速上手 腾讯云《意愿核身移动 H5》能力接入,基于 Java + SpringBoot 实现完整的接口调用流程。通过实战代码演示,涵盖了 获取 AccessToken、获取 Ticket、生成签名、启动 H5 意愿核身、查询结果回调 等关键环节,并结合 Hutool 工具类库 简化了 HTTP 请求和 JSON 处理,让接入过程更加高效。

在实名认证和远程身份核验场景中,意愿核身是保障业务安全合规的重要环节。本文基于腾讯云官方文档,整理出一套 后端直连接入 H5 意愿核身 的完整方案:

  1. 依赖配置:引入 hutool-all,简化请求与 JSON 解析;
  2. 接口实现:封装 getAccessToken、getApiTicket、sign 等工具方法;
  3. H5 启动:生成 FaceUrl 并跳转腾讯云意愿核身页面;
  4. 结果查询:回调获取 orderNo,调用 getWillFaceResult 查询最终结果;

通过本篇文章,你将能够快速完成腾讯云意愿核身的接入,避免掉入签名、参数拼接等常见坑,加速业务上线。

腾讯云《意愿核身移动 H5》 文档

在这里插入图片描述

pom.xml

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.25</version>
</dependency>

接口配置参数

import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.MessageFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

// Redis 缓存 Key
private final static String ACCESS_TOKEN_KEY = "face:tencent:access_token";
private final static String TICKET_KEY = "face:tencent:ticket";

// 接口参数
private final static String BASE_URL = "https://kyc1.qcloud.com/api";
private final static String APP_ID = "xxxxxxxxx";
private final static String SECRET_KEY = "xxxxxxxxx";
private final static String VERSION = "1.0.0";

// 意愿核身成功后回调 H5 页面地址
private final static String CALLBACK_URL = "https://test.domain/index";
private final static String QUESTION = "{0}是宇宙无敌帅。同意请回复“确认”,否则回复“不确认”。";
private final static String ANSWER = "确认";

获取 AccessToken

private String getAccessToken() {
    String accessToken = redisService.getCacheObject(ACCESS_TOKEN_KEY);
    if (accessToken != null) {
        return accessToken;
    }
    String url = MessageFormat.format(BASE_URL + "/oauth2/access_token?appId={0}&secret={1}&grant_type=client_credential&version={2}", APP_ID, SECRET_KEY, VERSION);
    HttpResponse response = HttpUtil.createGet(url).execute();
    String body = response.body();
    log.info("body: {}", body);
    JSONObject result = JSONUtil.parseObj(body);
    if (!result.containsKey("code") || !"0".equals(result.getStr("code"))) {
        throw new RuntimeException("获取Token失败:" + (result.containsKey("msg") ? result.getStr("msg") : "未知错误"));
    }
    accessToken = result.getStr("access_token");
    Long expireIn = result.getLong("expire_in");
    redisService.setCacheObject(ACCESS_TOKEN_KEY, accessToken, expireIn, TimeUnit.SECONDS);
    return accessToken;
}

获取 Ticket

private String getApiTicket() {
    String ticket = redisService.getCacheObject(TICKET_KEY);
    if (ticket != null) {
        return ticket;
    }
    String accessToken = getAccessToken();
    String url = MessageFormat.format(BASE_URL + "/oauth2/api_ticket?appId={0}&access_token={1}&type=SIGN&version={2}", APP_ID, accessToken, VERSION);
    HttpResponse response = HttpUtil.createGet(url).execute();
    String body = response.body();
    log.info("body: {}", body);
    JSONObject result = JSONUtil.parseObj(body);
    if (!result.containsKey("code") || !"0".equals(result.getStr("code"))) {
        throw new RuntimeException("获取Ticket失败:" + (result.containsKey("msg") ? result.getStr("msg") : "未知错误"));
    }
    JSONArray tickets = result.getJSONArray("tickets");
    if (tickets.isEmpty()) {
        throw new RuntimeException("获取Ticket失败,tickets为空");
    }
    JSONObject ticketJson = (JSONObject) tickets.get(0);
    ticket = ticketJson.getStr("value");
    Long expireIn = ticketJson.getLong("expire_in");
    redisService.setCacheObject(TICKET_KEY, ticket, expireIn, TimeUnit.SECONDS);
    return ticket;
}

生成签名 sign

private String sign(Map<String, Object> paramMap) {
    // 按值的字典序排
    List<Object> sortedValues = paramMap.values().stream().filter(Objects::nonNull).sorted(Comparator.comparing(Object::toString)).collect(Collectors.toList());
    String signStr = sortedValues.stream().map(Object::toString).collect(Collectors.joining(""));
    paramMap.remove("ticket");
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-1");
        byte[] hashBytes = digest.digest(signStr.getBytes(StandardCharsets.UTF_8));
        return bytesToHex(hashBytes).toUpperCase();
    } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException("SHA-1 algorithm not available.", e);
    }
}

private String bytesToHex(byte[] bytes) {
    StringBuilder hexString = new StringBuilder();
    for (byte b : bytes) {
        String hex = Integer.toHexString(0xff & b);
        if (hex.length() == 1) {
            hexString.append('0');
        }
        hexString.append(hex);
    }
    return hexString.toString();
}

启动 H5 意愿核身

public String getFaceUrl(String userId, String name, String idNo, Long depositAmount) {
	// 生成 32 位长度的订单号
    String orderNo = DateUtils.dateTimeNow() + StringUtils.getRandomString(18);
    String url = MessageFormat.format(BASE_URL + "/server/getWillFaceId?orderNo={0}", orderNo);
    Map<String, Object> paramMap = new HashMap<>();
    paramMap.put("appId", APP_ID);
    paramMap.put("userId", userId);
    paramMap.put("version", VERSION);
    // 生成 32 位长度的随机字符串
    paramMap.put("nonce", StringUtils.getRandomString(32));
    paramMap.put("ticket", getApiTicket());
    // 生成签名
    paramMap.put("sign", sign(paramMap));
    paramMap.put("name", name);
    paramMap.put("idNo", idNo);
    paramMap.put("orderNo", orderNo);
    paramMap.put("liveService", 2);
    // 参数值为1时,表示仅使用实时检测模式,不兼容的情况下回调错误码3005
    paramMap.put("liveInterType", 1);
    // 0:问答模式 1:播报模式 2:点头模式
    paramMap.put("willType", 0);
    paramMap.put("willLanguage", 0);

    // 系统播报问题文本/问题
    Map<String, String> questionMap = new LinkedHashMap<>();
    questionMap.put("id", "0");
    questionMap.put("question", MessageFormat.format(QUESTION, name));
    questionMap.put("answer", ANSWER);
    List<Map<String, String>> willContentList = new ArrayList<>();
    willContentList.add(questionMap);
    paramMap.put("willContentList", willContentList);
    paramMap.put("willMidAnswer", ANSWER);

    HttpRequest request = HttpUtil.createPost(url);
    request.header("Content-Type", "application/json");
    request.body(JSONUtil.toJsonStr(paramMap));
    HttpResponse response = request.execute();
    String body = response.body();
    log.info("body: {}", body);
    JSONObject result = JSONUtil.parseObj(body);
    if (!result.containsKey("code") || !"0".equals(result.getStr("code"))) {
        throw new RuntimeException("合作方后台上传信息失败:" + (result.containsKey("msg") ? result.getStr("msg") : "未知错误"));
    }
    // {
    //   "code": "0",
    //   "msg": "请求成功",
    //   "bizSeqNo": "xxxxxxxxxxxxxxx",
    //   "result": {
    //     "bizSeqNo": "xxxxxxxxxxxxxxx",
    //     "transactionTime": "20250903155605",
    //     "oriCode": "0",
    //     "orderNo": "xxxxxxxxxxxxxxx",
    //     "faceId": "xxxxxxxxxxxxxxx",
    //     "optimalDomain": "kyc1.qcloud.com",
    //     "success": true
    //   },
    //   "transactionTime": "20250903155605"
    // }
    result = result.getJSONObject("result");
    orderNo = result.getStr("orderNo");
    String faceId = result.getStr("faceId");
    String optimalDomain = "kyc1.qcloud.com";
    if (result.containsKey("optimalDomain")) {
        optimalDomain = result.getStr("optimalDomain");
    }
    return willLogin(orderNo, userId, faceId, optimalDomain);
}

private String willLogin(String orderNo, String userId, String faceId, String optimalDomain) {
    Map<String, Object> paramMap = new HashMap<>();
    paramMap.put("appId", APP_ID);
    paramMap.put("orderNo", orderNo);
    paramMap.put("userId", userId);
    paramMap.put("version", VERSION);
    paramMap.put("faceId", faceId);
    paramMap.put("ticket", getApiTicket());
    String nonceStr = StringUtils.getRandomString(32);
    paramMap.put("nonce", nonceStr);
    // 生成签名
    String signStr = sign(paramMap);
    try {
        String url = MessageFormat.format("https://{0}/api/web/willLogin?appId={1}&version={2}&nonce={3}&orderNo={4}&faceId={5}&url={6}&from=browser&userId={7}&sign={8}&redirectType=1",
                optimalDomain,
                APP_ID,
                VERSION,
                nonceStr,
                orderNo,
                faceId,
                // 回调时所带的参数:https://test.domain/index?orderNo=xxxx&code=0&newSignature=xxxx&liveRate=99&h5faceId=xxxx&willCode=0&faceCode=0&similarity=95.29
                URLEncoder.encode(CALLBACK_URL, String.valueOf(StandardCharsets.UTF_8)),
                userId,
                signStr
        );
        log.info("url: {}", url);
        return url;
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
        log.error("{}", e.getMessage());
        throw new RuntimeException("启动 H5 意愿核身失败");
    }
}

注: 将生成的 FaceUrl 链接放到浏览器访问,就会跳到下方的意愿核身页面。

https://kyc1.qcloud.com/api/web/willLogin?appId=appId001
&version=1.0.0
&nonce=4bu6a5nv9t678m2t9je5819q46y9hf93
&orderNo=161709188560917432576916585
&faceId=wb04f10695c3651ce155fea7070b74c9
&url=https%3a%2f%2ftest.domain%2findex
&from=browser
&userId=23333333333333
&sign=5DD4184F4FB26B7B9F6DC3D7D2AB3319E5F7415F
&redirectType=1

在这里插入图片描述

意愿核身查询结果

public FaceResult getFaceResult(String orderNo) {
    String url = MessageFormat.format(BASE_URL + "/server/getWillFaceResult?orderNo={0}", orderNo);
    Map<String, Object> paramMap = new HashMap<>();
    paramMap.put("appId", APP_ID);
    paramMap.put("orderNo", orderNo);
    paramMap.put("version", VERSION);
    paramMap.put("ticket", getApiTicket());
    String nonceStr = StringUtils.getRandomString(32);
    paramMap.put("nonce", nonceStr);
    // 生成签名
    paramMap.put("sign", sign(paramMap));
    paramMap.put("getFile", 1);
    paramMap.put("getWillFile", 0);

    HttpRequest request = HttpUtil.createPost(url);
    request.header("Content-Type", "application/json");
    request.body(JSONUtil.toJsonStr(paramMap));
    HttpResponse response = request.execute();
    String body = response.body();
    log.info("body: {}", body);
    JSONObject result = JSONUtil.parseObj(body);
    if (!result.containsKey("code") || !"0".equals(result.getStr("code"))) {
        throw new RuntimeException("识别结果查询失败:" + (result.containsKey("msg") ? result.getStr("msg") : "未知错误"));
    }
    return JSONUtil.toBean(result.getJSONObject("result"), FaceResult.class);
}

注: orderNo 参数从 回调地址 参数中获取 https://test.domain/index?orderNo=xxxx&code=0&newSignature=xxxx&liveRate=99&h5faceId=xxxx&willCode=0&faceCode=0&similarity=95.29