# 付款 API 文件

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

商戶須先在後台登記 RSA 公鑰，並使用配對私鑰對每個請求簽章；私鑰不得提供給前端或第三方。

## 1. 創建付款訂單

### 介面

`POST /payout/create`

### 請求參數

| 參數 | 類型 | 必填 | 說明 |
|---|---|---:|---|
| `mid` | string | 是 | 商戶 ID。 |
| `orderid` | string | 是 | 商戶付款訂單號；同一商戶下必須唯一。 |
| `amount` | decimal | 是 | 法幣金額，必須大於 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.888889,
  "toaddress": "TVArfDmKDux1LSQYavBTEgnyhaF5Ta3xBU",
  "created_time": 1785050247
}
```

### 失敗回應

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

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

## 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.888889,
  "toaddress": "TVArfDmKDux1LSQYavBTEgnyhaF5Ta3xBU",
  "updatetime": 1785050247
}
```

`status_code` 含義：

| 值 | 狀態 | `updatetime` |
|---:|---|---|
| `0` | 待下發 | 創建時間 |
| `1` | 已發送 | 鏈上交易成功時間。 |
| `2` | 已取消 | 後台取消時間。 |
| `3` | 下發中 | 後台服務領取訂單的時間。 |
| `4` | 已送出 | 交易已廣播、正在等待鏈上回執的時間。 |
| `5` | 下發失敗 | 廣播失敗或鏈上執行失敗時間。 |
| `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 相同：

1. 取請求內除 `sign` 外的所有參數。
2. 按參數名 ASCII 升序排序並拼成 `key=value&key=value`，**不要 URL 编碼**。
3. 用商戶 RSA 私鑰執行 `SHA256withRSA`（RSA PKCS#1 v1.5），將結果標準 Base64 编碼為 `sign`。
4. 平台用後台登記的商戶 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);
```
