# 支付 API 文件

## 1. 建立訂單 API

### 介面資訊
- **URL**: `/pay/create`
- **方法**: `POST`
- **Content-Type**: `application/x-www-form-urlencoded`

### 請求參數

| 參數名 | 類型 | 必填 | 說明 |
|--------|------|------|------|
| mid | int | 是 | 商戶 ID |
| orderid | string | 是 | 商戶訂單號，唯一標識 |
| amount | float | 是 | 訂單金額（依系統設定之幣種，預設 TWD 新台幣） |
| notifyurl | string | 是 | 支付結果通知 URL |
| sign | string | 是 | 簽章，請參考文件末尾簽章方法 |


### 回應格式

#### 成功回應
```json
{
    "status": "ok",
    "orderid": "ORDER123456",
    "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
    "uamount": "0.12",
    "url": "https://demosite.com/pay?id=abc123def456"
}
```

#### 錯誤回應
```json
{
    "status": "error",
    "message": "系統繁忙，請稍後再試！"
}
```

### 回應欄位說明

| 欄位名 | 類型 | 說明 |
|--------|------|------|
| status | string | 回應狀態：ok-成功，error-失敗 |
| orderid | string | 商戶訂單號 |
| address | string | 錢包地址 |
| uamount | string | 需要支付的 USDT 數量 |
| url | string | 支付頁面 URL，請將使用者瀏覽器導向到此地址，或由商戶自行展示上述錢包和金額資訊。 |
| message | string | 錯誤訊息（僅失敗時回傳） |

### 注意事項

1. **訂單號唯一性**：每個商戶的訂單號必須唯一。
2. **金額精度**：支援最多 2 位小數。
3. **通知 URL**：必須是可訪問的 HTTPS 地址。
4. **金額調整**：在交易繁忙時，系統會自動微調 USDT 支付金額（最多 +0.20）。
5. **來源 IP 白名單**：管理後台可為商戶設定收款 API IP 白名單；設定後，僅白名單中的 IP 可建立收款訂單。

## 2. 查詢訂單狀態 API

### 介面資訊
- **URL**: `/pay/checkorder`
- **方法**: `POST`
- **Content-Type**: `application/x-www-form-urlencoded`

### 請求參數

| 參數名 | 類型 | 必填 | 說明 |
|--------|------|------|------|
| mid | string | 是 | 商戶 ID |
| orderid | string | 是 | 商戶訂單號 |
| sign | string | 是 | 簽章，請參考文件末尾簽章方法 |

### 回應格式

#### 成功回應
```json
{
    "status": "ok",
    "orderid": "ORDER123456",
    "status_code": 0,
    "amount": 100.00,
    "uamount": 0.12,
    "updatetime": 1640995200
}
```

#### 錯誤回應
```json
{
    "status": "error",
    "message": "訂單不存在"
}
```

### 回應欄位說明

| 欄位名 | 類型 | 說明 |
|--------|------|------|
| orderid | string | 商戶訂單號 |
| status_code | int | 訂單狀態：0-未支付，1-已支付 |
| amount | float | 系統結算幣種訂單金額 |
| uamount | float | USDT 金額 |
| updatetime | int | 更新時間戳（status_code=0 時為建立時間，status_code=1 時為成功時間） |
| status | string | 回應狀態：ok-成功，error-失敗 |
| message | string | 錯誤訊息（僅失敗時回傳） |

### 注意事項

1. **訂單查詢**：只能查詢本商戶的訂單。
2. **時間戳**：updatetime 欄位根據訂單狀態回傳不同時間：
   - status=0：回傳訂單建立時間
   - status=1：回傳訂單成功時間

## 3. 支付通知回呼

### 通知機制說明

當訂單支付成功時，系統會主動向商戶提供的 `notifyurl` 發送 POST 請求通知支付結果。

### 通知參數

| 參數名 | 類型 | 說明 |
|--------|------|------|
| orderid | string | 商戶訂單號 |
| amount | float | 系統結算幣種訂單金額 |
| uamount | float | USDT 支付金額 |
| created_time | int | 訂單建立時間戳 |
| success_time | int | USDT 轉帳時間 |
| sign | string | 平台依該商戶設定的簽章演算法生成的簽章（RSA-SHA256 或 MD5），詳見文件末尾簽章方法 |

### 通知回應要求

商戶收到通知後，必須回傳 HTTP 狀態碼 200，表示成功接收。如果回傳非 200 狀態碼，系統會認為通知失敗並進行重試。通知收妥後務請回應成功訊息，避免系統多次重試堵塞通道。

**成功回應範例：**
```text
success
```

### 通知失敗重試機制

- **重試次數**：最多重試 9 次
- **重試間隔**：採用指數退避演算法
  - 第 1 次失敗：30 秒後重試
  - 第 2 次失敗：60 秒後重試  
  - 第 3 次失敗：120 秒後重試
  - 第 4 次及以後：2^(失敗次數-1) * 60 秒
- **逾時處理**：超過 9 次失敗後，系統會放棄此訂單的通知。

## 4. 簽章規則

商戶的簽章演算法由管理後台「商戶管理」中該商戶的設定決定，支援 **RSA-SHA256** 與 **MD5** 兩種；同一商戶所有請求與通知都使用相同的演算法。

### RSA-SHA256 簽章

當商戶簽章演算法為 **RSA** 時：商戶在後台建立商戶資料時上傳 **RSA 公鑰（PEM / PKIX）**，並自行安全保管配對私鑰。所有 API 請求由商戶私鑰使用 `RSA-SHA256`（PKCS#1 v1.5 + SHA-256）簽章；平台用該商戶公鑰驗簽。平台會自動生成自身金鑰對，後台「平台公鑰」介面回傳公鑰；商戶必須儲存該公鑰並用它驗證通知。

1. 將除 `sign` 外的參數按參數名 ASCII 升序排序。
2. 拼為 `key=value&key=value`，原始值不做 URL 編碼。
3. 對 UTF-8 原文計算 SHA-256，並以 RSA PKCS#1 v1.5 簽章。
4. 將簽章位元組作標準 Base64 編碼，放入 `sign` 參數。

### 簽章範例

```
原始參數：
mid=1001
orderid=ORDER123456
amount=100.00
notifyurl=https://example.com/notify

排序後：
amount=100.00&mid=1001&notifyurl=https://example.com/notify&orderid=ORDER123456

以上排序後的字串即簽章原文；用商戶 RSA 私鑰簽章並 Base64 編碼。
```

### 注意事項

- 所有 API 介面都需要使用相同的簽章規則。
- 簽章驗證失敗會導致請求被拒絕。
- 請妥善保管商戶 RSA 私鑰，絕不可上傳或洩露；後台只儲存公鑰。

### MD5 簽章

當商戶簽章演算法為 **MD5** 時：後台「商戶管理」會為商戶產生一組 **商戶金鑰（app_secret）**，商戶與平台共享此金鑰。所有 API 請求與通知都使用該金鑰計算 MD5 簽章；**商戶端不需生成或上傳任何金鑰**。

1. 將除 `sign` 外的參數按參數名 ASCII 升序排序。
2. 拼為 `key=value&key=value`，原始值不做 URL 編碼。
3. 於字串**末尾直接串接商戶金鑰**，對 UTF-8 全文計算 `MD5`。
4. 將 MD5 結果以**大寫十六進位**字串放入 `sign` 參數（驗證端大小寫不區分）。

### MD5 簽章範例

```
原始參數：
mid=1001
orderid=ORDER123456
amount=100.00
notifyurl=https://example.com/notify

排序後：
amount=100.00&mid=1001&notifyurl=https://example.com/notify&orderid=ORDER123456

商戶金鑰 app_secret：ABC123...
簽章原文 = amount=100.00&mid=1001&notifyurl=https://example.com/notify&orderid=ORDER123456ABC123...
sign = strtoupper(md5(簽章原文))
```

### MD5 簽章核心邏輯

```php
$params = ['mid'=>'1001', 'orderid'=>'ORDER123456', 'amount'=>'100.00', 'notifyurl'=>'https://example.com/notify'];
ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
$params['sign'] = strtoupper(md5($source . $appSecret));
```

```go
params := map[string]string{"mid":"1001", "orderid":"ORDER123456", "amount":"100.00", "notifyurl":"https://example.com/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, "&") + appSecret
params["sign"] = strings.ToUpper(fmt.Sprintf("%x", md5.Sum([]byte(source))))
```

```java
Map<String,String> params = new TreeMap<>();
params.put("mid", "1001"); params.put("orderid", "ORDER123456"); params.put("amount", "100.00"); params.put("notifyurl", "https://example.com/notify");
String source = params.entrySet().stream().map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&")) + appSecret;
MessageDigest md = MessageDigest.getInstance("MD5");
StringBuilder sb = new StringBuilder(); for (byte b : md.digest(source.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02X", b));
params.put("sign", sb.toString());
```

### MD5 通知驗簽核心邏輯

通知收到後，將 `sign` 取出並從參數集合移除；其餘參數按同樣規則排序拼接後串接商戶金鑰計算 MD5，與回傳的 `sign`（不分大小寫）比對。

```php
$signature = strtoupper($params['sign']); unset($params['sign']); ksort($params, SORT_STRING);
$source = implode('&', array_map(fn($k) => $k.'='.$params[$k], array_keys($params)));
$valid = strtoupper(md5($source . $appSecret)) === $signature;
```

```go
signature := strings.ToUpper(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]) }
source := strings.Join(parts, "&") + appSecret
valid := strings.ToUpper(fmt.Sprintf("%x", md5.Sum([]byte(source)))) == signature
```

```java
String signature = params.remove("sign").toUpperCase();
String source = params.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e -> e.getKey()+"="+e.getValue()).collect(Collectors.joining("&")) + appSecret;
MessageDigest md = MessageDigest.getInstance("MD5");
StringBuilder sb = new StringBuilder(); for (byte b : md.digest(source.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02X", b));
boolean valid = sb.toString().equals(signature);
```

> 注意：MD5 為共享金鑰簽章，商戶金鑰等同密碼，**請透過安全管道傳遞並妥善保管**，切勿於前端或公開場所洩露；後台重新產生金鑰後，舊金鑰立即失效。

### RSA 簽章核心邏輯

以下範例先用實際業務欄位構建請求參數與簽章原文，`privateKey` 為商戶私鑰；將得到的 `sign` 放回參數集合後送出。通知驗簽時改用平台公鑰和對應語言的驗簽 API。

```php
$params = ['mid'=>'1001', 'orderid'=>'ORDER123456', 'amount'=>'100.00', 'notifyurl'=>'https://example.com/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":"ORDER123456", "amount":"100.00", "notifyurl":"https://example.com/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, "&")
hash := sha256.Sum256([]byte(source))
raw, _ := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
params["sign"] = base64.StdEncoding.EncodeToString(raw)
```

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

### RSA 通知驗簽核心邏輯

通知收到後，將 `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);
```
