跳转到主要内容
License 发放是把一次 Stripe 成功支付(或 Paddle / Lemon / Creem 同等事件) 变成买家能真正下载到东西的整条链路。整条链路幂等——webhook 重试不会重发 license——下载端点也鉴权,客户不能把 key 转给一个没登录的朋友。这页走完 从 webhook 到 tarball 的全程。

前置条件

  • 一个能跑的 payment provider(看 /docs/payments/overview)。
  • license 表已经创建(pnpm db:push 时根据 src/db/license.schema.ts 建)。
  • LICENSE_DOWNLOAD_URL 已配(看步骤 6)——没配的话端点会返回一个纯文本 “联系客服” 兜底。

完整链路

  1. 客户付款。Stripe(或任何 provider)触发 checkout.completed。webhook handler 把事件归一化,调 src/payment/handlers/core.ts 里的 processNormalizedEvent
  2. 派发到 licenseprocessNormalizedEvent 插入 payment 行,然后调 dispatchLicense(paymentId, p)。这个 helper 检查 p.scene === siteConfig.product.id(也就是说买家买的是 vibestrap 本身, 不是 credit pack),匹配才调 issueLicense
  3. 签发 licenseissueLicense({userId, productId, paymentId})(userId, paymentId) 上幂等——webhook 重试安全。key 是 lic_<nanoid> (约 22 字符),存进 license.key 上有 unique 索引。
  4. 客户看到 license。买家访问 /settings/licenses 能看到 key、下载次数、 再下载按钮。
  5. 买家点下载。按钮链到 /api/license/download?key=lic_xxx。handler 校验 session、确认 key 属于当前用户、调 consumeKey()(自增 downloadCount、 写 lastDownloadAt),再 302 跳到 LICENSE_DOWNLOAD_URL
  6. 占位符替换LICENSE_DOWNLOAD_URL 里的 {key} 字符串会被替换成 encoded 后的 license key,上游就能识别买家。

下载链接的几种玩法

.env.localLICENSE_DOWNLOAD_URL 设成下面之一:
# GitHub 私有 release tarball —— 跳到一个签名好的 asset URL
LICENSE_DOWNLOAD_URL=https://api.github.com/repos/you/private/tarball/v1.0.0?token={key}

# S3 / R2 签名 URL —— 部署时生成,每个 release 轮换
LICENSE_DOWNLOAD_URL=https://r2.example.com/vibestrap-v1.0.0.zip?sig={key}

# Gumroad 邀请链接 —— key 当 invite 参数传
LICENSE_DOWNLOAD_URL=https://gumroad.com/d/your-product?invite={key}
{key} 占位符走了 encodeURIComponent,放在 URL 哪个位置都安全。

验证生效

  1. 在 Stripe 测试模式跑一笔测试购买。
  2. 看 Postgres 日志 / 用 pnpm db:studio 确认 license 表里多了一行: key = lic_...userId 对、paymentId 匹配。
  3. 用买家身份访问 /settings/licenses——列表里应该有这个 license。
  4. 点 “下载”。应该跳到 LICENSE_DOWNLOAD_URL 指向的地方(如果没配就看到 纯文本兜底信息)。
  5. 刷新 settings 页面——downloadCount 现在是 1。
  6. 重发同一个 Stripe webhook。确认没创建重复 license(幂等检查)。

常见坑

  1. LICENSE_DOWNLOAD_URL 没设。端点会返回纯文本让买家联系客服。dev 时 方便,生产丢人。收真钱前一定要配上。
  2. productIdsiteConfig.product.id 对不上dispatchLicense 在 scene 不是 siteConfig.product.id(或老的 'vibestrap-lifetime')时会 短路。如果你在 config 里改了 product slug,要么补刷历史 payment,要么在 dispatchLicense 里加个别名。
  3. 退款不会自动撤销 license。Stripe 的 charge.refunded 不会删 license 行。要在业务逻辑里处理——软删 license、过期 key、或者相信客户(大部分 独立开发者都这么做)。
  4. 忘了 {key} 占位符。URL 里没 {key} 上游就无法识别买家。永远带上, 除非你用的是一个所有人共享的下载链接(那为什么要发 key 呢)。
  5. 两个用户都登录时 key 能共享。端点查 row.userId === session.user.id, 所以 key 只能给原买家用。但账号共享就破防了——接受这个已知缺陷,或者 叠加 IP / 设备指纹。

官方文档