集成使用集成接入支付宝支付

使用集成接入支付宝支付

连接支付宝集成后在 Func 里用 ctx.payment.alipay 发起电脑网站支付并验签异步通知:应用私钥留在服务端,签名、通知验签与内容加密由平台完成,订单、金额核对与幂等发货仍由站点代码负责。

支付宝支付是托管集成:在编辑器里连接一次,之后在 Func 里用 ctx.payment.alipay 发起电脑网站支付、验签异步通知。应用私钥与 AES 密钥只保存在服务端,签名、验签和内容加密都由平台完成,代码里不会出现任何密钥。

payment 是命名空间,不是统一接口

方法名按支付宝自己的产品形状取,刻意不做跨平台的统一支付接口:各家支付的参数结构和回调语义差得很远(Stripe 是 Checkout Session、支付宝是表单签名跳转),硬抽一个 createCheckout() 只会变成一个更难懂的转发器。将来接别家是新增 ctx.payment.<provider>,已有代码不受影响。

分工是:平台负责密码学与凭据,站点代码负责钱和货

平台保证仍然由你写
RSA2 签名,以及对表单原文的异步通知验签金额来自服务端商品表
核对 app_id / seller_id 属于本渠道订单表与订单状态
可选的 AES 内容加密幂等发货
OpenAPI 响应按响应原文验签与本地订单核对金额
保存凭据时真实校验一次

连接支付宝

先在支付宝开放平台取参数:

  1. 进入收款用的应用,在「应用详情」记下 APPID
  2. 打开「开发设置 → 接口加签方式」,用 支付宝开发者工具生成 RSA2 应用密钥对:应用私钥自己保管,只把应用公钥上传给支付宝;上传后在同一处复制支付宝生成的支付宝公钥
  3. 记下实际收款账号的支付宝用户 ID(PID),它要与通知里的 seller_id 一致。
  4. 如果开了「接口内容加密方式」,复制那个 Base64 的 AES 密钥;没开就不用管。

然后打开编辑器的后端 → 集成,点支付宝,按下表填写。保存时平台会真的调一次支付宝,配置不对当场报错,而不是等到有人付款才发现。

字段填什么必填
APPID应用详情里的 APPID
应用私钥你自己生成并保管的应用私钥,不是应用公钥。PKCS#8 / PKCS#1 都行,带不带 PEM 头都行
支付宝公钥上传应用公钥后由支付宝提供的那一串
收款账号 PID实际收款账号的支付宝用户 ID
异步通知地址已发布的 https Func 地址,例如 https://example.com/func/alipay.notify
支付后返回地址付款后跳回的页面,只影响体验
AES 密钥「接口内容加密方式」里的 Base64 字符串开了内容加密才填
网关地址留空走正式环境;沙箱填对应的沙箱网关沙箱填

保存即校验做了什么

保存时平台用 alipay.trade.query 查一个随机订单号。这一次调用同时验四件事:APPID 存在且开通了接口;应用私钥与你上传的应用公钥是同一对(签名被支付宝接受);支付宝公钥正确(能验证支付宝的响应签名);网关与 APPID 配套(正式 / 沙箱)。填了 AES 密钥时连它一起验。

「查不到这笔交易」是成功信号——查的本来就是一个不存在的订单号,能收到这个业务错误恰好证明请求过了签名和权限校验。

报错该改哪一项
isv.invalid-signature应用私钥与上传给支付宝的应用公钥不是一对
isv.invalid-app-idAPPID 不对,或沙箱 APPID 配了正式网关
响应验签失败支付宝公钥填错了(常见:填成了自己的应用公钥)
isv.decrypt-errorAES 密钥不对
isv.insufficient-isv-permissions这个应用没签约对应产品,去开放平台的「产品签约」开通

订单表

支付状态存在你自己的表里。创建 /platform/table/payment_orders.json

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

发起支付

创建 /backend/func/alipay.ts

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

// 金额由服务端商品表决定,浏览器只传 productId。
const PRODUCTS = {
  starter: { subject: 'Starter plan', amount: '9.90' },
} as const

export 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, '')
  ctx.db.insert('payment_orders', {
    userId: String(user.id),
    productId,
    amount: product.amount,
    outTradeNo,
    status: 'pending',
  })

  const { payUrl } = ctx.payment.alipay.pageUrl({
    outTradeNo,
    subject: product.subject,
    amount: product.amount,
  })
  return { outTradeNo, payUrl }
}

pageUrl 返回 { payUrl, outTradeNo, amount, appId },浏览器拿到 payUrl 直接跳转:

import { invoke } from 'talizen/func'

const { payUrl } = await invoke<{ payUrl: string }>('alipay.create', {
  productId: 'starter',
})
window.location.assign(payUrl)
参数说明
outTradeNo必填,商户订单号,64 位以内可见 ASCII。平台不代生成:它是你订单表的主键和幂等键,必须先有订单行再跳转
subject必填,商品标题
amount必填,单位元、最多两位小数(9.9 会归一成 9.90
body可选,商品描述
returnUrl可选,覆盖集成里配的返回地址(只影响页面体验)

刻意不提供 notifyUrl 覆盖:支付事实只能从一个地方进来,异步通知地址在集成里配一次。

接收异步通知

集成里配的异步通知地址就指向这个方法——https://example.com/func/alipay.notify 对应 /backend/func/alipay.tsnotify

export async function notify(_input: unknown, ctx: TalizenFuncContext) {
  // 验签失败会抛错,伪造的通知走不到下面任何一行。
  // 不要 catch 成 success——返回非 success 时支付宝会重投。
  const n = ctx.payment.alipay.verifyNotify(await ctx.request.text())
  if (!n.paid) return new Response('success') // 关单等其他状态

  const { list } = ctx.db.query('payment_orders', {
    where: { outTradeNo: n.outTradeNo },
    limit: 1,
  })
  const order = list[0]
  if (!order) return new Response('failure', { status: 400 })
  if (order.amount !== n.totalAmount) return new Response('failure', { status: 400 })
  if (order.status === 'paid') return new Response('success') // 重复通知

  ctx.db.update('payment_orders', order.id, {
    status: 'paid',
    alipayTradeNo: n.tradeNo,
    paidAt: new Date().toISOString(),
  })
  // 发放权益同样要以 outTradeNo / tradeNo 做幂等保护。
  return new Response('success')
}

verifyNotify 收的必须是原始表单正文await ctx.request.text())。不要自己解析再拼回去——签名覆盖的是原文,重拼一次就验不过。

平台在这一步完成:RSA2 验签、app_idseller_id 必须属于这个渠道、必填字段检查。任何一步不通过都直接抛错,而不是返回一个可能被当成 falsy 忽略的值。

返回字段说明
paidTRADE_SUCCESSTRADE_FINISHED 都算已到账,直接用它,别自己判字符串
outTradeNo / tradeNo你的订单号 / 支付宝交易号
totalAmount实付金额,必须与自己订单表里的金额核对
tradeStatus原始交易状态
notifyId通知 id,可用来去重
buyerId / gmtPayment / subject买家 ID、付款时间、标题
params全部原始参数,读 passback_params 之类字段用它

查单、退款与其他接口

其余 OpenAPI 方法用 call,平台代签并对响应原文验签:

// 查单:对账,或用户回到页面时主动确认一次
const r = ctx.payment.alipay.call('alipay.trade.query', { out_trade_no: 'C0001' })
if (r.trade_status === 'TRADE_SUCCESS') { /* ... */ }

// 退款
ctx.payment.alipay.call('alipay.trade.refund', {
  out_trade_no: 'C0001',
  refund_amount: '9.90',
  out_request_no: 'R0001', // 退款请求号,重复退款靠它幂等
})

返回的是验签通过后的业务响应节点(alipay_xxx_response 里的内容);业务码不是 10000 时抛错。alipay.trade.page.pay 不能走 call——它是跳转流程,用 pageUrl

一个项目挂多个收款账号

加两份支付宝集成,给它们不同的渠道 tag,Func 里用 via() 选:

ctx.payment.alipay.pageUrl({ ... })                   // 默认渠道,等价于 via('default')
ctx.payment.alipay.via('overseas').pageUrl({ ... })   // 另一个收款账号
ctx.payment.alipay.via('overseas').verifyNotify(raw)  // 通知要用同一个渠道

平台不管的部分

这几条是支付里最容易出事的地方,平台帮不了你:

  • 金额由服务端商品表决定,浏览器只传 productId。让浏览器传金额等于让人自己定价。
  • 支付事实只来自验签通过的异步通知。returnUrl 只是付款后跳回的页面,用户可以直接访问它,不能据此标记已支付。
  • 验签通过后仍要核对本地订单和金额,再看 tradeNo 是否与已记录的一致。
  • 发放权益要幂等,以 outTradeNo / tradeNo 为键——通知会重投。

边界

  • 只支持电脑网站支付的公钥模式;手机网站支付、App 支付、当面付、证书模式还没做。
  • 支付集成不能打开「把密钥暴露给 Func 代码」:收款私钥不进沙箱,由平台代签。想完全自己接就走另一条路——在后端 → 环境变量里自己配 ALIPAY_*,用 fetch + crypto.subtle 写自己的 Func,两条路互不干扰,做法见 使用 Func 接入支付宝电脑网站支付
  • 沙箱与正式环境用各自的应用、密钥和网关。上线前必须在正式环境真实收到过一笔通知,这一步没有替代品。
  • 异步通知地址必须是已发布的 https 地址:预览域名下的 Func 收不到线上通知。

Render diagnostics