为业务功能接入付费权益

把业务能力、套餐、Stripe Checkout 和权益检查连接起来,并验证订阅与一次性购买。

前两篇建立了工作台和个人收藏接口。这一篇把收藏功能改为购买后才能使用,借此走通“配置套餐 → 完成支付 → 获得权益 → 访问业务”的链路。

这里先实现按月订阅,最后说明如何增加一次性购买方案。两种方案都授予同一种布尔权益,业务接口只判断是否具有该能力,不直接判断用户买了哪个 Stripe Price。

开始前先完成Stripe 配置,确保测试环境的支付凭据和 Webhook 已接通。本文中的 Price ID 都是占位值,需要替换为你自己测试环境中的真实 ID。

一、明确三个配置之间的关系

本例使用下面三个标识:

标识位置含义
savedLinks.useconfig/capability.ts业务接口要检查的能力
saved_links_accessconfig/entitlements.ts包含该能力的权益
saved_links.monthlyconfig/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,再进行上述支付验收。类型检查可以发现配置键名问题,只有实际测试支付才能验证回调和权益链路。