# 付款 API 文件

付款服務會依付款訂單將 USDT 下發至指定的 TRON 地址，並在鏈上結果確認後通知商戶。商戶請使用下列 API 路徑建立與查詢付款訂單；如需取消待下發之付款訂單，請聯繫平台客服或管理員處理。

> ⚠️ **重要提示**：付款（代付）功能**強制要求使用 RSA-SHA256 簽章演算法**（不支援 MD5）。商戶啟用代付功能必須在後台登記商戶 RSA 公鑰，並使用配對私鑰對每個請求簽章。商戶私鑰均不得提供給前端或第三方。

## 1. 建立付款訂單

### 介面

`POST /payout/create`

### 請求參數

| 參數 | 類型 | 必填 | 說明 |
|---|---|---:|---|
| `mid` | string | 是 | 商戶 ID。 |
| `orderid` | string | 是 | 商戶付款訂單號；同一商戶下必須唯一。 |
| `amount` | decimal | 是 | 系統結算幣種金額（依系統設定，預設 TWD 新台幣），必須大於 0，最多 2 位小數。 |
| `toaddress` | string | 是 | TRON/TRC20 收款地址，必須為 34 位、以 `T` 開頭。 |
| `notifyurl` | string | 是 | 付款結果通知地址，必須是有效的 `http` 或 `https` URL。 |
| `sign` | string | 是 | 請求簽章，規則見下文。 |

服務會在建立時按照當前系統匯率換算 `uamount`，並將該值固定到訂單中；之後即使修改匯率，也不會影響已建立訂單。

### 成功回應

```json
{
  "status": "ok",
  "orderid": "PAYOUT_20260726_001",
  "amount": 100,
  "uamount": 13.88,
  "toaddress": "TVArfDmKDux1LSQYavBTEgnyhaF5Ta3xBU",
  "created_time": 1785050247
}
```

### 失敗回應

```json
{
  "status": "error",
  "message": "商戶訂單號已存在"
}
```

常見失敗原因：參數缺失、TRON 地址格式錯誤、通知地址格式錯誤、簽章錯誤、來源 IP 不在商戶付款 API 白名單內、當前匯率不可用、同一商戶訂單號重複，或同一錢包地址 5 分鐘內發送相同金額。

## 2. 查詢付款訂單

### 介面

`POST /payout/checkorder`

### 請求參數

| 參數 | 類型 | 必填 | 說明 |
|---|---|---:|---|
| `mid` | string | 是 | 商戶 ID。 |
| `orderid` | string | 是 | 建立付款訂單時使用的商戶訂單號。 |
| `sign` | string | 是 | 請求簽章。 |

### 成功回應

```json
{
  "status": "ok",
  "orderid": "PAYOUT_20260726_001",
  "status_code": 0,
  "amount": 100,
  "uamount": 13.88,
  "toaddress": "TVArfDmKDux1LSQYavBTEgnyhaF5Ta3xBU",
  "updatetime": 1785050247
}
```

`status_code` 含義：

| 值 | 狀態 | 類型 | 說明 |
|---:|---|---|---|
| `0` | 待下發 | 處理中 | 訂單已建立，等待出款服務領取。 |
| `1` | 已發送 | **終態** | 鏈上轉帳成功並已確認。 |
| `2` | 已取消 | **終態** | 訂單已由平台管理員或客服手動取消。 |
| `3` | 下發中 | 處理中 | 出款服務已領取並鎖定訂單，準備進行廣播。 |
| `4` | 已送出 | 處理中 | 交易已廣播至區塊鏈，正在等待鏈上打包回執。 |
| `5` | 下發失敗 | **終態** | 廣播失敗或鏈上執行失敗。 |
| `6` | 待人工核查 | 異常掛起 | 下發請求結果無法判定（如逾時或連線中斷）；須人工確認鏈上結果。 |

> 📌 **終態說明**：只有進入**終態**（`1` 已發送、`2` 已取消、`5` 下發失敗）的訂單才會觸發商戶非同步通知回呼；處理中狀態（`0`、`3`、`4`）與異常掛起（`6`）不會發送通知。

## 付款結果通知回呼

付款訂單進入終態後，`payoutworker` 會以 `application/x-www-form-urlencoded`
向建立訂單時傳入的 `notifyurl` 發起 HTTP `POST` 通知回呼。終態包括已發送、已取消和下發失敗。狀態 `6`（待人工核查）不會發起通知，避免在下發結果無法判定時錯誤通知商戶。

通知會按原入款通知服務相同的規則重試：HTTP 狀態碼為 `200` 即視為成功；其他狀態碼或網路錯誤會指數退避重試，最多 9 次。

### 通知回呼參數

| 參數 | 類型 | 說明 |
|---|---|---|
| `orderid` | string | 商戶付款訂單號。 |
| `amount` | string | 系統結算幣種訂單金額，固定 2 位小數。 |
| `uamount` | string | 實際下發 USDT 金額，固定 2 位小數。 |
| `created_time` | string | 訂單建立時間，Unix 秒級時間戳。 |
| `success_time` | string | 訂單進入終態的時間，Unix 秒級時間戳。欄位名為相容原通知服務而保留。 |
| `status` | string | 終態：`1` 已發送、`2` 已取消、`5` 下發失敗。 |
| `txid` | string | TRON 交易 ID；取消或送出前失敗時為空。 |
| `sign` | string | 通知簽章，見下方規則。 |

### 通知回呼簽章

除 `sign` 外的所有通知參數按參數名 ASCII 升序排列，拼接為
`key=value&...`（不做 URL 編碼）。
平台使用 RSA PKCS#1 v1.5 + SHA-256 簽章後將標準 Base64 結果放入 `sign`，商戶使用在後台獲取的**平台 RSA 公鑰**驗證通知簽章。

簽章原文範例（已發送）：

```text
amount=100.00&created_time=1785050247&orderid=PAYOUT_20260726_001&status=1&success_time=1785050301&txid=交易ID&uamount=13.89
```

商戶收到並驗簽成功後應盡快回傳 HTTP `200`；無需等待鏈上確認，因為付款服務僅會在鏈上回執已確認終態後發送此通知。

## 3. 簽章規則

付款 API **強制使用 RSA-SHA256 簽章演算法**：

1. 取請求內除 `sign` 外的所有參數。
2. 按參數名 ASCII 升序排序並拼成 `key=value&key=value`，**不要 URL 編碼**。
3. 用商戶 RSA 私鑰執行 `SHA256withRSA`（RSA PKCS#1 v1.5），將結果標準 Base64 編碼為 `sign`；平台用後台登記的商戶 RSA 公鑰驗簽，通知由平台私鑰簽章、商戶用平台公鑰驗簽。

建立訂單時待簽章原文範例：

```text
amount=100.00&mid=1001&notifyurl=https://merchant.example.com/payout/notify&orderid=PAYOUT_20260726_001&toaddress=TVArfDmKDux1LSQYavBTEgnyhaF5Ta3xBU
```

查詢訂單時待簽章原文範例：

```text
mid=1001&orderid=PAYOUT_20260726_001
```

### 簽章核心邏輯

以下範例從建立付款訂單的業務欄位構建排序後的簽章原文；請求使用商戶私鑰簽章，付款通知使用平台公鑰驗簽。

```php
$params = ['mid'=>'1001', 'orderid'=>'PAYOUT_001', 'amount'=>'100.00', 'toaddress'=>'T...', 'notifyurl'=>'https://merchant.example/notify'];
ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
openssl_sign($source, $raw, $privateKey, OPENSSL_ALGO_SHA256);
$params['sign'] = base64_encode($raw);
```

```go
params := map[string]string{"mid":"1001", "orderid":"PAYOUT_001", "amount":"100.00", "toaddress":"T...", "notifyurl":"https://merchant.example/notify"}
keys := make([]string, 0, len(params)); for k := range params { keys = append(keys, k) }; sort.Strings(keys)
parts := make([]string, 0, len(keys)); for _, k := range keys { parts = append(parts, k+"="+params[k]) }; source := strings.Join(parts, "&")
digest := sha256.Sum256([]byte(source))
raw, _ := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
params["sign"] = base64.StdEncoding.EncodeToString(raw)
```

```java
Map<String,String> params = new TreeMap<>();
params.put("mid", "1001"); params.put("orderid", "PAYOUT_001"); params.put("amount", "100.00"); params.put("toaddress", "T..."); params.put("notifyurl", "https://merchant.example/notify");
String source = params.entrySet().stream().map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&"));
Signature s = Signature.getInstance("SHA256withRSA");
s.initSign(privateKey); s.update(source.getBytes(StandardCharsets.UTF_8));
params.put("sign", Base64.getEncoder().encodeToString(s.sign()));
```

### 通知回呼驗簽核心邏輯

取出通知中的 `sign` 並從參數集合移除，其餘參數排序後拼接。`platformPublicKey` 為後台展示的平台公鑰。

```php
$signature = base64_decode($params['sign']); unset($params['sign']); ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
$valid = openssl_verify($source, $signature, $platformPublicKey, OPENSSL_ALGO_SHA256) === 1;
```

```go
signature, _ := base64.StdEncoding.DecodeString(params["sign"]); delete(params, "sign")
keys := make([]string, 0, len(params)); for k := range params { keys = append(keys, k) }; sort.Strings(keys)
parts := make([]string, 0, len(keys)); for _, k := range keys { parts = append(parts, k+"="+params[k]) }; digest := sha256.Sum256([]byte(strings.Join(parts, "&")))
err := rsa.VerifyPKCS1v15(platformPublicKey, crypto.SHA256, digest[:], signature)
```

```java
byte[] signature = Base64.getDecoder().decode(params.remove("sign"));
String source = params.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&"));
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(platformPublicKey); verifier.update(source.getBytes(StandardCharsets.UTF_8));
boolean valid = verifier.verify(signature);
```
