集成使用集成接入 Stripe 支付

使用集成接入 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 只在服务端持有,永不进沙箱幂等发货
paidstatus + paymentStatus 一起算出与本地订单核对金额与币种
保存凭据时真实校验一次「这张单是不是当前用户的」

连接 Stripe

先在 Stripe 后台取参数:

  1. 打开 Developers → API keys,复制 Secret key。测试用 sk_test_ 开头的那把,正式用 sk_live_不要复制 Publishable key。
  2. (可选)在 Settings → Business 里记下账号 id,形如 acct_xxx。填了它,保存时会多一道「这把密钥确实属于这个账号」的校验。
  3. Webhook 签名密钥先不用管——它要等 Func 发布出去有了公开地址才能创建,见接收 webhook

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

字段填什么必填
Secret keysk_test_sk_live_ 开头的那把密钥
正式模式勾上表示这份配置收真钱。必须与密钥前缀一致,不一致直接报错
支付成功地址付款后跳回的页面。可以带 {CHECKOUT_SESSION_ID} 占位符,Stripe 会替换成真实 session id建议
支付取消地址买家在收银台点返回时跳到哪儿建议
账号 idacct_ 开头,填了就多一道账号校验
默认币种三位字母,如 usdcheckoutSession 不传 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_idmetadata.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 })
}
返回字段说明
paidstatus === 'complete'paymentStatus === 'paid',由平台算好。别自己只判其中一半——只看 paymentStatus 会把还没完成的 Session 算成已付
clientReferenceId你建单时传的订单号
amountTotal / currency实付金额与币种,必须与自己订单表里的核对
paymentIntentId存下来:退款事件是按 payment intent 匹配的
customerEmail买家在收银台填的邮箱,没填则是你预填的那个
status / paymentStatus原始状态:open / complete / expiredpaid / unpaid / no_payment_required
metadata / session元数据与完整 Stripe 对象,读上面没列出的字段用它

接收 webhook

回跳复核只覆盖「买家老老实实跳回来了」这一种情况。买家关掉页面、用异步支付方式、或者事后退款,都只能靠 webhook。

  1. 先把上面的 Func 发布出去,拿到公开地址,例如 https://example.com/func/stripe.webhook
  2. 在 Stripe 后台 Developers → Webhooks 新建 endpoint 指向它,勾上 checkout.session.completed 等需要的事件。注意测试模式和正式模式要各建一个。
  3. 复制这个 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 明确说明事件可能重复、可能乱序
typecheckout.session.completedcharge.refunded
objectevent.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 / DELETEpath 必须以 /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。上线前必须在正式模式真实收到过一笔事件,这一步没有替代品。

Render diagnostics