梨界支付中心 · 接入指南
本文档公开可访问,可作为 prompt 直接喂给 AI 助手生成接入代码。所有 敏感凭据(hmac_secret、微信商户私钥、支付宝应用私钥)只允许在服务端保存。
1. 接入参数
| Base URL | https://pay.ljmatrix.cn |
|---|---|
| site_code | 后台 /admin/sites 创建后获取,例 lxhs |
| hmac_secret | 注册 site 时一次性显示,仅服务端保存 |
| 支付方式 channel | wechat_native / wechat_jsapi / alipay_pc / alipay_wap / alipay_qralipay_pc 与 alipay_page 等价(前者业务方惯用,后者历史命名) |
| 金额单位 | 分(int),不是元。9.9 元 = 990 分 |
2. HMAC 签名算法
所有 /api/v1/* 请求和 callback 都用同一套 HMAC 协议。
2.1 请求头
X-Pay-Site: <site_code> X-Pay-Timestamp: <unix秒,10位> # 必须 ±5 分钟内 X-Pay-Nonce: <随机串,≥12字符> # 5 分钟内不可重复 X-Pay-Signature: sha256=<hmac_hex> Content-Type: application/json
2.2 Signing string(6 行用 \n 拼接)
METHOD # POST / GET
PATH_WITH_QUERY # /api/v1/order/create 或带 query 时按 key 排序后 URL-encode 拼接
TIMESTAMP # 同 X-Pay-Timestamp
NONCE # 同 X-Pay-Nonce
SITE_CODE # 同 X-Pay-Site
SHA256_HEX(RAW_BODY) # GET 用 sha256("") = e3b0c44...2.3 计算签名
signature = "sha256=" + HMAC_SHA256_HEX(hmac_secret, signing_string)
⚠️ 验签必须用 timingSafeEqual(防时序攻击),不能直接 ===。
3. 创建订单
POST https://pay.ljmatrix.cn/api/v1/order/create
Content-Type: application/json
X-Pay-Site: lxhs
X-Pay-Timestamp: 1778240000
X-Pay-Nonce: 7a3b9c1d4e5f6a8b
X-Pay-Signature: sha256=...
Idempotency-Key: ORDER202605080001 # 推荐:同 biz_order_no 即可
{
"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", "product": "lxhs" }
}字段说明
| 字段 | 必填 | 说明 |
|---|---|---|
biz_order_no | 是 | 业务侧订单号,同 site 唯一约束(见 §10) |
amount_fen | 是 | 金额,单位"分",正整数 |
channel | 是 | 见 §1 通道列表 |
subject | 是 | 商品/订单主题,最长 127 字符,会展示给用户 |
client_ip | 否 | 用户公网 IP(微信支付强制要求) |
user_agent | 否 | 仅日志/审计用 |
return_url | 条件必填 | 仅浏览器跳转通道(alipay_page / alipay_wap)必须。 用户付完款支付宝把浏览器跳回的目标 URL。不传则用户停留在支付宝"付款成功,3 秒后自动返回商户"页面 → 没跳转目标 → UX 卡死。 建议传业务方的 checkout / 订单详情页,并在该页轮询订单状态。 扫码通道(wechat_native / alipay_qr)不需要。 |
payer_openid | 否 | 仅 wechat_jsapi 通道用,公众号 H5 内识别用户的 openid |
metadata | 否 | 任意 JSON,会在 callback 原样回传 |
响应
{
"order_no": "lp_xxx", // pay 内部订单号
"biz_order_no": "ORDER202605080001",
"channel": "wechat_native",
"amount_fen": 9900,
"expires_at": 1778241800,
"instructions": {
"type": "QR", // QR / REDIRECT / JSAPI
"qr_data_url": "data:image/png;base64,...",
"raw_pay_url": "weixin://wxpay/bizpayurl?pr=..."
}
}前端拿到后:
type === "QR"→ 渲染<img src={qr_data_url} />type === "REDIRECT"→window.location.href = redirect_url
用户付完款后会跳到return_url,记得在创单时传; 没传 return_url 用户会卡在支付宝"付款成功,3 秒后自动返回商户"提示页。type === "JSAPI"→ 把jsapi_params喂给WeixinJSBridge.invoke()
3.5 前端付款后跳回的处理(新接入方必读)
关键事实:用户在支付宝付款成功 → 浏览器跳回 return_url 时,业务方后端还不一定已经被 lj-pay 通知到了。
实测时间线:
T+0s 用户在支付宝点"确认付款",支付宝标 PAID T+3s 用户浏览器跳回业务方 return_url T+3s 支付宝异步发 webhook → lj-pay → 业务方 callback(最快路径,正常 3-30 秒) ↓ 如果 webhook 丢失或被防火墙拦: T+90s+ lj-pay reconcile worker 主动查支付宝 → 走同样的 callback 路径
所以:用户跳回 return_url 那一刻(T+3s),业务方页面查订单可能还是 PENDING。不轮询,用户会看到"待支付"卡死。
解决方案(前后端协议)
第 1 步:业务方在创单时把 return_url 末尾带一个标记参数(如 ?paid=1):
// 业务方后端创单
const returnUrl = `https://your-site.com/checkout/${order.id}?paid=1`
await ljPayCreate({ ..., return_url: returnUrl })支付宝跳回时会追加自己的参数(out_trade_no / trade_no / sign 等), 最终 URL 变成 ?paid=1&out_trade_no=...&...。paid=1 仍能被前端识别,其他参数业务方不需要验证。
第 2 步:checkout 页检测到 paid=1 就启动 1Hz 轮询:
// 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") {
const u = new URL(window.location.href)
u.searchParams.delete("paid")
window.history.replaceState({}, "", u.toString())
toast.success("支付成功")
}
}, [data?.status])第 3 步:建议 5 分钟超时兜底(reconcile 最迟在创单后 ~3 分钟会标 PAID;超过 5 分钟还没 PAID 就引导用户联系客服)。
不要在前端用支付宝跳回 URL 里的 trade_status=TRADE_SUCCESS 当判断依据 —— 那是支付宝签的,业务方既没公钥也没必要验。只信你后端轮询的结果。
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}响应
{
"order_no": "lp_xxx",
"biz_order_no": "ORDER202605080001",
"channel": "wechat_native",
"status": "PAID", // PENDING/PAID/FAILED/REFUNDED/PARTIAL_REFUNDED/CANCELED/EXPIRED/PAY_AFTER_CLOSE
"amount_fen": 9900,
"paid_amount_fen": 9900,
"refunded_fen": 0,
"channel_trade_no": "4200001234",
"created_at": "2026-05-08T13:00:00Z",
"paid_at": "2026-05-08T13:01:23Z"
}5. 取消订单
POST https://pay.ljmatrix.cn/api/v1/order/cancel
{ "biz_order_no": "ORDER202605080001" }仅 PENDING 订单可取消。已支付的只能走退款。
6. 发起退款
POST https://pay.ljmatrix.cn/api/v1/order/refund
{
"biz_order_no": "ORDER202605080001",
"out_refund_no": "REFUND202605080001",
"refund_fen": 9900,
"reason": "用户申请退款"
}响应
{
"refund_no": "lpr_xxx",
"out_refund_no": "REFUND202605080001",
"status": "PROCESSING", // PROCESSING / SUCCESS / FAILED
"refund_fen": 9900,
"channel_refund_id": "5000xxx"
}退款是异步的,最终状态通过 refund.success webhook 通知业务方。
7. 业务回调(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 # 投递 ID,用于幂等去重
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
{
"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
{
"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 业务方必须做的 4 件事
- 用 raw text body(不是 JSON.parse 后再 stringify)算 HMAC 验签
- 校验
amount_fen === yourOrder.amountFen(防 pay 被攻破伪造) - 幂等:根据
biz_order_no + event去重,已处理直接返回 ok - 必须返回
HTTP 200 + {"ok":true},否则 pay 会重试 8 次
8. 状态机
PENDING ┬→ 支付通知/查单成功 → PAID
├→ cancel 接口 → CANCELED
├→ 超时未支付 → EXPIRED
└→ 渠道明确失败 → FAILED
PAID ┬→ 全额退款成功 → REFUNDED
└→ 部分退款成功 → PARTIAL_REFUNDED
PARTIAL_REFUNDED → 继续退款直到全额 → REFUNDED
异常状态:
EXPIRED/CANCELED/FAILED → 渠道查单发现已支付 → PAY_AFTER_CLOSE(人工处理)PAY_AFTER_CLOSE 含义:用户在订单已被本地关闭(超时 / 取消 / 失败)之后才完成支付。这是异常状态:钱已经收了但本地状态不正常。lj-pay 会发 order.pay_after_close 事件给业务方 callback,业务方收到这个事件应当人工介入(要么发货并恢复订单到 PAID,要么发起退款),不要自动当 PAID 处理。常见原因:用户把支付宝/微信付款页放后台太久了再付。
8.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 秒 |
| 订单 TTL | 30 分钟:超时进入 EXPIRED;之后该 biz_order_no 不能复用(见 §10) |
amount_fen | 正整数,单位"分";上限受上游渠道单笔限额约束 |
| 默认 currency | CNY(目前只支持 CNY) |
9. 回调重试节奏
业务回调失败后,pay 按以下间隔重试,最多 11 次约 24 小时:
1s, 5s, 30s, 2m, 5m, 15m, 1h, 3h, 6h, 12h, 24h
仍失败 → 进入 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。常见做法:
// 业务方代码示意:用户点「重新支付」时 const newBizOrderNo = originalBizOrderNo + "_retry_" + Date.now() // 或者:直接生成全新的 UUID / cuid const newBizOrderNo = crypto.randomUUID() // 把新订单号映射到原业务订单(DB 加一张 RetryMapping 表,或在 metadata 里存 original_id)
这是为了防止用户付款瞬间同时收到「PENDING 单过期 → 重新支付」和「过期单上其实已支付(PAY_AFTER_CLOSE)」造成账目错乱。
11. 错误码
| 错误码 | HTTP | 说明 |
|---|---|---|
INVALID_SIGNATURE | 401 | HMAC 验签失败 |
EXPIRED_TIMESTAMP | 401 | timestamp 超 ±5 分钟 |
REPLAYED_NONCE | 401 | nonce 已使用过(10min 缓存) |
SITE_NOT_FOUND | 401 | site_code 不存在 |
SITE_DISABLED | 403 | 站点已禁用 |
RATE_LIMITED | 429 | 该 site 调用频率超限(默认 100/min 创单、600/min 查单、30/min 退款) |
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_NOT_IMPLEMENTED | 501 | 通道暂未实现 |
CHANNEL_API_ERROR | 502 | 上游微信 / 支付宝返回错(含 raw 信息) |
WEBHOOK_INVALID | 400 | 渠道回调验签 / 解密失败 |
CALLBACK_URL_BLOCKED | 400 | callback URL 不在 allowlist 或解析到内网 |
11.5 HMAC 密钥轮换
业务方怀疑 hmac_secret 泄漏,或例行轮换:
- 联系 lj-pay 管理员(或自助到后台
/admin/sites/{site_code})触发轮换 - 系统生成新 secret,把旧 secret 标记为
previousSecretCiphertext,过期默认 24 小时 - 业务方在过期前更新
.env里LJ_PAY_HMAC_SECRET到新值并重启 - 过期窗口内 lj-pay 同时接受新旧两把 secret 签名(双签生效),保证不停机
- 过期后旧 secret 失效
callback(lj-pay → 业务方)方向用同一把 hmac_secret,业务方按新 secret 验签即可。
11.6 mock 通道警告
种子数据里有 wechat-mock / alipay-mock 两个通道,仅供本地开发与单元测试。
- 创单走 mock 时不会调上游、不会真扣款,返回伪造的
raw_pay_url - 生产环境必须把 mock 通道
isActive置为 false,并配真实的alipay-live-*/wechat-live-*通道 - 后台
/admin/channels可看现有通道清单与启用状态
12. 上线检查清单
- 业务后端已保存 order_no
- 金额单位用「分」(int)
- 所有写接口都带 Idempotency-Key
- 创建订单接口已联调
- 查询订单接口已联调
- callback endpoint 已实现
- callback HMAC 验签已实现
- callback 幂等去重已实现
- callback 校验 amount_fen === my_order.amountFen
- 重复 callback 不会重复发货 / 重复加积分
- 退款接口按需联调
- 生产 hmac_secret 没进前端 / 没进 git / 没进日志
- pay 后台能查到该产品的订单和回调记录
13. 容易踩的坑
- 金额单位是分。9.9 元 =
Math.round(9.9 * 100) = 990,不是9.9 - 验签用 raw text body,不是
JSON.stringify(await req.json())(顺序、空格会变) - GET 的 body_hash =
sha256("")=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,不是 omit - path 必须 canonical:query 参数按 key 排序后 URL-encode 拼回,无 query 时仅 pathname(不带末尾
/) - nonce 不能复用。重试请求时**重新**生成 nonce
- Idempotency-Key 强烈建议带(同 biz_order_no 即可),重试时不会重复创单
- biz_order_no 一旦生成 EXPIRED / CANCELED 单,重新支付要换新的 biz_order_no(schema unique 限制)
- callback 必须 200 + {"ok":true},4xx/5xx/超时都会触发重试
- pay 是 23:59 工作日吗? 不是。pay 是 24/7 服务,但微信支付商户后台某些操作有人工审批可能延迟
- callbackUrl 必须 HTTPS + 公网 IP,pay 拒绝内网 IP / localhost
14. 自抄 SDK 模板
登录后台 /admin/developer 可看到 Node.js / Python / PHP 三种 SDK 模板,带「复制」按钮。
15. 给 AI 助手用的提示词
把这段提示词喂给 ChatGPT / Claude / Gemini 即可让 AI 生成完整接入代码:
我要给项目 X 接入梨界支付中心。
完整接入文档:https://pay.ljmatrix.cn/docs/integration.md
OpenAPI 规范:https://pay.ljmatrix.cn/openapi.json
我的项目栈:[Next.js / Express / Flask / Django / Laravel ...]
我的 callback URL:[https://业务域名/api/internal/pay-callback]
我已注册 site_code = "[code]",hmac_secret 在业务后端 .env
请生成:
1. lib/lj-pay.{ts|py|php}(HMAC 签名 + create/refund/cancel/query 4 个 API)
2. /api/internal/pay-callback 路由(验签 + 幂等 + 金额校验 + 标已支付)
3. 一个 ¥0.01 端到端测试脚本
要求:
- 金额单位是分
- 验签用 raw body + timingSafeEqual
- callback 幂等(biz_order_no + event 去重)
- 全部代码自包含可直接跑