极客场馆 极客场馆
← 回到开发者文档

加密与签名接入

极客场馆开放平台 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
  • KeyAesKey(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。