本文面向通过服务端 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_token | OAuth 换令牌接口返回 | 是 | 是 | 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,生成签名时执行:
- 排除 sign。
- 排除键名为空、值为 null 或空字符串的参数。
- 保留数值 0 和布尔值 false,不要把它们当成空值删除。
- 数组和对象先转换为紧凑 JSON 字符串。
- 按参数名的字符升序排列。
- 依次连接“参数名 + 参数原始值”,中间不加 =、&、空格或换行。
- 在连接字符串的首尾各添加一次 app_secret。
- 使用 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_token | OAuth 换令牌接口返回 | 是 |
| 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_token | OAuth 换令牌接口返回 | 是 |
| 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. 错误处理与日志
调用方需要同时检查两层结果:
- HTTP 状态码是否为 2xx。
- 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 调用正式网关。
- 已设置连接与读取超时,并对可重试错误采用有限次数退避重试。
- 日志和告警不会泄露密钥、令牌或用户隐私数据。



