使用集成接入支付宝支付
连接支付宝集成后在 Func 里用 ctx.payment.alipay 发起电脑网站支付并验签异步通知:应用私钥留在服务端,签名、通知验签与内容加密由平台完成,订单、金额核对与幂等发货仍由站点代码负责。
支付宝支付是托管集成:在编辑器里连接一次,之后在 Func 里用 ctx.payment.alipay 发起电脑网站支付、验签异步通知。应用私钥与 AES 密钥只保存在服务端,签名、验签和内容加密都由平台完成,代码里不会出现任何密钥。
payment 是命名空间,不是统一接口
方法名按支付宝自己的产品形状取,刻意不做跨平台的统一支付接口:各家支付的参数结构和回调语义差得很远(Stripe 是 Checkout Session、支付宝是表单签名跳转),硬抽一个 createCheckout() 只会变成一个更难懂的转发器。将来接别家是新增 ctx.payment.<provider>,已有代码不受影响。
分工是:平台负责密码学与凭据,站点代码负责钱和货。
| 平台保证 | 仍然由你写 |
|---|---|
| RSA2 签名,以及对表单原文的异步通知验签 | 金额来自服务端商品表 |
核对 app_id / seller_id 属于本渠道 | 订单表与订单状态 |
| 可选的 AES 内容加密 | 幂等发货 |
| OpenAPI 响应按响应原文验签 | 与本地订单核对金额 |
| 保存凭据时真实校验一次 | — |
连接支付宝
先在支付宝开放平台取参数:
- 进入收款用的应用,在「应用详情」记下
APPID。 - 打开「开发设置 → 接口加签方式」,用 支付宝开发者工具生成 RSA2 应用密钥对:应用私钥自己保管,只把应用公钥上传给支付宝;上传后在同一处复制支付宝生成的支付宝公钥。
- 记下实际收款账号的支付宝用户 ID(PID),它要与通知里的
seller_id一致。 - 如果开了「接口内容加密方式」,复制那个 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-id | APPID 不对,或沙箱 APPID 配了正式网关 |
| 响应验签失败 | 支付宝公钥填错了(常见:填成了自己的应用公钥) |
isv.decrypt-error | AES 密钥不对 |
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.ts 的 notify:
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_id 与 seller_id 必须属于这个渠道、必填字段检查。任何一步不通过都直接抛错,而不是返回一个可能被当成 falsy 忽略的值。
| 返回字段 | 说明 |
|---|---|
paid | TRADE_SUCCESS 与 TRADE_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 收不到线上通知。
