# 飞侠快寄 · 开放平台 API v1 接入文档（B 端合作方）

> 适用：您已有自己的客户/小程序/App，希望通过接口代为下单寄快递。
> 合作模式：**客户付钱给您，您按批发价在我们平台下单**；资金不经过平台，平台从您的预存款余额按批发价扣款。

## 一、整体流程

```
1. 自助开户（门户页）→ 拿到 app_id / app_secret
2. 联系平台开通价格计划（绑定后才有批发价与充值入口）
3. 微信 H5 充值 → 余额实时到账
4. 调「比价」拿 quote_id 与批发价 → 展示给您的客户
5. 客户下单 → 调「创建订单」→ 平台扣余额 → 上游快递出单 → 返回运单号
6. 全程在门户查看余额 / 每笔费用明细 / 导出 CSV 账单
```

接口域名：`https://qmtong.top/api`

## 二、鉴权（HMAC-SHA256 签名，必带）

每个请求需携带 4 个头：

| Header | 说明 |
|---|---|
| `X-App-Id` | 您的账户 app_id |
| `X-Timestamp` | 当前毫秒时间戳，服务端校验 ±5 分钟 |
| `X-Nonce` | 随机字符串（同一 app 5 分钟内不可重复，防重放） |
| `X-Sign` | 签名，算法见下 |

**签名串**（用换行拼接）：

```
METHOD
完整请求路径（含 /api 前缀）
X-Timestamp
X-Nonce
请求体原始字符串（GET 为空）
```

`X-Sign = HMAC-SHA256(app_secret, 签名串) 的十六进制小写`

### Node.js 示例

```js
const crypto = require('crypto');

function sign(secret, method, path, body) {
  const ts = Date.now().toString();
  const nonce = crypto.randomBytes(8).toString('hex');
  const raw = body ? JSON.stringify(body) : '';
  const str = [method.toUpperCase(), path, ts, nonce, raw].join('\n');
  const sig = crypto.createHmac('sha256', secret).update(str).digest('hex');
  return { ts, nonce, sig };
}

// GET 余额
const s = sign(APP_SECRET, 'GET', '/api/open/v1/account/balance', null);
const res = await fetch('https://qmtong.top/api/open/v1/account/balance', {
  headers: { 'X-App-Id': APP_ID, 'X-Timestamp': s.ts, 'X-Nonce': s.nonce, 'X-Sign': s.sig },
});

// POST 下单（body 必须与签名时完全一致）
const body = { quote_id: '...', external_order_no: 'SO-20260903-001', /* ... */ };
const s2 = sign(APP_SECRET, 'POST', '/api/open/v1/order/create', body);
const res2 = await fetch('https://qmtong.top/api/open/v1/order/create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-App-Id': APP_ID, 'X-Timestamp': s2.ts, 'X-Nonce': s2.nonce, 'X-Sign': s2.sig },
  body: JSON.stringify(body),
});
```

### Python 示例

```python
import hashlib, hmac, json, time, secrets

def sign(secret: str, method: str, path: str, body=None):
    ts = str(int(time.time() * 1000))
    nonce = secrets.token_hex(8)
    raw = json.dumps(body, ensure_ascii=False, separators=(',', ':')) if body is not None else ''
    msg = '\n'.join([method.upper(), path, ts, nonce, raw]).encode('utf-8')
    sig = hmac.new(secret.encode('utf-8'), msg, hashlib.sha256).hexdigest()
    return {'X-Timestamp': ts, 'X-Nonce': nonce, 'X-Sign': sig}
```

> ⚠️ Python 序列化与 Node 的 JSON.stringify 对中文/空格处理不同，**签名必须与发送的 body 字节一致**——建议固定分隔符 `,`/`:`（如上示例），并保证请求发送时使用与签名时完全相同的字符串。

## 三、接口列表

统一返回格式：`{ "code": 200, "msg": "success", "data": {...} }`，`code!=200` 为失败。

### 1. 比价 `POST /api/open/v1/price/compare`

```json
// 请求 body
{
  "sender_phone": "13800000000",
  "sender_address": "福建省厦门市湖里区XX路1号",
  "receiver_address": "广东省深圳市南山区XX大道100号",
  "weight": 3.5,
  "goods": "服装",            // 选填
  "large_only": false         // 选填：true=只查重货（大件）
}
```

```json
// 响应 data
{
  "list": [
    { "channel": "jtexpress", "channel_name": "极兔速递", "express_type": "快递",
      "quote_id": "kv7h...", "wholesale_price": 6.1, "orderable": true },
    { "channel": "yuantong", "channel_name": "圆通速递", "express_type": "快递",
      "quote_id": "kv7h...", "wholesale_price": 6.5, "orderable": true }
  ],
  "recommended": "kv7h...",   // 推荐渠道 quote_id
  "plan_name": "标准计划",
  "total": 6
}
```

- `wholesale_price` 即您的**批发价**（含平台服务费），展示给客户时您可自行加价
- 报价有效期 15 分钟，下单必须用本次返回的 `quote_id`

### 2. 创建订单 `POST /api/open/v1/order/create`

```json
// 请求 body
{
  "quote_id": "kv7h...",
  "external_order_no": "SO-20260903-001",     // 您系统内的唯一单号（幂等键，≤64字符）
  "sender_name": "张三", "sender_phone": "13800000000",
  "sender_address": "福建省厦门市湖里区XX路1号",
  "receiver_name": "李四", "receiver_phone": "13900000000",
  "receiver_address": "广东省深圳市南山区XX大道100号",
  "goods": "服装",
  "remark": "",                                 // 选填
  "pickup_day_type": "明天",                    // 选填：今天/明天/后天
  "pickup_start_time": "09:00",                 // 与 end 成对
  "pickup_end_time": "12:00"
}
```

```json
// 成功响应 data
{
  "order_id": "fh77...",
  "order_no": "QJ20260903xxxx",    // 平台单号
  "external_order_no": "SO-20260903-001",
  "waybill_no": "JT0001234567890",  // 运单号（上游出单成功才有）
  "wholesale_price": 6.1,
  "balance_after": 493.90,
  "express_type": "快递"
}
```

- **幂等**：同一 `external_order_no` 重复请求不会重复扣款，返回已存在订单
- **扣款失败**（余额不足）`code=400`，msg 提示充值
- **上游出单失败**：自动全额退回余额（`code=500`，data 含 `refunded` 与 `balance`），您可稍后换渠道重试

### 3. 取消订单 `POST /api/open/v1/order/cancel`

```json
// 请求 body（二选一）
{ "external_order_no": "SO-20260903-001" }
// 或
{ "order_id": "fh77..." }
```

仅**未揽收**订单可取消；成功后批发价退回余额。已揽收订单需联系平台客服处理。

### 4. 查轨迹 `GET /api/open/v1/order/track?external_order_no=xxx`（或 `?order_id=`）

```json
// 响应 data
{
  "order_no": "QJ20260903xxxx", "external_order_no": "SO-...",
  "waybill_no": "JT0001234567890",
  "status": "pending_pickup", "logistics_status": "created",
  "trace": [ { "context": "快递已揽收", "time": "2026-09-03 10:00:00", "status": "揽收" } ],
  "provider": "kuaidi100"
}
```

### 5. 查余额 `GET /api/open/v1/account/balance`

```json
// 响应 data
{
  "balance": 493.90,
  "total_recharge": 500, "total_consume": 6.1, "total_refund": 0,
  "order_count": 1,
  "today_consume": 6.1
}
```

### 6. 查价格计划 `GET /api/open/v1/price/list`

```json
// 响应 data
{
  "plan_name": "标准计划",
  "rule": "批发价 = 实时成本价 + 首重服务费 0.60 元 + 续重服务费 0.10 元/kg（不足 1KG 按 1KG 计费）",
  "min_recharge": 100
}
```

## 四、计费与约定

- **重量**：小于 1KG 一律按 1KG 计费（行业惯例），与比价口径一致
- **件型**：15kg 及以上或重货渠道按大件处理，批发价仍按「成本+服务费」公式
- **批发价不含**：超重补差（实际揽收重量超出下单重量时按同公式补扣，门户账单可见）；该部分在您给客户的报价中建议预留余量
- 服务费加价幅度由平台配置（不公开上游成本），朋友与客户的差价即您的利润

## 五、门户自助

门户地址：`https://qmtong.top/b2b/`（上线后生效）
- 注册开户（app_id/secret 只显示一次，请保存；可随时重置密钥——重置后旧密钥立即失效）
- 微信充值（实时到账，最低充值额见价格计划）
- 账单：余额、每笔流水、每单费用明细（重量/计费）、CSV 月度导出
- 余额清退：申请后由平台审核，通过后线下打款

## 六、错误码

| code | 含义 | 处理 |
|---|---|---|
| 401 | 签名错误/时间戳超窗/nonce 重放 | 检查签名实现与服务器时钟 |
| 403 | 账户停用 / 未绑定价格计划 | 联系平台 |
| 404 | 资源不存在 | 检查参数 |
| 400 | 参数错误/业务拦截 | 看 msg 具体说明 |
| 500 | 服务端错误/上游出单失败（已自动退款） | 稍后重试或联系平台 |

## 七、安全提示

- `app_secret` 等同密码：只存服务端，绝不下发到小程序/App/网页前端
- 建议为每个环境（测试/生产）分别开户
- 所有请求走 HTTPS
