Skip to content

Next.js 接入(App Router) ​

这套接入适合谁 ​

用 Next.js 13+ App Router 做的电商站或 SaaS。它的 Route Handler 天然是服务端代码, 正好用来放"带 API KEY 去下单"这一步 —— 因此这是最贴合我们接口分工的框架。

前置准备 ​

  • 已拿到 API KEY(见立即接入)。
  • 已配置收款地址并设为启用。
  • 环境变量(只在服务端可用,不要加 NEXT_PUBLIC_ 前缀):
bash
# .env.local
API_BASE=https://api.web3sdk.com
PAY_API_KEY=your-api-key

别用 NEXT_PUBLIC_ 前缀

NEXT_PUBLIC_ 开头的变量会被内联进浏览器包。KEY 只能用不带前缀的名字, 并且只在 Route Handler / Server Action 里读。

安装 ​

Next.js 自带 Route Handler,无需额外依赖;链上交互按需:

bash
npm i viem

最小可跑示例 ​

1. Route Handler:服务端下单 ​

ts
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { orderNo, amount } = await req.json()

  const r = await fetch(`${process.env.API_BASE}/api/pay/open/createOrder`, {
    method: 'POST',
    headers: {
      'X-Pay-Api-Key': process.env.PAY_API_KEY!,   // 只在服务端
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      merchantOrderNo: orderNo,      // 幂等键
      chainId: 2,
      tokenId: 1,
      amount: String(amount),        // 代币单位小数字符串
      subject: `订单 ${orderNo}`,
      notifyUrl: `${process.env.SITE_URL}/api/pay/notify`,
      returnUrl: `${process.env.SITE_URL}/pay/done`,
    }),
    cache: 'no-store',
  }).then((x) => x.json())

  if (r.code !== 0) {
    return NextResponse.json({ code: r.code, msg: r.msg }, { status: 400 })
  }
  const o = r.data.order
  // 只回前端要展示的字段
  return NextResponse.json({
    code: 0,
    orderNo: o.orderNo,
    payAddress: o.payAddress,
    payAmountDisplay: o.payAmountDisplay,
    payAmountMinimal: o.payAmountMinimal,
    identify: o.identify,
    chainKey: o.chainKey,
    idempotentReplay: r.data.idempotentReplay,
  })
}

2. 查状态(同样在服务端) ​

ts
// app/api/order-status/route.ts
import { NextResponse } from 'next/server'

export async function GET(req: Request) {
  const orderNo = new URL(req.url).searchParams.get('orderNo')
  const r = await fetch(
    `${process.env.API_BASE}/api/pay/open/queryOrder?merchantOrderNo=${encodeURIComponent(orderNo!)}`,
    { headers: { 'X-Pay-Api-Key': process.env.PAY_API_KEY! }, cache: 'no-store' },
  ).then((x) => x.json())

  if (r.code !== 0) return NextResponse.json({ code: r.code, msg: r.msg }, { status: 400 })
  const o = r.data
  return NextResponse.json({
    code: 0,
    status: o.status,               // 1..6,只认 3/4 为已付款
    statusText: o.statusText,
    txHash: o.txHash,
    receivedAmountMinimal: o.receivedAmountMinimal,
  })
}

3. 客户端组件展示收银台 ​

tsx
// app/checkout/Checkout.tsx
'use client'
import { useEffect, useState } from 'react'

export default function Checkout({ orderNo, amount }: { orderNo: string; amount: string }) {
  const [order, setOrder] = useState<any>(null)
  const [status, setStatus] = useState(1)

  useEffect(() => {
    fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderNo, amount }),
    })
      .then((r) => r.json())
      .then(setOrder)
  }, [orderNo, amount])

  useEffect(() => {
    if (!order?.orderNo) return
    const t = setInterval(async () => {
      const s = await fetch(`/api/order-status?orderNo=${order.orderNo}`).then((r) => r.json())
      setStatus(s.status)
      if (s.status === 3 || s.status === 4) clearInterval(t)
    }, 3000)
    return () => clearInterval(t)
  }, [order])

  if (!order?.orderNo) return <p>正在创建订单…</p>

  return (
    <div className="cg-qr">
      <div>
        <p>请转账 <b>{order.payAmountDisplay}</b> USDT(链:{order.chainKey})</p>
        <p>收款地址:<span className="cg-copy">{order.payAddress}</span></p>
        <p>{status === 2 ? '已收到交易,等待链上确认…' : status >= 3 ? '支付已确认' : '等待付款…'}</p>
      </div>
    </div>
  )
}

回调处理 ​

回调也用 Route Handler 接收。注意要拿到原始报文体 —— App Router 里用 await req.text(), 不要先 req.json()。

ts
// app/api/pay/notify/route.ts
import { NextResponse } from 'next/server'
import crypto from 'node:crypto'

function verify(rawBody: string, ts: string, sig: string) {
  const want = crypto
    .createHmac('sha256', process.env.PAY_API_KEY!)
    .update(ts).update(rawBody)
    .digest('hex')
  return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))
}

export async function POST(req: Request) {
  const raw = await req.text()            // 原始字符串,别用 req.json()
  const ts = req.headers.get('x-pay-timestamp') ?? ''
  const sig = req.headers.get('x-pay-signature') ?? ''

  if (!verify(raw, ts, sig)) return new NextResponse('bad signature', { status: 401 })

  const p = JSON.parse(raw)
  if (await isProcessed(p.merchantOrderNo)) return new NextResponse(null, { status: 200 })
  await markPaid(p.merchantOrderNo, p.txHash)
  return new NextResponse(null, { status: 200 })   // 2xx 才会停止重试
}
  • 幂等:用 merchantOrderNo 去重。
  • 快返回:约 10 秒超时;耗时逻辑放队列或 after()。
  • 以查询为准:发货等关键动作前用查询接口复核。

常见问题 ​

问题说明
Route Handler 能放 KEY 吗能,它只在服务端运行。这也是推荐的落点
Server Action 行不行行,同样跑在服务端,读环境变量即可
静态导出(output: export)怎么办那就没有服务端了,下单必须放到独立的服务端(如一个小 Node 服务)
回调拿不到原始正文用 await req.text(),不要 req.json() 后再序列化
缓存导致状态不更新查询与下单请求都加 cache: 'no-store'

相关 ​

面向全球中小商家的 Web3 支付基础设施