接口对接指南
四步完成签名,剩下的就是发一个 POST
01 · 一分钟上手
调用受保护接口,只需要在请求头带上一组有时效、不可复用的签名。全程四个动作:
拿凭据
向我方申请 appid,并线下交换 appKey / apiKey。
算 token
按第 03 节四步,用当前时间戳和随机串生成签名。
发请求
POST + JSON,四个鉴权值放进请求头。
看 code
HTTP 状态恒为 200,成败只看返回体的 code。
你会拿到三样东西
| 值 | 用途 | 是否上网 |
|---|---|---|
appid | 你的系统编号,放在请求头里 | 是 |
appKey | 与 apiKey 一起派生签名密钥 | 否 |
apiKey | 与 appKey 一起派生签名密钥 | 否 |
http://localhost:18088。建议第一步先调 /api/ping:它不查任何业务数据,只回答"签名对不对"。返回 200 之后再往下接人员接口。
02 · 调用流程
一次完整调用长这样。签名只覆盖 4 个值和请求体,与 URL、请求方法、query 参数无关,所以你不用关心路径拼接顺序。
图 1 一次调用的完整时序
03 · 怎么算签名
四步,任何语言都能实现。下面所有字符串一律显式用 UTF-8,分隔符是单个换行符 \n(不是 CRLF)。
- 派生密钥:
key = SHA256( appKey + "\n" + apiKey ),取原始 32 字节,不要转成 hex 文本。appid 不参与。 - 算请求体指纹:
bodyHash = hex( SHA256( 最终发出去的原始 body 字节 ) ),64 位小写 hex。没有业务参数时也要传{}。 - 拼 4 行待签串,顺序固定、末行后不加换行:
appid ts nonce bodyHash
- 算 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 | 你的系统编号 | 字符串,可含字母 |
ts | 13 位毫秒时间戳 | 与服务端偏差不得超过 ±5 分钟 |
nonce | 随机串,≤64 字符 | 每次请求新生成,UUID 即可,窗口内不可重复 |
token | 第 03 节算出的签名 | 标准 Base64,44 字符 |
Content-Type | application/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 '{}'
05 · 接口清单
四个接口都是 POST,签名算法完全相同,换的只有路径和请求体。除 /api/ping 外,其余三个接口成功时返回的都是同一个人员对象,它到底包含哪些内容,见本节末尾的字段表。
不查业务数据,只确认签名和请求头是否送对。联调先打这个。
请求体
{}
响应 context
{
"appId": "1001",
"softLevel": 1,
"echoTs": "1793946201946",
"echoNonce": "a004ad74-dc70-4207-94c0-d6e27ddc1402",
"serverTime": 1793946201952,
"message": "签名校验通过"
}
三项信息必须同时匹配才会返回人员信息,缺一项或错一项都不返回:
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,不区分"账号不存在""口令错误"还是"电话不一致",也不会提示具体错在哪一项。
请求体
{ "emplId": "100619" }
响应 context
{ "emplId": "100619", "emplName": "示例姓名", ... }
证件号对应多条记录时,固定返回员工编码最小的一条,多次调用结果一致。
请求体
{ "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 | 工作名称 | 字符串 | 人员工作类别名称。 |
"";上面淡黄底的三行还可能因授权范围返回 ""。所以不要用「字段是否存在」判断权限,直接判值是否为空即可,也不用写两套解析逻辑。一次完整的成功响应
{
"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);
}
}
\n 分隔、HMAC 原始字节做 Base64、body 用最终发出的原始字节。08 · 联调自查
签名报 401 时,按这六条从上往下过一遍,基本能定位:
- 本机时钟与服务端偏差是否超过 5 分钟(响应里的
timestamp可以直接对时)。 - 待签串是否正好 4 行、用
\n分隔、末行后没有多余换行。 - Base64 编码的是 HMAC 的原始 32 字节,且长度是 44 字符。
- 请求体是否与参与签名的字符串逐字节一致(没有二次序列化、没有多余空格)。
- 四个请求头是否都送到了,名称全小写:
appid / ts / nonce / token。 - nonce 是否每次新生成——同一个值用第二次会返回 402 重放。
本文只描述接入方需要遵守的契约;接入过程中如有疑问,请联系我方接口负责人。