接口对接指南

四步完成签名,剩下的就是发一个 POST

版本 v3.2·更新 2026-11-09·HTTP POST + JSON·UTF-8

01 · 一分钟上手

调用受保护接口,只需要在请求头带上一组有时效、不可复用的签名。全程四个动作:

1

拿凭据

向我方申请 appid,并线下交换 appKey / apiKey。

2

算 token

按第 03 节四步,用当前时间戳和随机串生成签名。

3

发请求

POST + JSON,四个鉴权值放进请求头。

4

看 code

HTTP 状态恒为 200,成败只看返回体的 code。

你会拿到三样东西

值用途是否上网
appid你的系统编号,放在请求头里是
appKey与 apiKey 一起派生签名密钥否
apiKey与 appKey 一起派生签名密钥否
两个 Key 永远不进网络。它们只在你的服务器本地参与一次 SHA-256 运算;请求里出现的只有 appid、ts、nonce 和算出来的 token。服务地址由我方提供,联调环境通常是 http://localhost:18088。

建议第一步先调 /api/ping:它不查任何业务数据,只回答"签名对不对"。返回 200 之后再往下接人员接口。

02 · 调用流程

一次完整调用长这样。签名只覆盖 4 个值和请求体,与 URL、请求方法、query 参数无关,所以你不用关心路径拼接顺序。

接入方系统 gtbjahinfo ① 生成 ts / nonce 毫秒时间戳 + 每次新随机串 ② 本地算 token SHA256(appKey+apiKey) HMAC → Base64 ③ POST + 4 个请求头 + 原始 body appid / ts / nonce / token ④ 校验时间窗 / 签名 / 重放 通过后才执行业务 ⑤ 统一 JSON:{ code, timestamp, title, context } ts 允许 ±5 分钟偏差 · 同一个 nonce 只能用一次

图 1 一次调用的完整时序

03 · 怎么算签名

四步,任何语言都能实现。下面所有字符串一律显式用 UTF-8,分隔符是单个换行符 \n(不是 CRLF)。

  1. 派生密钥:key = SHA256( appKey + "\n" + apiKey ),取原始 32 字节,不要转成 hex 文本。appid 不参与。
  2. 算请求体指纹:bodyHash = hex( SHA256( 最终发出去的原始 body 字节 ) ),64 位小写 hex。没有业务参数时也要传 {}。
  3. 拼 4 行待签串,顺序固定、末行后不加换行:
appid
ts
nonce
bodyHash
  1. 算 token:token = Base64( HMAC-SHA256( key, 待签串 ) )。编码的是 HMAC 的原始 32 字节,标准 Base64(+/、保留 =),结果恒为 44 字符。

代入样例(可直接逐字节对拍)

用下面这组固定值,你算出的 token 必须与结果完全一致;对不上就是上面某一步的编码细节出了偏差。

appid      = 1001
appKey     = SK-9527
apiKey     = AK-7788
ts         = 1793946201946
nonce      = a004ad74-dc70-4207-94c0-d6e27ddc1402
body       = {}
bodyHash   = 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a

派生密钥(hex,仅供核对)
7410883dfab8ac1dc3b375cdfa78ecc7a9b406069f84af40e4f5cac05d9221ff

待签串(4 行,LF 分隔,末行无换行)
1001
1793946201946
a004ad74-dc70-4207-94c0-d6e27ddc1402
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a

token = NLingEKMqJ+wcQu2/hgS5WiVqr1cVVd0Vv9quG280PI=
四个最常见的翻车点:① 用了 System.lineSeparator()(Windows 上变成 CRLF);② 用无参 getBytes() 吃了系统默认编码;③ 先把 HMAC 转 hex 再 Base64,结果变成 88 字符;④ 签名时重新序列化了一遍 JSON,导致与最终发出的 body 字节不一致。
注意:上面这组 ts / nonce 是固定样例,只能用来对拍算法,不能真发请求——时间戳早已过期、nonce 也会被判重放。

04 · 请求头与 body

请求头值说明
appid你的系统编号字符串,可含字母
ts13 位毫秒时间戳与服务端偏差不得超过 ±5 分钟
nonce随机串,≤64 字符每次请求新生成,UUID 即可,窗口内不可重复
token第 03 节算出的签名标准 Base64,44 字符
Content-Typeapplication/json;charset=UTF-8请求体一律 UTF-8

body 怎么传:把参与签名的那个 JSON 字符串原样作为请求体发出,不要在签名之后再格式化、加空格、改字段顺序或重新序列化——只要一个字节不同,签名就对不上。没有参数时传 {},不要留空。

一条可以直接复制的请求

curl -X POST 'http://localhost:18088/api/ping' \
  -H 'Content-Type: application/json;charset=UTF-8' \
  -H 'appid: 1001' \
  -H 'ts: 1793946201946' \
  -H 'nonce: a004ad74-dc70-4207-94c0-d6e27ddc1402' \
  -H 'token: NLingEKMqJ+wcQu2/hgS5WiVqr1cVVd0Vv9quG280PI=' \
  -d '{}'
上面这条 cURL 用的是固定样例值,只用于对照格式;真实请求请把 ts 换成当前毫秒时间戳、nonce 换成新 UUID,并用新值重新计算 token。

05 · 接口清单

四个接口都是 POST,签名算法完全相同,换的只有路径和请求体。除 /api/ping 外,其余三个接口成功时返回的都是同一个人员对象,它到底包含哪些内容,见本节末尾的字段表。

POST/api/ping验签自检

不查业务数据,只确认签名和请求头是否送对。联调先打这个。

请求体
{}

响应 context
{
  "appId": "1001",
  "softLevel": 1,
  "echoTs": "1793946201946",
  "echoNonce": "a004ad74-dc70-4207-94c0-d6e27ddc1402",
  "serverTime": 1793946201952,
  "message": "签名校验通过"
}
POST/api/empl/emplbyhistel按账号、口令与电话取人

三项信息必须同时匹配才会返回人员信息,缺一项或错一项都不返回:

  • userName — HIS 登录码。
  • password — 明文口令,直接传原文即可。校验由我方系统完成,接入方不要自行加密或转换,也不要传任何密文串。
  • tel — 联系电话,须与系统中登记的值完全一致(不含空格、横线等额外字符)。
请求体
{ "userName": "his_login", "password": "明文口令", "tel": "13800000000" }

响应 context(人员对象,节选;完整字段见下方表格)
{
  "emplId": "100619",
  "emplName": "示例姓名",
  "userName": "his_login",
  "userPassword": "",
  "tel": "13800000000",
  "deptName": "示例科室",
  ...
}
口令绝不会出现在响应里(userPassword 恒为空字符串)。另外响应中的 tel 可能因接入权限返回空字符串,这与校验是否通过无关,请以 code 为准。

任一条件不满足统一返回 401,不区分"账号不存在""口令错误"还是"电话不一致",也不会提示具体错在哪一项。

POST/api/empl/query按员工编码查人
请求体
{ "emplId": "100619" }

响应 context
{ "emplId": "100619", "emplName": "示例姓名", ... }
POST/api/empl/queryByIdenno按证件号查人

证件号对应多条记录时,固定返回员工编码最小的一条,多次调用结果一致。

请求体
{ "idenno": "证件号码" }

响应 context
{ "emplId": "100619", "emplName": "示例姓名", ... }

人员对象:返回哪些内容

三个人员接口的 context 结构完全一致:固定 15 个字段,全部为字符串。下表就是全部内容,没有隐藏字段,也不会随查询条件增减。查无数据时 context 为 null(返回码 404),不会给一个空对象。

字段含义类型说明
emplId员工编码字符串人员在 HIS 中的唯一编码,可作为主键使用。
emplName姓名字符串员工姓名。
userName登录码字符串HIS 登录账号;三项匹配接口按此字段校验。
userPassword口令字符串恒为空字符串 "",任何情况下都不提供口令内容。
spellCode拼音码字符串姓名检索码,用于按拼音快速定位人员。
tel联系电话字符串按接入方授权范围提供;未授权时为空字符串 ""。
idenno证件号码字符串按接入方授权范围提供;未授权时为空字符串 ""。
deptId科室编码字符串所属科室编码。
deptName科室名称字符串所属科室名称。
posiCode职级编码字符串职级编码。
posiName职级字符串职级名称。
levlCode职称编码字符串职称编码。
levlName职称字符串职称名称。
emplType工作编码字符串人员工作类别编码。
emplTypeName工作名称字符串人员工作类别名称。
按固定结构解析即可:无论有没有值,这 15 个字段始终出现在 JSON 里,不会少 key。源数据没有值时返回空字符串 "";上面淡黄底的三行还可能因授权范围返回 ""。所以不要用「字段是否存在」判断权限,直接判值是否为空即可,也不用写两套解析逻辑。

一次完整的成功响应

{
  "code": 200,
  "timestamp": 1793946202000,
  "title": "查询成功",
  "context": {
    "emplId": "100619",
    "emplName": "示例姓名",
    "userName": "his_login",
    "userPassword": "",
    "spellCode": "SLXM",
    "tel": "13800000000",
    "idenno": "示例证件号码",
    "deptId": "0101",
    "deptName": "示例科室",
    "posiCode": "01",
    "posiName": "示例职级",
    "levlCode": "02",
    "levlName": "示例职称",
    "emplType": "1",
    "emplTypeName": "示例类别"
  }
}

06 · 返回格式

所有接口 HTTP 状态恒为 200,业务成败一律看返回体的 code,不需要分两套解析逻辑。

{
  "code": 200,
  "timestamp": 1793946202000,
  "title": "查询成功",
  "context": { ... }
}
字段说明
code业务返回码,见下表
timestamp服务端毫秒时间戳,可用于校准本机时钟
title可直接展示的结果短语
context业务数据;失败时为 null
code含义常见原因
200成功—
401失败参数为空、时间戳超窗、签名不匹配、三项信息不匹配
402拒绝缺少鉴权请求头、appid 未登记、nonce 重放
404查无数据人员编码或证件号不存在
900其他服务端异常
失败示例
{ "code": 401, "timestamp": 1793946202000, "title": "签名不匹配", "context": null }

07 · 示例代码

下面是一份可直接编译运行的 Java 客户端(JDK 8 及以上,零第三方依赖)。改 BASE_URL / PATH / BODY 三个常量就能调任意接口;把 APP_ID / APP_KEY / API_KEY 换成实际分配的值即可。

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.UUID;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
 * gtbjahinfo —— 第三方接入签名 Demo(客户端)
 *
 * 运行环境:JDK 1.8 及以上。所有依赖均为 JDK 自带包,无需任何第三方 jar、无需 Maven 依赖。
 * 编译运行:
 *   javac -encoding UTF-8 ApiClientDemo.java
 *   java  -Dfile.encoding=UTF-8 ApiClientDemo
 *
 * 签名契约见《接口对接指南》第 03 节:
 *   密钥   = SHA256(appKey + "\n" + apiKey)                  原始 32 字节
 *   待签串 = appid \n ts \n nonce \n hex(SHA256(body))       固定 4 行
 *   token  = Base64(HMAC-SHA256(密钥, 待签串))               标准 Base64,44 字符
 *
 * 四条铁律(改错一个签名就对不上):
 *   1 分隔符固定 "\n",不能用 System.lineSeparator()
 *   2 所有字符串显式 UTF-8,不能用无参 getBytes()
 *   3 Base64 编码 HMAC 的原始 32 字节,不是 hex 文本
 *   4 签名按「最终发出去的原始 body 字符串」计算,不能重新序列化 JSON
 */
public class ApiClientDemo {

    private static final String LF = "\n";
    private static final String HMAC_ALGO = "HmacSHA256";

    // 1) 凭据:由我方分配 / 双方线下交换。appKey、apiKey 永不上网,只在本地参与运算。
    private static final String APP_ID = "1001";
    private static final String APP_KEY = "SK-9527";
    private static final String API_KEY = "AK-7788";

    // 2) 服务地址由我方提供;换接口只改 PATH / BODY 两个常量,签名算法与路径无关
    private static final String BASE_URL = "http://localhost:18088";
    private static final String PATH = "/api/ping";
    private static final String BODY = "{}";
    // 三项匹配取人:    PATH = "/api/empl/emplbyhistel"; BODY = "{\"userName\":\"his_login\",\"password\":\"明文口令\",\"tel\":\"13800000000\"}";
    // 按员工编码查人:  PATH = "/api/empl/query";         BODY = "{\"emplId\":\"100619\"}";
    // 按证件号查人:    PATH = "/api/empl/queryByIdenno"; BODY = "{\"idenno\":\"证件号码\"}";
    // 人员接口返回同一套固定字段;个别字段可能因接入权限返回空字符串,按固定结构解析即可。

    public static void main(String[] args) throws Exception {

        // 1) 接收什么:ts 毫秒时间戳(±5 分钟)、nonce 每次新生成、body 取原始字节
        long ts = System.currentTimeMillis();
        String nonce = UUID.randomUUID().toString();
        byte[] bodyBytes = BODY.getBytes(StandardCharsets.UTF_8);

        // 2) 哈希什么:SHA256(bodyBytes) → 64 位小写 hex(请求体完整性靠这一行)
        String bodyHash = sha256Hex(bodyBytes);

        // 3) 拼凑什么:固定 4 行待签串,分隔符 LF,末行后不加换行
        //    appid \n ts \n nonce \n hex(SHA256(body))
        String canonical = APP_ID + LF
                + ts + LF
                + nonce + LF
                + bodyHash;

        // 4) 派生密钥:SHA256(appKey + "\n" + apiKey) 的原始 32 字节;appid 不参与派生
        byte[] key = deriveKey(APP_KEY, API_KEY);

        // 5) 算签名:HMAC-SHA256(key, 待签串 UTF-8 字节) → 原始 32 字节
        Mac mac = Mac.getInstance(HMAC_ALGO);
        mac.init(new SecretKeySpec(key, HMAC_ALGO));
        byte[] digest = mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));

        // 6) 输出 token:标准 Base64(+/、带 =、不插换行),恒 44 字符
        String token = Base64.getEncoder().encodeToString(digest);

        // 7) 打印本次请求全量要素,联调时与服务端 / 测试页逐项对拍
        System.out.println("========== 本次请求要素 ==========");
        System.out.println("POST " + BASE_URL + PATH);
        System.out.println("appid   : " + APP_ID);
        System.out.println("ts      : " + ts);
        System.out.println("nonce   : " + nonce);
        System.out.println("token   : " + token);
        System.out.println("body    : " + BODY);
        System.out.println("bodyHash: " + bodyHash);
        System.out.println("待签串(4 行):");
        String[] lines = canonical.split("\n", -1);
        for (int i = 0; i < lines.length; i++) {
            System.out.println("  " + (i + 1) + " | " + lines[i]);
        }

        // 8) 发送:4 个鉴权头 + 原始 body;HTTP 状态恒 200,成败看返回体 code
        System.out.println("========== 服务端响应 ==========");
        System.out.println(post(ts, nonce, token, BODY));
    }

    /** 2) SHA-256 → 64 位小写 hex */
    private static String sha256Hex(byte[] data) throws Exception {
        byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
        StringBuilder sb = new StringBuilder(digest.length * 2);
        for (byte b : digest) {
            sb.append(String.format("%02x", b & 0xff));
        }
        return sb.toString();
    }

    /** 4) 派生密钥:SHA256(appKey + LF + apiKey) 的原始 32 字节 */
    private static byte[] deriveKey(String appKey, String apiKey) throws Exception {
        String material = appKey + LF + apiKey;
        return MessageDigest.getInstance("SHA-256").digest(material.getBytes(StandardCharsets.UTF_8));
    }

    /** 8) 发 POST,返回响应字符串(UTF-8) */
    private static String post(long ts, String nonce, String token, String body) throws Exception {
        HttpURLConnection conn = (HttpURLConnection) new URL(BASE_URL + PATH).openConnection();
        conn.setRequestMethod("POST");
        conn.setDoOutput(true);
        conn.setConnectTimeout(8000);
        conn.setReadTimeout(15000);
        conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
        conn.setRequestProperty("appid", APP_ID);
        conn.setRequestProperty("ts", String.valueOf(ts));
        conn.setRequestProperty("nonce", nonce);
        conn.setRequestProperty("token", token);

        OutputStream os = conn.getOutputStream();
        os.write(body.getBytes(StandardCharsets.UTF_8));
        os.flush();
        os.close();

        int http = conn.getResponseCode();
        InputStream in = (http >= 200 && http < 300) ? conn.getInputStream() : conn.getErrorStream();
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        if (in != null) {
            byte[] buf = new byte[4096];
            int n;
            while ((n = in.read(buf)) != -1) {
                bos.write(buf, 0, n);
            }
            in.close();
        }
        conn.disconnect();
        return "HTTP " + http + "  " + new String(bos.toByteArray(), StandardCharsets.UTF_8);
    }
}
其他语言只要照第 03 节四步实现即可,重点核对:UTF-8、\n 分隔、HMAC 原始字节做 Base64、body 用最终发出的原始字节。

08 · 联调自查

签名报 401 时,按这六条从上往下过一遍,基本能定位:

站点里的接口测试台会实时展示 ts、nonce、待签串原文、token 和可直接复制的 cURL,也可以用地址条指向远程服务器,联调时拿它对拍最省事。
gtbjahinfo · 接口对接指南 v3.2
本文只描述接入方需要遵守的契约;接入过程中如有疑问,请联系我方接口负责人。