使用集成接入 Stripe 支付
连接 Stripe 集成后在 Func 里用 ctx.payment.stripe 创建 Checkout 收银台、复核回跳并验签 webhook:secret key 与 webhook 签名密钥留在服务端,订单、金额核对与幂等发货仍由站点代码负责。
Stripe 是托管集成:在编辑器里连接一次,之后在 Func 里用 ctx.payment.stripe 创建 Checkout 收银台、复核回跳、验签 webhook。Secret key 与 webhook 签名密钥只保存在服务端,代码里不会出现任何密钥。
payment 是命名空间,不是统一接口
方法名按 Stripe 自己的产品形状取,与 ctx.payment.alipay.* 一个方法都对不上——这是预期结果而不是缺陷:支付宝是「签一个表单跳过去、等异步通知」,Stripe 是「建一个 Session 跳过去、回跳复核 + webhook」。硬抽一个 createCheckout() 只会变成一个更难懂的转发器。
分工是:平台负责密码学与凭据,站点代码负责钱和货。
| 平台保证 | 仍然由你写 |
|---|---|
| webhook 的 HMAC-SHA256 验签(对原始正文)与 5 分钟时间戳容差 | 金额来自服务端商品表 |
读取 Stripe-Signature 请求头 | 订单表与订单状态 |
事件与 Session 的 livemode 必须与集成一致 | 事件去重表 |
| Secret key 只在服务端持有,永不进沙箱 | 幂等发货 |
paid 由 status + paymentStatus 一起算出 | 与本地订单核对金额与币种 |
| 保存凭据时真实校验一次 | 「这张单是不是当前用户的」 |
连接 Stripe
先在 Stripe 后台取参数:
- 打开 Developers → API keys,复制 Secret key。测试用
sk_test_开头的那把,正式用sk_live_。不要复制 Publishable key。 - (可选)在 Settings → Business 里记下账号 id,形如
acct_xxx。填了它,保存时会多一道「这把密钥确实属于这个账号」的校验。 - Webhook 签名密钥先不用管——它要等 Func 发布出去有了公开地址才能创建,见接收 webhook。
然后打开编辑器的后端 → 集成,点 Stripe,按下表填写。保存时平台会真的调一次 Stripe,配置不对当场报错,而不是等到有人付款才发现。
| 字段 | 填什么 | 必填 |
|---|---|---|
| Secret key | sk_test_ 或 sk_live_ 开头的那把密钥 | 是 |
| 正式模式 | 勾上表示这份配置收真钱。必须与密钥前缀一致,不一致直接报错 | 是 |
| 支付成功地址 | 付款后跳回的页面。可以带 {CHECKOUT_SESSION_ID} 占位符,Stripe 会替换成真实 session id | 建议 |
| 支付取消地址 | 买家在收银台点返回时跳到哪儿 | 建议 |
| 账号 id | acct_ 开头,填了就多一道账号校验 | 否 |
| 默认币种 | 三位字母,如 usd。checkoutSession 不传 currency 时用它 | 否 |
| Webhook 签名密钥 | whsec_ 开头,接 webhook 时再回来填 | 接 webhook 才填 |
保存即校验做了什么
保存时平台调一次 GET /v1/account。这一次调用同时验三件事:密钥有效且没被吊销;密钥所属模式与「正式模式」勾选一致;账号 id 是你填的那个(填了才验)。
| 报错 | 该改哪一项 |
|---|---|
Invalid API Key provided | 密钥贴错了,或在 Stripe 后台被 roll 掉了 |
| marked as live mode but the secret key is a test key | 勾了正式模式却用了 sk_test_ 密钥 |
| marked as test mode but the secret key is a live key | 用了 sk_live_ 密钥却没勾正式模式 |
belongs to account acct_…, not acct_… | 密钥来自另一个 Stripe 账号 |
expected it to start with sk_test_ or sk_live_ | 贴成了 Publishable key(pk_)或 webhook secret(whsec_) |
订单表与事件表
支付状态存在你自己的表里。创建 /platform/table/payment_orders.json:
{
"name": "Payment orders",
"desc": "Stripe order state",
"json_schema": {
"type": "object",
"properties": {
"userId": { "type": "string" },
"productId": { "type": "string" },
"amount": { "type": "integer" },
"currency": { "type": "string" },
"orderNo": { "type": "string" },
"status": { "type": "string", "enum": ["pending", "paid", "closed", "refunded"] },
"stripeSessionId": { "type": "string" },
"stripePaymentIntentId": { "type": "string" },
"paidAt": { "type": "string" }
},
"required": ["userId", "productId", "amount", "currency", "orderNo", "status"]
}
}
再建一张 /platform/table/payment_events.json 用于 webhook 去重:
{
"name": "Payment events",
"desc": "Processed Stripe webhook events",
"json_schema": {
"type": "object",
"properties": {
"eventId": { "type": "string" },
"orderNo": { "type": "string" },
"type": { "type": "string" }
},
"required": ["eventId", "type"]
}
}
发起支付
创建 /backend/func/stripe.ts:
import type { TalizenFuncContext } from 'talizen/func-runtime'
// 金额由服务端商品表决定,浏览器只传 productId。
// 单位是最小货币单位的整数:500 表示 $5.00。
const PRODUCTS = {
pro: { name: 'Pro plan', amount: 500, currency: 'usd' },
} 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')
// 订单号自己生成并先落库:它同时是订单表的键、client_reference_id 和幂等键。
const orderNo = 'C' + crypto.randomUUID().replace(/-/g, '')
ctx.db.insert('payment_orders', {
userId: String(user.id),
productId,
amount: product.amount,
currency: product.currency,
orderNo,
status: 'pending',
})
const session = ctx.payment.stripe.checkoutSession({
clientReferenceId: orderNo,
amount: product.amount,
currency: product.currency,
name: product.name,
customerEmail: user.email,
})
ctx.db.update('payment_orders', ctx.db.query('payment_orders', {
where: { orderNo }, limit: 1,
}).list[0].id, { stripeSessionId: session.id })
return { orderNo, payUrl: session.url }
}
浏览器拿到 payUrl 直接跳转:
import { invoke } from 'talizen/func'
const { payUrl } = await invoke<{ payUrl: string }>('stripe.create', {
productId: 'pro',
})
window.location.assign(payUrl)
| 参数 | 说明 |
|---|---|
clientReferenceId | 必填,你的订单号,200 字符以内的字母、数字、-、_。平台不代生成:它同时是订单表的键、Stripe 的 client_reference_id、metadata.client_reference_id 和幂等键的默认值 |
amount | 必填,最小货币单位的整数:500 表示 $5.00。传 '9.90' 这种小数字符串会直接报错,不会被当成 9 分 |
currency | 三位币种代码,留空取集成里配的默认值 |
name | 必填,收银台上显示的商品名 |
description / quantity | 可选,商品描述与数量(默认 1) |
successUrl / cancelUrl | 可选,覆盖集成里配的回跳地址 |
customerEmail | 可选,预填收银台上的邮箱 |
metadata | 可选,附加元数据,会回到 Session 与 webhook 事件上 |
expiresAt | 可选,unix 秒或 Date。Stripe 只接受 30 分钟到 24 小时之间;留空用 Stripe 默认的 24 小时 |
idempotencyKey | 可选,默认由订单号推导。同一个订单号重复建单只会拿到同一张 Session |
返回 { id, url, clientReferenceId, amountTotal, currency, expiresAt, livemode }。
买家付完回跳
买家付完会跳到 successUrl。把地址里的 session_id 传回 Func 复核:
export function confirm(input: { sessionId?: string }, ctx: TalizenFuncContext) {
const user = ctx.auth.requireUser()
const s = ctx.payment.stripe.retrieveSession(String(input.sessionId || ''))
if (!s.paid) return { paid: false }
const { list } = ctx.db.query('payment_orders', {
where: { orderNo: s.clientReferenceId },
limit: 1,
})
const order = list[0]
// 这三条平台替不了:单子存不存在、是不是这个用户的、金额币种对不对。
if (!order || order.userId !== String(user.id)) throw new Error('order not found')
if (order.amount !== s.amountTotal || order.currency !== s.currency) {
throw new Error('amount mismatch')
}
if (order.status === 'paid') return { paid: true } // 已经处理过
ctx.db.update('payment_orders', order.id, {
status: 'paid',
stripePaymentIntentId: s.paymentIntentId,
paidAt: new Date().toISOString(),
})
// 发放权益同样要以 orderNo 做幂等保护。
return { paid: true }
}
// 回跳地址形如 https://example.com/pay/done?session_id=cs_live_xxx
const sessionId = new URLSearchParams(location.search).get('session_id')
if (sessionId) {
const { paid } = await invoke<{ paid: boolean }>('stripe.confirm', { sessionId })
}
| 返回字段 | 说明 |
|---|---|
paid | status === 'complete' 且 paymentStatus === 'paid',由平台算好。别自己只判其中一半——只看 paymentStatus 会把还没完成的 Session 算成已付 |
clientReferenceId | 你建单时传的订单号 |
amountTotal / currency | 实付金额与币种,必须与自己订单表里的核对 |
paymentIntentId | 存下来:退款事件是按 payment intent 匹配的 |
customerEmail | 买家在收银台填的邮箱,没填则是你预填的那个 |
status / paymentStatus | 原始状态:open / complete / expired,paid / unpaid / no_payment_required |
metadata / session | 元数据与完整 Stripe 对象,读上面没列出的字段用它 |
接收 webhook
回跳复核只覆盖「买家老老实实跳回来了」这一种情况。买家关掉页面、用异步支付方式、或者事后退款,都只能靠 webhook。
- 先把上面的 Func 发布出去,拿到公开地址,例如
https://example.com/func/stripe.webhook。 - 在 Stripe 后台 Developers → Webhooks 新建 endpoint 指向它,勾上
checkout.session.completed等需要的事件。注意测试模式和正式模式要各建一个。 - 复制这个 endpoint 的
whsec_...签名密钥,回到后端 → 集成填进 Stripe 集成。
export async function webhook(_input: unknown, ctx: TalizenFuncContext) {
// 验签失败会抛错,伪造的事件走不到下面任何一行。
// 必须传原文:用 input 或 JSON.stringify 重新序列化过的正文验不过签。
const event = ctx.payment.stripe.verifyWebhook(await ctx.request.text())
// 事件会重投、会乱序,去重表是你自己的。
const seen = ctx.db.query('payment_events', { where: { eventId: event.id }, limit: 1 })
if (seen.list.length > 0) return new Response('ok')
if (event.type === 'checkout.session.completed') {
const session = event.object as any
if (session.payment_status === 'paid') {
const { list } = ctx.db.query('payment_orders', {
where: { orderNo: session.client_reference_id },
limit: 1,
})
const order = list[0]
if (!order) return new Response('order not found', { status: 400 })
if (order.amount !== session.amount_total) {
return new Response('amount mismatch', { status: 400 })
}
if (order.status !== 'paid') {
ctx.db.update('payment_orders', order.id, {
status: 'paid',
stripePaymentIntentId: String(session.payment_intent || ''),
paidAt: new Date().toISOString(),
})
}
ctx.db.insert('payment_events', {
eventId: event.id,
orderNo: order.orderNo,
type: event.type,
})
}
}
return new Response('ok')
}
平台在这一步完成:HMAC-SHA256 验签、5 分钟时间戳容差(挡重放)、签名头读取、事件 livemode 与集成一致。任何一步不通过都直接抛错,而不是返回一个可能被当成 falsy 忽略的值。返回非 2xx 时 Stripe 会重投。
| 返回字段 | 说明 |
|---|---|
id | 事件 id,用它做去重表的键——Stripe 明确说明事件可能重复、可能乱序 |
type | 如 checkout.session.completed、charge.refunded |
object | event.data.object,绝大多数场景要读的就是它 |
event | 完整事件,读 data.previous_attributes 之类字段用它 |
livemode / created / apiVersion | 模式、时间与事件的 API 版本 |
退款与其余 API
退款、订阅、查 charge 等等用 call,平台代持密钥:
// 全额退款
const refund = ctx.payment.stripe.call('POST', '/v1/refunds', {
payment_intent: order.stripePaymentIntentId,
})
// 带嵌套参数:{ a: { b: [1] } } 会展开成 a[b][0]=1
const session = ctx.payment.stripe.call('POST', '/v1/checkout/sessions', {
mode: 'subscription',
line_items: [{ price: 'price_xxx', quantity: 1 }],
success_url: 'https://example.com/pay/done',
cancel_url: 'https://example.com/pay/cancel',
})
签名是 call(method, path, params?, idempotencyKey?)。method 支持 GET / POST / DELETE,path 必须以 /v1/ 开头。参数按 Stripe 的表单规则展开,嵌套直接写对象和数组即可。HTTP 错误会按 Stripe 的错误类型降级成 400(你的配置或参数不对)或 502(Stripe 侧的问题)。
一个项目挂两个 Stripe 账号
给两份集成配不同的 tag,用 via() 选:
ctx.payment.stripe.checkoutSession({ ... }) // 默认渠道,等价于 via('default')
ctx.payment.stripe.via('eu').checkoutSession({ ... }) // 另一个收款账号
ctx.payment.stripe.via('eu').verifyWebhook(raw) // webhook 要用同一个渠道
平台不管的部分
这几条是支付里最容易出事的地方,平台帮不了你:
- 金额由服务端商品表决定,浏览器只传
productId。让浏览器传金额等于让人自己定价。 - 订单归属要自己核。
retrieveSession能证明这张单属于你的 Stripe 账号,不能证明它属于当前登录用户。 - 金额与币种要和本地订单核对,两个都要比。
- webhook 事件要去重,以
event.id为键——事件会重投、会乱序。 - 发放权益要幂等,以订单号为键:回跳复核和 webhook 可能同时把同一笔算成已付。
边界
- 只支持一次性支付的 Checkout(内联
price_data)。订阅、后台建好的priceId、Stripe Connect 还没有专用方法,用call()。 - 金额是最小货币单位的整数,平台不做元/分换算。
ctx.payment.alipay用的是「元」字符串,两边不一致是故意的——各自跟自己的上游对齐,比强行统一更不容易错。 - 支付集成不能打开「把密钥暴露给 Func 代码」:收款密钥不进沙箱,由平台代调。想完全自己接就走另一条路——在后端 → 环境变量里自己配
STRIPE_*,用fetch+crypto.subtle写自己的 Func,两条路互不干扰。 - 回跳地址与 webhook 地址都必须是 https,webhook 还必须是已发布的地址:预览域名下的 Func 收不到线上事件。
- 测试模式与正式模式用各自的密钥和各自的 webhook endpoint。上线前必须在正式模式真实收到过一笔事件,这一步没有替代品。
