使用 Func 接入支付宝电脑网站支付
支付宝电脑网站支付的完整 Func 示例:公钥模式、RSA2、可选 AES 内容加密、可信订单、异步通知验签与幂等更新。
使用 Creght Func 接入支付宝电脑网站支付:服务端创建可信订单,使用 RSA2 签名,可选启用 AES 内容加密,并通过验签后的异步通知幂等更新支付状态。
范围与前提
金额必须由服务端商品表决定,浏览器只传 productId。支付成功状态只来自验签通过的异步通知,不能相信前端回跳参数。
准备支付宝参数并配置环境变量
1. 在支付宝开放平台获取参数
- 登录支付宝开放平台控制台并进入用于收款的应用。在「应用详情」记录
APPID。 - 打开「开发设置 → 开发信息 → 接口加签方式」。使用支付宝开发者工具生成 RSA2 应用密钥对;应用私钥由自己保管,只把应用公钥上传到支付宝。配置完成后,在同一位置复制支付宝生成的支付宝公钥。
- 如需内容加密,打开「接口内容加密方式」,查看或生成 AES 密钥。它是 Base64 字符串,与 RSA 密钥用途不同。
不要混淆密钥与地址:应用私钥由开发者生成并用于请求签名;应用公钥上传给支付宝;支付宝公钥由支付宝提供并用于验证响应和异步通知。图中的「支付宝网关地址」是 Func 向支付宝发起 OpenAPI 请求的目标地址。支付异步通知地址和支付后返回地址不在本页配置,而是在每次
alipay.trade.page.pay请求中分别通过notify_url和return_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
- 进入目标站点编辑器,打开左侧「后端 → 环境变量」。
- 点击右上角「New variable」,按上表逐项新增;Key 必须完全一致,Value 粘贴对应的完整值。
- 密钥只放环境变量,在 Func 中通过
process.env.ALIPAY_PRIVATE_KEY等名称读取。不要写入页面、Func 源码、CMS、日志或浏览器返回值。
环境变量页面只应显示变量名和「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_id、seller_id、本地订单、金额和支付宝交易号;重复通知返回相同的success。 - 示例只进行幂等订单状态更新。若还要发放权益,必须继续以
outTradeNo/tradeNo做幂等保护。 - 沙箱与正式环境使用各自的应用、密钥和网关,上线前必须在支付宝对应环境完成一次真实通知验收。
