本文面向通过服务端 HTTP 调用站斧开放平台的外部开发者,重点说明公共字段来源、MD5 签名规则,以及 PHP、Java、Python、Go 的完整调用方式。

示例接口:

  • rux.open.user.info:获取当前授权用户信息。
  • rux.open.mall.bindMall.mallList:获取当前授权用户的店铺列表。
本文只演示服务端调用。app_secret 不得下发给浏览器、插件、移动端或其他不受信任的客户端。

1. 请求入口

正式环境:

https://gw-open.zhanfubrowser.com/api/route

调用要求:

  • 请求方法必须为 POST。
  • 请求与响应统一使用 UTF-8。
  • 本文示例使用 application/x-www-form-urlencoded 请求体。
  • 每个字段值由 HTTP 库执行表单编码;不要手工重复编码。
  • 期望 JSON 响应时设置 Accept: application/json。

2. 一次 API 调用的完整过程

准备公共参数和业务参数
        ↓
数组/对象转为紧凑 JSON 字符串
        ↓
过滤空字段并按参数名升序排列
        ↓
使用 app_secret 生成大写 MD5 签名
        ↓
加入 sign,执行 URL/form 编码
        ↓
POST 到 /api/route
        ↓
检查 HTTP 状态、errorCode 和业务数据

3. 字段来源与用途

3.1 公共字段

字段从哪里获取是否参与签名是否发送示例
api每个接口文档规定是是rux.open.user.info
app_key站斧开放平台为应用分配是是your_app_key
app_secret站斧开放平台为应用分配否,作为签名首尾密钥否your_app_secret
timestamp调用方服务端当前时间是是1480411125
access_tokenOAuth 换令牌接口返回是是ACCESS_TOKEN_VALUE
sign调用方按第 4 节生成否是804D...986

timestamp 使用 10 位 Unix 秒级时间戳。服务器时钟应通过可靠时间源保持同步。

3.2 业务字段

业务字段由具体 API 定义,也必须参与签名并随请求发送。例如分页字段:

字段来源示例说明
page调用方设置1原始资料说明默认值为 1
limit调用方设置10原始资料说明默认值为 10

如果某个接口的正式文档使用 page_size 而不是 limit,必须以该接口定义为准。字段名不同会产生不同签名。

3.3 数组和对象字段

数组或对象必须先序列化为紧凑 JSON 字符串,再将这个同一个字符串用于签名和请求。例如:

[10001,10002,10003]

不要一处使用带空格的 JSON,另一处使用无空格 JSON;不要在签名后重新排列对象键或改变字符转义方式。

4. MD5 签名算法

4.1 规则

设全部待发送参数为 params,生成签名时执行:

  1. 排除 sign。
  2. 排除键名为空、值为 null 或空字符串的参数。
  3. 保留数值 0 和布尔值 false,不要把它们当成空值删除。
  4. 数组和对象先转换为紧凑 JSON 字符串。
  5. 按参数名的字符升序排列。
  6. 依次连接“参数名 + 参数原始值”,中间不加 =、&、空格或换行。
  7. 在连接字符串的首尾各添加一次 app_secret。
  8. 使用 UTF-8 字节计算 MD5,将十六进制结果转换为大写。

伪代码:

sign = UPPERCASE(  MD5_UTF8(    app_secret    + key1 + value1    + key2 + value2    + ...    + app_secret  ))

4.2 可复现签名示例

输入参数:

access_token = asd78172s8ds9a921j9qqwda12312w1w21211api = rux.open.user.infoapp_key = 1page = 1page_size = 10timestamp = 1480411125app_secret = testSecret

排序后的键:

access_token, api, app_key, page, page_size, timestamp

待计算 MD5 的完整字符串:

testSecretaccess_tokenasd78172s8ds9a921j9qqwda12312w1w21211apirux.open.user.infoapp_key1page1page_size10timestamp1480411125testSecret

结果:

804D1442D0534A9AF2928A7EC0E9A986

可以把这组数据作为本地签名函数的单元测试。只有签名原串中的每个字符都相同,结果才会相同。

5. 通用请求示例

以下代码都遵循相同约定:

  • ZHANFU_APP_KEY:部署环境变量,值来自开放平台。
  • ZHANFU_APP_SECRET:部署环境变量,值来自开放平台,仅服务端可读。
  • ZHANFU_ACCESS_TOKEN:示例环境变量,实际项目通常从加密存储中读取当前授权用户的令牌。
  • api:由代码根据要调用的接口填写。
  • timestamp:由代码在请求前生成。
  • sign:由代码自动计算,不需要开发者手填。

5.1 PHP

运行要求:PHP 7.4+,启用 cURL 扩展。

 $value) {
        if ($key === '' || $value === null || $value === '') {
            continue;
        }
        if (is_array($value)) {
            $encoded = json_encode(
                $value,
                JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
            );
            if ($encoded === false) {
                throw new RuntimeException('业务参数 JSON 序列化失败');
            }
            $result[$key] = $encoded;
        } elseif (is_bool($value)) {
            $result[$key] = $value ? 'true' : 'false';
        } else {
            $result[$key] = (string) $value;
        }
    }
    return $result;
}

function createSign(array $normalizedParams, string $appSecret): string
{
    unset($normalizedParams['sign']);
    ksort($normalizedParams, SORT_STRING);

    $source = $appSecret;
    foreach ($normalizedParams as $key => $value) {
        $source .= $key . $value;
    }
    $source .= $appSecret;

    return strtoupper(md5($source));
}

function callOpenApi(
    string $api,
    string $accessToken,
    array $businessParams = []
): array {
    $appKey = getenv('ZHANFU_APP_KEY');             // 开放平台分配
    $appSecret = getenv('ZHANFU_APP_SECRET');       // 开放平台分配,仅服务端保存
    if (!$appKey || !$appSecret || $accessToken === '') {
        throw new RuntimeException('缺少 app_key、app_secret 或 access_token');
    }

    $params = normalizeParams(array_merge($businessParams, [
        'access_token' => $accessToken,             // OAuth 换令牌接口返回
        'api' => $api,                              // 具体接口名
        'app_key' => $appKey,                       // 开放平台分配
        'timestamp' => (string) time(),              // 服务端当前 Unix 秒
    ]));
    $params['sign'] = createSign($params, $appSecret);

    $curl = curl_init(OPEN_API_URL);
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($params, '', '&', PHP_QUERY_RFC3986),
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/x-www-form-urlencoded; charset=utf-8',
            'Accept: application/json',
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException(curl_error($curl));
    }
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    curl_close($curl);

    if ($status < 200 || $status >= 300) {
        throw new RuntimeException("HTTP {$status}: {$body}");
    }
    $result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    if (($result['errorCode'] ?? null) !== 0) {
        throw new RuntimeException($result['message'] ?? '站斧 API 调用失败');
    }
    return $result;
}

$accessToken = getenv('ZHANFU_ACCESS_TOKEN'); // 实际项目应从加密存储读取

// 示例 1:获取当前授权用户信息,无额外业务参数。
$userInfo = callOpenApi('rux.open.user.info', $accessToken);

// 示例 2:获取店铺列表,page 和 limit 来自调用方分页条件。
$mallList = callOpenApi(
    'rux.open.mall.bindMall.mallList',
    $accessToken,
    ['page' => 1, 'limit' => 10]
);

5.2 Java

运行要求:Java 11+。示例返回 JSON 字符串,可使用项目已有的 Jackson、Gson 等 JSON 库解析。

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public final class ZhanfuOpenApiClient {
    private static final String OPEN_API_URL =
        "https://gw-open.zhanfubrowser.com/api/route";

    private final String appKey;       // 开放平台分配
    private final String appSecret;    // 开放平台分配,仅服务端保存
    private final HttpClient httpClient;

    public ZhanfuOpenApiClient(String appKey, String appSecret) {
        this.appKey = appKey;
        this.appSecret = appSecret;
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    }

    static String createSign(Map input, String appSecret)
        throws Exception {
        Map sorted = new TreeMap<>();
        input.forEach((key, value) -> {
            if (!"sign".equals(key)
                && key != null && !key.isEmpty()
                && value != null && !value.isEmpty()) {
                sorted.put(key, value);
            }
        });

        StringBuilder source = new StringBuilder(appSecret);
        sorted.forEach((key, value) -> source.append(key).append(value));
        source.append(appSecret);

        byte[] digest = MessageDigest.getInstance("MD5")
            .digest(source.toString().getBytes(StandardCharsets.UTF_8));
        StringBuilder hex = new StringBuilder();
        for (byte b : digest) {
            hex.append(String.format("%02X", b & 0xff));
        }
        return hex.toString();
    }

    static String formEncode(Map params) {
        return params.entrySet().stream()
            .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
                + "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
            .collect(Collectors.joining("&"));
    }

    public String call(
        String api,
        String accessToken,
        Map businessParams
    ) throws Exception {
        Map params = new HashMap<>(businessParams);
        params.put("access_token", accessToken); // OAuth 换令牌接口返回
        params.put("api", api);                  // 具体接口名
        params.put("app_key", appKey);           // 开放平台分配
        params.put("timestamp", Long.toString(Instant.now().getEpochSecond()));
        params.put("sign", createSign(params, appSecret));

        HttpRequest request = HttpRequest.newBuilder(URI.create(OPEN_API_URL))
            .timeout(Duration.ofSeconds(15))
            .header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
            .header("Accept", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(formEncode(params)))
            .build();

        HttpResponse response = httpClient.send(
            request,
            HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
        );
        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            throw new IllegalStateException(
                "HTTP " + response.statusCode() + ": " + response.body()
            );
        }
        return response.body();
    }

    public static void main(String[] args) throws Exception {
        ZhanfuOpenApiClient client = new ZhanfuOpenApiClient(
            System.getenv("ZHANFU_APP_KEY"),
            System.getenv("ZHANFU_APP_SECRET")
        );
        String accessToken = System.getenv("ZHANFU_ACCESS_TOKEN");

        // 示例 1:获取当前授权用户信息,无额外业务参数。
        String userInfo = client.call(
            "rux.open.user.info",
            accessToken,
            Map.of()
        );

        // 示例 2:获取店铺列表,分页值来自调用方。
        String mallList = client.call(
            "rux.open.mall.bindMall.mallList",
            accessToken,
            Map.of("page", "1", "limit", "10")
        );

        // 使用项目 JSON 库检查 errorCode 并解析 data,避免输出完整令牌。
    }
}

Java 中的数组或对象业务参数,应先使用项目 JSON 库转换为紧凑 JSON 字符串,再放入 businessParams。签名和请求必须复用该字符串。

5.3 Python

运行要求:Python 3.9+,仅使用标准库。

import hashlib
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional

OPEN_API_URL = "https://gw-open.zhanfubrowser.com/api/route"


def normalize(value):
    if isinstance(value, (list, dict)):
        return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
    if isinstance(value, bool):
        return "true" if value else "false"
    return str(value)


def normalize_params(params: dict) -> dict[str, str]:
    return {
        str(key): normalize(value)
        for key, value in params.items()
        if key != "" and value is not None and value != ""
    }


def create_sign(params: dict[str, str], app_secret: str) -> str:
    sign_params = {
        key: value for key, value in params.items() if key != "sign"
    }
    source = app_secret + "".join(
        key + sign_params[key] for key in sorted(sign_params)
    ) + app_secret
    return hashlib.md5(source.encode("utf-8")).hexdigest().upper()


def call_open_api(
    api: str,
    access_token: str,
    business_params: Optional[dict] = None,
) -> dict:
    app_key = os.environ["ZHANFU_APP_KEY"]        # 开放平台分配
    app_secret = os.environ["ZHANFU_APP_SECRET"] # 仅服务端保存

    params = dict(business_params or {})
    params.update({
        "access_token": access_token,  # OAuth 换令牌接口返回
        "api": api,                    # 具体接口名
        "app_key": app_key,            # 开放平台分配
        "timestamp": int(time.time()), # 服务端当前 Unix 秒
    })
    normalized = normalize_params(params)
    normalized["sign"] = create_sign(normalized, app_secret)

    body = urllib.parse.urlencode(normalized).encode("utf-8")
    request = urllib.request.Request(
        OPEN_API_URL,
        data=body,
        method="POST",
        headers={
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "Accept": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            result = json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {error.code}: {body}") from error

    if result.get("errorCode") != 0:
        raise RuntimeError(result.get("message", "站斧 API 调用失败"))
    return result


access_token = os.environ["ZHANFU_ACCESS_TOKEN"] # 实际项目从加密存储读取

# 示例 1:获取当前授权用户信息,无额外业务参数。
user_info = call_open_api("rux.open.user.info", access_token)

# 示例 2:获取店铺列表,分页值来自调用方。
mall_list = call_open_api(
    "rux.open.mall.bindMall.mallList",
    access_token,
    {"page": 1, "limit": 10},
)

5.4 Go

运行要求:Go 1.20+,仅使用标准库。

package main

import (
	"crypto/md5"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"reflect"
	"sort"
	"strconv"
	"strings"
	"time"
)

const openAPIURL = "https://gw-open.zhanfubrowser.com/api/route"

func normalize(value any) (string, error) {
	if value == nil {
		return "", nil
	}
	kind := reflect.TypeOf(value).Kind()
	if kind == reflect.Array || kind == reflect.Slice || kind == reflect.Map {
		encoded, err := json.Marshal(value)
		return string(encoded), err
	}
	if boolean, ok := value.(bool); ok {
		return strconv.FormatBool(boolean), nil
	}
	return fmt.Sprint(value), nil
}

func normalizeParams(input map[string]any) (map[string]string, error) {
	result := make(map[string]string, len(input))
	for key, value := range input {
		if key == "" || value == nil {
			continue
		}
		normalized, err := normalize(value)
		if err != nil {
			return nil, err
		}
		if normalized != "" {
			result[key] = normalized
		}
	}
	return result, nil
}

func createSign(params map[string]string, appSecret string) string {
	keys := make([]string, 0, len(params))
	for key, value := range params {
		if key != "sign" && key != "" && value != "" {
			keys = append(keys, key)
		}
	}
	sort.Strings(keys)

	var source strings.Builder
	source.WriteString(appSecret)
	for _, key := range keys {
		source.WriteString(key)
		source.WriteString(params[key])
	}
	source.WriteString(appSecret)

	digest := md5.Sum([]byte(source.String()))
	return strings.ToUpper(hex.EncodeToString(digest[:]))
}

func callOpenAPI(
	api string,
	accessToken string,
	businessParams map[string]any,
) (map[string]any, error) {
	appKey := os.Getenv("ZHANFU_APP_KEY")       // 开放平台分配
	appSecret := os.Getenv("ZHANFU_APP_SECRET") // 仅服务端保存
	if appKey == "" || appSecret == "" || accessToken == "" {
		return nil, fmt.Errorf("缺少 app_key、app_secret 或 access_token")
	}

	raw := make(map[string]any, len(businessParams)+4)
	for key, value := range businessParams {
		raw[key] = value
	}
	raw["access_token"] = accessToken     // OAuth 换令牌接口返回
	raw["api"] = api                     // 具体接口名
	raw["app_key"] = appKey              // 开放平台分配
	raw["timestamp"] = time.Now().Unix() // 服务端当前 Unix 秒

	params, err := normalizeParams(raw)
	if err != nil {
		return nil, err
	}
	params["sign"] = createSign(params, appSecret)

	form := url.Values{}
	for key, value := range params {
		form.Set(key, value)
	}
	req, err := http.NewRequest(http.MethodPost, openAPIURL, strings.NewReader(form.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
	req.Header.Set("Accept", "application/json")

	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
	}

	var result map[string]any
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, err
	}
	if code, ok := result["errorCode"].(float64); !ok || code != 0 {
		return nil, fmt.Errorf("站斧 API 调用失败: %v", result["message"])
	}
	return result, nil
}

func main() {
	accessToken := os.Getenv("ZHANFU_ACCESS_TOKEN") // 实际项目从加密存储读取

	// 示例 1:获取当前授权用户信息,无额外业务参数。
	userInfo, err := callOpenAPI(
		"rux.open.user.info",
		accessToken,
		map[string]any{},
	)
	if err != nil {
		panic(err)
	}

	// 示例 2:获取店铺列表,分页值来自调用方。
	mallList, err := callOpenAPI(
		"rux.open.mall.bindMall.mallList",
		accessToken,
		map[string]any{"page": 1, "limit": 10},
	)
	if err != nil {
		panic(err)
	}

	_, _ = userInfo, mallList
}

6. 两个示例接口的参数说明

6.1 获取授权用户信息

接口名:

rux.open.user.info

请求字段:

字段来源必填
api固定为 rux.open.user.info是
app_key开放平台分配是
timestamp服务端当前 Unix 秒是
access_tokenOAuth 换令牌接口返回是
sign服务端计算是

该示例没有额外业务字段。

响应结构示意:

{  "errorCode": 0,  "message": "获取个人信息成功",  "data": {    "userInfo": {}  }}

userInfo 的实际字段以正式 API 文档和当前账号返回结果为准。

6.2 获取授权用户店铺列表

接口名:

rux.open.mall.bindMall.mallList

请求字段:

字段来源必填
api固定为 rux.open.mall.bindMall.mallList是
app_key开放平台分配是
timestamp服务端当前 Unix 秒是
access_tokenOAuth 换令牌接口返回是
page调用方分页条件,示例为 1按接口定义
limit调用方分页条件,示例为 10按接口定义
sign服务端计算,包含上述全部非空字段是

原始资料没有提供该接口的完整请求和响应字段,因此除公共参数外,业务字段及返回结构应以 rux.open.mall.bindMall.mallList 的正式接口文档为准。

7. URL 编码与 Content-Type

签名和编码的先后顺序必须固定:

原始字段值 → 数组转 JSON → 生成 sign → 表单 URL 编码 → POST

注意:

  • application/x-www-form-urlencoded:使用语言标准库编码整个表单。
  • multipart/form-data:字段值不需要开发者预先 URL 编码;字符集应使用 UTF-8。
  • application/json:只有具体接口明确接受 JSON 请求体时才使用,不能只因为响应是 JSON 就把请求类型改为 JSON。
  • 接收 JSON 响应时应发送 Accept: application/json。

8. 错误处理与日志

调用方需要同时检查两层结果:

  1. HTTP 状态码是否为 2xx。
  2. JSON 响应中的 errorCode 是否为 0。

建议记录以下排查信息:

  • api
  • app_key
  • timestamp
  • 业务参数名
  • HTTP 状态码
  • errorCode 和 message
  • 请求追踪 ID(若响应提供)

不得记录:

  • 完整 app_secret
  • 完整 access_token 或 refresh_token
  • 完整授权码 code
  • 包含用户隐私数据的完整响应

排查签名时,可以在仅开发环境临时记录“签名原串的脱敏版本”和字段顺序,问题解决后应关闭该日志。

9. 上线检查清单

  • API 请求全部从可信服务端发起。
  • app_secret 通过环境变量或密钥管理服务注入。
  • 服务器时间准确,timestamp 为秒级 Unix 时间戳。
  • 签名函数通过第 4.2 节测试向量。
  • 所有公共参数和业务参数都参与签名,sign 除外。
  • 数组参数只序列化一次,签名和发送复用同一字符串。
  • URL 编码发生在签名之后。
  • 只使用 POST 调用正式网关。
  • 已设置连接与读取超时,并对可重试错误采用有限次数退避重试。
  • 日志和告警不会泄露密钥、令牌或用户隐私数据。