文档中心

实时接口对接

一、概述

平台 WebSocket 服务基于安全实时通信协议,支持事件订阅、推送和处理,适用于开发者事件(如用户登出、数据更新等)监听。 为确保接口安全性和数据完整性,接入流程采用 HMAC-SHA256 算法进行签名和验证,防止请求篡改和重放攻击。

二、接入准备

1. IP 白名单报备

为确保服务的合法性和安全性,同时便于监管部门进行监督和管理,请在接入平台前联系平台运营人员进行 IP 白名单报备。

报备信息包括:

  • 服务商名称
  • 联系人信息
  • 服务器出口 IP 地址
  • 预计调用频率

2. 获取访问凭证

联系平台运营人员获取以下访问凭证:

  • accessKeyId:用于标识服务商身份的唯一 ID
  • accessKeySecret:用于生成签名的密钥,请妥善保管

3. 环境准备

测试环境: ws://tech-test-api.jxszpt.com 生产环境: wss://tech-api.jxszpt.com

三、签名算法

签名生成步骤

  1. 准备签名参数:accessKeyIdaccessKeySecrettimestamp
  2. 将参数按格式拼接:${accessKeyId}-${accessKeySecret}-${timestamp}
  3. 使用 HMAC-SHA256 算法对拼接字符串进行签名
  4. 将签名结果转换为十六进制字符串

请求头设置

将签名信息添加到请求头中:

Header 名称描述示例
X-AccessKeyId访问密钥 IDyour_access_key_id
X-Signature生成的签名a1b2c3d4e5f6...
X-Timestamp时间戳(毫秒)1692518400000

签名生成步骤

  1. 准备签名参数:accessKeyIdaccessKeySecrettimestamp
  2. 将参数按格式拼接:${accessKeyId}-${accessKeySecret}-${timestamp}
  3. 使用 HMAC-SHA256 算法对拼接字符串进行签名
  4. 将签名结果转换为十六进制字符串

语言特定签名生成示例

Node.js

const crypto = require('crypto');

function generateSignature(accessKeyId, accessKeySecret) {
  const timestamp = Date.now().toString();
  const stringToSign = `${accessKeyId}-${accessKeySecret}-${timestamp}`;
  const hmac = crypto.createHmac('sha256', accessKeySecret);
  hmac.update(stringToSign);
  const signature = hmac.digest('hex');
  return { signature, timestamp };
}

Java

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

public class SignatureGenerator {
    private static String toHexString(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    public static String[] generateSignature(String accessKeyId, String accessKeySecret) throws Exception {
        String timestamp = String.valueOf(System.currentTimeMillis());
        String stringToSign = accessKeyId + "-" + accessKeySecret + "-" + timestamp;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(accessKeySecret.getBytes("UTF-8"), "HmacSHA256"));
        byte[] hmacBytes = mac.doFinal(stringToSign.getBytes("UTF-8"));
        String signature = toHexString(hmacBytes);
        return new String[]{signature, timestamp};
    }
}

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "time"
)

func generateSignature(accessKeyId, accessKeySecret string) (string, string) {
    timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
    stringToSign := accessKeyId + "-" + accessKeySecret + "-" + timestamp
    mac := hmac.New(sha256.New, []byte(accessKeySecret))
    mac.Write([]byte(stringToSign))
    signature := hex.EncodeToString(mac.Sum(nil))
    return signature, timestamp
}

请求头设置

签名生成后,将以下参数放入 WebSocket 连接的请求头:

  • X-AccessKeyIdaccessKeyId 值,标识服务商身份。
  • X-Signature:生成的 HMAC-SHA256 签名,用于验证请求完整性。
  • X-Timestamptimestamp 值,用于防重放攻击。

在 WebSocket 连接中,请求头是传递认证信息的关键。每个语言的 WebSocket 客户端库有不同方式设置 headers,确保 X-AccessKeyIdX-SignatureX-Timestamp 正确包含。

Node.js (使用 socket.io-client)

socket.io-client 支持通过 extraHeaders 配置请求头,签名参数直接在连接时传递。示例:

const io = require('socket.io-client');
const crypto = require('crypto');

function connectWebSocket() {
  const accessKeyId = process.env.TENANT_ACCESS_KEY_ID;
  const accessKeySecret = process.env.TENANT_ACCESS_KEY_SECRET;
  const timestamp = Date.now().toString();
  const stringToSign = `${accessKeyId}-${accessKeySecret}-${timestamp}`;
  const hmac = crypto.createHmac('sha256', accessKeySecret);
  hmac.update(stringToSign);
  const signature = hmac.digest('hex');

  const socket = io('wss://tech-api.jxszpt.com', {
    path: '/developer.event',
    reconnection: true,
    transportOptions: {
      polling: {
        extraHeaders: {
          'X-AccessKeyId': accessKeyId,
          'X-Signature': signature,
          'X-Timestamp': timestamp
        }
      }
    }
  });

  socket.on('connect', () => {
    console.log('已连接到 developer.event 命名空间');
  });

  socket.on('disconnect', (reason) => {
    console.warn(`连接断开: ${reason}`);
  });

  socket.on('portal.user.logout', (message) => {
    console.log('接收到 portal.user.logout:', message);
  });

  socket.on('error', (error) => {
    console.error('WebSocket 错误:', error);
  });

  return socket;
}

connectWebSocket();

说明

  • extraHeaders 嵌套在 transportOptions.polling 中,因为 socket.io 初始使用 HTTP 轮询,后升级为 WebSocket。
  • 每次重连需重新生成 timestampsignature,以避免时间戳过期。

Node.js (使用 ws)

ws 库在构造函数中直接支持 headers 参数。示例:

const WebSocket = require('ws');
const crypto = require('crypto');

function connectWebSocket() {
  const accessKeyId = process.env.TENANT_ACCESS_KEY_ID;
  const accessKeySecret = process.env.TENANT_ACCESS_KEY_SECRET;
  const timestamp = Date.now().toString();
  const stringToSign = `${accessKeyId}-${accessKeySecret}-${timestamp}`;
  const hmac = crypto.createHmac('sha256', accessKeySecret);
  hmac.update(stringToSign);
  const signature = hmac.digest('hex');

  const ws = new WebSocket('wss://tech-api.jxszpt.com/developer.event', {
    headers: {
      'X-AccessKeyId': accessKeyId,
      'X-Signature': signature,
      'X-Timestamp': timestamp
    }
  });

  ws.on('open', () => {
    console.log('已连接到 developer.event 命名空间');
  });

  ws.on('close', () => {
    console.log('连接断开');
    setTimeout(connectWebSocket, 5000); // 重连
  });

  ws.on('message', (data) => {
    console.log('接收到消息:', data.toString());
  });

  ws.on('error', (error) => {
    console.error('WebSocket 错误:', error);
  });
}

connectWebSocket();

说明

  • headers 直接在 WebSocket 构造函数中设置,适用于纯 WebSocket 协议。
  • 需手动实现重连逻辑,每次重连重新生成签名。

Java (使用 java-websocket)

java-websocket 允许在 WebSocketClient 构造函数中传入 headers。示例:

import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;

public class WebSocketConnector extends WebSocketClient {
    public WebSocketConnector(URI serverUri, Map<String, String> headers) {
        super(serverUri, headers);
    }

    @Override
    public void onOpen(ServerHandshake handshakedata) {
        System.out.println("已连接到 developer.event 命名空间");
    }

    @Override
    public void onMessage(String message) {
        System.out.println("接收到消息: " + message);
    }

    @Override
    public void onClose(int code, String reason, boolean remote) {
        System.out.println("连接断开: " + reason);
        // 重连逻辑
    }

    @Override
    public void onError(Exception ex) {
        System.err.println("错误: " + ex.getMessage());
    }

    public static void main(String[] args) throws Exception {
        String accessKeyId = System.getenv("TENANT_ACCESS_KEY_ID");
        String accessKeySecret = System.getenv("TENANT_ACCESS_KEY_SECRET");
        String timestamp = String.valueOf(System.currentTimeMillis());
        String stringToSign = accessKeyId + "-" + accessKeySecret + "-" + timestamp;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(accessKeySecret.getBytes("UTF-8"), "HmacSHA256"));
        byte[] hmacBytes = mac.doFinal(stringToSign.getBytes("UTF-8"));
        String signature = bytesToHex(hmacBytes);

        Map<String, String> headers = new HashMap<>();
        headers.put("X-AccessKeyId", accessKeyId);
        headers.put("X-Signature", signature);
        headers.put("X-Timestamp", timestamp);

        URI uri = new URI("wss://tech-api.jxszpt.com/developer.event");
        WebSocketConnector client = new WebSocketConnector(uri, headers);
        client.setConnectionLostTimeout(0); // 启用自动重连
        client.connect();
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

说明

  • Headers 通过 Map 传递给 WebSocketClient 构造函数。
  • setConnectionLostTimeout(0) 启用自动重连,需重新生成签名。

Go (使用 gorilla/websocket)

gorilla/websocket 使用 http.Header 设置请求头。示例:

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "log"
    "os"
    "time"
    "github.com/gorilla/websocket"
    "net/http"
)

func main() {
    accessKeyId := os.Getenv("TENANT_ACCESS_KEY_ID")
    accessKeySecret := os.Getenv("TENANT_ACCESS_KEY_SECRET")
    timestamp := time.Now().UnixMilli()
    stringToSign := accessKeyId + "-" + accessKeySecret + "-" + strconv.FormatInt(timestamp, 10)
    mac := hmac.New(sha256.New, []byte(accessKeySecret))
    mac.Write([]byte(stringToSign))
    signature := hex.EncodeToString(mac.Sum(nil))

    url := "wss://tech-api.jxszpt.com/developer.event"
    header := http.Header{}
    header.Add("X-AccessKeyId", accessKeyId)
    header.Add("X-Signature", signature)
    header.Add("X-Timestamp", strconv.FormatInt(timestamp, 10))

    conn, _, err := websocket.DefaultDialer.Dial(url, header)
    if err != nil {
        log.Fatal("连接错误:", err)
    }
    defer conn.Close()

    log.Println("已连接到 developer.event 命名空间")

    for {
        _, message, err := conn.ReadMessage()
        if err != nil {
            log.Println("连接断开:", err)
            // 重连
            return
        }
        log.Printf("接收到消息: %s", message)
    }
}

说明

  • http.Header 用于设置认证 headers,直接传递给 Dial
  • 重连需重新生成 timestampsignature

请求头设置注意事项

  • 确保 headers 名称大小写正确(如 X-AccessKeyId)。
  • 每次连接或重连时,重新生成 timestampsignature
  • 某些库可能在 WebSocket 协议升级后忽略 headers,需验证库支持(如 socket.io 使用 extraHeaders)。
  • 签名仅用于初始连接认证,事件消息无需签名。

五、服务端验签逻辑

验签步骤

  1. 参数提取:从请求头中提取 X-AccessKeyIdX-SignatureX-Timestamp
  2. 时间戳验证:检查请求时间戳是否在当前时间的 5 分钟范围内
  3. 签名验证:使用相同算法重新生成签名,与接收到的签名进行比较

服务端验签示例(Node.js)

import crypto from 'crypto';
import express from 'express';

// 中间件:验证 API 签名
function validateSignature(req: express.Request, res: express.Response, next: express.NextFunction) {
    try {
        const accessKeyId = req.headers['x-accesskeyid'] as string;
        const signature = req.headers['x-signature'] as string;
        const timestamp = req.headers['x-timestamp'] as string;

        if (!accessKeyId || !signature || !timestamp) {
            return res.status(400).json({ error: '缺少必要的请求头参数' });
        }

        // 验证时间戳(5分钟内有效)
        const now = Date.now();
        const timestampNum = parseInt(timestamp, 10);
        if (Math.abs(now - timestampNum) > 5 * 60 * 1000) {
            return res.status(401).json({ error: '请求已过期' });
        }

        // 根据 accessKeyId 获取对应的 accessKeySecret(通常从数据库查询)
        const accessKeySecret = getAccessKeySecret(accessKeyId);
        if (!accessKeySecret) {
            return res.status(401).json({ error: '无效的访问密钥' });
        }

        // 重新生成签名并验证
        const stringToSign = `${accessKeyId}-${accessKeySecret}-${timestamp}`;
        const hmac = crypto.createHmac('sha256', accessKeySecret);
        hmac.update(stringToSign);
        const expectedSignature = hmac.digest('hex');

        if (signature !== expectedSignature) {
            return res.status(401).json({ error: '签名验证失败' });
        }

        // 验证通过,继续处理请求
        next();
    } catch (error) {
        return res.status(500).json({ error: '签名验证过程中发生错误' });
    }
}

// 模拟获取密钥的函数(实际应用中应从数据库查询)
function getAccessKeySecret(accessKeyId: string): string | null {
    const keyMap: { [key: string]: string } = {
        'your_access_key_id': 'your_access_key_secret'
    };
    return keyMap[accessKeyId] || null;
}