发卡身份验证
本文介绍发卡 API 的统一接入方式,包含普通 JSON 接口和文件上传接口。两种请求共用商户密钥、请求头、RSA 签名规则及错误码。请先完成商户开通、公钥绑定及联调,再发起业务请求。
每次请求需使用商户私钥生成签名。平台使用已绑定的商户公钥验证请求,并检查商户状态、来源 IP、时间戳和请求是否重复。
目录
1 接入准备
请向平台获取并确认以下信息:
| 信息 | 说明 |
|---|---|
merchantId | 商户 ID |
appId | 发卡appId |
IP Whitelist | IP 白名单,启用 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"
}| code | message |
|---|---|
2001 | http_method_not_supply |
2003 | appId is required |
2004 | appId does not exist |
2006 | merchantId header must not be blank |
2007 | signedString header must not be blank |
2008 | Nonce must be a standard UUID |
2009 | curTime must be a 13-digit millisecond timestamp |
2010 | Request has expired; update curTime |
2011 | curTime exceeds the allowed future clock skew |
2012 | appId in the header does not match the body |
2013 | Request body must be a valid JSON object without duplicate fields |
2014 | signedString must be a valid Base64 signature |
2015 | RSA signature verification failed; check the signing content and key pair |
2016 | Failed to parse the request body as JSON |
2017 | Request body exceeds the allowed size |
2018 | fileSha256 must be a 64-character lowercase SHA-256 hex digest |
2019 | fileSha256 does not match the uploaded file |
2020 | Upload must contain exactly one non-empty file part named file |
2021 | File must not exceed 20 MiB; multipart request must not exceed 21 MiB |
2022 | Request expired during authentication; update curTime |
2023 | Nonce has already been used; generate a new one |
2024 | Merchant does not exist |
2025 | Merchant is disabled |
2026 | Merchant public key is not configured |
2027 | Invalid merchant public key format |
2028 | Issuing product key is not configured |
2029 | Request IP is not in the whitelist |
2030 | Authentication service is unavailable; try again later |
2031 | Nonce deduplication service is unavailable; try again later |
2032 | merchantId header must not be repeated |
2033 | signedString header must not be repeated |
2034 | Nonce header must not be repeated |
2035 | curTime header must not be repeated |
2036 | appId header must not be repeated |
2037 | Request header must not be repeated |
Updated about 6 hours ago