# 方传智付 开放 API 接入指南（AI 可直接执行版）

> 本文档为「自包含」接入说明：复制全文交给任意 AI，它即可据此生成可运行的接入代码。所有示例均已按服务端签名规则校准。

## 1. 基本信息

- 统一前缀（Base Path）：`/openapi/v1`
- 服务地址示例：`http://localhost:3000`（生产请替换为平台分配的域名）
- 鉴权方式：HMAC-SHA256 签名 + 请求头传递
- 响应统一结构：`{"code":"0","message":"success","data":{...},"request_id":"..."}`；错误时 `{"code":"E1002","message":"...","request_id":"..."}`
- 金额单位：接口以「整数分」(`xxx_fen`) 与「元字符串」(`xxx`) 双字段返回；入参可用 `amount_fen`(整数分) 或 `amount`(元字符串)。

## 2. 鉴权与签名（最关键）

每个请求必须在请求头携带以下四项，且 `sign` 由它们 + 业务参数按规则算出：

| 请求头 | 说明 |
| --- | --- |
| X-App-Id | 应用 ID（如 `fcxxxxxxxx`） |
| X-Timestamp | 秒级时间戳（10 位），与服务端偏差须 ≤ 300 秒 |
| X-Nonce | 随机串（8~64 位字母/数字/连字符），5 分钟内不可重复（防重放） |
| X-Sign | 签名值，小写十六进制 |

### 2.1 签名算法（服务端 utils/sign.js 完全一致）

1. 收集待签名参数 = 业务参数（query + body 的**顶层标量**字段） + 公共参数 `app_id`、`timestamp`、`nonce`；
2. 追加 `method`（HTTP 方法，大写，如 `GET`/`POST`）和 `path`（**完整路径**，含 `/openapi/v1`，不含 query，如 `/openapi/v1/balance`）；
3. 剔除 `sign` 字段、空字符串、以及值为对象/数组的字段；
4. 按参数名 **ASCII 升序** 排序，拼接为 `k1=v1&k2=v2&...`（值**不做 URL 编码**，用原始值，`v` 统一转字符串）；
5. `sign = HMAC-SHA256(canonical_string, app_secret).hexdigest()`（小写十六进制）。

> 注意：`app_id` / `timestamp` / `nonce` 既用于签名，也放在请求头里发送；签名串里的 `path` 必须是完整路径（含 `/openapi/v1`）。

### 2.2 Node.js 签名与请求（可直接用）

```js
'use strict';

/**
 * 方传智付 开放 API —— Node.js 接入示例（零依赖，仅用内置 crypto / https）
 * ---------------------------------------------------------------------------
 * 用法：
 *   export FC_APP_ID=fcxxxxxxxx FC_APP_SECRET=sk_xxxx FC_HOST=http://localhost:3000
 *   node sdk-node.js
 *
 * 签名规则与服务端 utils/sign.js 完全一致：
 *   1. 业务参数（query + body 顶层标量）+ 公共参数 app_id / timestamp / nonce；
 *   2. 追加 method（大写）、path（完整路径，含 /openapi/v1）；
 *   3. 剔除 sign、空值、对象/数组；
 *   4. 按参数名 ASCII 升序拼接 k=v（值不做 URL 编码）；
 *   5. sign = HMAC-SHA256(canonical, app_secret).hexdigest() 小写。
 */

const crypto = require('crypto');

const CONFIG = {
  host: process.env.FC_HOST || 'http://localhost:3000',
  appId: process.env.FC_APP_ID || 'YOUR_APP_ID',
  appSecret: process.env.FC_APP_SECRET || 'YOUR_APP_SECRET'
};

/** 构造待签名串（与服务端一致） */
function buildCanonical(params, method, path) {
  const merged = {};
  for (const [k, v] of Object.entries(params || {})) {
    if (k === 'sign') continue;
    if (v === undefined || v === null || v === '') continue;
    if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') continue;
    merged[k] = String(v);
  }
  merged.method = String(method || 'GET').toUpperCase();
  merged.path = String(path || '');
  return Object.keys(merged)
    .sort()
    .map((k) => `${k}=${merged[k]}`)
    .join('&');
}

/** 计算签名 */
function sign(params, method, path, secret) {
  const canonical = buildCanonical(params, method, path);
  return crypto.createHmac('sha256', secret).update(canonical).digest('hex');
}

/**
 * 发起一次 API 调用
 * @param {string} method  HTTP 方法
 * @param {string} path    完整路径，例如 /openapi/v1/balance
 * @param {object} query   查询参数
 * @param {object} body    JSON 请求体（POST）
 */
function request(method, path, query, body) {
  const qs = query || {};
  const bs = body || {};
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(12).toString('hex');

  // 服务端会把 query+body+公共参数一起参与签名，因此这里也要并入
  const params = { ...qs, ...bs, app_id: CONFIG.appId, timestamp, nonce };
  const sig = sign(params, method, path, CONFIG.appSecret);

  const url = new URL(CONFIG.host.replace(/\/$/, '') + path);
  for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, String(v));

  const payload = Object.keys(bs).length ? JSON.stringify(bs) : null;
  const headers = {
    'X-App-Id': CONFIG.appId,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Sign': sig,
    'Content-Type': 'application/json'
  };

  return new Promise((resolve, reject) => {
    const req = require('https').request(
      {
        hostname: url.hostname,
        port: url.port || 443,
        path: url.pathname + url.search,
        method,
        headers
      },
      (res) => {
        let data = '';
        res.on('data', (c) => (data += c));
        res.on('end', () => {
          let parsed = null;
          try { parsed = data ? JSON.parse(data) : null; } catch (e) { parsed = data; }
          resolve({ status: res.statusCode, body: parsed });
        });
      }
    );
    req.on('error', reject);
    if (payload) req.write(payload);
    req.end();
  });
}

/* ------------------------------- 业务示例 ------------------------------- */

async function main() {
  console.log('== ping ==');
  console.log(await request('GET', '/openapi/v1/ping'));

  console.log('== 查询余额（按手机号）==');
  console.log(await request('GET', '/openapi/v1/balance', { phone: '13800000001' }));

  console.log('== 代付（扣款）==');
  console.log(
    await request('POST', '/openapi/v1/pay', null, {
      phone: '13800000001',
      amount: '10.00',
      out_trade_no: 'ORDER_' + Date.now(),
      remark: 'SDK 示例代付'
    })
  );

  console.log('== 入账（充值）==');
  console.log(
    await request('POST', '/openapi/v1/recharge', null, {
      phone: '13800000001',
      amount_fen: 5000,
      out_trade_no: 'RECH_' + Date.now(),
      remark: 'SDK 示例入账'
    })
  );
}

if (require.main === module) {
  main().catch((e) => {
    console.error('调用失败：', e.message);
    process.exit(1);
  });
}

module.exports = { buildCanonical, sign, request, CONFIG };

```

### 2.3 Python 签名与请求（可直接用）

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方传智付 开放 API —— Python 接入示例（零依赖，仅用标准库）
---------------------------------------------------------------------------
用法：
    export FC_APP_ID=fcxxxxxxxx FC_APP_SECRET=sk_xxxx FC_HOST=http://localhost:3000
    python3 sdk-python.py

签名规则与服务端 utils/sign.js 完全一致：
  1. 业务参数（query + body 顶层标量）+ 公共参数 app_id / timestamp / nonce；
  2. 追加 method（大写）、path（完整路径，含 /openapi/v1）；
  3. 剔除 sign、空值、对象/数组；
  4. 按参数名 ASCII 升序拼接 k=v（值不做 URL 编码）；
  5. sign = HMAC-SHA256(canonical, app_secret).hexdigest() 小写。
"""

import hashlib
import hmac
import json
import os
import time
import urllib.request
import urllib.parse
import urllib.error

CONFIG = {
    "host": os.environ.get("FC_HOST", "http://localhost:3000"),
    "app_id": os.environ.get("FC_APP_ID", "YOUR_APP_ID"),
    "app_secret": os.environ.get("FC_APP_SECRET", "YOUR_APP_SECRET"),
}


def build_canonical(params, method, path):
    """构造待签名串（与服务端一致）"""
    merged = {}
    for k, v in (params or {}).items():
        if k == "sign":
            continue
        if v is None or v == "":
            continue
        if not isinstance(v, (str, int, float, bool)):
            continue
        merged[k] = str(v)
    merged["method"] = str(method or "GET").upper()
    merged["path"] = str(path or "")
    return "&".join("{}={}".format(k, merged[k]) for k in sorted(merged))


def sign(params, method, path, secret):
    canonical = build_canonical(params, method, path)
    return hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()


def request(method, path, query=None, body=None):
    """
    发起一次 API 调用
    :param method: HTTP 方法
    :param path:   完整路径，例如 /openapi/v1/balance
    :param query:  查询参数字典
    :param body:   JSON 请求体（POST 用）
    """
    q = query or {}
    b = body or {}
    timestamp = str(int(time.time()))
    import os as _os
    nonce = _os.urandom(12).hex()

    # 服务端会把 query+body+公共参数一起参与签名，因此这里也要并入
    params = {**q, **b, "app_id": CONFIG["app_id"], "timestamp": timestamp, "nonce": nonce}
    sig = sign(params, method, path, CONFIG["app_secret"])

    url = CONFIG["host"].rstrip("/") + path
    if q:
        url += "?" + urllib.parse.urlencode(q)
    data = json.dumps(b).encode("utf-8") if b else None

    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("X-App-Id", CONFIG["app_id"])
    req.add_header("X-Timestamp", timestamp)
    req.add_header("X-Nonce", nonce)
    req.add_header("X-Sign", sig)
    req.add_header("Content-Type", "application/json")

    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            raw = resp.read().decode("utf-8")
            return {"status": resp.status, "body": json.loads(raw) if raw else None}
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8")
        return {"status": e.code, "body": json.loads(raw) if raw else None}


def main():
    print("== ping ==")
    print(request("GET", "/openapi/v1/ping"))

    print("== 查询余额（按手机号）==")
    print(request("GET", "/openapi/v1/balance", {"phone": "13800000001"}))

    print("== 代付（扣款）==")
    print(request("POST", "/openapi/v1/pay", None, {
        "phone": "13800000001",
        "amount": "10.00",
        "out_trade_no": "ORDER_" + str(int(time.time())),
        "remark": "SDK 示例代付",
    }))

    print("== 入账（充值）==")
    print(request("POST", "/openapi/v1/recharge", None, {
        "phone": "13800000001",
        "amount_fen": 5000,
        "out_trade_no": "RECH_" + str(int(time.time())),
        "remark": "SDK 示例入账",
    }))


if __name__ == "__main__":
    main()

```

## 3. 接口列表

| 方法 | 路径 | 所需 Scope |
| --- | --- | --- |
| GET | /openapi/v1/ping | 无（仍需签名） |
| GET | /openapi/v1/system/status | 无（仍需签名） |
| GET | /openapi/v1/balance | balance.read |
| GET | /openapi/v1/transactions | transactions.read |
| POST | /openapi/v1/pay | pay.debit |
| POST | /openapi/v1/recharge | wallet.recharge |
| GET | /openapi/v1/kyc/status | kyc.read |

## 4. 接口详情

### GET /openapi/v1/ping

- 说明：校验签名与鉴权是否通过，并返回服务器时间，可用于本地时间校准。无需业务 Scope，但仍需签名。
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | app_id | string | 当前应用标识 |
  | server_time | integer | 服务器秒级时间戳 |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "app_id": "fcxxxxxxxx",
      "server_time": 1700000000
    },
    "request_id": "req-xxx"
  }
  ```

### GET /openapi/v1/system/status

- 说明：返回全局支付服务状态，供第三方在前端展示「暂停 / 正常」提示。无需业务 Scope。
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | status | string | normal=正常；halted=全局止付（支付服务暂停） |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "status": "normal"
    },
    "request_id": "req-xxx"
  }
  ```

### GET /openapi/v1/balance

- 说明：按 user_id 或 phone 查询用户账户余额与实名 / 账户状态。
- 必填：user_id 或 phone 至少其一
- 参数：
  | 名称 | 位置 | 类型 | 必填 | 说明 |
  | --- | --- | --- | --- | --- |
  | user_id | query | integer | 否 | 用户 ID（与 phone 二选一） |
  | phone | query | string | 否 | 注册手机号，1[3-9] 开头 11 位 |
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | user_id | integer | 用户 ID |
  | balance_fen | integer | 余额（整数分） |
  | balance | string | 余额（元，字符串，2 位小数） |
  | currency | string | 币种，固定 CNY |
  | kyc_status | string | 实名状态：verified / pending / failed / none |
  | status | string | 账户状态：active / frozen / disabled |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "user_id": 12345,
      "balance_fen": 50000,
      "balance": "500.00",
      "currency": "CNY",
      "kyc_status": "verified",
      "status": "active"
    },
    "request_id": "req-xxx"
  }
  ```
- 可能返回的错误码：`E1001`、`E1002`、`E1004`、`E2001`、`E4002`、`E4003`

### GET /openapi/v1/transactions

- 说明：分页查询指定用户的交易流水，可按日期区间过滤。
- 必填：user_id 或 phone 至少其一
- 参数：
  | 名称 | 位置 | 类型 | 必填 | 说明 |
  | --- | --- | --- | --- | --- |
  | user_id | query | integer | 否 | 用户 ID（与 phone 二选一） |
  | phone | query | string | 否 | 注册手机号 |
  | page | query | integer | 否 | 页码，1~1000，默认 1 |
  | page_size | query | integer | 否 | 每页条数，1~50，默认 20 |
  | from | query | string | 否 | 起始日期 YYYY-MM-DD |
  | to | query | string | 否 | 结束日期 YYYY-MM-DD |
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | user_id | integer | 用户 ID |
  | total | integer | 总记录数 |
  | page | integer | 当前页 |
  | page_size | integer | 每页条数 |
  | list | array | 流水数组 |
  | list[].tx_no | string | 交易号 |
  | list[].type | string | 类型：pay/transfer/recharge/... |
  | list[].direction | string | credit=入账 / debit=出账 |
  | list[].amount_fen | integer | 金额（分） |
  | list[].amount | string | 金额（元） |
  | list[].status | string | 交易状态 |
  | list[].remark | string | 备注 |
  | list[].created_at | string | 时间（ISO） |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "user_id": 12345,
      "total": 1,
      "page": 1,
      "page_size": 20,
      "list": [
        {
          "tx_no": "RC...",
          "type": "recharge",
          "direction": "credit",
          "amount_fen": 5000,
          "amount": "50.00",
          "status": "success",
          "remark": "",
          "created_at": "2026-01-01T00:00:00.000Z"
        }
      ]
    },
    "request_id": "req-xxx"
  }
  ```
- 可能返回的错误码：`E1001`、`E1002`、`E1004`、`E2001`、`E4002`、`E4003`

### POST /openapi/v1/pay

- 说明：从指定用户余额扣减金额（代付）。out_trade_no 为幂等键，重复提交同号返回首次结果（replayed=true）。
- 必填：user_id/phone 至少其一；amount_fen/amount 至少其一
- 参数：
  | 名称 | 位置 | 类型 | 必填 | 说明 |
  | --- | --- | --- | --- | --- |
  | user_id | body | integer | 否 | 目标用户 ID（与 phone 二选一） |
  | phone | body | string | 否 | 目标用户手机号 |
  | amount_fen | body | integer | 否 | 金额（分，正整数，上限 100000000） |
  | amount | body | string | 否 | 金额（元，格式 \d{1,7}(\.\d{1,2})?） |
  | out_trade_no | body | string | 是 | 商户订单号，6~64 位字母/数字/下划线/连字符，幂等键 |
  | remark | body | string | 否 | 备注，≤60 字 |
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | tx_no | string | 平台交易号 |
  | user_id | integer | 用户 ID |
  | amount_fen | integer | 本次金额（分） |
  | amount | string | 本次金额（元） |
  | balance_fen | integer | 交易后余额（分） |
  | status | string | 交易状态 |
  | replayed | boolean | 是否因幂等复用已有结果 |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "tx_no": "RC...",
      "user_id": 12345,
      "amount_fen": 5000,
      "amount": "50.00",
      "balance_fen": 45000,
      "status": "success",
      "replayed": false
    },
    "request_id": "req-xxx"
  }
  ```
- 可能返回的错误码：`E1001`、`E1002`、`E1004`、`E2001`、`E3001`、`E3002`、`E3005`、`E4001`、`E4002`、`E4003`、`E9001`、`E5000`

### POST /openapi/v1/recharge

- 说明：为指定用户增加余额（入账）。out_trade_no 为幂等键，重复提交同号返回首次结果（replayed=true）。
- 必填：user_id/phone 至少其一；amount_fen/amount 至少其一
- 参数：
  | 名称 | 位置 | 类型 | 必填 | 说明 |
  | --- | --- | --- | --- | --- |
  | user_id | body | integer | 否 | 目标用户 ID（与 phone 二选一） |
  | phone | body | string | 否 | 目标用户手机号 |
  | amount_fen | body | integer | 否 | 金额（分，正整数） |
  | amount | body | string | 否 | 金额（元） |
  | out_trade_no | body | string | 是 | 商户订单号，6~64 位，幂等键 |
  | remark | body | string | 否 | 备注，≤60 字 |
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | tx_no | string | 平台交易号 |
  | user_id | integer | 用户 ID |
  | amount_fen | integer | 本次金额（分） |
  | amount | string | 本次金额（元） |
  | balance_fen | integer | 交易后余额（分） |
  | status | string | 交易状态 |
  | replayed | boolean | 是否因幂等复用已有结果 |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "tx_no": "RC...",
      "user_id": 12345,
      "amount_fen": 5000,
      "amount": "50.00",
      "balance_fen": 55000,
      "status": "success",
      "replayed": false
    },
    "request_id": "req-xxx"
  }
  ```
- 可能返回的错误码：`E1001`、`E1002`、`E1004`、`E2001`、`E3002`、`E3005`、`E4001`、`E4002`、`E4003`、`E5000`

### GET /openapi/v1/kyc/status

- 说明：按 user_id 或 phone 查询用户实名认证状态。
- 必填：user_id 或 phone 至少其一
- 参数：
  | 名称 | 位置 | 类型 | 必填 | 说明 |
  | --- | --- | --- | --- | --- |
  | user_id | query | integer | 否 | 用户 ID（与 phone 二选一） |
  | phone | query | string | 否 | 注册手机号 |
- 响应 data 字段：
  | 字段 | 类型 | 说明 |
  | --- | --- | --- |
  | user_id | integer | 用户 ID |
  | kyc_status | string | 实名状态：verified / pending / failed / none |
  | verified | boolean | 是否已实名（kyc_status==verified） |
- 响应示例：
  ```json
  {
    "code": "0",
    "message": "success",
    "data": {
      "user_id": 12345,
      "kyc_status": "verified",
      "verified": true
    },
    "request_id": "req-xxx"
  }
  ```
- 可能返回的错误码：`E1001`、`E1002`、`E1004`、`E2001`、`E4002`、`E4003`

## 5. 回调通知（Webhook）

- 平台在资金变动 / 实名状态变更时，向应用配置的 `callback_url` 发送 `POST application/json`。
- 回调请求头：

  | 头 | 值 |
  | --- | --- |
  | Content-Type | application/json |
  | X-FC-App-Id | 应用 ID |
  | X-FC-Timestamp | 秒级时间戳 |
  | X-FC-Nonce | 随机串 |
  | X-FC-Signature | HMAC-SHA256(timestamp.nonce.rawBody, app_secret) 小写十六进制 |
  | X-FC-Event-Id | 事件唯一 ID（幂等去重用） |

- 验签规则：`signature = HMAC-SHA256(`${timestamp}.${nonce}.${rawBody}`, app_secret) 的小写十六进制；rawBody 为请求原文（未解析的 JSON 字符串）`
- 重试策略：第三方必须按 X-FC-Event-Id 做幂等处理；平台失败按 10s/60s/300s/1800s/7200s 指数退避重试，最多 5 次
- 事件类型：
  - `transaction.success`：{"tx_no":"RC...","type":"pay","status":"success","user_id":12345,"direction":"debit","amount_fen":5000,"amount":"50.00","balance_fen":45000,"balance":"450.00","remark":"","created_at":"2026-01-01T00:00:00.000Z"}
  - `kyc.status_changed`：{"user_id":12345,"kyc_status":"verified"}

### 5.1 回调验签示例（Node.js）

```js
const crypto = require('crypto');
function verifyCallback(timestamp, nonce, rawBody, signature, appSecret) {
  const expected = crypto.createHmac('sha256', appSecret)
    .update(`${timestamp}.${nonce}.${rawBody}`).digest('hex');
  return expected.toLowerCase() === String(signature).toLowerCase();
}
```

## 6. 错误码

| 错误码 | HTTP | 说明 |
| --- | --- | --- |
| 0 | 200 | 成功 |
| E1001 | 400 | 参数错误（字段缺失或格式不正确） |
| E1002 | 400 | 业务校验失败（如兑换码已被使用、用户状态不允许） |
| E1004 | 404 | 资源不存在（用户 / 接口不存在） |
| E2001 | 401 | 未登录 / 未携带有效鉴权 |
| E3001 | 400 | 余额不足 |
| E3002 | 400 | 交易金额超出限额 |
| E3005 | 403 | 账户被冻结 / 状态不允许该操作 |
| E4001 | 429 | 调用频率超出应用 QPS 配额 |
| E4002 | 401 | 接口鉴权失败（缺参数 / 时间戳过期 / 签名错误 / 应用停用） |
| E4003 | 403 | 应用缺少对应 Scope 权限 |
| E4004 | 403 | 开放接口已全局关闭 |
| E9001 | 403 | 支付服务暂停（全局 / 单用户止付），/pay 在止付期间返回此码 |
| E5000 | 500 | 系统繁忙，请稍后重试（详情写入服务端日志） |

## 7. 接入步骤（给 AI / 开发者的指令）

1. 在平台后台「开放 API / 应用」中创建应用，获取 `app_id` 与 `app_secret`（secret 仅展示一次，请妥善保存）；按需勾选 Scope 并填写 `callback_url`。
2. 复制第 2 节的 Node.js 或 Python 客户端，填入 `app_id` / `app_secret` 与平台地址。
3. 先调用 `GET /openapi/v1/ping` 验证签名是否正确（返回 `code:"0"` 即成功）。
4. 调用业务接口；金额优先用 `amount_fen`（整数分）以避免浮点误差。
5. 实现 `callback_url` 端点：校验 `X-FC-Signature`、按 `X-FC-Event-Id` 幂等处理，并返回 2xx 表示接收成功。
6. 生产环境注意：校准服务器时间（避免时间戳过期）、为每个请求生成唯一 nonce、对 `/pay` 在返回 `E9001` 时做友好提示。
