加密与签名接入
极客场馆开放平台 AppId / AppSecret / AesKey 凭据、HMAC-SHA256 签名规范、AES 加密通道,附 Java / Node / Python 代码与自检清单。
极客场馆开放平台 · 加密与签名接入
版本 v1 · 配套文档:通用约定与错误码 · 会员 · 订单 · 钱包 适用对接方:商城 / 自助机 / 第三方 SaaS / 任何需要调用门店能力的外部系统
本文只讲怎么通过 AppId / AppSecret / AesKey 完成加密登录与签名。具体的响应结构、错误码、幂等约定见《通用约定与错误码》;各 scope 的接口字段见对应分组页。
1. 网络与基础约定
- HTTPS only,HTTP 不支持
- 接口基地址:
https://www.gymker.com/api/open/v1/ - 字符编码:UTF-8;请求/响应均为 JSON
- 每个
AppId硬绑定一家门店(一个 AppId 只对应一个 gymId)。请求里即使带gymId也会被忽略,平台按凭据绑定的门店过滤。多门店对接需向每家门店分别申请凭据。
2. 凭据
平台为每家门店颁发一组凭据:
| 字段 | 说明 |
|---|---|
AppId | 公开标识,明文随请求头发送,例:gk_open_20260527_fjb2ui |
AppSecret | 签名密钥,绝不能进网络 / 日志 / git(32 字节随机,base64) |
AesKey | 数据加密密钥(仅启用加密通道时下发),AES-256 |
存储要求:放进 KMS / Vault / Secret Manager;不入 git、不打日志、不返前端。
AppSecret/AesKey仅在创建 / 轮换时返回一次。丢了只能让门店管理员在后台「轮换 Secret」,旧值立即失效。
3. 签名算法(HMAC-SHA256)
每个请求都要带 4 个签名头,服务端逐项校验。
3.1 必传请求头
X-Gk-AppId: gk_open_20260527_fjb2ui
X-Gk-Timestamp: 1716700000 # 秒级 Unix 时间戳,服务端 ±300s 外拒绝
X-Gk-Nonce: 550e8400e29b41d4a716 # 32 字符内随机串,服务端 10 分钟内去重防重放
X-Gk-Signature: 9f86d081884c7d659a2... # HMAC-SHA256 hex(小写)
Content-Type: application/json
3.2 待签字符串(6 行,\n 分隔,无结尾换行)
METHOD\n # 大写:GET / POST / PUT / DELETE
PATH\n # 不含 host 和 query,含 /api 前缀,例 /api/open/v1/wallets/123/deduct
QUERY_STRING_SORTED\n # key 字典序拼接 k=v&k=v;无 query 时为空串(仍占一行)
TIMESTAMP\n
NONCE\n
BODY_SHA256_HEX # 请求体 SHA-256 hex(小写);空 body = SHA256("")
3.3 签名
Signature = lower(hex(HMAC-SHA256(AppSecret, 待签字符串)))
3.4 示例:POST 扣款
请求:
POST /api/open/v1/wallets/12345/deduct
Body: {"outBizNo":"ext-mall-20260527-0001","amount":88.00,"remark":"购买蛋白棒"}
待签字符串(第 3 行 query 为空但仍占一行):
POST
/api/open/v1/wallets/12345/deduct
1716700000
550e8400e29b41d4a716
b5d3e8...(body 的 SHA-256 hex)
3.5 最小可跑通示例(curl + openssl)
APP_ID="gk_open_20260527_fjb2ui"
APP_SECRET="你的明文 AppSecret"
TS=$(date +%s)
NONCE=$(openssl rand -hex 12)
BODY_SHA=$(printf '' | openssl dgst -sha256 | awk '{print $NF}') # 空 body
TO_SIGN=$(printf '%s\n%s\n%s\n%s\n%s\n%s' "GET" "/api/open/v1/ping" "" "$TS" "$NONCE" "$BODY_SHA")
SIG=$(printf '%s' "$TO_SIGN" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
curl -s https://www.gymker.com/api/open/v1/ping \
-H "X-Gk-AppId: $APP_ID" -H "X-Gk-Timestamp: $TS" \
-H "X-Gk-Nonce: $NONCE" -H "X-Gk-Signature: $SIG"
调通 /ping(不消耗任何 scope,只校验签名)即说明签名链路全通。
3.6 Java 签名
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public class GymkerSigner {
public static String sign(String method, String path, String query,
String timestamp, String nonce,
byte[] body, String appSecret) throws Exception {
String bodySha = sha256Hex(body == null ? new byte[0] : body);
String toSign = String.join("\n",
method.toUpperCase(), path,
query == null ? "" : sortQuery(query),
timestamp, nonce, bodySha);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return toHex(mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)));
}
private static String sortQuery(String raw) {
return java.util.Arrays.stream(raw.split("&")).filter(s -> !s.isEmpty())
.sorted().reduce((a, b) -> a + "&" + b).orElse("");
}
private static String sha256Hex(byte[] d) throws Exception {
return toHex(MessageDigest.getInstance("SHA-256").digest(d));
}
private static String toHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
}
3.7 Node.js 签名
const crypto = require('crypto')
function sign({ method, path, query = '', timestamp, nonce, body = '', appSecret }) {
const bodySha = crypto.createHash('sha256').update(body).digest('hex')
const sortedQuery = query ? query.split('&').filter(Boolean).sort().join('&') : ''
const toSign = [method.toUpperCase(), path, sortedQuery, String(timestamp), nonce, bodySha].join('\n')
return crypto.createHmac('sha256', appSecret).update(toSign).digest('hex')
}
3.8 Python 签名
import hashlib, hmac
def sign(method, path, query, timestamp, nonce, body_bytes, app_secret):
body_sha = hashlib.sha256(body_bytes or b'').hexdigest()
sorted_q = '&'.join(sorted(filter(None, query.split('&')))) if query else ''
to_sign = '\n'.join([method.upper(), path, sorted_q, str(timestamp), nonce, body_sha])
return hmac.new(app_secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
4. AES 加密通道(可选)
仅当请求 / 响应携带身份证号、手机号原文等敏感字段时启用。需在凭据创建时勾选启用加密通道才会下发 AesKey。
4.1 启用方式
请求头加 X-Gk-Encrypted: 1,请求 body 替换为下面结构(先加密、再签名——签名的 BODY_SHA256_HEX 是对外层这个 JSON 算的):
{
"iv": "base64(12B)",
"ciphertext": "base64(密文 || 16B GCM Tag)",
"alg": "AES-256-GCM"
}
参数:
- 算法:
AES-256-GCM - Key:
AesKey(32 字节) - IV:每次请求随机生成 12 字节
- AAD(绑定本次会话防重放):
AppId + "|" + Timestamp + "|" + Nonce
响应在 X-Gk-Encrypted: 1 时按同结构返回,解密后才是标准业务 JSON。
4.2 Java 加解密
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.security.SecureRandom;
public class GymkerAes {
public static String[] encrypt(byte[] plain, byte[] aesKey, byte[] aad) throws Exception {
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(128, iv));
if (aad != null) c.updateAAD(aad);
byte[] ct = c.doFinal(plain);
return new String[]{
Base64.getEncoder().encodeToString(iv),
Base64.getEncoder().encodeToString(ct)
};
}
/** AesKey 形如 "ek_<base64>",去前缀后 base64 decode 得 32 字节 */
public static byte[] decodeAesKey(String aesKey) {
String body = aesKey.startsWith("ek_") ? aesKey.substring(3) : aesKey;
return Base64.getDecoder().decode(body);
}
}
5. 环境
| 环境 | 表现 | 备注 |
|---|---|---|
| sandbox | 写类接口(扣款等)返回成功但不真扣;读类接口返回真实数据 | 联调用,上线前必须先跑通 |
| prod | 全部接口真实生效 | 上线后用 |
凭据创建时由门店选择环境。GET /ping 响应里的 environment 字段会告诉你当前调用走哪个环境。
6. 接入自检清单(上线前逐项确认)
-
AppSecret/AesKey已入 Vault / KMS,未进 git / 日志 / 前端 - 服务器时钟挂 NTP,偏差 < 60s(签名时间戳 ±300s 才通过)
-
Nonce用 UUIDv4 或同强度随机源(不要用时间戳拼) - 写接口的
outBizNo已持久化到自家库,可重放 -
5xx/ 超时重试用指数退避,并复用同一outBizNo - 生产出口 IP 已报备给门店加白名单(若门店配置了白名单)
- 已在 sandbox 跑通:
/ping → 查余额 → 扣款 → 查流水 → 冲正 - AppSecret 轮换流程已演练(门店点轮换 → 你能 30s 内切到新 secret)
7. 鉴权相关 FAQ
Q:AppSecret 丢了怎么办? 找门店管理员在后台「开放平台」页点「轮换 Secret」,新 secret 仅显示一次,旧 secret 立即失效。
Q:能不能跨门店调用?
不能。每个 AppId 硬绑定一家门店;请求里给 gymId 也会被忽略。多门店请分别申请凭据。
Q:签名一直返 40101 怎么排查?
按顺序核对:① PATH 是否带 /api 前缀且不含 query;② query 是否按 key 字典序;③ 空 body 的 SHA256 是否算的是空串;④ AppSecret 是否粘贴完整;⑤ 待签字符串行尾不要多余换行。
Q:时钟轻微不准会怎样? 偏差 > 300s 直接返 40102。务必挂 NTP。