# 商户对接文档

对接京东金融充值收银系统。协议兼容常见「易支付」：**MD5 签名 + 表单/JSON 请求**。

在线阅读：`{网关}/docs` · 本文 Markdown：`/docs/merchant-api.md`

---

## 一、先看这三件事（必读）

### 1. 你要准备什么

| 名称 | 在哪获取 | 干什么用 |
|------|----------|----------|
| **商户号 `pid`** | 管理后台 → 商户账号 → 列表第一列 | 所有 API 必传 |
| **API 密钥** | 同上，创建商户时生成 | 请求签名 + 验签回调 |
| **通道码 `channel`** | 管理后台 → 支付通道 | 指定走哪条收款通道；也可在商户里绑定默认通道 |
| **网关地址** | 本平台域名，如 `https://pay.example.com` | 拼在所有接口前面 |

密钥**只能放服务端**，不要写进前端或公开仓库。

### 2. 你要实现什么

对接完成后，商户侧通常需要 **两个地址**（可以不同 URL）：

| 地址 | 请求方式 | 干什么 | 能不能用来入账 |
|------|----------|--------|----------------|
| **`notify_url`** | 平台 **POST** 到你服务器 | 支付成功/退款后通知你 **发货、改订单** | ✅ **必须用这个入账** |
| **`return_url`** | 用户浏览器 **GET** 跳转 | 支付成功后把用户 **带回你的网页** 看结果 | ❌ 只展示，不能单独入账 |

**一句话：notify 管钱和货，return 管用户眼睛。**

下单时两个都传最稳妥：

```
notify_url = https://你的域名/api/pay/notify   ← 后端接口，接收 POST
return_url = https://你的域名/pay/success      ← 前端页面，用户跳转
```

### 3. 标准流程（5 步）

```
① 你的服务器  POST /api/epay/submit  下单（带 sign）
② 拿到 payurl，让用户浏览器打开（收银台）
③ 用户在收银台扫码/支付
④ 平台 POST 到 notify_url → 你验签 → 改订单为已支付 → 响应 success
⑤ 收银台自动把用户 GET 跳到 return_url（带签名参数）→ 展示「支付成功」
```

**第 ④ 步才是入账依据。** 第 ⑤ 步只是让用户回到你的网站，不能代替第 ④ 步。

---

## 二、两个回调详解

### 2.1 异步通知 `notify_url`（入账必做）

- **何时触发**：用户支付成功、订单退款等状态变化后
- **怎么请求**：`POST`，Body 为 `application/x-www-form-urlencoded`
- **你要做什么**：
  1. 用 API 密钥 **验签**（规则见第三节）
  2. 看 `trade_status`：`TRADE_SUCCESS` 表示支付成功
  3. 按 `out_trade_no` **幂等**更新本地订单（已处理过的不要重复发货）
  4. 处理完返回 HTTP 200，**响应正文包含 `success`**（大小写不敏感）
- **失败重试**：若未正确返回 success，平台约重试 **5 次**

**POST 参数：**

| 参数 | 说明 |
|------|------|
| `pid` | 商户号 |
| `trade_no` | 平台订单号 |
| `out_trade_no` | 你的商户订单号 |
| `type` | 固定 `jd` |
| `name` | 商品名称 |
| `money` | 下单金额 |
| `pay_amount` | 实付金额（有则传） |
| `trade_status` | `TRADE_SUCCESS` / `TRADE_REFUND` 等 |
| `sign_type` | `MD5` |
| `sign` | 签名，必须验签 |

### 2.2 同步跳转 `return_url`（用户回跳）

- **何时触发**：用户在 **收银台** 支付成功后（约每 3 秒轮询检测到成功即跳转）
- **怎么请求**：浏览器 **GET** 打开你传的 `return_url`，参数在 **Query 字符串**里
- **你要做什么**：可选验签后展示「支付成功」页面；**不要仅凭 GET 参数发货**

**Query 参数与 notify 基本相同**，`trade_status` 固定为 `TRADE_SUCCESS`。

**跳转后 URL 示例：**

```
https://你的域名/pay/success?
  pid=1001&
  trade_no=JD202601011234567890&
  out_trade_no=20260101120000&
  type=jd&
  name=测试商品&
  money=1.00&
  pay_amount=1.00&
  trade_status=TRADE_SUCCESS&
  sign_type=MD5&
  sign=xxxxxxxx
```

### 2.3 没传 `return_url` 会怎样？

收银台支付成功后的跳转规则：

| 下单时传了什么 | 用户会被带到 |
|----------------|--------------|
| 传了 `return_url` | ✅ 你的 `return_url` + 签名参数 |
| 只传 `notify_url` | ⚠️ 浏览器 GET 打开 notify 地址（**多数后端只收 POST，会报错**） |
| 都没传 | 本平台 `/pay/success?trade_no=…` |

**所以：只要希望用户回到自己网站，就必须传 `return_url`。**

---

## 三、签名算法（MD5）

所有 **商户发起的 API** 和 **notify/return 验签**，规则相同。

1. 取参与签名的参数：**去掉** `sign`、`sign_type`，**去掉值为空的参数**
2. 按参数名 **ASCII 升序**
3. 拼成 `key1=value1&key2=value2&...`（**不要** URL 编码）
4. 末尾 **直接拼接 API 密钥**（没有 `&key=`）
5. 整串 **MD5**，32 位 **小写** 十六进制 → 即 `sign`

**示例：**

参与签名参数：

```
money=1.00
name=测试商品
notify_url=https://merchant.example.com/notify
out_trade_no=20260101120000
pid=1001
type=jd
```

密钥：`abcdef0123456789abcdef0123456789`

待签名字符串（注意末尾直接接密钥）：

```
money=1.00&name=测试商品&notify_url=https://merchant.example.com/notify&out_trade_no=20260101120000&pid=1001&type=jdabcdef0123456789abcdef0123456789
```

对此字符串 MD5 得到 `sign`，再放入请求。

---

## 四、接口说明

### 4.1 公共约定

| 项目 | 说明 |
|------|------|
| 成功/失败 | JSON 里 `code=1` 成功，`code=-1` 失败，看 `msg`；HTTP 状态码通常为 200 |
| 请求格式 | `application/x-www-form-urlencoded` / `multipart/form-data` / `application/json` 均可 |
| 签名类型 | 仅 `MD5` |

### 4.2 创建订单（最常用）

```
POST {网关}/api/epay/submit
```

与 `/api/channel/submit` 完全相同，任选其一。

**最少要传这些：**

| 参数 | 必填 | 说明 |
|------|------|------|
| `pid` | ✅ | 商户号（可用 `mch_id`） |
| `out_trade_no` | ✅ | 商户订单号，**同一商户不可重复** |
| `money` | ✅ | 金额，如 `1.00` |
| `sign` | ✅ | MD5 签名 |
| `channel` | 建议 | 通道码；商户已绑默认通道时可省略 |
| `name` | 建议 | 商品名称 |
| `notify_url` | 建议 | 异步通知地址 |
| `return_url` | 建议 | 支付成功用户回跳地址 |
| `type` | 可选 | 建议传 `jd` |
| `sign_type` | 可选 | 传 `MD5` |

**成功响应：**

```json
{
  "code": 1,
  "msg": "success",
  "trade_no": "JD202601011234567890",
  "out_trade_no": "20260101120000",
  "payurl": "https://pay.example.com/pay/cashier?trade_no=JD202601011234567890",
  "qrcode": "https://wepay.jd.com/...",
  "money": "1.00",
  "pay_amount": "1.00"
}
```

| 字段 | 你怎么用 |
|------|----------|
| `payurl` | **推荐**：把用户浏览器跳转到这个地址（自带收银台、倒计时、支付成功后自动跳 return_url） |
| `qrcode` | 京东支付直链；自己生成二维码时用这个，并自行轮询查单 |
| `trade_no` | 平台订单号，查单时用 |

**失败示例：**

```json
{ "code": -1, "msg": "签名错误" }
```

### 4.3 查询订单

```
GET 或 POST {网关}/api/epay/query
```

| 参数 | 必填 | 说明 |
|------|------|------|
| `pid` + `sign` | ✅ | 商户号与签名 |
| `out_trade_no` | 二选一 | 商户订单号 |
| `trade_no` | 二选一 | 平台订单号 |

待支付订单查询时会向京东同步一次最新状态。

**订单状态 `trade_status`：**

| 值 | 含义 |
|----|------|
| `WAIT_BUYER_PAY` | 待支付 |
| `TRADE_SUCCESS` | 支付成功 |
| `TRADE_FAILED` | 失败或超时（默认 15 分钟未付） |
| `TRADE_REFUND` | 已退款 |

### 4.4 页面跳转下单（不用先调 API）

适合浏览器直接打开链接：

```
GET {网关}/pay/{通道码}?pid=1001&out_trade_no=xxx&money=1.00&name=商品&sign=xxx&sign_type=MD5
```

验签通过后自动创建订单，并 **302 跳转** 到收银台。同样可带 `notify_url`、`return_url`。

---

## 五、收银台说明

用户打开 `payurl` 后进入：`GET {网关}/pay/cashier?trade_no=平台订单号`

- 展示二维码、金额、倒计时（默认 **900 秒 / 15 分钟**，以通道配置为准）
- 每 **3 秒** 检测是否支付成功
- **成功后自动跳转** `redirect_url`（即按第二节规则生成的 return 地址）

若你 **自建支付页**（不用本平台收银台），可轮询：

```
GET /api/cashier/poll?trade_no=平台订单号
```

成功时响应含 `redirect_url`，逻辑与收银台内跳转一致。

---

## 六、三种对接方式怎么选

| 方式 | 做法 | 适合 |
|------|------|------|
| **A. 收银台（推荐）** | 下单拿 `payurl` → 用户打开 → notify 入账 + return 回跳 | PC/H5 大多数场景 |
| **B. 自建扫码页** | 下单拿 `qrcode` → 自己展示二维码 → 轮询 `/api/epay/query` | App、自定义 UI |
| **C. 页面跳转** | 直接打开 `/pay/{通道码}?…` | 简单 H5，不想写下单代码 |

无论哪种，**入账都以 notify POST 为准**。

---

## 七、完整代码示例

### 7.1 下单并跳转收银台（PHP）

```php
<?php
function epaySign(array $params, string $key): string {
    unset($params['sign'], $params['sign_type']);
    $params = array_filter($params, fn($v) => $v !== '' && $v !== null);
    ksort($params);
    $str = '';
    foreach ($params as $k => $v) {
        $str .= ($str === '' ? '' : '&') . $k . '=' . $v;
    }
    return md5($str . $key);
}

$gateway = 'https://pay.example.com';
$apiKey  = '你的API密钥';

$params = [
    'pid'          => '1001',
    'type'         => 'jd',
    'out_trade_no' => 'ORDER' . time(),
    'money'        => '1.00',
    'name'         => '测试商品',
    'channel'      => 'your_channel_code',
    'notify_url'   => 'https://你的域名/api/pay/notify',   // POST 入账
    'return_url'   => 'https://你的域名/pay/success',      // GET 用户回跳
    'sign_type'    => 'MD5',
];
$params['sign'] = epaySign($params, $apiKey);

$ch = curl_init($gateway . '/api/epay/submit');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query($params),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if (($result['code'] ?? -1) === 1) {
    header('Location: ' . $result['payurl']);  // 去收银台
    exit;
}
echo '下单失败: ' . ($result['msg'] ?? '');
```

### 7.2 接收 notify 并入账（PHP）

```php
<?php
function verifySign(array $data, string $key): bool {
    $sign = $data['sign'] ?? '';
    unset($data['sign'], $data['sign_type']);
    $data = array_filter($data, fn($v) => $v !== '' && $v !== null);
    ksort($data);
    $str = '';
    foreach ($data as $k => $v) {
        $str .= ($str === '' ? '' : '&') . $k . '=' . $v;
    }
    return strtolower($sign) === md5($str . $key);
}

$apiKey = '你的API密钥';
if (!verifySign($_POST, $apiKey)) {
    http_response_code(400);
    exit('sign error');
}

$outTradeNo   = $_POST['out_trade_no'];
$tradeStatus  = $_POST['trade_status'];

if ($tradeStatus === 'TRADE_SUCCESS') {
    // 幂等：本地订单未支付时才更新并发货
    // updateOrderPaid($outTradeNo);
}

echo 'success';  // 必须返回 success，否则平台会重试
```

### 7.3 下单（Python）

```python
import hashlib
import requests

def epay_sign(params: dict, key: str) -> str:
    data = {k: v for k, v in params.items()
            if k not in ("sign", "sign_type") and str(v).strip()}
    s = "&".join(f"{k}={data[k]}" for k in sorted(data))
    return hashlib.md5((s + key).encode()).hexdigest()

gateway = "https://pay.example.com"
key = "你的API密钥"
params = {
    "pid": "1001",
    "type": "jd",
    "out_trade_no": f"ORDER{int(__import__('time').time())}",
    "money": "1.00",
    "name": "测试商品",
    "channel": "your_channel_code",
    "notify_url": "https://你的域名/api/pay/notify",
    "return_url": "https://你的域名/pay/success",
    "sign_type": "MD5",
}
params["sign"] = epay_sign(params, key)
r = requests.post(f"{gateway}/api/epay/submit", data=params, timeout=30)
print(r.json())
```

---

## 八、常见错误

| msg | 原因 | 处理 |
|-----|------|------|
| 签名错误 | 排序、空值过滤或密钥不对 | 对照第三节重算 sign |
| 商户订单号重复 | `out_trade_no` 已用过 | 换新的商户订单号 |
| 未指定通道 | 没传 channel 且商户未绑通道 | 传 channel 或在后台绑定 |
| 商户不存在 / 已禁用 | pid 错误或商户被关 | 检查后台商户状态 |
| 缺少 out_trade_no 或 money | 必填参数缺失 | 补全参数 |

---

## 九、对接检查清单

上线前逐项确认：

- [ ] 已拿到 `pid`、API 密钥，通道已配置并绑定商户
- [ ] 下单接口签名正确，能拿到 `payurl`
- [ ] **`notify_url` 已实现**：验签 → 幂等入账 → 返回 `success`
- [ ] **`return_url` 已配置**（需要用户回到商户站时）
- [ ] 实测：支付成功 → notify 收到且入账 → 用户跳到 return 页
- [ ] 生产环境全程 HTTPS

---

## 十、常见问题

**Q：只传 notify，不传 return，用户支付完去哪？**  
A：浏览器可能 GET 打开你的 notify 地址，而后端通常只收 POST，页面会异常。**请单独传 return_url。**

**Q：notify 和 return 哪个先到？**  
A：顺序不固定。**只有 notify 能作为入账依据。**

**Q：用户关掉收银台还会 notify 吗？**  
A：会。京东支付成功后，平台仍会 POST 到 notify_url。

**Q：payurl 和 qrcode 选哪个？**  
A：`payurl` = 本平台收银台（倒计时 + 自动跳 return）；`qrcode` = 京东直链，需自己查单。

**Q：订单多久过期？**  
A：默认 15 分钟（900 秒），可在通道配置里改。过期后状态为 `TRADE_FAILED`。

**Q：退款会 notify 吗？**  
A：会，`trade_status=TRADE_REFUND`。return_url 仅在支付成功离开收银台时跳一次。

---

*文档与当前 `/api/epay/*` 实现一致。*
