# Union Pay 商户 API RSA 签名与创建代收示例 接口地址:`POST https://pay.fourpay.online/api/payment/create`。 交互测试工具位于文档首页的「RSA 签名规则」旁: `https://unionpaydocs.pages.dev/#rsa-test-tool`。 ## 公钥验证,私钥签名 商户在自己的机器生成 RSA 密钥对,把公钥登记到后台「API 对接」页面。 商户私钥用来生成 `sign`,平台使用登记的公钥验证该签名。公钥不能生成 sign, 也不能从公钥恢复私钥。工具提供两种模式: - 公钥验签:填写商户 ID、创建代收参数、RSA 公钥和商户程序生成的 sign。 - 私钥生成 sign:填写商户 ID、参数和 RSA 私钥;可另外填写公钥及商户 sign 检查密钥是否匹配、结果是否完全一致。 工具使用浏览器 Web Crypto,本机计算,不上传密钥、不写入浏览器存储、不下单。 也可以在本机随机生成2048位演示密钥测试 SDK;演示公钥没有登记到平台。 ## 算法 | 项目 | 取值 | | --- | --- | | RSA 密钥位数 | 至少2048位 | | 摘要 | SHA-256 | | 填充 | PKCS#1 v1.5;不是 PSS、OAEP 或 RSA 加密 | | 输入 | 待签名字符串的 UTF-8 字节 | | 输出 | 签名字节的标准 Base64,作为 JSON 字符串 `sign` | | Java 名称 | SHA256withRSA | | 浏览器名称 | RSASSA-PKCS1-v1_5,hash=SHA-256 | RSA-SHA256 签名 API 会自行做 SHA-256。传入原始字符串的 UTF-8 字节,不能 先把 SHA-256 的十六进制字符串再次交给签名 API,否则会发生二次摘要。 相同私钥、参数与算法产生完全相同的签名,可以直接对比 Base64 结果。 ## 生成并登记密钥 ```bash openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out merchant-private.pem openssl pkey -in merchant-private.pem -pubout -out merchant-public.pem ``` 私钥保留在商户机器,不上传到平台。将完整 `merchant-public.pem` 内容提交到 后台,或使用登录后的接口: ```http PUT /backend/merchant/updateApiPublicKey Content-Type: application/json {"apiPublicKey":"-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"} ``` 平台接受至少2048位的 RSA 公钥。浏览器工具支持 `PUBLIC KEY`(SPKI)与 `RSA PUBLIC KEY`(PKCS#1),私钥支持未加密的 `PRIVATE KEY`(PKCS#8)与 `RSA PRIVATE KEY`(PKCS#1)。若手头是证书,可先在本机提取公钥: ```bash openssl x509 -in merchant-cert.pem -pubkey -noout > merchant-public.pem ``` ## 第一步:构造创建代收参数 ```json { "merchantId": "M_DEMO_001", "merchantOrderNo": "PAYIN_DEMO_20261012_001", "amount": 500.00, "customerName": "张三", "customerEmail": "buyer@example.com", "customerPhone": "9876543210", "notifyUrl": "https://merchant.example/notify?source=unionpay&v=1", "returnUrl": "https://merchant.example/paid", "remark": "测试订单" } ``` 必填字段为 `merchantId`、`merchantOrderNo`、`amount` 和生成后的 `sign`。 金额发送时是 JSON number;手机号、商户号、订单号等是 string。签名时把金额 格式化为两位小数字符串,例如 JSON 中的 `500` 和 `500.00` 都使用 `500.00`。 参与签名的创建代收字段为:`merchantId`、`merchantOrderNo`、`amount`、 `customerName`、`customerEmail`、`customerPhone`、`notifyUrl`、`returnUrl`、 `remark`。若使用沙箱指定通道参数 `preferCardId`,它非空时也要参与。 只有接口定义的这些字段参与,`sign` 永远排除。选填字段未传、null 或空字符串 时略过;字段值内部或首尾的空格、换行不裁剪。不要加入自定义字段、HTTP 方法、 接口路径、请求头或共享 key。工具会提示未知参数,避免误把它们加进待签名串。 金额建议最多两位小数。实际格式化使用服务端 float64 两位小数规则;例如 `2.625` 格式化为 `2.62`。工具与 Go 后端交叉验证,避免 JS `toFixed()` 在 此类边界值上的差异。 ## 第二步:按 ASCII 字段名排序并拼接 对参与参数的字段名排序,连接成 `k1=v1&k2=v2`。本例为一个连续字符串: ```text amount=500.00&customerEmail=buyer@example.com&customerName=张三&customerPhone=9876543210&merchantId=M_DEMO_001&merchantOrderNo=PAYIN_DEMO_20261012_001¬ifyUrl=https://merchant.example/notify?source=unionpay&v=1&remark=测试订单&returnUrl=https://merchant.example/paid ``` 这里的中文、空格、URL 中的 `?`、`&`、`=` 都保持原值,不 URL 编码。 不要把中文转换成 `\uXXXX`,不要增加前后 `&` 或末尾换行,不追加 `&key=...`。 JSON 的字段顺序、缩进、500/500.00 的字面写法均不改变此签名字符串。 ## 第三步:签名、Base64 编码并放回请求 ```text bytes = UTF8(canonical_string) signature_bytes = RSA_PKCS1_v1_5_SHA256(merchant_private_key, bytes) sign = Base64(signature_bytes) ``` 将 sign 放回原请求 JSON。`amount` 仍为 number,其他值不得在签名后再修改。 最终 JSON 不包含私钥、公钥或待签名字符串,只包含接口请求字段和 sign。 ## 完整 Python 示例 安装 `cryptography`。此示例只生成签名与请求体,实际下单的语句放在注释中。 ```python import base64 import json from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding body = { "merchantId": "M_DEMO_001", "merchantOrderNo": "PAYIN_DEMO_20261012_001", "amount": 500.00, "customerName": "张三", "customerEmail": "buyer@example.com", "customerPhone": "9876543210", "notifyUrl": "https://merchant.example/notify?source=unionpay&v=1", "returnUrl": "https://merchant.example/paid", "remark": "测试订单", } allowed = { "merchantId", "merchantOrderNo", "amount", "customerName", "customerEmail", "customerPhone", "notifyUrl", "returnUrl", "remark", "preferCardId", } params = {key: value for key, value in body.items() if key in allowed and value is not None and value != ""} params["amount"] = format(float(body["amount"]), ".2f") canonical = "&".join(f"{key}={params[key]}" for key in sorted(params)) private_key = serialization.load_pem_private_key( open("merchant-private.pem", "rb").read(), password=None) signature = private_key.sign( canonical.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()) body["sign"] = base64.b64encode(signature).decode("ascii") print("待签名串:", canonical) print("sign:", body["sign"]) print(json.dumps(body, ensure_ascii=False, indent=2)) # 替换为自己的商户号、公私钥和有效参数,并先在后台登记机器公钥后再实际下单: # import requests # response = requests.post("https://pay.fourpay.online/api/payment/create", # json=body, timeout=15) # print(response.json()) ``` Java 使用 `Signature.getInstance("SHA256withRSA")`,`initSign(privateKey)` 后 `update(canonical.getBytes(StandardCharsets.UTF_8))`,最后对 `sign()` 的结果 执行 `Base64.getEncoder().encodeToString(...)`。 ## 使用 OpenSSL 独立核对 把工具输出的完整待签名串保存为 UTF-8 的 `payment-signing.txt`,不要带 BOM 或 末尾换行。计算签名: ```bash openssl dgst -sha256 -sign merchant-private.pem -out payment-signature.bin payment-signing.txt openssl base64 -A -in payment-signature.bin openssl dgst -sha256 -verify merchant-public.pem -signature payment-signature.bin payment-signing.txt ``` 最后一行应返回 `Verified OK`。工具中的 SHA-256 仅供比对中间结果,不是 最终 sign;要比较 sign,请确保两边用的是同一私钥。 ## 验签失败排查顺序 1. 比较双方的完整待签名串和 UTF-8 SHA-256,检查字段选择、金额、空字段和编码。 2. 检查私钥、公钥是否成对,公钥是否与后台登记的公钥一致。 3. 检查算法为 PKCS#1 v1.5 + SHA-256,未二次摘要、未使用 PSS/RSA 加密。 4. 检查 sign 是 Base64 的签名字节,不是 hex、MD5、摘要值或 URL 编码字符串。 5. 签名后没有更改 amount、商户号、订单号、姓名、URL 或其他参与参数。 其他商户接口也采用 RSA-SHA256,但参与参数应按各接口定义选择。 异步回调仍按当前兼容协议验签,见文档首页「回调通知」;不要把回调共享密钥 用作商户 RSA 请求私钥。 浏览器算法与密钥格式参考 [Web Crypto sign](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign) 和 [importKey](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey)。