发卡身份验证

本文介绍发卡 API 的统一接入方式,包含普通 JSON 接口和文件上传接口。两种请求共用商户密钥、请求头、RSA 签名规则及错误码。请先完成商户开通、公钥绑定及联调,再发起业务请求。

每次请求需使用商户私钥生成签名。平台使用已绑定的商户公钥验证请求,并检查商户状态、来源 IP、时间戳和请求是否重复。

目录

1 接入准备

请向平台获取并确认以下信息:

信息说明
merchantId商户 ID
appId发卡appId
IP WhitelistIP 白名单,启用 IP 限制时配置请求服务器的公网出口 IP

测试与生产使用各自的接入地址和凭据。不要将私钥放在浏览器、移动客户端或公共代码仓库中。仅在受控的商户服务器生成和发送签名请求。

2 生成并绑定 RSA 密钥

选择语言生成 RSA 公私钥。cURL 选项使用本地 OpenSSL 命令生成密钥。

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;

public class Main {

    public static void main(String[] args) throws Exception {
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048);
        KeyPair keyPair = generator.generateKeyPair();
        System.out.println("\n公钥 Base64(无标记、无换行):");
        System.out.println(Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()));

        System.out.println("\n私钥 Base64(无标记、无换行,自行保存):");
        System.out.println(Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()));
    }
}
<?php
// 需要启用 PHP 的 OpenSSL 扩展。
$keyPair = openssl_pkey_new([
    'private_key_type' => OPENSSL_KEYTYPE_RSA,
    'private_key_bits' => 2048,
]);
if ($keyPair === false || !openssl_pkey_export($keyPair, $privatePem)) {
    throw new RuntimeException('Failed to generate the RSA key pair');
}
$details = openssl_pkey_get_details($keyPair);
if ($details === false || strpos($privatePem, '-----BEGIN PRIVATE KEY-----') === false) {
    throw new RuntimeException('Failed to export the RSA key pair in the required format');
}
$publicBase64 = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $details['key']);
$privateBase64 = preg_replace('/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/', '', $privatePem);

echo '公钥(提交给平台):' . PHP_EOL;
echo $publicBase64 . PHP_EOL;
echo PHP_EOL . '私钥(自行保存):' . PHP_EOL;
echo $privateBase64 . PHP_EOL;
# cURL 不生成本地密钥,此处使用 OpenSSL。
set -eu
PRIVATE_KEY="$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null)"

printf '公钥(提交给平台):\n'
printf '%s\n' "$PRIVATE_KEY" | openssl pkey -pubout -outform DER | openssl base64 -A
printf '\n\n私钥(自行保存):\n'
printf '%s\n' "$PRIVATE_KEY" | openssl pkcs8 -topk8 -nocrypt -outform DER | openssl base64 -A
printf '\n'
# pip install cryptography
import base64

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa

key_pair = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_bytes = key_pair.public_key().public_bytes(
    serialization.Encoding.DER,
    serialization.PublicFormat.SubjectPublicKeyInfo,
)
private_bytes = key_pair.private_bytes(
    serialization.Encoding.DER,
    serialization.PrivateFormat.PKCS8,
    serialization.NoEncryption(),
)

print("公钥(提交给平台):")
print(base64.b64encode(public_bytes).decode("ascii"))
print("\n私钥(自行保存):")
print(base64.b64encode(private_bytes).decode("ascii"))

程序生成 RSA 2048 位密钥对,公钥和私钥均输出为单行 Base64:

  • 公钥:X.509 格式,提交给平台完成商户绑定。
    • 私钥:PKCS#8 格式,自行保存,用于请求签名。

3 请求头

所有接口使用 HTTPS POST。根据接口类型设置请求体:

请求类型Content-Type参与签名的 JSON
普通接口application/json接口的实际 JSON Body
文件上传multipart/form-data仅包含 fileSha256 的 JSON,实际请求体仍为文件表单

文件上传的摘要生成方法见第 4 节,调用示例见第 6 节。

Header是否必填说明
Content-Type是普通接口使用 application/json;文件上传使用 multipart/form-data
merchantId是商户 ID
Nonce是UUID 格式
curTime是当前 Unix 毫秒时间戳,13 位十进制数字
signedString是SHA256withRSA 签名的标准 Base64 编码
appId是发卡appId
fileSha256仅文件上传必填文件原始字节的 SHA-256,64 位小写十六进制字符串

HTTP Header 名称不区分大小写;Header 值、JSON 字段名及字符串值区分大小写。不要重复发送同一个身份或签名请求头。merchantId、appId、Nonce、curTime 的值必须与参与签名的值完全一致。

4 生成请求签名

使用商户私钥,按 SHA256withRSA 算法生成签名,将结果转为 Base64 后放入请求头 signedString。平台使用商户公钥验证签名,确认请求来自商户且内容未被修改。接口通过 HTTPS 调用。

步骤 1 准备参与签名的 JSON

普通 JSON 请求: 使用接口实际发送的 JSON Body。以下参数仅演示排序和签名,不对应具体业务接口:

{
  "merchantReference": "DEMO-001",
  "amount": {
    "value": 100,
    "currency": "USD"
  },
  "lineItems": [
    {
      "quantity": 1,
      "description": "demo"
    }
  ]
}

文件上传请求: 先对文件原始字节计算 SHA-256,转成 64 位小写十六进制,填写请求头 fileSha256。构造以下紧凑 JSON 参与签名:

{"fileSha256":"文件的64位小写SHA-256摘要"}

这个 JSON 仅用于签名,不作为额外表单字段发送。实际请求使用 multipart 表单上传一个名为 file 的文件。摘要针对文件内容计算,不是文件名、路径、Base64 字符串或整个 multipart 请求。

步骤 2 递归排序并序列化

对每一层 JSON 对象按字段名升序排序,包括数组中的对象;数组元素保持原始顺序。排序后生成紧凑 JSON,不增加格式化空格或换行。

上面的普通 JSON 示例排序后为:

{"amount":{"currency":"USD","value":100},"lineItems":[{"description":"demo","quantity":1}],"merchantReference":"DEMO-001"}

这段结果称为 SortedString。请遵循以下规则:

  • 普通请求的所有 Body 字段均参与签名,包括 lineItems、空字符串和 null;上传请求对上述仅包含 fileSha256 的 JSON 排序。
    • 字段名按 Java 字符串自然顺序比较,大小写敏感,不使用本地语言排序规则。
    • 数组元素不能重新排序;嵌套数组同样保留顺序。
    • JSON 根节点必须是对象,不允许重复字段或在同一 Body 拼接多段 JSON。
    • 小数按 BigDecimal 规则处理,避免二进制浮点数舍入;例如 1.2300 规范化为 1.23,1e-8 输出为 0.00000001。
    • 大整数不得因语言默认浮点数类型而丢失精度;金额、中文、转义字符和嵌套对象需保持同一 JSON 语义。接入时请验证大整数和小数的序列化结果。

步骤 3 拼接签名原文

PlainText = SortedString + merchantId + appId + Nonce + curTime

按以上顺序拼接,各字段之间不添加分隔符、空格或换行。merchantId、appId、Nonce、curTime 使用实际请求头的原始值,不转换大小写或增删空格。请求 URL 和查询参数不拼入原文;Body 中的 appId 同时作为 JSON 字段参与排序和签名。

步骤 4 使用商户私钥签名

对 PlainText 的 UTF-8 字节使用 SHA256withRSA 签名,再将签名二进制编码为 Base64,放入 signedString。SHA-256 由签名算法内部处理,不要先将 PlainText 转成十六进制摘要再签名。

使用 SHA256withRSA 时,先加载商户保存的 PKCS#8 私钥,对上述 PlainText 的 UTF-8 字节签名,再将结果编码为标准 Base64。

签名生成后,普通请求发送参与签名的业务 JSON,上传请求发送计算摘要时使用的原文件。不要修改业务数据、文件内容、Nonce 或 curTime。两类请求均使用相同的字段拼接顺序,不添加分隔符或换行。

5 普通 JSON 接口调用

填写接口地址、商户 ID、发卡appId、私钥和业务 JSON,选择示例语言查看对应代码,点击代码框的复制按钮即可复制当前示例。

Java 示例使用 Jackson;PHP 需要启用 OpenSSL 和 cURL 扩展;Python 需要安装 requests 和 cryptography。PHP、Python 示例中的 bodyJson/body_json 是已排序的紧凑 JSON,替换业务内容时按第 4 节完成排序,并发送同一份 JSON。

cURL 示例中的签名和请求头需按第 4 节生成,request.json 保存参与签名的请求内容。

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public class MerchantApiRequestExample {
    private static final ObjectMapper MAPPER = new ObjectMapper()
            .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
            .enable(SerializationFeature.WRITE_BIGDECIMAL_AS_PLAIN)
            .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
            .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);

    public static void main(String[] args) throws Exception {
        String apiUrl = "https://YOUR_API_HOST/YOUR_API_PATH";
        String merchantId = "YOUR_MERCHANT_ID";
        String appId = "YOUR_APP_ID"; // 发卡appId
        String privateKey = "YOUR_PRIVATE_KEY"; // PKCS#8 私钥,使用纯 Base64
        String bodyJson = "{}"; // 按实际业务接口填写 JSON

        if (apiUrl.contains("YOUR_") || merchantId.contains("YOUR_") || appId.contains("YOUR_")
                || privateKey.contains("YOUR_")) {
            throw new IllegalArgumentException("Set the API URL, merchantId, FP issuing product ID and private key before running");
        }
        URL url = new URL(apiUrl);
        if (!"https".equalsIgnoreCase(url.getProtocol())) {
            throw new IllegalArgumentException("The API URL must use HTTPS");
        }
        String nonce = UUID.randomUUID().toString();
        String curTime = Long.toString(System.currentTimeMillis());
        String signedString = generateSign(bodyJson, merchantId, appId, nonce, curTime, privateKey);

        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("merchantId", merchantId);
        headers.put("appId", appId);
        headers.put("Nonce", nonce);
        headers.put("curTime", curTime);
        headers.put("signedString", signedString);
        System.out.println(post(url, bodyJson, headers));
    }

    public static String generateSign(String bodyJson, String merchantId, String appId, String nonce,
                                      String curTime, String privateKey) throws Exception {
        JsonNode body = MAPPER.readTree(bodyJson);
        if (body == null || !body.isObject()) {
            throw new IllegalArgumentException("Request body must be a JSON object");
        }
        String sortedString = MAPPER.writeValueAsString(sortJson(body));
        String plainText = sortedString + merchantId + appId + nonce + curTime;
        String keyContent = privateKey.replaceAll("\\s", "");
        PrivateKey key = KeyFactory.getInstance("RSA").generatePrivate(
                new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyContent)));
        Signature signer = Signature.getInstance("SHA256withRSA");
        signer.initSign(key);
        signer.update(plainText.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(signer.sign());
    }

    private static JsonNode sortJson(JsonNode node) {
        if (node.isObject()) {
            ObjectNode sorted = MAPPER.createObjectNode();
            List<String> fields = new ArrayList<>();
            node.fieldNames().forEachRemaining(fields::add);
            Collections.sort(fields);
            for (String field : fields) {
                sorted.set(field, sortJson(node.get(field)));
            }
            return sorted;
        }
        if (node.isArray()) {
            ArrayNode sorted = MAPPER.createArrayNode();
            for (JsonNode item : node) {
                sorted.add(sortJson(item));
            }
            return sorted;
        }
        return node;
    }

    public static String post(URL url, String bodyJson, Map<String, String> headers) throws Exception {
        byte[] body = bodyJson.getBytes(StandardCharsets.UTF_8);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        try {
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);
            connection.setInstanceFollowRedirects(false);
            connection.setConnectTimeout(10000);
            connection.setReadTimeout(30000);
            connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
            for (Map.Entry<String, String> header : headers.entrySet()) {
                connection.setRequestProperty(header.getKey(), header.getValue());
            }
            connection.setFixedLengthStreamingMode(body.length);
            try (OutputStream out = connection.getOutputStream()) {
                out.write(body);
            }
            int status = connection.getResponseCode();
            System.out.println("HTTP status: " + status);
            InputStream response = status >= 400 ? connection.getErrorStream() : connection.getInputStream();
            if (response == null) {
                return "";
            }
            try (InputStream in = response; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                byte[] buffer = new byte[4096];
                int count;
                while ((count = in.read(buffer)) != -1) {
                    out.write(buffer, 0, count);
                }
                return new String(out.toByteArray(), StandardCharsets.UTF_8);
            }
        } finally {
            connection.disconnect();
        }
    }
}
<?php
// 需要启用 PHP 的 OpenSSL 和 cURL 扩展。
$apiUrl = 'https://YOUR_API_HOST/YOUR_API_PATH';
$merchantId = 'YOUR_MERCHANT_ID';
$appId = 'YOUR_APP_ID';
$privateKey = 'YOUR_PRIVATE_KEY'; // PKCS#8 Base64

// 按第 4 节完成排序后的紧凑 JSON,同时作为实际请求体发送。
$bodyJson = '{"merchantReference":"DEMO-001"}';
if (strpos($apiUrl . $merchantId . $appId . $privateKey, 'YOUR_') !== false) {
    throw new RuntimeException('Set the API URL, merchantId, appId and private key');
}
if (strtolower(parse_url($apiUrl, PHP_URL_SCHEME) ?? '') !== 'https') {
    throw new RuntimeException('The API URL must use HTTPS');
}
$uuidBytes = random_bytes(16);
$uuidBytes[6] = chr((ord($uuidBytes[6]) & 0x0f) | 0x40);
$uuidBytes[8] = chr((ord($uuidBytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($uuidBytes);
$nonce = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-'
    . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
$curTime = (string) (int) floor(microtime(true) * 1000);
$plainText = $bodyJson . $merchantId . $appId . $nonce . $curTime;
$privateKey = "-----BEGIN PRIVATE KEY-----\n"
    . chunk_split(preg_replace('/\s+/', '', $privateKey), 64, "\n")
    . "-----END PRIVATE KEY-----\n";
$key = openssl_pkey_get_private($privateKey);
if ($key === false || !openssl_sign($plainText, $signature, $key, OPENSSL_ALGO_SHA256)) {
    throw new RuntimeException('Failed to sign the request; check the RSA private key');
}
$signedString = base64_encode($signature);
$curl = curl_init($apiUrl);
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $bodyJson,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json; charset=UTF-8',
        'merchantId: ' . $merchantId,
        'appId: ' . $appId,
        'Nonce: ' . $nonce,
        'curTime: ' . $curTime,
        'signedString: ' . $signedString,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => false,
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 30,
]);
try {
    $response = curl_exec($curl);
    if ($response === false) {
        throw new RuntimeException(curl_error($curl));
    }
    echo 'HTTP status: ' . curl_getinfo($curl, CURLINFO_HTTP_CODE) . PHP_EOL;
    echo $response . PHP_EOL;
} finally {
    curl_close($curl);
}
curl --request POST 'https://YOUR_API_HOST/YOUR_API_PATH' \
  --header 'Content-Type: application/json; charset=UTF-8' \
  --header 'merchantId: YOUR_MERCHANT_ID' \
  --header 'appId: YOUR_APP_ID' \
  --header 'Nonce: THE_UUID_USED_FOR_SIGNING' \
  --header 'curTime: THE_MILLISECOND_TIMESTAMP_USED_FOR_SIGNING' \
  --header 'signedString: THE_BASE64_SIGNATURE' \
  --data-binary '@request.json'
# pip install requests cryptography
import base64
import time
import uuid

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

api_url = "https://YOUR_API_HOST/YOUR_API_PATH"
merchant_id = "YOUR_MERCHANT_ID"
app_id = "YOUR_APP_ID"
private_key_text = "YOUR_PRIVATE_KEY"  # PKCS#8 Base64

# 按第 4 节完成排序后的紧凑 JSON,同时作为实际请求体发送。
body_json = '{"merchantReference":"DEMO-001"}'
if any("YOUR_" in value for value in (api_url, merchant_id, app_id, private_key_text)):
    raise ValueError("Set the API URL, merchantId, appId and private key")
if not api_url.lower().startswith("https://"):
    raise ValueError("The API URL must use HTTPS")

private_key = serialization.load_der_private_key(
    base64.b64decode("".join(private_key_text.split()), validate=True), password=None
)
if not isinstance(private_key, rsa.RSAPrivateKey):
    raise ValueError("An RSA private key is required")

nonce = str(uuid.uuid4())
cur_time = str(time.time_ns() // 1_000_000)
plain_text = body_json + merchant_id + app_id + nonce + cur_time
signed_string = base64.b64encode(private_key.sign(
    plain_text.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
)).decode("ascii")

response = requests.post(
    api_url,
    data=body_json.encode("utf-8"),
    headers={
        "Content-Type": "application/json; charset=UTF-8",
        "merchantId": merchant_id,
        "appId": app_id,
        "Nonce": nonce,
        "curTime": cur_time,
        "signedString": signed_string,
    },
    timeout=(10, 30),
    allow_redirects=False,
)
print("HTTP status:", response.status_code)
print(response.text)

6 文件上传接口调用

接口地址

POST /api/v1/issuing/reference/upload

文件表单

字段必填说明
file是上传文件,最大 20MB。支持 .jpg、.jpeg、.png、.pdf,扩展名不区分大小写,文件内容须与文件类型一致。

调用示例

填写商户 ID、发卡appId、私钥和文件路径,选择示例语言查看并复制对应代码。

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Locale;
import java.util.UUID;

/** 文件上传调用示例,仅使用 JDK,支持 Java 8 及以上版本。 */
public class MerchantFileUploadExample {
    public static void main(String[] args) throws Exception {
        String apiUrl = "https://api.futurepay-develop.com/api/v1/issuing/reference/upload";
        String merchantId = "YOUR_MERCHANT_ID";
        String appId = "YOUR_APP_ID";
        String privateKey = "YOUR_PRIVATE_KEY"; // PKCS#8,使用纯 Base64
        Path file = Paths.get("front.png");
        if (merchantId.contains("YOUR_") || appId.contains("YOUR_") || privateKey.contains("YOUR_")) {
            throw new IllegalArgumentException("Set merchantId, appId and private key before running");
        }
        long size = Files.size(file);
        if (size == 0 || size > 20L * 1024 * 1024) {
            throw new IllegalArgumentException("File must be non-empty and must not exceed 20 MiB");
        }
        String name = file.getFileName().toString();
        String extension = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
        String mimeType;
        if ("jpg".equals(extension) || "jpeg".equals(extension)) {
            mimeType = "image/jpeg";
        } else if ("png".equals(extension)) {
            mimeType = "image/png";
        } else if ("pdf".equals(extension)) {
            mimeType = "application/pdf";
        } else {
            throw new IllegalArgumentException("Only jpg, jpeg, png and pdf files are supported");
        }

        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] buffer = new byte[8192];
        try (InputStream in = Files.newInputStream(file)) {
            int count;
            while ((count = in.read(buffer)) != -1) {
                digest.update(buffer, 0, count);
            }
        }
        StringBuilder hex = new StringBuilder(64);
        for (byte value : digest.digest()) {
            hex.append(Character.forDigit((value >>> 4) & 15, 16));
            hex.append(Character.forDigit(value & 15, 16));
        }
        String fileSha256 = hex.toString();
        String nonce = UUID.randomUUID().toString();
        String curTime = Long.toString(System.currentTimeMillis());
        String signingJson = "{\"fileSha256\":\"" + fileSha256 + "\"}";
        String plainText = signingJson + merchantId + appId + nonce + curTime;
        String keyContent = privateKey.replaceAll("\\s", "");
        PrivateKey key = KeyFactory.getInstance("RSA").generatePrivate(
                new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyContent)));
        Signature signer = Signature.getInstance("SHA256withRSA");
        signer.initSign(key);
        signer.update(plainText.getBytes(StandardCharsets.UTF_8));
        String signedString = Base64.getEncoder().encodeToString(signer.sign());

        String boundary = "----FuturePay" + UUID.randomUUID().toString().replace("-", "");
        byte[] prefix = ("--" + boundary + "\r\n"
                + "Content-Disposition: form-data; name=\"file\"; filename=\"upload." + extension + "\"\r\n"
                + "Content-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8);
        byte[] suffix = ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8);
        URL url = new URL(apiUrl);
        if (!"https".equalsIgnoreCase(url.getProtocol())) {
            throw new IllegalArgumentException("The API URL must use HTTPS");
        }
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        try {
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);
            connection.setInstanceFollowRedirects(false);
            connection.setConnectTimeout(10000);
            connection.setReadTimeout(60000);
            connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
            connection.setRequestProperty("merchantId", merchantId);
            connection.setRequestProperty("appId", appId);
            connection.setRequestProperty("Nonce", nonce);
            connection.setRequestProperty("curTime", curTime);
            connection.setRequestProperty("fileSha256", fileSha256);
            connection.setRequestProperty("signedString", signedString);
            connection.setFixedLengthStreamingMode(prefix.length + size + suffix.length);
            try (OutputStream out = connection.getOutputStream()) {
                out.write(prefix);
                Files.copy(file, out);
                out.write(suffix);
            }
            int status = connection.getResponseCode();
            System.out.println("HTTP status: " + status);
            InputStream response = status >= 400 ? connection.getErrorStream() : connection.getInputStream();
            if (response != null) {
                try (InputStream in = response; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                    int count;
                    while ((count = in.read(buffer)) != -1) {
                        out.write(buffer, 0, count);
                    }
                    System.out.println(new String(out.toByteArray(), StandardCharsets.UTF_8));
                }
            }
        } finally {
            connection.disconnect();
        }
    }
}
<?php
// 需要启用 PHP 的 OpenSSL 和 cURL 扩展。
$apiUrl = 'https://api.futurepay-develop.com/api/v1/issuing/reference/upload';
$merchantId = 'YOUR_MERCHANT_ID';
$appId = 'YOUR_APP_ID';
$privateKey = 'YOUR_PRIVATE_KEY'; // PKCS#8 Base64
$file = __DIR__ . '/front.png';
if (strpos($merchantId . $appId . $privateKey, 'YOUR_') !== false) {
    throw new RuntimeException('Set merchantId, appId and private key');
}
if (!is_file($file) || !is_readable($file)) {
    throw new RuntimeException('The upload file cannot be read');
}
$size = filesize($file);
if ($size === false || $size === 0 || $size > 20 * 1024 * 1024) {
    throw new RuntimeException('File must be non-empty and must not exceed 20MB');
}
$types = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'pdf' => 'application/pdf'];
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!isset($types[$extension])) {
    throw new RuntimeException('Only jpg, jpeg, png and pdf files are supported');
}
$fileSha256 = hash_file('sha256', $file);
if ($fileSha256 === false) {
    throw new RuntimeException('Failed to calculate the file digest');
}
$uuidBytes = random_bytes(16);
$uuidBytes[6] = chr((ord($uuidBytes[6]) & 0x0f) | 0x40);
$uuidBytes[8] = chr((ord($uuidBytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($uuidBytes);
$nonce = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-'
    . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
$curTime = (string) (int) floor(microtime(true) * 1000);
$signingJson = '{"fileSha256":"' . $fileSha256 . '"}';
$plainText = $signingJson . $merchantId . $appId . $nonce . $curTime;
$privateKey = "-----BEGIN PRIVATE KEY-----\n"
    . chunk_split(preg_replace('/\s+/', '', $privateKey), 64, "\n")
    . "-----END PRIVATE KEY-----\n";
$key = openssl_pkey_get_private($privateKey);
if ($key === false || !openssl_sign($plainText, $signature, $key, OPENSSL_ALGO_SHA256)) {
    throw new RuntimeException('Failed to sign the request; check the RSA private key');
}
$curl = curl_init($apiUrl);
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => ['file' => new CURLFile($file, $types[$extension], basename($file))],
    CURLOPT_HTTPHEADER => [
        'merchantId: ' . $merchantId,
        'appId: ' . $appId,
        'Nonce: ' . $nonce,
        'curTime: ' . $curTime,
        'fileSha256: ' . $fileSha256,
        'signedString: ' . base64_encode($signature),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => false,
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 60,
]);
try {
    $response = curl_exec($curl);
    if ($response === false) {
        throw new RuntimeException(curl_error($curl));
    }
    echo 'HTTP status: ' . curl_getinfo($curl, CURLINFO_HTTP_CODE) . PHP_EOL;
    echo $response . PHP_EOL;
} finally {
    curl_close($curl);
}
curl 'https://api.futurepay-develop.com/api/v1/issuing/reference/upload' \
  --header "merchantId: ${MERCHANT_ID}" \
  --header "appId: ${APP_ID}" \
  --header "Nonce: ${NONCE}" \
  --header "curTime: ${CUR_TIME}" \
  --header "fileSha256: ${FILE_SHA256}" \
  --header "signedString: ${SIGNED_STRING}" \
  --form 'file=@./front.png;type=image/png'
# pip install requests cryptography
import base64
import hashlib
import time
import uuid
from pathlib import Path

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

api_url = "https://api.futurepay-develop.com/api/v1/issuing/reference/upload"
merchant_id = "YOUR_MERCHANT_ID"
app_id = "YOUR_APP_ID"
private_key_text = "YOUR_PRIVATE_KEY"  # PKCS#8 Base64
file = Path("front.png")
if any("YOUR_" in value for value in (merchant_id, app_id, private_key_text)):
    raise ValueError("Set merchantId, appId and private key")
size = file.stat().st_size
if size == 0 or size > 20 * 1024 * 1024:
    raise ValueError("File must be non-empty and must not exceed 20MB")
types = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".pdf": "application/pdf"}
mime_type = types.get(file.suffix.lower())
if mime_type is None:
    raise ValueError("Only jpg, jpeg, png and pdf files are supported")

digest = hashlib.sha256()
with file.open("rb") as source:
    for chunk in iter(lambda: source.read(8192), b""):
        digest.update(chunk)
file_sha256 = digest.hexdigest()
signing_json = '{"fileSha256":"' + file_sha256 + '"}'
nonce = str(uuid.uuid4())
cur_time = str(time.time_ns() // 1_000_000)
plain_text = signing_json + merchant_id + app_id + nonce + cur_time
private_key = serialization.load_der_private_key(
    base64.b64decode("".join(private_key_text.split()), validate=True), password=None
)
if not isinstance(private_key, rsa.RSAPrivateKey):
    raise ValueError("An RSA private key is required")
signed_string = base64.b64encode(private_key.sign(
    plain_text.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
)).decode("ascii")

with file.open("rb") as source:
    response = requests.post(
        api_url,
        headers={
            "merchantId": merchant_id,
            "appId": app_id,
            "Nonce": nonce,
            "curTime": cur_time,
            "fileSha256": file_sha256,
            "signedString": signed_string,
        },
        files={"file": (file.name, source, mime_type)},
        timeout=(10, 60),
        allow_redirects=False,
    )
print("HTTP status:", response.status_code)
print(response.text)

上传响应

上传成功后,响应中的 data 为:

{
  "path": "0123456789abcdef0123456789abcdef.png"
}

7 时间戳及重复请求

curTime 为当前 UTC Unix 毫秒时间戳,即从 1970-01-01 00:00:00 UTC 到当前时刻经过的毫秒数,为 13 位数字。Java 使用 System.currentTimeMillis() 获取,无需时区转换。默认允许与平台时间前后相差 5 分钟,请保持服务器时钟同步。

每次请求使用新的 UUID Nonce。同一商户下,有效期内重复使用相同 Nonce 的请求将被拒绝。发生网络超时或需要重试时,重新生成 Nonce、curTime 和签名。

8 错误响应及排查

认证失败示例:

{
  "code": "2015",
  "msg": "RSA signature verification failed; check the signing content and key pair"
}
codemessage
2001http_method_not_supply
2003appId is required
2004appId does not exist
2006merchantId header must not be blank
2007signedString header must not be blank
2008Nonce must be a standard UUID
2009curTime must be a 13-digit millisecond timestamp
2010Request has expired; update curTime
2011curTime exceeds the allowed future clock skew
2012appId in the header does not match the body
2013Request body must be a valid JSON object without duplicate fields
2014signedString must be a valid Base64 signature
2015RSA signature verification failed; check the signing content and key pair
2016Failed to parse the request body as JSON
2017Request body exceeds the allowed size
2018fileSha256 must be a 64-character lowercase SHA-256 hex digest
2019fileSha256 does not match the uploaded file
2020Upload must contain exactly one non-empty file part named file
2021File must not exceed 20 MiB; multipart request must not exceed 21 MiB
2022Request expired during authentication; update curTime
2023Nonce has already been used; generate a new one
2024Merchant does not exist
2025Merchant is disabled
2026Merchant public key is not configured
2027Invalid merchant public key format
2028Issuing product key is not configured
2029Request IP is not in the whitelist
2030Authentication service is unavailable; try again later
2031Nonce deduplication service is unavailable; try again later
2032merchantId header must not be repeated
2033signedString header must not be repeated
2034Nonce header must not be repeated
2035curTime header must not be repeated
2036appId header must not be repeated
2037Request header must not be repeated

Did this page help you?