后端使用 Func 接入支付宝电脑网站支付

使用 Func 接入支付宝电脑网站支付

支付宝电脑网站支付的完整 Func 示例:公钥模式、RSA2、可选 AES 内容加密、可信订单、异步通知验签与幂等更新。

使用 Creght Func 接入支付宝电脑网站支付:服务端创建可信订单,使用 RSA2 签名,可选启用 AES 内容加密,并通过验签后的异步通知幂等更新支付状态。

范围与前提

金额必须由服务端商品表决定,浏览器只传 productId。支付成功状态只来自验签通过的异步通知,不能相信前端回跳参数。

准备支付宝参数并配置环境变量

1. 在支付宝开放平台获取参数

  1. 登录支付宝开放平台控制台并进入用于收款的应用。在「应用详情」记录 APPID
  2. 打开「开发设置 → 开发信息 → 接口加签方式」。使用支付宝开发者工具生成 RSA2 应用密钥对;应用私钥由自己保管,只把应用公钥上传到支付宝。配置完成后,在同一位置复制支付宝生成的支付宝公钥
  3. 如需内容加密,打开「接口内容加密方式」,查看或生成 AES 密钥。它是 Base64 字符串,与 RSA 密钥用途不同。
支付宝开放平台应用开发设置页面,标出接口加签状态和支付宝正式网关地址
支付宝开放平台「开发设置」:确认接口加签和内容加密配置;正式环境使用页面显示的支付宝网关地址。点击图片可查看大图。

不要混淆密钥与地址:应用私钥由开发者生成并用于请求签名;应用公钥上传给支付宝;支付宝公钥由支付宝提供并用于验证响应和异步通知。图中的「支付宝网关地址」是 Func 向支付宝发起 OpenAPI 请求的目标地址。支付异步通知地址和支付后返回地址不在本页配置,而是在每次 alipay.trade.page.pay 请求中分别通过 notify_urlreturn_url 传入。

2. 对应到 Creght 环境变量

Creght Key值的来源是否必需
ALIPAY_APP_ID支付宝应用详情中的 APPID必需
ALIPAY_PRIVATE_KEY自己生成并妥善保存的应用私钥;不是应用公钥必需
ALIPAY_PUBLIC_KEY上传应用公钥后,支付宝提供的支付宝公钥必需
ALIPAY_AES_KEY接口内容加密方式中的 Base64 AES 密钥启用 AES 时必需
ALIPAY_SELLER_ID实际收款账号的支付宝用户 ID(PID),应与通知中的 seller_id 一致必需
ALIPAY_NOTIFY_URL稳定且已发布的 HTTPS Func 地址,例如 https://example.com/func/alipay.notify必需
ALIPAY_RETURN_URL支付后返回的网站页面 URL;只用于页面体验可选
ALIPAY_GATEWAY正式环境可省略并使用默认网关;沙箱填写对应沙箱网关沙箱可选

3. 写入 Creght Backend / Env

  1. 进入目标站点编辑器,打开左侧「后端 → 环境变量」。
  2. 点击右上角「New variable」,按上表逐项新增;Key 必须完全一致,Value 粘贴对应的完整值。
  3. 密钥只放环境变量,在 Func 中通过 process.env.ALIPAY_PRIVATE_KEY 等名称读取。不要写入页面、Func 源码、CMS、日志或浏览器返回值。
Creght 编辑器的后端环境变量页面,右上角 New variable 按钮被标出
Creght 编辑器「后端 → 环境变量」:点击 New variable,为每个配置项分别创建一条变量。点击图片可查看大图。

环境变量页面只应显示变量名和「Value set」状态。截图、报错、日志和工单中都不要暴露私钥或 AES 密钥;轮换密钥后需同步更新支付宝与 Creght 配置,并重新验证沙箱或正式环境。

4. AES 的生效范围

支付宝使用 AES/CBC/PKCS5Padding 和 16 字节全零 IV;Func 中对应原生 Web Crypto 的 AES-CBC,运行时会自动进行兼容的 PKCS#7 填充,不要手工补位。在支付宝后台配置 AES 密钥不会强制所有调用加密;本示例只有检测到 ALIPAY_AES_KEY 时,才会显式加密 UTF-8 biz_content、设置 encrypt_type=AES,最后对包含密文的完整参数做 RSA2 签名。详见支付宝接口内容加密方式

异步通知不做 AES 解密:alipay.trade.page.pay 的标准异步通知仍是表单参数,直接按支付宝规则对参数执行 RSA2 验签。只有具体产品文档明确标为密文的字段才单独解密,不能解密整个通知正文。

订单表

创建 /platform/table/payment_orders.json

{
  "name": "Payment orders",
  "desc": "Alipay order state",
  "json_schema": {
    "type": "object",
    "properties": {
      "userId": { "type": "string" },
      "productId": { "type": "string" },
      "subject": { "type": "string" },
      "amount": { "type": "string" },
      "outTradeNo": { "type": "string" },
      "status": {
        "type": "string",
        "enum": ["pending", "paid", "closed"]
      },
      "alipayTradeNo": { "type": "string" },
      "paidAt": { "type": "string" }
    },
    "required": [
      "userId", "productId", "subject", "amount", "outTradeNo", "status"
    ]
  }
}

Func 实现

创建 /backend/func/alipay.ts

import type { TalizenFuncContext } from 'talizen/func-runtime'

type OrderRow = {
  id: string
  userId: string
  productId: string
  subject: string
  amount: string
  outTradeNo: string
  status: 'pending' | 'paid' | 'closed'
  alipayTradeNo?: string
  paidAt?: string
}

const TABLE = 'payment_orders'
const PRODUCTS = {
  starter: { subject: 'Starter plan', amount: '9.90' },
} as const

function env(name: string): string {
  const value = process.env[name]
  if (!value) throw new Error(name + ' is required')
  return value
}

function binaryStringToBytes(value: string): Uint8Array {
  const bytes: number[] = []
  for (let i = 0; i < value.length; i++) bytes.push(value.charCodeAt(i))
  return new Uint8Array(bytes)
}

function pemBytes(value: string): Uint8Array {
  const base64 = value
    .replace(/-----BEGIN [^-]+-----/g, '')
    .replace(/-----END [^-]+-----/g, '')
    .replace(/\s+/g, '')
  if (!base64) throw new Error('RSA key is empty')
  return binaryStringToBytes(atob(base64))
}

function bytesToBase64(value: ArrayBuffer): string {
  let binary = ''
  for (const byte of new Uint8Array(value)) binary += String.fromCharCode(byte)
  return btoa(binary)
}

function base64ToBytes(value: string): Uint8Array {
  return binaryStringToBytes(atob(value))
}

async function encryptAES(content: string): Promise<string> {
  const keyBytes = base64ToBytes(env('ALIPAY_AES_KEY'))
  if (![16, 24, 32].includes(keyBytes.length)) throw new Error('invalid AES key')
  const key = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'AES-CBC' },
    false,
    ['encrypt'],
  )
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-CBC', iv: new Uint8Array(16) },
    key,
    new TextEncoder().encode(content),
  )
  return bytesToBase64(encrypted)
}

async function decryptAES(content: string): Promise<string> {
  const keyBytes = base64ToBytes(env('ALIPAY_AES_KEY'))
  if (![16, 24, 32].includes(keyBytes.length)) throw new Error('invalid AES key')
  const key = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'AES-CBC' },
    false,
    ['decrypt'],
  )
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-CBC', iv: new Uint8Array(16) },
    key,
    base64ToBytes(content),
  )
  return new TextDecoder().decode(decrypted)
}

function decodeFormPart(value: string): string {
  return decodeURIComponent(value.replace(/\+/g, ' '))
}

function parseForm(raw: string): Array<[string, string]> {
  if (!raw) return []
  return raw.split('&').map((part) => {
    const separator = part.indexOf('=')
    const key = separator < 0 ? part : part.slice(0, separator)
    const value = separator < 0 ? '' : part.slice(separator + 1)
    return [decodeFormPart(key), decodeFormPart(value)]
  })
}

function formValue(pairs: Array<[string, string]>, name: string): string {
  const pair = pairs.find(([key]) => key === name)
  return pair ? pair[1] : ''
}

function canonicalPairs(
  pairs: Array<[string, string]>,
  ignored: string[] = [],
): string {
  const ignoredSet = new Set(ignored)
  return pairs
    .filter(([key]) => !ignoredSet.has(key))
    .map(([key, value]) => key + '=' + value)
    .sort()
    .join('&')
}

function canonicalParams(params: Record<string, string>): string {
  return Object.keys(params)
    .map((key) => key + '=' + params[key])
    .sort()
    .join('&')
}

function queryString(params: Record<string, string>): string {
  return Object.keys(params)
    .sort()
    .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
    .join('&')
}

function chinaTimestamp(): string {
  const china = new Date(Date.now() + 8 * 60 * 60 * 1000)
  return china.toISOString().slice(0, 19).replace('T', ' ')
}

function normalizeAmount(value: string): string {
  const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim())
  if (!match) throw new Error('invalid amount')
  const integer = match[1].replace(/^0+(?=\d)/, '')
  return integer + '.' + (match[2] || '').padEnd(2, '0')
}

async function signRSA2(content: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    'pkcs8',
    pemBytes(env('ALIPAY_PRIVATE_KEY')),
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['sign'],
  )
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    key,
    new TextEncoder().encode(content),
  )
  return bytesToBase64(signature)
}

async function verifyRSA2(content: string, signature: string): Promise<boolean> {
  const key = await crypto.subtle.importKey(
    'spki',
    pemBytes(env('ALIPAY_PUBLIC_KEY')),
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['verify'],
  )
  return crypto.subtle.verify(
    'RSASSA-PKCS1-v1_5',
    key,
    base64ToBytes(signature),
    new TextEncoder().encode(content),
  )
}

export async function create(
  input: { productId?: string },
  ctx: TalizenFuncContext,
) {
  const user = ctx.auth.requireUser()
  const productId = String(input.productId || '') as keyof typeof PRODUCTS
  const product = PRODUCTS[productId]
  if (!product) throw new Error('invalid product')

  const outTradeNo = 'C' + crypto.randomUUID().replace(/-/g, '')
  const params: Record<string, string> = {
    app_id: env('ALIPAY_APP_ID'),
    method: 'alipay.trade.page.pay',
    format: 'JSON',
    charset: 'utf-8',
    sign_type: 'RSA2',
    timestamp: chinaTimestamp(),
    version: '1.0',
    notify_url: env('ALIPAY_NOTIFY_URL'),
  }
  const bizContent = JSON.stringify({
    out_trade_no: outTradeNo,
    product_code: 'FAST_INSTANT_TRADE_PAY',
    subject: product.subject,
    total_amount: product.amount,
  })
  if (process.env.ALIPAY_AES_KEY) {
    params.encrypt_type = 'AES'
    params.biz_content = await encryptAES(bizContent)
  } else {
    params.biz_content = bizContent
  }
  if (process.env.ALIPAY_RETURN_URL) {
    params.return_url = process.env.ALIPAY_RETURN_URL
  }
  params.sign = await signRSA2(canonicalParams(params))

  ctx.db.insert(TABLE, {
    userId: String(user.id),
    productId,
    subject: product.subject,
    amount: product.amount,
    outTradeNo,
    status: 'pending',
  })

  const gateway = process.env.ALIPAY_GATEWAY || 'https://openapi.alipay.com/gateway.do'
  return { outTradeNo, payUrl: gateway + '?' + queryString(params) }
}

export async function notify(_input: unknown, ctx: TalizenFuncContext) {
  const failure = (reason: string, status = 400) => {
    console.warn('alipay notify rejected:', reason)
    return new Response('failure', { status })
  }

  try {
    const pairs = parseForm(await ctx.request.text())
    const signature = formValue(pairs, 'sign')
    if (!signature || formValue(pairs, 'sign_type') !== 'RSA2') {
      return failure('invalid sign fields')
    }

    const content = canonicalPairs(pairs, ['sign', 'sign_type', 'alipay_cert_sn'])
    if (!(await verifyRSA2(content, signature))) {
      return failure('signature verification failed')
    }
    if (formValue(pairs, 'app_id') !== env('ALIPAY_APP_ID')) {
      return failure('app_id mismatch')
    }
    if (formValue(pairs, 'seller_id') !== env('ALIPAY_SELLER_ID')) {
      return failure('seller_id mismatch')
    }

    const outTradeNo = formValue(pairs, 'out_trade_no')
    const tradeNo = formValue(pairs, 'trade_no')
    const tradeStatus = formValue(pairs, 'trade_status')
    const totalAmount = formValue(pairs, 'total_amount')
    if (!outTradeNo || !tradeNo || !totalAmount) {
      return failure('required trade fields are missing')
    }

    const result = ctx.db.query<OrderRow>(TABLE, {
      where: { outTradeNo },
      limit: 1,
    })
    const order = result.list[0]
    if (!order) return failure('local order not found')
    if (normalizeAmount(order.amount) !== normalizeAmount(totalAmount)) {
      return failure('amount mismatch')
    }

    if (order.status === 'paid') {
      if (order.alipayTradeNo && order.alipayTradeNo !== tradeNo) {
        return failure('trade_no mismatch')
      }
      return new Response('success')
    }

    if (tradeStatus === 'TRADE_CLOSED') {
      ctx.db.update(TABLE, order.id, { status: 'closed' })
      return new Response('success')
    }
    if (tradeStatus !== 'TRADE_SUCCESS' && tradeStatus !== 'TRADE_FINISHED') {
      return new Response('success')
    }

    ctx.db.update(TABLE, order.id, {
      status: 'paid',
      alipayTradeNo: tradeNo,
      paidAt: new Date().toISOString(),
    })
    return new Response('success')
  } catch (error) {
    console.error('alipay notify failed:', error)
    return new Response('failure', { status: 500 })
  }
}

浏览器发起支付

浏览器发起支付:

import { invoke } from 'talizen/func'

const { payUrl } = await invoke<{ payUrl: string }>(
  'alipay.create',
  { productId: 'starter' },
)
window.location.assign(payUrl)

验收与安全边界

  • AES 只加密 OpenAPI 的 biz_content,不会替代 RSA2。异步通知仍按表单原文执行 RSA2 验签,不要把整个通知正文当作 AES 密文。其他 OpenAPI 返回加密响应时,先按支付宝规定的响应节点原文验签,再把密文传给 decryptAES()
  • return_url 只用于支付后的页面体验,不能据此标记已支付;支付事实只来自验签通过的异步通知。
  • 回调先验 RSA2 签名,再核对 app_idseller_id、本地订单、金额和支付宝交易号;重复通知返回相同的 success
  • 示例只进行幂等订单状态更新。若还要发放权益,必须继续以 outTradeNo/tradeNo 做幂等保护。
  • 沙箱与正式环境使用各自的应用、密钥和网关,上线前必须在支付宝对应环境完成一次真实通知验收。

Render diagnostics