开放接口 v1

接入文档

签名规则、接口参数、通知与错误码说明。限额、退款规则与通知重试间隔为平台当前设置;示例中的「你的商户号」请替换为控制台中的商户号。

接入准备

接口地址
https://pay.siyun223.com/api/v1
认证方式
SIYUN-SHA256-RSA
数据格式
请求与应答均为 JSON,UTF-8 编码;金额单位为分
请求频率
每个商户每分钟最多 1,200 次

接入步骤

  1. 注册商户账号并完成实名认证

    实名认证在线自动核验:个人刷脸;个体工商户和企业核验营业执照登记信息后,由经营者或法定代表人刷脸。实名认证通过后才能调用下单接口;查询、对账类接口不受影响。

    免费注册
  2. 生成商户密钥

    登录商户控制台,在「密钥管理」点击「生成密钥」,浏览器会在本地生成 RSA 3072 位密钥对。把私钥(PKCS#8 格式)保存到你的服务器,再保存公钥,即可获得 40 位的公钥序列号,签名时填入 serial_no。私钥只在你的浏览器中生成,平台不会也无法获取。

  3. 生成回调密钥

    32 位回调密钥用于解密通知报文中的 resource,在控制台「密钥管理」中生成。下单前必须生成;密钥只在生成时显示一次,请立即保存。

  4. 获取平台公钥

    平台公钥用于验证接口应答与通知的签名,可在控制台下载,也可以调用 GET /api/v1/certificates 获取全部有效的平台公钥。

  5. 连通性测试与联调

    调用 GET /api/v1/merchant,返回商户号、状态与 can_transact 即表示签名正确。之后在控制台「测试工具」中发起小额测试支付、发送测试通知,确认验签与解密代码正确;每一次请求都可以在「请求日志」中按请求 ID 查看。

  6. 正式上线

    下单 → 引导付款人打开收银台或展示支付凭据 → 接收支付通知并更新订单 → 未收到通知时用查单兜底 → 每日调用对账接口或下载账单核对。

示例中的「你的商户号」请替换为控制台「账户信息」中的商户号。

签名与验签

请求签名

  1. 按以下 5 行拼接签名串,每一行(包括最后一行)末尾都有一个换行符 \n:
    HTTP 请求方法
    请求路径(含查询串)
    请求时间戳
    请求随机串
    请求体
  2. 使用商户私钥对签名串做 SHA256withRSA 签名,签名结果做 Base64 编码。
  3. 放入 Authorization 请求头:
    Authorization: SIYUN-SHA256-RSA mch_no="你的商户号",serial_no="商户公钥序列号",timestamp="1792840000",nonce_str="593bec0c930bf1afeb40b4a08c8fb242",signature="Base64 签名值"

下单请求的签名串示例(最后一行请求体之后也有换行符):

POST
/api/v1/orders
1792840000
593bec0c930bf1afeb40b4a08c8fb242
{"out_trade_no":"M202610210001","amount":100,"subject":"会员充值","notify_url":"https://example.com/pay/notify"}
  • 请求路径必须与实际发送的完全一致,包含查询串,例如 /api/v1/orders?date_from=2026-10-01&date_to=2026-10-31。查询参数中有中文等字符时,先做 URL 编码,再用编码后的路径签名并发送。
  • 请求体必须与实际发送的内容逐字节一致:先把 JSON 序列化成字符串,用这个字符串签名并原样发送,不要让 HTTP 库再次序列化。
  • GET 请求和关单等无请求体的请求,请求体为空字符串,签名串以「随机串\n\n」结尾。
  • 时间戳为 10 位秒级 Unix 时间,与平台时间相差不能超过 5 分钟;随机串为 16~32 位字母或数字,15 分钟内不能重复使用,重试时也要重新生成并重新签名。
  • 请求体不超过 1 MB,使用 UTF-8 编码,请求头带 Content-Type: application/json。

应答验签

接口应答(包括签名错误、参数错误等错误应答)都由平台私钥签名。收到应答后,用 Siyun-Serial 对应的平台公钥,校验签名串「Siyun-Timestamp\nSiyun-Nonce\n应答体\n」的签名 Siyun-Signature,并检查时间戳在 5 分钟内。

例外:请求路径不存在(NOT_FOUND)或请求方法不支持(METHOD_NOT_ALLOWED)时应答不带签名,示例代码会提示验签失败,请先核对请求方法与路径。

应答头 说明
Siyun-Timestamp应答时间戳(10 位秒级)
Siyun-Nonce应答随机串
Siyun-Serial签名所用平台公钥的序列号
Siyun-SignatureBase64 编码的 SHA256withRSA 签名
Siyun-Request-Id请求 ID,联系客服或在「请求日志」中排查时使用

平台更换签名密钥时,新旧公钥会同时有效一段时间。建议缓存 GET /api/v1/certificates 返回的全部公钥,遇到未知的序列号时重新获取。

接口列表

接口 请求 说明
查询商户信息 GET /api/v1/merchant 连通性测试,返回商户状态与是否可交易
获取平台公钥 GET /api/v1/certificates 返回全部有效平台公钥(data 数组:serial_no、public_key、is_current、expires_at)
下单 POST /api/v1/orders 需要交易权限
查询订单 GET /api/v1/orders/{order_no}
GET /api/v1/orders/out-trade-no/{out_trade_no}
按平台订单号或商户订单号查询
关闭订单 POST /api/v1/orders/out-trade-no/{out_trade_no}/close 请求体为空
申请退款 POST /api/v1/refunds 原路退回付款人,支持部分退款与多次退款
查询退款 GET /api/v1/refunds/{refund_no}
GET /api/v1/refunds/out-refund-no/{out_refund_no}
按平台退款单号或商户退款单号查询
订单对账 GET /api/v1/orders?date_from=&date_to= 按创建时间或支付时间分页拉取订单
退款对账 GET /api/v1/refunds?date_from=&date_to= 按创建时间或退款成功时间分页拉取退款单
资金流水 GET /api/v1/fund-logs?date_from=&date_to= 分页拉取资金变动流水
查询余额 GET /api/v1/balance 平台代收资金的待结算、可结算、结算中与投诉冻结余额
结算单列表 GET /api/v1/settlements?date_from=&date_to= 按申请时间分页拉取结算单
查询结算单 GET /api/v1/settlements/{settle_no} 按结算单号查询结算状态与打款结果

下单

POST/api/v1/orders
参数 类型 必填 说明
out_trade_nostring是商户订单号,6~64 位字母、数字、下划线或短横线,本商户内唯一
amountinteger是订单金额,单位为分,必须是 JSON 整数(不能写成字符串)。平台单笔限额 0.01~100,000.00 元,各支付产品的限额见「产品与费率」
subjectstring是商品描述,最长 64 个字符,不能包含控制字符
notify_urlstring是支付结果通知地址:https:// 开头的公网地址,最长 255 个字符,不能指向内网地址或本平台
productstring否支付产品,取值见下方「支付产品」。不传时只返回收银台地址,由付款人在收银台选择支付方式
return_urlstring否付款完成后返回的页面,http 或 https 地址,最长 255 个字符。跳转时附带 out_trade_no 与 order_no,不能作为支付成功的依据
attachstring否附加数据,最长 255 个字符,查单与通知中原样返回
expire_minutesinteger否订单有效期(分钟),1~120,不传时为 30 分钟
wx_appidstring否微信小程序 AppID,与 wx_openid 同时传入,须与支付通道配置的小程序一致
wx_openidstring否付款人在该小程序下的 openid。product 为 wechat_miniapp 且传入时,应答的 pay_info.jsapi 即 wx.requestPayment 所需参数

请求示例

{
  "out_trade_no": "M202610210001",
  "amount": 100,
  "subject": "会员充值",
  "product": "alipay_qr",
  "notify_url": "https://example.com/pay/notify",
  "return_url": "https://example.com/pay/done",
  "attach": "uid=10086",
  "expire_minutes": 15
}

应答示例

{
  "mch_no": "你的商户号",
  "order_no": "2026102112000012345678",
  "out_trade_no": "M202610210001",
  "status": "pending",
  "status_label": "待支付",
  "amount": 100,
  "refunded_amount": 0,
  "subject": "会员充值",
  "product": "alipay_qr",
  "product_label": "支付宝扫码",
  "channel_trade_no": null,
  "attach": "uid=10086",
  "late_paid": false,
  "test": false,
  "create_time": "2026-10-21T12:00:00+08:00",
  "expire_time": "2026-10-21T12:15:00+08:00",
  "paid_time": null,
  "close_time": null,
  "close_reason": null,
  "close_reason_label": null,
  "service_url": "https://pay.siyun223.com/pay/service/...",
  "cashier_url": "https://pay.siyun223.com/pay/cashier/...",
  "pay_info": {
    "type": "qrcode",
    "code_url": "https://qr.alipay.com/..."
  }
}
  • 订单可支付时返回 cashier_url(收银台地址,带有效期签名,订单关闭后失效)。指定支付产品下单时还返回 pay_info:type 为 qrcode 时用 code_url 生成二维码,为 redirect 时跳转 pay_url,为 jsapi 时用 jsapi 中的参数调起支付。
  • service_url 为订单服务页(付款人可自助核实支付结果、通知商户、提交问题反馈,订单创建后 90 天内有效)。建议放在你的订单详情页,例如「付款遇到问题?」,付款人遇到问题时先找你处理,减少向支付渠道投诉。
  • 幂等:同一商户订单号重复下单,参数一致且订单待支付时返回原订单;参数不一致返回 OUT_TRADE_NO_USED;订单已关闭返回 ORDER_CLOSED,需要换新的商户订单号。
  • 下单超时或返回 SYSTEM_ERROR 时,用同一商户订单号重试或先查单,不要直接更换单号,以免付款人重复付款。

支付产品

实际可用的产品、费率与限额以「产品与费率」为准。

product 取值 名称 支付凭据 说明
alipay_qr 支付宝扫码 二维码内容 code_url 当面付预下单,生成二维码供付款人用支付宝扫码;在手机浏览器中可直接唤起支付宝
alipay_wap 支付宝手机网站 跳转链接 pay_url 手机浏览器跳转支付宝手机收银台,可唤起支付宝 App 付款
alipay_page 支付宝电脑网站 跳转链接 pay_url 电脑浏览器跳转支付宝网页收银台,可扫码或登录账号付款
wechat_native 微信扫码 二维码内容 code_url 生成二维码,付款人用微信「扫一扫」付款
wechat_jsapi 微信公众号 只能经收银台发起,只返回收银台地址 在微信内打开收银台,经公众号网页授权获取付款人身份后调起支付
wechat_h5 微信 H5 只能经收银台发起,只返回收银台地址 在微信以外的手机浏览器中跳转微信支付中间页,唤起微信付款
wechat_miniapp 微信小程序 传入 wx_appid 与 wx_openid 时返回调起参数 jsapi,否则只返回收银台地址 在小程序内调起微信支付:商户小程序经接口传入付款人 openid 下单,或由收银台跳转到通道配置的小程序完成付款
douyin_native 抖音扫码 二维码内容 code_url 生成二维码,付款人用抖音「扫一扫」付款
douyin_h5 抖音 H5 只能经收银台发起,只返回收银台地址 在手机浏览器中跳转抖音支付中间页,唤起抖音付款

查单与关单

查询订单

GET/api/v1/orders/{order_no}

GET/api/v1/orders/out-trade-no/{out_trade_no}

  • order_no 为 22 位平台订单号;应答为订单字段,订单可支付时同时返回 cashier_url。
  • 订单待支付时,平台会先向渠道同步一次付款结果再返回(同一订单 10 秒内只同步一次)。没有按时收到支付通知时,以查单结果为准。

关闭订单

POST/api/v1/orders/out-trade-no/{out_trade_no}/close

  • 请求体为空。平台先向渠道查单、关单,再关闭订单,应答为关闭后的订单字段。
  • 已关闭的订单重复关单直接返回成功;已支付的订单返回 ORDER_PAID;渠道关单失败返回 CLOSE_FAILED,可稍后重试。
  • 关单后该商户订单号不能再用于下单。订单超过有效期未付款时,平台会自动关闭,不需要调用关单接口。

订单字段

下单、查单、关单应答与支付通知解密后的内容
字段 类型 说明
mch_nostring商户号
order_nostring平台订单号,22 位数字
out_trade_nostring商户订单号
statusstring订单状态,取值见下表
status_labelstring订单状态的中文名称
amountinteger订单金额(分)
refunded_amountinteger累计退款成功金额(分)
subjectstring商品描述
productstring | null支付产品;未指定产品且付款人尚未选择时为 null
product_labelstring | null支付产品的中文名称
channel_trade_nostring | null渠道交易号(支付宝、微信等的交易单号),支付成功后返回
attachstring | null下单时传入的附加数据
late_paidboolean是否为关闭后付款:订单已关闭但付款人仍完成了付款,平台按已支付处理
testboolean是否为「测试工具」发起的测试订单
create_timestring创建时间,ISO 8601 格式(东八区),下同
expire_timestring过期时间
paid_timestring | null支付时间
close_timestring | null关闭时间
close_reasonstring | null关闭原因,取值见下表
close_reason_labelstring | null关闭原因的中文名称
service_urlstring订单服务页地址(付款人自助核实支付结果、通知商户、提交问题反馈),订单创建后 90 天内有效;测试订单与支付通知中不含
cashier_urlstring收银台地址,仅订单可支付时返回(支付通知中不含)
pay_infoobject支付凭据,仅指定支付产品下单时返回:type、code_url、pay_url、jsapi

订单状态 status

取值名称说明
pending 待支付 可通过 cashier_url 或支付凭据付款;超过有效期由平台自动关闭
paid 已支付 付款成功。发生退款时状态不变,累计退款金额见 refunded_amount
closed 已关闭 超时、商户关单或平台关闭。关闭后付款人仍完成付款的,订单会变为已支付并标记 late_paid

关闭原因 close_reason

取值名称说明
expired 超时关闭 超过有效期未付款
merchant 商户关闭 调用关单接口或在控制台关闭
admin 管理员关闭 平台管理员关闭

退款

POST/api/v1/refunds

当前平台退款规则

退款功能
已开放
可退款期限
支付后 90 天内
退款审核
无需审核,申请后立即提交渠道
手续费
不退还手续费

手续费不退还,退款金额全部从商户资金扣除

  • 退款原路退回付款人,支持部分退款与多次退款,累计不超过订单金额。
  • 平台通道订单:从你的资金中扣除「退款金额 − 退还的手续费」,优先扣除该订单尚未转入可结算的待结算金额,不足部分从可结算余额扣除;余额不足时不能退款。
  • 自有通道订单:直接从你的渠道账户原路退回,不影响平台资金。
  • 因交易投诉被冻结或扣除的订单不能退款;账号冻结期间不能申请退款,查询不受影响。
参数 类型 必填 说明
order_nostring二选一原订单的平台订单号(22 位),与 out_trade_no 二选一,同时传入时以 order_no 为准
out_trade_nostring二选一原订单的商户订单号
out_refund_nostring是商户退款单号,6~64 位字母、数字、下划线或短横线,本商户内唯一
amountinteger是退款金额,单位为分,JSON 整数,不超过订单剩余可退款金额
reasonstring否退款原因,最长 80 个字符
notify_urlstring否退款结果通知地址,要求同下单的 notify_url;传入时需已生成回调密钥。不传则不推送退款结果,请通过查询接口获取

请求示例

{
  "out_trade_no": "M202610210001",
  "out_refund_no": "R202610220001",
  "amount": 50,
  "reason": "协商退款",
  "notify_url": "https://example.com/pay/refund-notify"
}

应答示例

{
  "mch_no": "你的商户号",
  "refund_no": "2026102210000087654321",
  "out_refund_no": "R202610220001",
  "order_no": "2026102112000012345678",
  "out_trade_no": "M202610210001",
  "status": "processing",
  "status_label": "退款中",
  "amount": 50,
  "order_amount": 100,
  "order_refunded_amount": 0,
  "reason": "协商退款",
  "channel_refund_no": null,
  "source": "api",
  "source_label": "开放接口",
  "fail_reason": null,
  "test": false,
  "create_time": "2026-10-22T10:00:00+08:00",
  "success_time": null,
  "finish_time": null
}
  • 幂等:同一商户退款单号重复请求,订单与金额一致时返回原退款单,否则返回 OUT_REFUND_NO_USED。超时或返回 SYSTEM_ERROR 时,用同一商户退款单号重试。
  • 应答 status 为 processing(退款中)或 reviewing(待审核)时,等待退款通知或调用查询接口,以 succeeded(退款成功)或 failed、rejected 为最终结果。
  • 在控制台发起的退款,结果通知发送到「密钥管理」中设置的退款通知地址。

查询退款

GET/api/v1/refunds/{refund_no}

GET/api/v1/refunds/out-refund-no/{out_refund_no}

退款中时,平台会先向渠道同步一次结果再返回(同一退款单 10 秒内只同步一次)。

退款字段 类型 说明
refund_nostring平台退款单号,22 位数字
out_refund_nostring商户退款单号;控制台发起的退款与平台退款单号相同
order_no / out_trade_nostring原订单的平台订单号与商户订单号
status / status_labelstring退款状态及中文名称,取值见下表
amountinteger本次退款金额(分)
order_amountinteger原订单金额(分)
order_refunded_amountinteger原订单累计退款成功金额(分)
reasonstring | null退款原因
channel_refund_nostring | null渠道退款单号
source / source_labelstring发起来源及中文名称,取值见下表
fail_reasonstring | null退款失败或被驳回的原因
testboolean是否为测试订单的退款
create_timestring申请时间
success_timestring | null退款成功时间
finish_timestring | null完结时间(成功、失败、驳回或撤销)

退款状态 status

取值名称说明
reviewing 待审核 平台开启退款审核时,平台通道订单的退款需审核通过后才提交渠道,扣款已预留
processing 退款中 已提交渠道,等待渠道处理,结果以退款通知或查询结果为准
succeeded 退款成功 款项已原路退回付款人,到账时间以渠道为准
failed 退款失败 渠道退款失败,原因见 fail_reason;预留扣款已退回,可使用新的商户退款单号重新申请
rejected 已驳回 平台审核未通过,原因见 fail_reason;预留扣款已退回
canceled 已撤销 待审核期间在控制台撤销;预留扣款已退回

发起来源 source

取值名称说明
merchant 商户控制台 在商户控制台的订单详情中发起
api 开放接口 调用申请退款接口发起
admin 平台后台 平台管理员代为发起(含收款通道没有退款接口时,平台在渠道后台退款后登记的手动退款)
complaint 投诉自动退款 订单收到投诉时,按商户在控制台「投诉设置」中开启的自动退款发起,全额原路退款;退款通知推送到控制台设置的退款通知地址

对账

也可以在「账单下载」中下载 CSV 账单

GET/api/v1/orders订单

GET/api/v1/refunds退款单

GET/api/v1/fund-logs资金流水

查询参数 适用接口 说明
date_from / date_to全部(必填)起止日期,格式 2026-10-01,包含首尾两天,跨度最多 31 天
page / page_size全部页码(默认 1)与每页条数(默认 50,最多 100)
date_type订单、退款单订单:created 按创建时间(默认)、paid 按支付时间(只返回已支付订单);退款单:created 按申请时间(默认)、succeeded 按退款成功时间(只返回退款成功的退款单)
status订单、退款单按状态筛选,取值同订单状态、退款状态;仅 date_type 为 created 时生效
type资金流水按流水类型筛选,取值见下表

应答示例

{
  "page": 1,
  "page_size": 50,
  "total": 128,
  "items": [
    "……订单字段 / 退款字段 / 流水字段……"
  ]
}

items 按记录 ID 升序排列,订单与退款单的字段与查询接口一致。按 total 计算页数,逐页拉取直到取完。

资金流水字段

字段说明
id流水 ID
type / type_label流水类型及中文名称
amount变动金额(分)。资金总额变动类:正数增加、负数减少;余额转移类为转移金额
pending_after变动后待结算余额
available_after变动后可结算余额
frozen_after变动后结算中金额
complaint_frozen_after变动后投诉冻结金额
order_no / out_trade_no关联订单
settle_no / refund_no关联结算单、退款单
remark / create_time备注、发生时间
流水类型 type 名称 资金变动
order_income 订单收入 计入资金总额
income_release 转入可结算 余额转移:待结算 → 可结算
settle_freeze 结算冻结 余额转移:可结算 → 结算中
settle_paid 结算出款 计入资金总额
settle_return 结算退回 余额转移:结算中 → 可结算
admin_adjust 人工调账 计入资金总额
complaint_freeze 投诉冻结 余额转移:转入投诉冻结
complaint_release 投诉解冻 余额转移:投诉冻结转回
complaint_deduct 投诉扣除 计入资金总额
refund_deduct 退款扣款 计入资金总额
refund_return 退款退回 计入资金总额

余额与结算

只包含平台代收的资金,自有通道的款项直接进入你的渠道账户

查询余额

GET/api/v1/balance

{
  "mch_no": "你的商户号",
  "platform_funds": true,
  "pending_balance": 12000,
  "available_balance": 56800,
  "frozen_balance": 10000,
  "complaint_frozen_balance": 0,
  "total_income": 1280000,
  "total_settled": 1200000,
  "total_settle_fee": 1200,
  "total_refund": 2000,
  "query_time": "2026-10-21T12:00:00+08:00"
}
字段说明
platform_funds是否使用平台代收;为 false 时各项金额均为 0
pending_balance待结算余额(分),到达结算周期后转入可结算
available_balance可结算余额(分),可申请结算,退款也从这里扣除
frozen_balance结算中金额(分),已申请结算、尚未完结
complaint_frozen_balance投诉冻结金额(分),投诉处理完结后解冻
total_income / total_refund累计入账、累计退款(分)
total_settled / total_settle_fee累计已结算金额、累计结算手续费(分)

结算单列表与查询

GET/api/v1/settlements?date_from=2026-10-01&date_to=2026-10-31

GET/api/v1/settlements/{settle_no}

列表按申请时间筛选,date_from / date_to 必填,跨度最多 31 天;可用 status 按状态筛选,page / page_size 分页规则与对账接口相同。应答为 page、page_size、total、items,items 中每条记录的字段与查询结算单相同。

结算单字段 类型 说明
settle_nostring结算单号,22 位数字
source / source_labelstring来源及中文名称:merchant 商户申请、auto 自动结算
status / status_labelstring结算状态及中文名称,取值见下表
account_type / account_type_labelstring收款账户类型:alipay 支付宝、wechat 微信、bank 银行卡
account_name / account_maskstring收款人姓名、脱敏后的收款账号
bank_namestring | null开户银行,银行卡收款时返回
amountinteger结算金额(分)
fee / actual_amountinteger结算手续费、实际到账金额(分)
reasonstring | null驳回或打款失败的原因
transfer_nostring | null打款流水号,已打款时返回
create_time / review_timestring | null申请时间、审核时间
paid_time / finish_timestring | null打款时间、完结时间(已打款、驳回、失败或撤销)

结算状态 status

取值名称说明
pending 待审核 已提交,等待平台审核;结算金额已从可结算余额转入结算中
approved 待打款 审核通过,等待平台打款
paying 打款中 已提交自动打款,等待收款渠道返回结果
paid 已打款 已打款,转账流水号见 transfer_no,到账时间以收款渠道为准
rejected 已驳回 平台审核未通过,原因见 reason;结算金额已退回可结算余额
failed 打款失败 打款失败,原因见 reason;结算金额已退回可结算余额
canceled 已撤销 待审核期间在控制台撤销;结算金额已退回可结算余额

支付与退款通知

订单支付成功、退款完结时,平台向通知地址发送 POST 请求,请求体为 JSON,请求头带签名,签名方式与接口应答相同;业务数据 resource 使用回调密钥加密。

通知报文

{
  "id": "01JB7Y6K3F8W2C5N9D4Q1R0T6V",
  "create_time": "2026-10-21T12:00:05+08:00",
  "event_type": "TRANSACTION.SUCCESS",
  "resource_type": "encrypt-resource",
  "summary": "支付成功",
  "resource": {
    "algorithm": "AEAD_AES_256_GCM",
    "ciphertext": "Base64(密文 + 16 字节认证标签)",
    "nonce": "Base64(12 字节随机数)",
    "associated_data": "transaction"
  }
}

通知请求头

请求头说明
Siyun-Timestamp通知时间戳
Siyun-Nonce通知随机串
Siyun-Serial平台公钥序列号
Siyun-Signature对「时间戳\n随机串\n请求体\n」的签名
Siyun-Notify-Id通知 ID,与报文中的 id 相同,可用于去重
event_type 说明 associated_data resource 解密后
TRANSACTION.SUCCESS支付成功transaction订单字段(不含 cashier_url、pay_info)
REFUND.SUCCESS退款成功refund退款字段
REFUND.FAILED退款失败或被驳回refund退款字段,原因见 fail_reason
NOTIFY.TEST「测试工具」发送的测试通知transaction测试数据(mch_no、test、message、send_time),不要按支付成功处理

处理步骤

  1. 读取原始请求体,用 Siyun-Serial 对应的平台公钥验证签名,并检查时间戳在 5 分钟内。不要先解析 JSON 再重新序列化后验签。
  2. 用回调密钥解密 resource:算法 AES-256-GCM,密钥为 32 位回调密钥本身;nonce、ciphertext 先做 Base64 解码,ciphertext 末尾 16 字节为认证标签;associated_data 作为附加数据。
  3. 按 event_type 处理业务,并核对金额与本地订单一致。同一笔订单或退款可能收到多次通知,请按通知 ID 或单号做幂等处理,已处理过的直接返回成功。
  4. 处理成功后返回任意 HTTP 2xx 状态码,响应内容不限。

返回非 2xx 状态码、超过 5 秒未响应或连接失败视为推送失败,平台按 15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、1 小时、3 小时、6 小时、12 小时的间隔重试,共 10 次(约 23 小时内)。仍失败时停止自动推送,可在「通知记录」中查看推送结果并手动重发。

错误码

请求失败时返回 4xx 或 5xx 状态码与以下结构(NOT_FOUND、METHOD_NOT_ALLOWED 之外的错误应答同样带有平台签名)。detail 只在部分错误中返回,例如参数错误时的 detail.fields。

{
  "code": "PARAM_ERROR",
  "message": "订单金额必须是 JSON 整数",
  "request_id": "01JB7Y9M2A6S4D8F0G1H3J5K7L",
  "detail": {
    "fields": {
      "amount": ["订单金额必须是 JSON 整数"]
    }
  }
}
错误码 HTTP 状态码 说明 处理建议
签名与认证
SIGN_MISSING 401 缺少 Authorization 请求头,或认证类型不是 SIYUN-SHA256-RSA 按签名规则添加请求头
SIGN_PARAM_ERROR 401 Authorization 中的参数缺失或格式不正确 mch_no 为 8~20 位数字,serial_no 为 40 位大写十六进制,timestamp 为 10 位秒级时间戳,nonce_str 为 16~32 位字母或数字
TIMESTAMP_EXPIRED 401 请求时间戳与平台时间相差超过 5 分钟 校准服务器时间,建议开启 NTP 自动同步
MCH_NOT_EXISTS 401 商户号不存在 核对商户号
SERIAL_INVALID 401 商户公钥序列号不存在或已停用 在「密钥管理」中核对序列号与公钥状态
SIGN_ERROR 401 签名验证失败 确认签名串中的路径(含查询串)、请求体与实际发送的完全一致,且签名私钥与 serial_no 对应的公钥是同一对
NONCE_REPLAYED 401 请求随机串已被使用 每次请求(包括重试)都重新生成随机串并重新签名
MCH_DISABLED 403 商户已被禁用 联系平台客服
IP_NOT_ALLOWED 403 请求来源 IP 不在白名单中 在「密钥管理」中添加服务器出口 IP,或清空白名单
AUTH_FAILED_TOO_MANY 429 同一 IP 1 分钟内认证失败超过 120 次,本分钟内暂停该 IP 的请求(这类请求不记入请求日志) 按提示的秒数等待后重试,并按上面各项错误逐一检查签名、时间戳、商户号、公钥序列号与 IP 白名单
通用
PARAM_ERROR 400 请求参数不正确 detail.fields 中按字段列出了错误原因
NOT_FOUND 404 接口路径不存在 核对请求方法与路径
METHOD_NOT_ALLOWED 405 接口不支持该请求方法 核对请求方法
BODY_TOO_LARGE 413 请求体超过 1 MB 精简请求内容
RATE_LIMITED 429 请求过于频繁(每个商户每分钟最多 1200 次) 按提示的秒数等待后重试
ORDER_BUSY 429 同一订单正在处理其他请求 稍后重试
SYSTEM_ERROR 500 系统繁忙 稍后用相同参数重试;下单、退款请沿用原单号重试或先查询,不要更换单号
下单、查单与关单
MCH_STATUS_INVALID 403 商户当前状态不允许交易或退款 在控制台查看账号状态
MCH_NOT_CERTIFIED 403 尚未完成实名认证 完成实名认证后再发起交易
CALLBACK_SECRET_MISSING 403 尚未生成回调密钥 在「密钥管理」中生成回调密钥
PRODUCT_UNAVAILABLE 403 未开通该支付产品 在「产品与费率」中查看已开通的产品
NO_AVAILABLE_CHANNEL 403 暂无可用的支付通道 稍后重试或更换支付产品,持续出现请联系平台客服
AMOUNT_OUT_OF_RANGE 400 订单金额超出平台或支付产品的单笔限额 在「产品与费率」中查看限额
PAYER_APPID_MISMATCH 400 wx_appid 与可用支付通道配置的小程序不一致 核对小程序 AppID
CHANNEL_ERROR 502 支付通道下单失败 使用同一商户订单号稍后重试
OUT_TRADE_NO_USED 409 商户订单号已存在,且与本次请求参数不一致 使用新的商户订单号
ORDER_CLOSED 409 订单已关闭或已过期 使用新的商户订单号重新下单
ORDER_PAID 409 订单已支付 已支付的订单不能关闭,需要退还时请申请退款
ORDER_NOT_FOUND 404 订单不存在 核对订单号,只能查询本商户的订单
CLOSE_FAILED 502 渠道关单失败 稍后重试
退款
REFUND_DISABLED 403 平台暂未开放退款功能 联系平台客服
ORDER_NOT_PAID 409 订单未支付,不能退款 确认订单已支付
REFUND_EXPIRED 409 订单支付已超过可退款期限 联系平台客服协商处理
ORDER_FROZEN 409 订单因交易投诉被冻结或已扣除 在「投诉处理」中处理投诉,或联系平台客服
REFUND_AMOUNT_EXCEEDED 409 退款金额超过订单剩余可退款金额 剩余可退款 = 订单金额 − 待审核、退款中、退款成功的金额
BALANCE_NOT_ENOUGH 409 可结算余额不足以扣除本次退款 等待待结算资金转入可结算后再退款,或减少本次退款金额
OUT_REFUND_NO_USED 409 商户退款单号已存在,且订单或金额不一致 使用新的商户退款单号
REFUND_NOT_FOUND 404 退款单不存在 核对退款单号
CHANNEL_UNAVAILABLE 409 原收款通道已不可用,无法原路退款 联系平台客服
REFUND_UNSUPPORTED 409 原收款通道没有退款接口,不能在线退款 联系平台客服,由平台在渠道后台退款后登记;经自有通道收款的订单请登录渠道商户后台退款
余额与结算
SETTLEMENT_NOT_FOUND 404 结算单不存在 核对结算单号,只能查询本商户的结算单

示例代码

包含请求签名、应答与通知验签、通知解密;请替换公钥序列号、文件路径与回调密钥

PHP SDK

单文件 SiyunPay.php(版本 1.0.0),已填入接口地址,商户号请替换为控制台中的商户号。封装了本文档的全部接口、请求签名、应答验签、平台公钥更新、通知验签与解密,PHP 7.4 及以上可用,不依赖第三方库。用法见文件开头的注释。

下载 PHP SDK

PHP

PHP 7.4 及以上,需要 openssl、curl 扩展

<?php
// 商户号、商户公钥序列号(在「密钥管理」中查看)、接口域名
const MCH_NO = '你的商户号';
const SERIAL_NO = '你的商户公钥序列号';
const API_HOST = 'https://pay.siyun223.com';

/** 发起请求并验证应答签名。$path 含查询串,例如 /api/v1/orders/out-trade-no/M2026102100001 */
function siyun_request(string $method, string $path, ?array $data = null): array
{
    $body = $data === null ? '' : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $timestamp = (string) time();
    $nonce = bin2hex(random_bytes(16));
    $message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";

    $privateKey = openssl_pkey_get_private(file_get_contents('/path/to/merchant_private.pem'));
    openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256);

    $authorization = sprintf(
        'SIYUN-SHA256-RSA mch_no="%s",serial_no="%s",timestamp="%s",nonce_str="%s",signature="%s"',
        MCH_NO, SERIAL_NO, $timestamp, $nonce, base64_encode($signature)
    );

    $ch = curl_init(API_HOST . $path);
    $options = [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER => true,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => ['Authorization: ' . $authorization, 'Content-Type: application/json', 'Accept: application/json'],
    ];
    if ($body !== '') {
        $options[CURLOPT_POSTFIELDS] = $body;
    }
    curl_setopt_array($ch, $options);
    $raw = curl_exec($ch);
    if ($raw === false) {
        throw new RuntimeException('请求失败:' . curl_error($ch));
    }
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    $headers = [];
    foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
        if (strpos($line, ':') !== false) {
            [$name, $value] = explode(':', $line, 2);
            $headers[strtolower(trim($name))] = trim($value);
        }
    }
    $responseBody = substr($raw, $headerSize);

    // 验证应答签名,确认应答来自平台且未被篡改;平台公钥按应答头 Siyun-Serial 选择
    $platformKey = file_get_contents('/path/to/siyun_platform_public_' . ($headers['siyun-serial'] ?? '') . '.pem');
    if (! siyun_verify($headers['siyun-timestamp'] ?? '', $headers['siyun-nonce'] ?? '', $responseBody, $headers['siyun-signature'] ?? '', $platformKey)) {
        throw new RuntimeException('应答验签失败');
    }

    return ['status' => $status, 'data' => json_decode($responseBody, true)];
}

/** 验签:应答与通知共用。签名串为「时间戳\n随机串\n报文主体\n」 */
function siyun_verify(string $timestamp, string $nonce, string $body, string $signature, string $platformPublicKey): bool
{
    if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";

    return openssl_verify($message, base64_decode($signature), $platformPublicKey, OPENSSL_ALGO_SHA256) === 1;
}

/** 解密通知中的 resource:AES-256-GCM,密文末尾 16 字节为认证标签 */
function siyun_decrypt(array $resource, string $callbackSecret): array
{
    $raw = base64_decode($resource['ciphertext']);
    $plain = openssl_decrypt(
        substr($raw, 0, -16), 'aes-256-gcm', $callbackSecret, OPENSSL_RAW_DATA,
        base64_decode($resource['nonce']), substr($raw, -16), $resource['associated_data']
    );
    if ($plain === false) {
        throw new RuntimeException('通知解密失败,请检查回调密钥');
    }

    return json_decode($plain, true);
}

// ---------- 下单 ----------
$result = siyun_request('POST', '/api/v1/orders', [
    'out_trade_no' => 'M' . date('YmdHis') . mt_rand(1000, 9999),
    'amount' => 100,                      // 金额,单位分
    'subject' => '会员充值',
    'notify_url' => 'https://example.com/pay/notify',
    'return_url' => 'https://example.com/pay/done',
]);
// 收银台地址:$result['data']['cashier_url']

// ---------- 接收通知(notify_url 对应的处理程序) ----------
// 必须用原始请求体验签,不要先 json_decode 再重新编码
$body = file_get_contents('php://input');
$serial = $_SERVER['HTTP_SIYUN_SERIAL'] ?? '';
$platformKey = file_get_contents('/path/to/siyun_platform_public_' . $serial . '.pem');
if (! siyun_verify($_SERVER['HTTP_SIYUN_TIMESTAMP'] ?? '', $_SERVER['HTTP_SIYUN_NONCE'] ?? '', $body, $_SERVER['HTTP_SIYUN_SIGNATURE'] ?? '', $platformKey)) {
    http_response_code(401);
    exit;
}
$notify = json_decode($body, true);
$data = siyun_decrypt($notify['resource'], '你的回调密钥');
switch ($notify['event_type']) {
    case 'TRANSACTION.SUCCESS':
        // 按 out_trade_no 更新订单为已支付(注意幂等:同一订单可能收到多次通知)
        break;
    case 'REFUND.SUCCESS':
    case 'REFUND.FAILED':
        // 按 out_refund_no 更新退款结果
        break;
    case 'NOTIFY.TEST':
        // 控制台发送的测试通知,不做业务处理
        break;
}
http_response_code(200);
echo 'success';

Java

JDK 11 及以上,源文件按 UTF-8 编译;控制台生成的私钥为 PKCS#8 格式,可直接读取

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.UUID;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public final class SiyunPay {
    public static final String MCH_NO = "你的商户号";
    public static final String SERIAL_NO = "你的商户公钥序列号";
    public static final String API_HOST = "https://pay.siyun223.com";

    private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();

    /** 加载 PKCS#8 格式的商户私钥(-----BEGIN PRIVATE KEY-----) */
    public static PrivateKey loadPrivateKey(String pem) throws Exception {
        String base64 = pem.replaceAll("-----(BEGIN|END) PRIVATE KEY-----", "").replaceAll("\\s", "");
        return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64)));
    }

    /** 加载平台公钥(-----BEGIN PUBLIC KEY-----) */
    public static PublicKey loadPublicKey(String pem) throws Exception {
        String base64 = pem.replaceAll("-----(BEGIN|END) PUBLIC KEY-----", "").replaceAll("\\s", "");
        return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(base64)));
    }

    /** 生成 Authorization 请求头。path 含查询串,body 为实际发送的请求体(GET 为空字符串) */
    public static String authorization(PrivateKey key, String method, String path, String body) throws Exception {
        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
        String nonce = UUID.randomUUID().toString().replace("-", "");
        String message = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n";

        Signature signer = Signature.getInstance("SHA256withRSA");
        signer.initSign(key);
        signer.update(message.getBytes(StandardCharsets.UTF_8));
        String signature = Base64.getEncoder().encodeToString(signer.sign());

        return String.format("SIYUN-SHA256-RSA mch_no=\"%s\",serial_no=\"%s\",timestamp=\"%s\",nonce_str=\"%s\",signature=\"%s\"",
            MCH_NO, SERIAL_NO, timestamp, nonce, signature);
    }

    /** 验签:应答与通知共用,签名串为「时间戳\n随机串\n报文主体\n」 */
    public static boolean verify(String timestamp, String nonce, String body, String signature, PublicKey platformKey) throws Exception {
        if (!timestamp.matches("\\d{10}") || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > 300) {
            return false;
        }
        Signature verifier = Signature.getInstance("SHA256withRSA");
        verifier.initVerify(platformKey);
        verifier.update((timestamp + "\n" + nonce + "\n" + body + "\n").getBytes(StandardCharsets.UTF_8));
        return verifier.verify(Base64.getDecoder().decode(signature));
    }

    /** 解密通知中的 resource(AES-256-GCM,密文末尾带 16 字节认证标签) */
    public static String decrypt(String ciphertext, String nonce, String associatedData, String callbackSecret) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE,
            new SecretKeySpec(callbackSecret.getBytes(StandardCharsets.UTF_8), "AES"),
            new GCMParameterSpec(128, Base64.getDecoder().decode(nonce)));
        cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8));
        return new String(cipher.doFinal(Base64.getDecoder().decode(ciphertext)), StandardCharsets.UTF_8);
    }

    /** 发起请求并验证应答签名,返回应答体(JSON)。path 含查询串,body 为 JSON 字符串(无请求体时传空字符串) */
    public static String request(PrivateKey key, String method, String path, String body) throws Exception {
        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(API_HOST + path))
            .timeout(Duration.ofSeconds(15))
            .header("Authorization", authorization(key, method, path, body))
            .header("Accept", "application/json");
        if (body.isEmpty()) {
            builder.method(method, HttpRequest.BodyPublishers.noBody());
        } else {
            builder.header("Content-Type", "application/json")
                .method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
        }
        HttpResponse<String> response = HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

        // 平台公钥按应答头 Siyun-Serial 选择
        String serial = response.headers().firstValue("Siyun-Serial").orElse("");
        PublicKey platformKey = loadPublicKey(Files.readString(Path.of("/path/to/siyun_platform_public_" + serial + ".pem")));
        boolean valid = verify(
            response.headers().firstValue("Siyun-Timestamp").orElse(""),
            response.headers().firstValue("Siyun-Nonce").orElse(""),
            response.body(),
            response.headers().firstValue("Siyun-Signature").orElse(""),
            platformKey);
        if (!valid) {
            throw new IllegalStateException("应答验签失败");
        }
        return response.body();
    }

    public static void main(String[] args) throws Exception {
        PrivateKey key = loadPrivateKey(Files.readString(Path.of("/path/to/merchant_private.pem")));
        // 下单(金额单位为分),应答中的 cashier_url 为收银台地址
        String body = "{\"out_trade_no\":\"M" + System.currentTimeMillis() + "\",\"amount\":100,"
            + "\"subject\":\"会员充值\",\"notify_url\":\"https://example.com/pay/notify\"}";
        System.out.println(request(key, "POST", "/api/v1/orders", body));
    }

    // 接收通知:用原始请求体与请求头 Siyun-Timestamp、Siyun-Nonce、Siyun-Signature 调用 verify(),
    // 通过后解析 JSON,取 resource 的 ciphertext、nonce、associated_data 调用 decrypt(),处理完成后返回 HTTP 200。
}

Python

Python 3.8 及以上,依赖 cryptography、requests:pip install cryptography requests

import base64
import json
import secrets
import time
from typing import Optional

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

MCH_NO = "你的商户号"
SERIAL_NO = "你的商户公钥序列号"
API_HOST = "https://pay.siyun223.com"

with open("/path/to/merchant_private.pem", "rb") as f:
    PRIVATE_KEY = serialization.load_pem_private_key(f.read(), password=None)


def authorization(method: str, path: str, body: str = "") -> str:
    """path 含查询串;body 必须与实际发送的请求体完全一致"""
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    message = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n".encode("utf-8")
    signature = base64.b64encode(PRIVATE_KEY.sign(message, padding.PKCS1v15(), hashes.SHA256())).decode()
    return (f'SIYUN-SHA256-RSA mch_no="{MCH_NO}",serial_no="{SERIAL_NO}",'
            f'timestamp="{timestamp}",nonce_str="{nonce}",signature="{signature}"')


def verify(timestamp: str, nonce: str, body: str, signature: str, platform_public_pem: bytes) -> bool:
    """验签:应答与通知共用"""
    if not timestamp or abs(time.time() - int(timestamp)) > 300:
        return False
    key = serialization.load_pem_public_key(platform_public_pem)
    try:
        key.verify(base64.b64decode(signature), f"{timestamp}\n{nonce}\n{body}\n".encode("utf-8"),
                   padding.PKCS1v15(), hashes.SHA256())
        return True
    except Exception:
        return False


def decrypt(resource: dict, callback_secret: str) -> dict:
    """解密通知中的 resource(AES-256-GCM)"""
    plain = AESGCM(callback_secret.encode("utf-8")).decrypt(
        base64.b64decode(resource["nonce"]),
        base64.b64decode(resource["ciphertext"]),
        resource["associated_data"].encode("utf-8"),
    )
    return json.loads(plain)


def request(method: str, path: str, data: Optional[dict] = None) -> dict:
    body = "" if data is None else json.dumps(data, ensure_ascii=False, separators=(",", ":"))
    resp = requests.request(
        method, API_HOST + path,
        data=body.encode("utf-8") if body else None,
        headers={"Authorization": authorization(method, path, body),
                 "Content-Type": "application/json", "Accept": "application/json"},
        timeout=15,
    )
    text = resp.content.decode("utf-8")
    # 平台公钥按应答头 Siyun-Serial 选择
    with open(f"/path/to/siyun_platform_public_{resp.headers.get('Siyun-Serial', '')}.pem", "rb") as f:
        if not verify(resp.headers.get("Siyun-Timestamp", ""), resp.headers.get("Siyun-Nonce", ""),
                      text, resp.headers.get("Siyun-Signature", ""), f.read()):
            raise RuntimeError("应答验签失败")
    return json.loads(text)


# 下单(金额单位为分)
order = request("POST", "/api/v1/orders", {
    "out_trade_no": "M" + time.strftime("%Y%m%d%H%M%S"),
    "amount": 100,
    "subject": "会员充值",
    "notify_url": "https://example.com/pay/notify",
})
print(order.get("cashier_url"))

# 接收通知:用原始请求体(例如 Flask 的 request.get_data(as_text=True))调用 verify(),
# 通过后 decrypt(json.loads(raw)["resource"], "你的回调密钥"),处理完成后返回 HTTP 200。

Node.js

Node.js 18 及以上(内置 fetch 与 crypto),无需第三方依赖

const crypto = require('crypto');
const fs = require('fs');

const MCH_NO = '你的商户号';
const SERIAL_NO = '你的商户公钥序列号';
const API_HOST = 'https://pay.siyun223.com';
const privateKey = fs.readFileSync('/path/to/merchant_private.pem', 'utf8');

// path 含查询串;body 必须与实际发送的请求体完全一致(GET 为空字符串)
function authorization(method, path, body = '') {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString('hex');
  const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
  const signature = crypto.createSign('RSA-SHA256').update(message, 'utf8').sign(privateKey, 'base64');
  return `SIYUN-SHA256-RSA mch_no="${MCH_NO}",serial_no="${SERIAL_NO}",timestamp="${timestamp}",nonce_str="${nonce}",signature="${signature}"`;
}

// 验签:应答与通知共用
function verify(timestamp, nonce, body, signature, platformPublicKey) {
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  return crypto.createVerify('RSA-SHA256')
    .update(`${timestamp}\n${nonce}\n${body}\n`, 'utf8')
    .verify(platformPublicKey, signature, 'base64');
}

// 解密通知中的 resource(AES-256-GCM,密文末尾 16 字节为认证标签)
function decrypt(resource, callbackSecret) {
  const raw = Buffer.from(resource.ciphertext, 'base64');
  const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(callbackSecret, 'utf8'), Buffer.from(resource.nonce, 'base64'));
  decipher.setAuthTag(raw.subarray(raw.length - 16));
  decipher.setAAD(Buffer.from(resource.associated_data, 'utf8'));
  const plain = Buffer.concat([decipher.update(raw.subarray(0, raw.length - 16)), decipher.final()]);
  return JSON.parse(plain.toString('utf8'));
}

async function request(method, path, data) {
  const body = data === undefined ? '' : JSON.stringify(data);
  const res = await fetch(API_HOST + path, {
    method,
    headers: { Authorization: authorization(method, path, body), 'Content-Type': 'application/json', Accept: 'application/json' },
    body: body || undefined,
  });
  const text = await res.text();
  // 平台公钥按应答头 Siyun-Serial 选择
  const platformKey = fs.readFileSync(`/path/to/siyun_platform_public_${res.headers.get('siyun-serial')}.pem`, 'utf8');
  if (!verify(res.headers.get('siyun-timestamp'), res.headers.get('siyun-nonce'), text, res.headers.get('siyun-signature'), platformKey)) {
    throw new Error('应答验签失败');
  }
  return { status: res.status, data: JSON.parse(text) };
}

// 下单(金额单位为分)
request('POST', '/api/v1/orders', {
  out_trade_no: 'M' + Date.now(),
  amount: 100,
  subject: '会员充值',
  notify_url: 'https://example.com/pay/notify',
}).then((r) => console.log(r.data.cashier_url));

// 接收通知:用原始请求体字符串(不要先 JSON.parse 再 JSON.stringify)调用 verify(),
// 通过后 decrypt(JSON.parse(raw).resource, '你的回调密钥'),处理完成后返回 HTTP 200。

Go

Go 1.20 及以上,只使用标准库

package siyun

import (
	"crypto"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/hex"
	"encoding/pem"
	"errors"
	"fmt"
	"strconv"
	"time"
)

const (
	MchNo    = "你的商户号"
	SerialNo = "你的商户公钥序列号"
	APIHost  = "https://pay.siyun223.com"
)

// ParsePrivateKey 解析商户私钥(PKCS#8 或 PKCS#1)
func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
	block, _ := pem.Decode(pemBytes)
	if block == nil {
		return nil, errors.New("私钥格式不正确")
	}
	if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
		if rsaKey, ok := key.(*rsa.PrivateKey); ok {
			return rsaKey, nil
		}
	}
	return x509.ParsePKCS1PrivateKey(block.Bytes)
}

// ParsePublicKey 解析平台公钥
func ParsePublicKey(pemBytes []byte) (*rsa.PublicKey, error) {
	block, _ := pem.Decode(pemBytes)
	if block == nil {
		return nil, errors.New("公钥格式不正确")
	}
	key, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return nil, err
	}
	rsaKey, ok := key.(*rsa.PublicKey)
	if !ok {
		return nil, errors.New("不是 RSA 公钥")
	}
	return rsaKey, nil
}

// Authorization 生成请求头。path 含查询串,body 为实际发送的请求体(GET 为空字符串)
func Authorization(key *rsa.PrivateKey, method, path, body string) (string, error) {
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	buf := make([]byte, 16)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	nonce := hex.EncodeToString(buf)
	digest := sha256.Sum256([]byte(method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"))
	sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
	if err != nil {
		return "", err
	}
	return fmt.Sprintf(`SIYUN-SHA256-RSA mch_no="%s",serial_no="%s",timestamp="%s",nonce_str="%s",signature="%s"`,
		MchNo, SerialNo, timestamp, nonce, base64.StdEncoding.EncodeToString(sig)), nil
}

// Verify 验签:应答与通知共用,签名串为「时间戳\n随机串\n报文主体\n」
func Verify(timestamp, nonce, body, signature string, platformKey *rsa.PublicKey) error {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return errors.New("时间戳无效")
	}
	if diff := time.Now().Unix() - ts; diff > 300 || diff < -300 {
		return errors.New("时间戳已过期")
	}
	sig, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		return err
	}
	digest := sha256.Sum256([]byte(timestamp + "\n" + nonce + "\n" + body + "\n"))
	return rsa.VerifyPKCS1v15(platformKey, crypto.SHA256, digest[:], sig)
}

// Decrypt 解密通知中的 resource(AES-256-GCM,密文末尾带 16 字节认证标签)
func Decrypt(ciphertext, nonce, associatedData, callbackSecret string) ([]byte, error) {
	raw, err := base64.StdEncoding.DecodeString(ciphertext)
	if err != nil {
		return nil, err
	}
	iv, err := base64.StdEncoding.DecodeString(nonce)
	if err != nil {
		return nil, err
	}
	block, err := aes.NewCipher([]byte(callbackSecret))
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	if len(iv) != gcm.NonceSize() {
		return nil, errors.New("nonce 长度不正确")
	}
	return gcm.Open(nil, iv, raw, []byte(associatedData))
}

// 发起请求:用 Authorization() 生成请求头后通过 net/http 发送;
// 收到应答后用应答头 Siyun-Timestamp、Siyun-Nonce、Siyun-Signature 与应答体调用 Verify(),平台公钥按 Siyun-Serial 选择。
// 接收通知:用原始请求体调用 Verify(),通过后取 resource 调用 Decrypt(),处理完成后返回 HTTP 200。