# 梨界支付中心 · 集成指南 (markdown)

> 最新更新：2026-05-09（Phase 38：补 §3.5 前端轮询、§7.5 callback 完整代码、§16 FAQ）
> 公开文档，可作为 prompt 喂给 AI 助手生成完整接入代码。
> 敏感凭据（hmac_secret / 微信商户私钥 / 支付宝应用私钥）只允许在服务端保存。

## 1. 接入参数

| 参数 | 说明 |
|---|---|
| Base URL | `https://pay.ljmatrix.cn` |
| site_code | 后台 `/admin/sites` 创建后获取 |
| hmac_secret | 注册 site 时一次性显示，仅服务端保存 |
| channel | wechat_native / wechat_jsapi / alipay_pc / alipay_wap / alipay_qr（alipay_pc 与 alipay_page 等价）|
| 金额单位 | 分（int），9.9 元 = 990 分 |

## 2. HMAC 签名（每个请求都要带）

### Headers

```
X-Pay-Site:        <site_code>
X-Pay-Timestamp:   <unix秒,10位>      # 必须 ±5 分钟内
X-Pay-Nonce:       <随机串,≥12字符>    # 5 分钟内不可重复
X-Pay-Signature:   sha256=<hmac_hex>
Idempotency-Key:   <写接口幂等键>      # 可选；POST 推荐传，建议同 nonce / biz_order_no
Content-Type:      application/json
```

⚠️ `Idempotency-Key` **不带 X- 前缀**（按 RFC 7240 / IETF idempotency-key 草案）。常见错误：业务方 SDK 写成 `X-Idempotency-Key` → lj-pay 服务端读不到 → 幂等不生效，重试时会创出多张 PENDING 单。

### Signing string（6 行用 \n 拼接）

```
METHOD                              # POST / GET
PATH_WITH_QUERY                     # /api/v1/order/create  或带 query 时按 key 排序
TIMESTAMP                           # 同 X-Pay-Timestamp
NONCE                               # 同 X-Pay-Nonce
SITE_CODE                           # 同 X-Pay-Site
SHA256_HEX(RAW_BODY)                # GET 用 sha256("") = e3b0c44...
```

```
signature = "sha256=" + HMAC_SHA256_HEX(hmac_secret, signing_string)
```

⚠️ **验签必须用 timingSafeEqual**，不能直接 `===`。

## 3. 创建订单

```
POST https://pay.ljmatrix.cn/api/v1/order/create
```

请求 body：

```json
{
  "biz_order_no": "ORDER202605080001",
  "amount_fen": 9900,
  "channel": "wechat_native",
  "subject": "课程报名费",
  "client_ip": "1.2.3.4",
  "user_agent": "Mozilla/5.0 ...",
  "return_url": "https://your-site.com/checkout/ORDER202605080001",
  "metadata": { "user_id": "u_123" }
}
```

字段说明：

| 字段 | 必填 | 说明 |
|---|---|---|
| biz_order_no | 是 | 业务侧订单号；同 site 唯一约束（见 §10）|
| amount_fen | 是 | 支付金额，单位"分"，正整数 |
| channel | 是 | 见 §1 通道列表 |
| subject | 是 | 商品/订单主题，会展示给用户，最长 127 字符 |
| client_ip | 否 | 用户公网 IP（微信支付强制要求；不传时取 request 来源 IP，可能不准）|
| user_agent | 否 | 用户浏览器 UA（仅日志/审计用）|
| return_url | 否 | **仅浏览器跳转通道（alipay_page / alipay_wap）必须**；用户付完款支付宝把浏览器跳回的目标 URL。**不传则用户停留在支付宝"付款成功，3 秒后自动返回商户"页面 → 没跳转目标 → UX 卡死**。建议传业务方的 checkout / 订单详情页，并在该页轮询订单状态。扫码通道（wechat_native / alipay_qr）不需要此字段。|
| payer_openid | 否 | 仅 wechat_jsapi 通道用，公众号 H5 内识别用户的 openid |
| metadata | 否 | 任意 JSON，会在 callback 原样回传 |

响应：

```json
{
  "order_no": "lp_xxx",
  "biz_order_no": "ORDER202605080001",
  "amount_fen": 9900,
  "instructions": {
    "type": "QR",
    "qr_data_url": "data:image/png;base64,...",
    "raw_pay_url": "weixin://wxpay/bizpayurl?pr=..."
  }
}
```

前端拿到 `instructions` 后：
- type === "QR" → 渲染 `<img src={qr_data_url} />`
- type === "REDIRECT" → `window.location.href = redirect_url`（**用户付完款后会跳到 `return_url`，记得在创单时传**；没传 return_url 用户会卡在支付宝"付款成功，3 秒后自动返回商户"提示页）
- type === "JSAPI" → 喂给 `WeixinJSBridge.invoke()`

## 3.5 前端付款后跳回的处理（**新接入方必读**）

**关键事实**：用户在支付宝付款成功 → 浏览器跳回 `return_url` 时，业务方后端**还不一定**已经被 lj-pay 通知到了。

时间线（实测）：

```
T+0s    用户在支付宝点"确认付款"，支付宝标 PAID
T+3s    用户浏览器跳回业务方 return_url
T+3s    支付宝异步发 webhook 给 lj-pay（**最快路径，正常 3-30 秒到**）
T+3s    lj-pay 验签 → markPaid → outbox dispatch → 业务方 callback → 业务方 Order 标 PAID
↓ 如果 webhook 丢失或被防火墙拦：
T+90s+  lj-pay reconcile worker 主动查支付宝 → 发现 PAID → 同上 outbox 路径
```

**问题**：用户跳回 `return_url` 那一刻（T+3s），业务方页面查订单可能还是 PENDING。如果不轮询，用户会看到"待支付"卡住。

**解决方案**（前后端协议）：

**第 1 步：业务方在创单时把 `return_url` 末尾带一个标记参数**（如 `?paid=1`）：

```js
// 业务方后端创单
const returnUrl = `https://your-site.com/checkout/${order.id}?paid=1`
await ljPayCreate({ ..., return_url: returnUrl })
```

支付宝跳回时会**追加**自己的参数 (`out_trade_no` / `trade_no` / `sign` 等)，最终 URL 变成：

```
https://your-site.com/checkout/ORDER202605080001?paid=1&out_trade_no=...&trade_no=...&sign=...
```

支付宝追加的参数业务方**不需要也不应该**用来判断订单是否已支付（payments 端验签是 lj-pay 的事）—— 只用来感知"用户刚从支付宝跳回"。

**第 2 步：checkout 页检测到 `paid=1` 就启动轮询**（每 1-2 秒一次）：

```tsx
// React + react-query 例子
const justPaid = new URLSearchParams(window.location.search).has("paid")
const { data } = useQuery({
  queryKey: ["order", orderId],
  queryFn: () => fetch(`/api/orders/${orderId}`).then(r => r.json()),
  refetchInterval: justPaid && data?.status !== "PAID" ? 1000 : false,
  refetchIntervalInBackground: false,
})

useEffect(() => {
  if (data?.status === "PAID") {
    // 移除 paid 参数避免下次刷新重新触发轮询
    const u = new URL(window.location.href)
    u.searchParams.delete("paid")
    window.history.replaceState({}, "", u.toString())
    toast.success("支付成功")
  }
}, [data?.status])
```

**第 3 步**：轮询持续到 status 变为 PAID。建议加最长 5 分钟超时兜底（reconcile worker 最迟在创单后 ~3 分钟就会把订单 PAID，如果 5 分钟还没 PAID 八成是真出事了，引导用户联系客服）。

**不要**在前端用支付宝跳回 URL 里的 `trade_status=TRADE_SUCCESS` 当判断依据 —— 那个签名是支付宝签的，业务方既没公钥（在 lj-pay 内部）也没必要再验一次；信你自己后端轮询的结果就行。

## 4. 查询订单

```
GET https://pay.ljmatrix.cn/api/v1/order/by-biz-no/{biz_order_no}
GET https://pay.ljmatrix.cn/api/v1/order/by-pay-no/{order_no}
```

## 5. 取消订单（仅 PENDING 可取消）

```
POST https://pay.ljmatrix.cn/api/v1/order/cancel
{ "biz_order_no": "ORDER202605080001" }
```

## 6. 退款

```
POST https://pay.ljmatrix.cn/api/v1/order/refund
{
  "biz_order_no": "ORDER202605080001",
  "out_refund_no": "REFUND202605080001",
  "refund_fen": 9900,
  "reason": "用户申请退款"
}
```

退款是异步的；最终状态通过 `refund.success` callback 通知。

## 7. 业务回调（pay → 业务）

pay 在订单状态变化时反向调业务方 callback URL，**用同一套 HMAC**。

### 7.1 Headers

```
POST {site.callback_url}
Content-Type: application/json
X-Pay-Site:        <site_code>
X-Pay-Timestamp:   <unix秒>
X-Pay-Nonce:       <随机串>
X-Pay-Request-Id:  dlv_xxx
X-Pay-Delivery-Id: dlv_xxx
X-Pay-Event:       order.paid       # / refund.success / order.pay_after_close
X-Pay-Signature:   sha256=...
```

### 7.2 order.paid body

```json
{
  "event": "order.paid",
  "order_no": "lp_xxx",
  "biz_order_no": "ORDER202605080001",
  "channel": "wechat_native",
  "amount_fen": 9900,
  "paid_amount_fen": 9900,
  "channel_trade_no": "4200001234",
  "paid_at": "2026-05-08T13:01:23.000Z",
  "metadata": { "user_id": "u_123" }
}
```

### 7.3 refund.success body

```json
{
  "event": "refund.success",
  "order_no": "lp_xxx",
  "biz_order_no": "ORDER202605080001",
  "refund_no": "lpr_yyy",
  "out_refund_no": "REFUND202605080001",
  "refund_fen": 9900,
  "channel_refund_id": "5000xxx",
  "refunded_at": "2026-05-08T13:05:00.000Z"
}
```

### 7.4 业务方必须做

1. 用 raw text body 验签（不是 JSON.parse 后再 stringify）
2. 校验 `amount_fen === your_order.amountFen`（防 pay 被攻破伪造）
3. 幂等去重（biz_order_no + event）
4. 必须返回 `HTTP 200 + {"ok":true}`

⚠️ 第 1 条最容易踩。下面 §7.5 给三种栈的完整 handler 范例。

### 7.5 Callback handler 完整代码（Next.js / Express / Flask）

#### 共用：验签函数

```typescript
import crypto from "node:crypto"

export function verifyLjPayCallback(args: {
  rawBody: string                                   // 必须是原始 text，不是 JSON.parse 后再 stringify
  headers: { site: string; timestamp: string; nonce: string; signature: string }
  callbackPath: string                              // 同 site 注册时填的路径，如 "/api/internal/pay-callback"
  expectedSite: string                              // 你的 site_code
  hmacSecret: string                                // 你的 hmac_secret
}): boolean {
  const { rawBody, headers, callbackPath, expectedSite, hmacSecret } = args
  // 1. 时间戳 ±5 分钟
  const ts = Number(headers.timestamp)
  if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false
  // 2. site 必须匹配
  if (headers.site !== expectedSite) return false
  // 3. 签名重算
  const sha256 = (s: string) => crypto.createHash("sha256").update(s).digest("hex")
  const signingString = ["POST", callbackPath, headers.timestamp, headers.nonce, expectedSite, sha256(rawBody)].join("\n")
  const expected = "sha256=" + crypto.createHmac("sha256", hmacSecret).update(signingString).digest("hex")
  // 4. 长度等长才能 timingSafeEqual
  if (headers.signature.length !== expected.length) return false
  try {
    return crypto.timingSafeEqual(Buffer.from(headers.signature), Buffer.from(expected))
  } catch {
    return false
  }
}
```

#### Next.js App Router (`app/api/internal/pay-callback/route.ts`)

```typescript
import { NextRequest, NextResponse } from "next/server"
import { verifyLjPayCallback } from "@/lib/lj-pay-callback"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

export async function POST(req: NextRequest) {
  // 关键：用 .text() 拿 raw body，不要 .json()
  const rawBody = await req.text()
  const ok = verifyLjPayCallback({
    rawBody,
    headers: {
      site: req.headers.get("x-pay-site") || "",
      timestamp: req.headers.get("x-pay-timestamp") || "",
      nonce: req.headers.get("x-pay-nonce") || "",
      signature: req.headers.get("x-pay-signature") || "",
    },
    callbackPath: "/api/internal/pay-callback",
    expectedSite: process.env.LJ_PAY_SITE_CODE!,
    hmacSecret: process.env.LJ_PAY_HMAC_SECRET!,
  })
  if (!ok) return NextResponse.json({ ok: false, error: "invalid signature" }, { status: 401 })

  const event = req.headers.get("x-pay-event")
  const body = JSON.parse(rawBody)

  if (event === "order.paid") {
    // 1. 校验金额防伪造
    const order = await db.order.findUnique({ where: { id: body.biz_order_no } })
    if (!order) return NextResponse.json({ ok: true, note: "order not found" })   // 200 让 lj-pay 别重试
    if (Math.round(Number(order.amount) * 100) !== body.amount_fen) {
      return NextResponse.json({ ok: false, error: "amount mismatch" }, { status: 400 })
    }
    // 2. 幂等：用 biz_order_no + event 去重
    if (order.status === "PAID") return NextResponse.json({ ok: true, note: "already paid" })
    // 3. 业务处理
    await markOrderPaid(order.id, { externalTradeNo: body.channel_trade_no, paidAt: new Date(body.paid_at) })
  }
  // refund.success / order.pay_after_close 类似分支...

  return NextResponse.json({ ok: true })
}
```

#### Express + body-parser 陷阱

```typescript
import express from "express"
const app = express()

// 关键：必须用 raw 解析，不能 express.json()（会把 stream 消费掉，后面拿不到 raw）
// 把 lj-pay callback 路径单独挂 raw parser，其他路径正常 .json()
app.post("/api/internal/pay-callback",
  express.raw({ type: "*/*", limit: "1mb" }),
  async (req, res) => {
    const rawBody = (req.body as Buffer).toString("utf8")
    const ok = verifyLjPayCallback({
      rawBody,
      headers: {
        site: req.header("x-pay-site") || "",
        timestamp: req.header("x-pay-timestamp") || "",
        nonce: req.header("x-pay-nonce") || "",
        signature: req.header("x-pay-signature") || "",
      },
      callbackPath: "/api/internal/pay-callback",
      expectedSite: process.env.LJ_PAY_SITE_CODE!,
      hmacSecret: process.env.LJ_PAY_HMAC_SECRET!,
    })
    if (!ok) return res.status(401).json({ ok: false, error: "invalid signature" })

    const body = JSON.parse(rawBody)
    // ... 同 Next.js 分支处理
    res.json({ ok: true })
  }
)
```

#### Python Flask

```python
import hmac, hashlib, time, os, json
from flask import Flask, request, jsonify

app = Flask(__name__)
SITE = os.environ["LJ_PAY_SITE_CODE"]
SECRET = os.environ["LJ_PAY_HMAC_SECRET"].encode()

def verify(raw_body: bytes, h: dict, path: str) -> bool:
    ts = h.get("X-Pay-Timestamp", "")
    if not ts.isdigit() or abs(int(time.time()) - int(ts)) > 300: return False
    if h.get("X-Pay-Site") != SITE: return False
    body_hash = hashlib.sha256(raw_body).hexdigest()
    msg = "\n".join(["POST", path, ts, h.get("X-Pay-Nonce",""), SITE, body_hash]).encode()
    expected = "sha256=" + hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
    sig = h.get("X-Pay-Signature", "")
    return len(sig) == len(expected) and hmac.compare_digest(sig, expected)

@app.post("/api/internal/pay-callback")
def lj_pay_callback():
    # 关键：get_data(cache=True) 拿 raw bytes；不要用 request.json（会消费 stream）
    raw = request.get_data(cache=True)
    if not verify(raw, dict(request.headers), "/api/internal/pay-callback"):
        return jsonify(ok=False, error="invalid signature"), 401
    event = request.headers.get("X-Pay-Event")
    body = json.loads(raw)
    # ... 同 Next.js 分支
    return jsonify(ok=True)
```

### 7.6 Raw body 取法的常见坑

| 框架 | 正确 | 错误 |
|---|---|---|
| Next.js App Router | `await req.text()` | `await req.json()` 然后 `JSON.stringify(...)` 重算（key 顺序、空格、Unicode 转义都会变） |
| Express | `express.raw({type:"*/*"})` 单挂 callback 路径 | 全局 `express.json()` → 后续 handler 里 raw 拿不到 |
| Hono | `await c.req.text()` | 同上 |
| Flask | `request.get_data(cache=True)` | `request.json` 或 `request.form` |
| Koa | `koa-raw-body` 中间件 | `koa-bodyparser` |
| Spring (Java) | `@RequestBody String rawBody` | `@RequestBody MyDto dto` |

通用判断：如果你的 framework 自动把 body 解析成 JSON object 给你，**它就消费了 raw stream**；你必须想办法在 parse 前留一份 raw 副本。

## 8. 状态机

```
PENDING ┬→ 支付通知/查单 → PAID
        ├→ cancel → CANCELED
        ├→ 超时 → EXPIRED
        └→ 渠道失败 → FAILED

PAID    ┬→ 全额退 → REFUNDED
        └→ 部分退 → PARTIAL_REFUNDED → 继续退 → REFUNDED

异常：EXPIRED/CANCELED/FAILED + 渠道发现已支付 → PAY_AFTER_CLOSE
```

**PAY_AFTER_CLOSE 含义**：用户在订单已经被本地关闭（超时 / 取消 / 失败）之后才完成支付。这是异常状态：钱已经收了但本地状态不正常。lj-pay 会发 `order.pay_after_close` 事件给业务方 callback，**业务方收到这个事件应当人工介入**（要么发货并把订单恢复到 PAID，要么发起退款），不要自动当 PAID 处理。常见原因：用户把支付宝/微信付款页放后台太久了再付。

## 9. 回调重试节奏

```
1s, 5s, 30s, 2m, 5m, 15m, 1h, 3h, 6h, 12h, 24h
```

11 次失败 → DEAD，可在后台手动重放。

## 10. biz_order_no 关键约束（必读）

**同一个 site + biz_order_no 在 lj-pay 中只能存活一笔订单。**

- 第一次创单 → `PENDING`
- `PENDING` 状态下重复用同 biz_order_no 调创单 → 返回原订单（幂等）
- `PAID` 后再用同 biz_order_no → `409 ORDER_ALREADY_PAID`
- `EXPIRED / CANCELED / FAILED` 后用**同** biz_order_no 重新支付 → `409 ORDER_ALREADY_PAID`

**用户重试支付时，业务方必须生成新的 biz_order_no**：

```js
const newBizOrderNo = originalBizOrderNo + "_retry_" + Date.now()
// 或 crypto.randomUUID()
// DB 加张 RetryMapping 表把新单号映射回原业务订单
```

这是为了防止过期单上同时收到「过期事件」和「PAY_AFTER_CLOSE 事件」造成账目错乱。

## 11. 限流（Rate Limit）

每个 site 独立限流，超过返回 `429 RATE_LIMITED`：

| 接口组 | 默认限额 |
|---|---|
| `/order/create` | 100/分钟 |
| `/order/by-*` 查单 | 600/分钟 |
| `/order/refund` | 30/分钟 |
| `/order/cancel` | 100/分钟 |
| 其他 | 200/分钟 |

业务方做指数退避重试即可。lj-pay Redis 故障时 fail-open（不会因限流挂掉支付）。

## 11.5 限制与默认值

| 字段 | 限制 |
|---|---|
| biz_order_no | 必填，字符串，最长 **128 字符**（建议 ASCII，不要混中文）|
| out_refund_no | 必填，字符串，最长 128 字符 |
| subject | 必填，最长 **127 字符**（支付宝硬性约束）|
| reason（退款）| 可选，最长 255 字符 |
| metadata | 可选，任意 JSON object（必须是对象，不能是数组）|
| X-Pay-Nonce | 最短 **12 字符**；5 分钟内同 site 不可重复（防重放）|
| X-Pay-Timestamp | Unix 秒，10 位整数；与服务端时差 **±300 秒**（5 分钟）|
| 订单 TTL | **30 分钟**：创单后未支付的 PENDING 订单超时进入 EXPIRED；之后该 biz_order_no 不能复用（见 §10）|
| amount_fen | 正整数，单位"分"；上限受上游渠道单笔限额约束 |
| 默认 currency | CNY（目前只支持 CNY）|

## 12. 错误码

| code | HTTP | 说明 |
|---|---:|---|
| INVALID_SIGNATURE | 401 | HMAC 验签失败 |
| EXPIRED_TIMESTAMP | 401 | timestamp 超 ±5 分钟 |
| REPLAYED_NONCE | 401 | nonce 重用 |
| SITE_NOT_FOUND | 401 | site_code 不存在 |
| SITE_DISABLED | 403 | 站点已禁用 |
| RATE_LIMITED | 429 | 该 site 调用频率超限 |
| INVALID_REQUEST | 400 | 参数 / JSON 异常 |
| INVALID_AMOUNT | 400 | amount_fen 非正整数 |
| DUPLICATE_PENDING_ORDER | 409 | 同 biz_order_no 已有未过期 PENDING |
| ORDER_ALREADY_PAID | 409 | 已支付 / 已退款 / 已过期不能复用 biz_order_no |
| ORDER_NOT_FOUND | 404 | 订单不存在 |
| ORDER_NOT_REFUNDABLE | 400 | 订单非 PAID / PARTIAL_REFUNDED |
| REFUND_AMOUNT_EXCEEDED | 400 | 超过可退金额 |
| CHANNEL_DISABLED | 400 | channel 未启用 |
| CHANNEL_API_ERROR | 502 | 上游错误 |
| WEBHOOK_INVALID | 400 | 渠道回调验签失败 |
| CALLBACK_URL_BLOCKED | 400 | callback URL 不在 allowlist 或解析到内网 |

## 12.5 HMAC 密钥轮换流程

业务方怀疑 hmac_secret 泄漏，或例行轮换：

1. 联系 lj-pay 管理员（或自助到后台 `/admin/sites/{site_code}`）触发轮换
2. 系统生成 **新 secret**，把旧 secret 标记为 `previousSecretCiphertext`，过期时间默认 **24 小时**（可调）
3. 业务方在过期前更新 `.env` 里 `LJ_PAY_HMAC_SECRET` 到新值并重启
4. 过期窗口内 lj-pay 会**同时接受新旧两把 secret 签名**（双签生效），保证不停机
5. 过期后旧 secret 失效，全部走新 secret

callback（lj-pay → 业务方）方向不在轮换范围 —— callback 用的是同一把 hmac_secret，业务方拿到的请求 `X-Pay-Signature` 用的也是同一套，业务方按新 secret 验签即可。

## 12.6 通道说明：mock 通道

种子数据里有 `wechat-mock` / `alipay-mock` 两个通道，**仅供本地开发与单元测试**。

- 创单走 mock 时不会调上游、不会真扣款，返回伪造的 `raw_pay_url`
- 生产环境必须把这两个通道的 `isActive` 置为 `false`，并配真实的 `alipay-live-*` / `wechat-live-*` 通道
- 后台 `/admin/channels` 可看现有通道清单与启用状态

## 13. Node.js SDK（自抄）

```typescript
import crypto from "node:crypto"

const BASE = process.env.LJ_PAY_BASE_URL!
const SITE = process.env.LJ_PAY_SITE_CODE!
const SECRET = process.env.LJ_PAY_HMAC_SECRET!

const sha256 = (s: string) => crypto.createHash("sha256").update(s).digest("hex")
const sign = (m: string, p: string, ts: string, n: string, b: string) =>
  "sha256=" + crypto.createHmac("sha256", SECRET)
    .update([m.toUpperCase(), p, ts, n, SITE, sha256(b)].join("\n")).digest("hex")

export async function ljp(method: "POST" | "GET", path: string, body?: object) {
  const ts = Math.floor(Date.now() / 1000).toString()
  const nonce = crypto.randomBytes(16).toString("hex")
  const bodyStr = body ? JSON.stringify(body) : ""
  const r = await fetch(`${BASE}${path}`, {
    method, body: bodyStr || undefined,
    headers: {
      "Content-Type": "application/json",
      "X-Pay-Site": SITE,
      "X-Pay-Timestamp": ts,
      "X-Pay-Nonce": nonce,
      "X-Pay-Signature": sign(method, path, ts, nonce, bodyStr),
    },
  })
  if (!r.ok) throw new Error(`lj-pay ${r.status}: ${await r.text()}`)
  return r.json()
}

// 验 callback 签名
export function verifyCallback(rawBody: string, headers: Record<string, string>, callbackPath: string): boolean {
  const ts = headers["x-pay-timestamp"]
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) return false
  if (headers["x-pay-site"] !== SITE) return false
  const expected = sign("POST", callbackPath, ts, headers["x-pay-nonce"], rawBody)
  return crypto.timingSafeEqual(Buffer.from(headers["x-pay-signature"]), Buffer.from(expected))
}
```

## 14. 上线检查清单

**接入前**
- [ ] 已联系 lj-pay 管理员注册 site_code，拿到 hmac_secret 与 callback URL allowlist 名额
- [ ] callback URL 是 **HTTPS + 公网可达**（不能是 localhost / 内网 IP / 私有域名）

**写接口**
- [ ] 业务后端已保存 order_no（lj-pay 内部单号）+ biz_order_no 映射
- [ ] 金额一律用「分」（int）；元 → `Math.round(yuan * 100)`
- [ ] 写接口（create / refund / cancel）带 `Idempotency-Key`（**不带 X- 前缀**）
- [ ] biz_order_no 业务方控制，最长 128 字符；用户重试支付要换新号（见 §10）
- [ ] alipay_pc / alipay_wap 创单时**必传** `return_url`，**末尾带 `?paid=1`**（见 §3.5）

**前端 / UX**
- [ ] checkout 页检测到 URL 含 `paid=1` 就启动 1Hz 轮询订单状态（见 §3.5 React 例子）
- [ ] 订单状态变成 PAID 后清掉 `paid` 参数 + 提示用户成功
- [ ] 5 分钟超时兜底，引导用户联系客服

**callback handler**
- [ ] 用 **raw text body** 验签（见 §7.5 / §7.6 框架例子）
- [ ] 验签 + 时间戳 ±5 分钟 + site_code 三道关都过
- [ ] 校验 `amount_fen === 你 DB 里订单的金额`（防伪造）
- [ ] 幂等：用 `biz_order_no + event` 做去重，已处理过直接返回 `200 + {ok:true}`
- [ ] 找不到订单（被 cron 误删等）也返回 `200 + {ok:true, note:"order not found"}` 让 lj-pay 别重试
- [ ] 重复 callback 不会重复发货 / 重复加积分

**安全 / 运维**
- [ ] hmac_secret 只在服务端 .env，**不进 git / 前端 / 日志**
- [ ] callback handler **不依赖 cookie / session**（lj-pay 调你时没有用户身份）
- [ ] 监控：lj-pay 后台 `/admin/callbacks/outbox` 看 DEAD 队列
- [ ] 退款接口联调
- [ ] 后台 `https://pay.ljmatrix.cn/admin` 能查到该产品的订单

## 15. 给 AI 助手的提示词

把整段贴给 ChatGPT / Claude / Cursor 等 AI 助手即可，文档里的细节够 AI 一次出对的代码。

```
我要给项目 X 接入梨界支付中心 (lj-pay)。请严格遵守以下规范产出代码。

【参考文档】
完整接入文档（必读，含 §3.5 前端轮询、§7.5 callback 完整代码、§16 FAQ）：
  https://pay.ljmatrix.cn/docs/integration.md
OpenAPI：https://pay.ljmatrix.cn/openapi.json

【我的环境】
栈：[Next.js 15 App Router + Prisma + PostgreSQL]   ← 改成你的实际栈
callback URL：[https://your-domain.com/api/internal/pay-callback]
支付通道：alipay_pc + wechat_native（PC 浏览器 + 微信扫码）
site_code 与 hmac_secret 我已经从 lj-pay 管理员处拿到，放在 .env：
  LJ_PAY_BASE_URL=https://pay.ljmatrix.cn
  LJ_PAY_SITE_CODE=xxx
  LJ_PAY_HMAC_SECRET=ljpay_xxxxx

【请你产出】
1. lib/lj-pay-client.ts —— 出向 SDK：HMAC sign + create / queryByBizNo / refund / cancel
   - 必须用 `Idempotency-Key` 头（**不带 X- 前缀**，参考 §2 警告）
   - alipay_pc 通道创单时，returnUrl **必须带 ?paid=1**（参考 §3.5）
2. app/api/internal/pay-callback/route.ts —— 入向 callback handler，参考 §7.5 Next.js 范例：
   - 用 `await req.text()` 拿 raw body，**严禁** `JSON.parse` 后再 `JSON.stringify`
   - 验签三道关：HMAC + 时间戳 ±300s + site_code 匹配
   - 金额校验：amount_fen === DB 订单金额（防伪造）
   - 幂等：order.status === "PAID" 直接返回 ok，不重复发货
   - 找不到订单也返回 200（让 lj-pay 别重试 DEAD 一笔）
3. app/checkout/[orderId]/page.tsx 里的轮询逻辑（参考 §3.5 React 例子）：
   - URL 含 ?paid=1 时启动 1Hz 轮询
   - PAID 后清掉 ?paid 参数 + 弹 toast
4. ¥0.01 e2e 测试脚本：调创单 → 拿 redirect_url → 用户手动付款 → 看 DB 订单变 PAID

【硬约束】
- 金额单位是「分」(int)
- 验签用 raw text body + crypto.timingSafeEqual
- callback handler 返回 200 + {"ok":true}（错误也 200，签名失败用 401）
- callback handler 不依赖 cookie / session
- hmac_secret 只在服务端 .env，绝不进前端 / git / 日志
- 全部自包含可直接跑，不要留 TODO 占位
```

---

## 16. 常见踩坑 / FAQ

**Q1: 用户跳回 checkout 页显示"待支付"，但 lj-pay / 渠道都说已支付？**

99% 是没按 §3.5 配 `?paid=1` + 前端轮询。webhook 到达有 3-90 秒延迟，回跳那一刻业务方 DB 还没收到 callback。

**Q2: 我的 callback handler 一直 INVALID_SIGNATURE？**

按概率排：
1. 用了 `JSON.parse(body)` 后 `JSON.stringify` 重新算 sha256 → key 顺序、空格、Unicode 转义都会变 → 必失败。改成 raw text body。
2. callback URL 路径写错。lj-pay 签名包含路径，业务方注册时填的什么路径，验签就要用什么路径，不能加查询参数。
3. site_code 拼错或 hmac_secret 拼错。
4. 时间戳超 ±5 分钟 → 检查服务器 NTP。

**Q3: 同一个 callback 收到两次（甚至三次）？**

正常。lj-pay 在网络异常 / 业务方 5xx 时会按 §9 节奏重试（1s, 5s, 30s, ..., 24h）。**幂等是业务方的责任**：用 biz_order_no + event 做去重，已处理过直接返回 200。

**Q4: 用户点了"重新支付"怎么处理？**

PENDING 单 30 分钟内可以直接复用同 biz_order_no（lj-pay 返回原单 instructions，幂等）。30 分钟后 PENDING 自动 EXPIRED → 业务方必须**生成新 biz_order_no** 重新创单（见 §10）。常见做法：DB 加张 RetryMapping 表把新单号映射回原业务订单。

**Q5: 支付宝跳回的 URL 一堆参数（`out_trade_no=...&total_amount=...&trade_no=...&sign=...`），业务方需要验吗？**

**不需要**。这些是支付宝 sync return 的参数，签名是支付宝签的；业务方既没公钥也没必要再验。**只信你后端轮询的订单状态**就行，`?paid=1` 之外的参数全部忽略。

**Q6: 微信支付为啥没有 `return_url`？**

微信原生扫码（`wechat_native`）没有跳转概念 —— 用户用微信 App 扫 QR，付款成功停在微信付款成功页，不会跳回浏览器。业务方的 checkout 页用 react-query 轮询订单状态即可（`paymentDetails?.type === "WECHAT_PAY"` 时启动 polling，参考 §3.5）。

**Q7: 沙箱 / 测试环境怎么联调？**

lj-pay 没有独立沙箱（支付宝/微信沙箱接入太麻烦不维护）。本地开发用 `wechat-mock` / `alipay-mock` 通道（不真实扣款，返回伪造 raw_pay_url）。集成测试建议 ¥0.01 真实小额 + 真实账号付。

**Q8: callback URL 必须 HTTPS 吗？localhost 行吗？**

必须 HTTPS + 公网可达。lj-pay 内置 SSRF 防御 —— callback URL 必须在 `CALLBACK_ALLOWED_HOSTS` 白名单且**解析到非内网 IP**。本地开发用 ngrok / cloudflare tunnel 暴露 localhost，把 tunnel 域名加到 allowlist。

**Q9: 退款是同步还是异步？**

异步。`POST /api/v1/order/refund` 只发起，立刻返回（status 可能是 PROCESSING / SUCCESS / FAILED / UNKNOWN）。最终结果通过 `refund.success` callback 通知。即使 webhook 丢，lj-pay reconcile worker 5 分钟内会主动查上游补状态。

**Q10: 我用 cron 轮询订单状态而不依赖 callback 行吗？**

不推荐（callback 更实时、更省 API 配额），但可以：
```
GET https://pay.ljmatrix.cn/api/v1/order/by-biz-no/{biz_order_no}
```
查询接口限流 600/分钟/site，业务方做指数退避。

---

公开文档完。