为业务功能接入付费权益
把业务能力、套餐、Stripe Checkout 和权益检查连接起来,并验证订阅与一次性购买。
前两篇建立了工作台和个人收藏接口。这一篇把收藏功能改为购买后才能使用,借此走通“配置套餐 → 完成支付 → 获得权益 → 访问业务”的链路。
这里先实现按月订阅,最后说明如何增加一次性购买方案。两种方案都授予同一种布尔权益,业务接口只判断是否具有该能力,不直接判断用户买了哪个 Stripe Price。
开始前先完成Stripe 配置,确保测试环境的支付凭据和 Webhook 已接通。本文中的 Price ID 都是占位值,需要替换为你自己测试环境中的真实 ID。
一、明确三个配置之间的关系
本例使用下面三个标识:
| 标识 | 位置 | 含义 |
|---|---|---|
savedLinks.use | config/capability.ts | 业务接口要检查的能力 |
saved_links_access | config/entitlements.ts | 包含该能力的权益 |
saved_links.monthly | config/products.ts | 授予该权益的产品和套餐 |
能力是业务判断的单位,权益把能力组合起来,套餐描述购买后得到什么。后续增加年付或其他销售方式时,可以继续授予同一权益,不必改业务接口。
这与权限与权益中的角色、能力、权益模型一致。本例使用布尔能力,不涉及次数扣减。需要额度时,继续参考设计套餐与权益。
二、添加展示文案
在语言文件根对象中合并 recipes.savedLinks。如果根下已有 recipes,继续合并它的子项。
先在 locales/en.json 添加:
"recipes": {
"savedLinks": {
"name": "Saved links",
"description": "Save and browse your personal links.",
"monthly": "Monthly access",
"month": "/month",
"oneTime": "One-time access"
}
}然后在其他已启用的语言文件中添加相同结构。当前三种语言可以采用下面的文案:
| 键的末段 | 简体中文 | 繁体中文 |
|---|---|---|
name | 收藏链接 | 收藏連結 |
description | 保存并浏览你的个人收藏链接。 | 儲存並瀏覽你的個人收藏連結。 |
monthly | 按月使用 | 按月使用 |
month | /月 | /月 |
oneTime | 一次性购买 | 一次性購買 |
下面的配置都引用这些资源键,同时保留 value 供配置展示等现有调用方使用。用户界面的翻译由资源键提供,英文 value 应与英文资源保持一致。
三、定义能力与权益
把下面的 savedLinks 合并到 config/capability.ts 已有的 websiteCapabilityDefinitions 对象中。该文件已经导入 CapabilityType,沿用现有导入即可。
savedLinks: {
use: {
type: CapabilityType.Boolean,
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
},
},再把下面的 saved_links_access 合并到 config/entitlements.ts 的 websiteEntitlementDefinitions:
saved_links_access: {
name: { value: 'Saved links', key: 'recipes.savedLinks.name' },
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
capabilities: ['savedLinks.use'],
},保留两个文件末尾已有的 as const satisfies ... 和类型导出。项目从这些定义推导可用标识,键名写错时应在类型检查阶段发现。
四、增加月付产品
为了让示例独立于模板原有的 saavo_starter 演示套餐,把下面的 saved_links 合并到 config/products.ts 的 websiteProductDefinitions 根对象中:
saved_links: {
name: { value: 'Saved links', key: 'recipes.savedLinks.name' },
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
plans: {
monthly: {
type: 'recurring',
interval: 'month',
intervalCount: 1,
priceId: 'price_saved_links_monthly_replace_me',
salePrice: '$9',
title: { value: 'Monthly access', key: 'recipes.savedLinks.monthly' },
billingLabel: { value: '/month', key: 'recipes.savedLinks.month' },
allowRepurchase: false,
access: {
entitlements: [{
target: 'saved_links_access',
config: {
kind: 'boolean',
priority: 100,
},
}],
},
},
},
},本例假设对应的 Stripe Price 为每月 9 美元。salePrice 是页面展示文本,实际收费金额、币种和周期由支付平台上的 Price 决定,两边需要手动对齐。
一个 Plan 对应一个 Price。以后增加年付时,在 plans 下增加 yearly,分别填写年付 Price、interval: 'year' 和年付展示价格,不要把多个周期的价格塞进同一个 Plan。
这里没有授予通用的 premium 角色,因为收藏功能只依赖自己的权益。购买了其他产品不应因此自动获得收藏功能,是否共享权益由产品规则决定。
五、发起测试购买
先重启开发服务器,让配置更新生效。在已登录的本地网站控制台执行:
const response = await fetch('/api/payments/checkout/saved_links/monthly', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const result = await response.json();
console.log(response.status, result);
if (result.success && result.data.type === 'redirect') {
window.location.assign(result.data.url);
}这一步调用已有 Checkout API,不需要新写支付接口。参数中的 saved_links 和 monthly 必须与配置键一致,浏览器不提交金额、Price ID 或要发放的权益。
这段控制台代码用于验收购买链路。正式购买按钮应复用现有支付调用方式,处理加载、失败和重复点击,并从语言资源读取文案。新增配置也不等于首页会自动长出一个符合你产品设计的购买区域,仍需要检查当前购买组件如何读取和展示产品目录。
支付成功回到站点后,等待 Webhook 和后续事件处理完成,再验证权益。成功页只是浏览器的返回位置,不能在成功页直接给用户发放权益。
六、保护收藏接口
在上一教程创建的 src/api/saved-links/index.ts 增加导入:
import { authz } from '@/authz';分别在创建和列表 handler 内,紧接 authenticatedGuard 的失败处理之后、调用业务 Service 之前,加入:
if (!(await authz.hasEntitlementCapability(c, 'savedLinks.use'))) {
return c.json({
success: false,
code: gResultCode.authEntitlementDenied,
}, 403);
}本例把创建和读取都视为付费功能。因此订阅失效后,已保存的数据仍在数据库中,但接口不再允许读取。若你的产品约定允许用户继续查看历史数据,只在创建入口检查权益即可,同时修改页面说明和验证预期。
页面可以根据相同判断显示购买入口,但 API 中的检查必须保留。其他入口如果也能调用这项业务,例如后台任务或另一个 API,也需要在对应的可信入口执行适合它的授权检查。
付费权益不替代资源所有权。收藏查询中的 user_id 限制仍然保留,付费用户也只能访问自己的数据。
七、增加一次性购买方案
如果产品确实需要一次性购买,把下面的 one_time 合并到 saved_links.plans,并绑定一个一次性 Price:
one_time: {
type: 'one-time',
priceId: 'price_saved_links_one_time_replace_me',
salePrice: '$99',
title: { value: 'One-time access', key: 'recipes.savedLinks.oneTime' },
allowRepurchase: false,
access: {
entitlements: [{
target: 'saved_links_access',
config: {
kind: 'boolean',
priority: 100,
},
}],
},
},对应测试接口改为:
POST /api/payments/checkout/saved_links/one_time一次性方案不填 interval 或 intervalCount。它表达的是收费方式,权益是否长期有效还取决于授予配置和后续业务规则,不能仅凭 one-time 就把营销文案写成“永久有效”。
allowRepurchase: false 用于限制已完成购买后的再次购买,并不锁住支付完成前创建的多个 Checkout Session。界面仍需防止连续点击,也不要把这个字段当成支付请求的幂等保证。
如果同时销售月付和一次性方案,要明确已经购买一次性方案的用户是否还应看到订阅入口。这个商业规则需要在购买界面和服务端购买规则中一致实现,本例不额外引入跨套餐互斥规则。可以先只上线一种方案,再扩展第二种。
八、验证完整链路
使用测试账户和测试支付环境逐项确认:
| 场景 | 检查结果 |
|---|---|
| 未登录请求收藏 API | 返回 401 |
| 已登录但没有收藏权益 | 返回 403 |
| 取消 Checkout,没有完成支付 | 不获得权益 |
| 月付成功且 Webhook 处理完成 | 可以创建、读取自己的收藏 |
| 用户手工打开支付成功页 | 不会因此获得权益 |
| 订阅设置为周期结束时取消 | 不把“已设置取消”误认为立即失效,按有效期验证 |
| 订阅实际结束、状态同步完成 | 月付权益失效,没有其他有效来源时接口返回 403 |
| 一次性购买成功 | 获得配置中的权益,业务检查方式不变 |
| 重复收到同一支付事件 | 检查已有支付和事件处理记录,没有重复业务结果 |
退款记录与权益撤销是两个业务动作。当前流程不能简单理解为“退款后一定自动撤销所有权益”,发布前应按你的退款规则单独核对并验证处理方式,详见订单与支付。
如果显示支付成功却仍返回 403,依次检查 Price 所属环境、Webhook 到达情况、事件处理记录、套餐的 access 和能力键名。不要通过绕过权益检查来掩盖同步问题。
完成配置与 API 修改后,运行 npm run lint、npm run typecheck 和 npm run build,再进行上述支付验收。类型检查可以发现配置键名问题,只有实际测试支付才能验证回调和权益链路。