# 思云支付开放接口接入文档（v1）

> 网页版：https://pay.siyun223.com/docs　Markdown 版：https://pay.siyun223.com/docs.md
> 限额、退款规则与通知重试间隔为平台当前设置，以网页版为准。示例中的「你的商户号」请替换为商户控制台中的商户号。

## 给 AI 编程助手的接入要点

请严格按本文档实现，不要套用其他支付平台（如微信支付、支付宝、易支付）的签名或参数规则。

- 接口地址：`https://pay.siyun223.com/api/v1`，请求与应答均为 JSON（UTF-8），请求头带 `Content-Type: application/json`。
- 金额一律为整数，单位为分（100 = 1.00 元），必须是 JSON 整数，不能写成字符串或小数。
- 每个请求都要签名：SHA256withRSA + Base64，放入 `Authorization: SIYUN-SHA256-RSA ...` 请求头，签名串规则见「签名与验签」。先把请求体序列化为字符串，用这个字符串签名并原样发送，不要让 HTTP 库再次序列化。
- 每次请求（包括重试）都重新生成时间戳、随机串并重新签名。
- 接口应答与支付、退款通知都必须用平台公钥验签；通知的 resource 用回调密钥按 AES-256-GCM 解密。
- 支付是否成功以「支付通知」或「查询订单」结果为准，return_url 跳转不能作为支付成功的依据。
- 通知可能重复送达，按通知 ID 或单号做幂等处理；处理成功返回 HTTP 2xx。
- 下单、退款超时或返回 SYSTEM_ERROR 时，用原商户订单号 / 原商户退款单号重试或先查询，不要更换单号。
- 需要商户提供的配置（放在配置文件或环境变量中，不要写死在代码里，也不要提交到代码仓库）：
  - 商户号 mch_no：在商户控制台查看
  - 商户公钥序列号 serial_no：40 位，在控制台「密钥管理」生成密钥后显示
  - 商户私钥：PKCS#8 格式 PEM 文件，只保存在商户服务器
  - 平台公钥：控制台下载，或调用 `GET /api/v1/certificates` 获取（按序列号缓存全部有效公钥）
  - 回调密钥：32 位，用于解密通知，在控制台「密钥管理」生成
- PHP 项目可以直接使用单文件 SDK：https://pay.siyun223.com/docs/sdk/php（SiyunPay.php，PHP 7.4 及以上，不依赖第三方库）。其他语言参考文末「示例代码」。

## 接入准备

| 项目 | 说明 |
| --- | --- |
| 接口地址 | 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. 联调：在控制台「测试工具」中发起小额测试支付，或向通知地址发送测试通知（NOTIFY.TEST），确认验签与解密代码正确。
7. 正式上线：下单 → 引导付款人打开收银台或展示支付凭据 → 接收支付通知并更新订单 → 未收到通知时用查单兜底 → 每日调用对账接口或下载账单核对。

## 签名与验签

### 请求签名

1. 按以下 5 行拼接签名串，每一行（包括最后一行）末尾都有一个换行符 `\n`：

```text
HTTP 请求方法
请求路径（含查询串）
请求时间戳
请求随机串
请求体
```

2. 使用商户私钥对签名串做 SHA256withRSA 签名，签名结果做 Base64 编码。
3. 放入 Authorization 请求头：

```text
Authorization: SIYUN-SHA256-RSA mch_no="你的商户号",serial_no="商户公钥序列号",timestamp="1792840000",nonce_str="593bec0c930bf1afeb40b4a08c8fb242",signature="Base64 签名值"
```

下单请求的签名串示例（最后一行请求体之后也有换行符）：

```text
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-Signature | Base64 编码的 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}`<br>`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}`<br>`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_no | string | 是 | 商户订单号，6～64 位字母、数字、下划线或短横线，本商户内唯一 |
| amount | integer | 是 | 订单金额，单位为分，必须是 JSON 整数（不能写成字符串）。平台单笔限额 0.01～100,000.00 元，各支付产品的限额见「产品与费率」 |
| subject | string | 是 | 商品描述，最长 64 个字符，不能包含控制字符 |
| notify_url | string | 是 | 支付结果通知地址：https:// 开头的公网地址，最长 255 个字符，不能指向内网地址或本平台 |
| product | string | 否 | 支付产品，取值见下方「支付产品」。不传时只返回收银台地址，由付款人在收银台选择支付方式 |
| return_url | string | 否 | 付款完成后返回的页面，http 或 https 地址，最长 255 个字符。跳转时附带 out_trade_no 与 order_no，不能作为支付成功的依据 |
| attach | string | 否 | 附加数据，最长 255 个字符，查单与通知中原样返回 |
| expire_minutes | integer | 否 | 订单有效期（分钟），1～120，不传时为 30 分钟 |
| wx_appid | string | 否 | 微信小程序 AppID，与 wx_openid 同时传入，须与支付通道配置的小程序一致 |
| wx_openid | string | 否 | 付款人在该小程序下的 openid。product 为 wechat_miniapp 且传入时，应答的 pay_info.jsapi 即 wx.requestPayment 所需参数 |

请求示例：

```json
{
  "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
}
```

应答示例：

```json
{
  "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_no | string | 商户号 |
| order_no | string | 平台订单号，22 位数字 |
| out_trade_no | string | 商户订单号 |
| status | string | 订单状态，取值见下表 |
| status_label | string | 订单状态的中文名称 |
| amount | integer | 订单金额（分） |
| refunded_amount | integer | 累计退款成功金额（分） |
| subject | string | 商品描述 |
| product | string \| null | 支付产品；未指定产品且付款人尚未选择时为 null |
| product_label | string \| null | 支付产品的中文名称 |
| channel_trade_no | string \| null | 渠道交易号（支付宝、微信等的交易单号），支付成功后返回 |
| attach | string \| null | 下单时传入的附加数据 |
| late_paid | boolean | 是否为关闭后付款：订单已关闭但付款人仍完成了付款，平台按已支付处理 |
| test | boolean | 是否为「测试工具」发起的测试订单 |
| create_time | string | 创建时间，ISO 8601 格式（东八区），下同 |
| expire_time | string | 过期时间 |
| paid_time | string \| null | 支付时间 |
| close_time | string \| null | 关闭时间 |
| close_reason | string \| null | 关闭原因，取值见下表 |
| close_reason_label | string \| null | 关闭原因的中文名称 |
| service_url | string | 订单服务页地址（付款人自助核实支付结果、通知商户、提交问题反馈），订单创建后 90 天内有效；测试订单与支付通知中不含 |
| cashier_url | string | 收银台地址，仅订单可支付时返回（支付通知中不含） |
| pay_info | object | 支付凭据，仅指定支付产品下单时返回：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_no | string | 二选一 | 原订单的平台订单号（22 位），与 out_trade_no 二选一，同时传入时以 order_no 为准 |
| out_trade_no | string | 二选一 | 原订单的商户订单号 |
| out_refund_no | string | 是 | 商户退款单号，6～64 位字母、数字、下划线或短横线，本商户内唯一 |
| amount | integer | 是 | 退款金额，单位为分，JSON 整数，不超过订单剩余可退款金额 |
| reason | string | 否 | 退款原因，最长 80 个字符 |
| notify_url | string | 否 | 退款结果通知地址，要求同下单的 notify_url；传入时需已生成回调密钥。不传则不推送退款结果，请通过查询接口获取 |

请求示例：

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

应答示例：

```json
{
  "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_no | string | 平台退款单号，22 位数字 |
| out_refund_no | string | 商户退款单号；控制台发起的退款与平台退款单号相同 |
| order_no / out_trade_no | string | 原订单的平台订单号与商户订单号 |
| status / status_label | string | 退款状态及中文名称，取值见下表 |
| amount | integer | 本次退款金额（分） |
| order_amount | integer | 原订单金额（分） |
| order_refunded_amount | integer | 原订单累计退款成功金额（分） |
| reason | string \| null | 退款原因 |
| channel_refund_no | string \| null | 渠道退款单号 |
| source / source_label | string | 发起来源及中文名称，取值见下表 |
| fail_reason | string \| null | 退款失败或被驳回的原因 |
| test | boolean | 是否为测试订单的退款 |
| create_time | string | 申请时间 |
| success_time | string \| null | 退款成功时间 |
| finish_time | string \| 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 | 资金流水 | 按流水类型筛选，取值见下表 |

应答示例：

```json
{
  "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`

```json
{
  "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_no | string | 结算单号，22 位数字 |
| source / source_label | string | 来源及中文名称：merchant 商户申请、auto 自动结算 |
| status / status_label | string | 结算状态及中文名称，取值见下表 |
| account_type / account_type_label | string | 收款账户类型：alipay 支付宝、wechat 微信、bank 银行卡 |
| account_name / account_mask | string | 收款人姓名、脱敏后的收款账号 |
| bank_name | string \| null | 开户银行，银行卡收款时返回 |
| amount | integer | 结算金额（分） |
| fee / actual_amount | integer | 结算手续费、实际到账金额（分） |
| reason | string \| null | 驳回或打款失败的原因 |
| transfer_no | string \| null | 打款流水号，已打款时返回 |
| create_time / review_time | string \| null | 申请时间、审核时间 |
| paid_time / finish_time | string \| null | 打款时间、完结时间（已打款、驳回、失败或撤销） |

结算状态 status：

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

## 支付与退款通知

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

通知报文：

```json
{
  "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。

```json
{
  "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 白名单 |

### 通用

| 错误码 | HTTP 状态码 | 说明 | 处理建议 |
| --- | --- | --- | --- |
| 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 | 系统繁忙 | 稍后用相同参数重试；下单、退款请沿用原单号重试或先查询，不要更换单号 |

### 下单、查单与关单

| 错误码 | HTTP 状态码 | 说明 | 处理建议 |
| --- | --- | --- | --- |
| 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 | 渠道关单失败 | 稍后重试 |

### 退款

| 错误码 | HTTP 状态码 | 说明 | 处理建议 |
| --- | --- | --- | --- |
| 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 | 原收款通道没有退款接口，不能在线退款 | 联系平台客服，由平台在渠道后台退款后登记；经自有通道收款的订单请登录渠道商户后台退款 |

### 余额与结算

| 错误码 | HTTP 状态码 | 说明 | 处理建议 |
| --- | --- | --- | --- |
| SETTLEMENT_NOT_FOUND | 404 | 结算单不存在 | 核对结算单号，只能查询本商户的结算单 |

## 示例代码

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

PHP 项目也可以直接下载单文件 SDK：https://pay.siyun223.com/docs/sdk/php（SiyunPay.php，版本 1.0.0），封装了本文档的全部接口、请求签名、应答验签、平台公钥更新、通知验签与解密。

### PHP

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

```php
<?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 格式，可直接读取

```java
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

```python
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），无需第三方依赖

```javascript
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 及以上，只使用标准库

```go
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。
```

