关于 recur-entitlements
recur-entitlements 是围绕 Recur 订阅和购买权限检查的开发技能,面向在应用中实现访问控制的开发者。内容涵盖客户端状态就绪处理,并强调敏感授权应在服务端校验。
主要功能
- 01按产品标识检查权限
- 02处理客户端加载就绪状态
- 03提供服务端权限检查示例
- 04涵盖订阅状态与拒绝原因
- 05支持结账后刷新权限
适用场景
- 01按订阅等级控制功能入口
- 02结账后刷新用户界面权限
- 03在服务端保护下载或付费操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ recur-tw/ skills --skill recur-entitlements安装后在 Claude Code 中输入/recur-entitlements确认已加载。
详细介绍
Recur 权益与访问控制
你正在帮助使用 Recur 的权益系统实现访问控制。权益可用于检查客户是否有权使用你的产品(订阅或一次性购买)。
快速入门:客户端检查
import { RecurProvider, useCustomer } from 'recur-tw'
// Your own hook, defined in "Knowing when the answer is real" below.
import { useEntitlementsReady } from './hooks/use-entitlements-ready'
// The identifier you hand the provider. Pass the same value to the readiness hook.
const customerEmail = '[email protected]'
// 1. Wrap app with provider and identify customer
function App() {
return (
<RecurProvider
config={{ publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY }}
customer={{ email: customerEmail }}
>
<MyApp />
</RecurProvider>
)
}
// 2. Check access anywhere in your app
function PremiumFeature() {
const { check } = useCustomer()
const ready = useEntitlementsReady(customerEmail)
if (!ready) return <div>Loading...</div>
const { allowed } = check('pro-plan')
if (!allowed) {
return <UpgradePrompt />
}
return <PremiumContent />
}
判断答案何时有效
useCustomer() 没有“已初始化”标志。isLoading 初始为 false,因为请求只会在 effect 中开始;而 customer 在请求之前以及客户确实不存在时都会是 null,因此这两者都无法区分“尚未获取”和“没有此客户”。仅根据 isLoading 判断,会在首次渲染时闪现升级提示;根据 !customer 判断,则会让新客户永远停留在加载状态。
自行跟踪首次完成的加载:
// hooks/use-entitlements-ready.ts
import { useEffect, useRef, useState } from 'react'
import { useCustomer } from 'recur-tw'
// `customerKey` is the identifier you pass to RecurProvider (email, externalId or id).
export function useEntitlementsReady(customerKey: string | null | undefined) {
const { isLoading, error, customer } = useCustomer()
const [ready, setReady] = useState(false)
const [seenKey, setSeenKey] = useState(customerKey)
const pending = useRef(false)
const switched = useRef(false)
// Adjusted during render, not in an effect: RecurProvider kicks off the new request
// from an effect and leaves the old cache with `isLoading === false` until it does,
// so an effect-only guard would still pass for one render after a switch.
if (customerKey !== seenKey) {
setSeenKey(customerKey)
setReady(false)
switched.current = true
}
useEffect(() => {
if (isLoading) {
pending.current = true
setReady(false) // a new fetch: the cache still holds the previous customer
} else if (pending.current) {
pending.current = false
setReady(!error) // ready only when that request actually succeeded
}
}, [isLoading, error])
// A customer already in the cache proves a request finished. Without this, a gate that
// first mounts AFTER the provider settled never sees isLoading go true, so it would wait
// forever. Only before a switch: afterwards the cache still holds the previous customer.
return ready || (!switched.current && !isLoading && customer !== null)
}
重置 !error 很重要,因为请求失败时,SDK 会恢复上一次成功的缓存,同时仍会清除 isLoading;如果没有这个条件,获取失败后就会基于之前缓存的数据打开访问控制。读取 useCustomer() 返回的 error,以区分“仍在加载”和“获取失败”。
如果没有向 RecurProvider 传入 customer,它会一直是 false,因为请求根本不会运行——这是开发环境中需要捕获的情况。
在 Provider 下挂载访问控制组件
将访问控制组件放在 RecurProvider 下方的一个组件中,而不要放在每个叶子组件中:
function EntitlementsGate({ children }: { children: React.ReactNode }) {
const ready = useEntitlementsReady(customerEmail)
if (!ready) return <Spinner />
return <>{children}</>
}
<RecurProvider config={{ publishableKey }} customer={{ email: customerEmail }}>
<EntitlementsGate>
<App /> {/* everything below can call check() freely */}
</EntitlementsGate>
</RecurProvider>
随 Provider 挂载的访问控制组件会监视整个请求,这是 hook 能感知加载过程的唯一方式。稍后才挂载的访问控制组件依赖 hook 最后一行中的缓存回退机制;但当 Recur 中不存在该客户时,这并无帮助:对于“没有此客户”和“尚未获取”,权益端点都会以 200 返回 customer: null,因此,针对未知客户延迟挂载的访问控制组件会一直等待。
此 hook 无法做到的事
它无法确保切换客户时的安全性。RecurProvider 没有请求排序机制:标识符变化时,它会在第一个请求尚未完成时启动第二个请求,最后返回的请求结果会生效。之前客户的响应可能覆盖新客户的缓存,并清除共享的 isLoading 标志;此时,这个 hook 会基于错误的数据报告加载完成。渲染时重置只能缩小问题出现的时间窗口,无法从 SDK 外部彻底解决。
因此,如果一个会话可能原地切换客户,请勿将客户端缓存视为授权决策。应在服务器上使用密钥,并读取你实际需要的客户信息来确认。切换时重新挂载应用也可以避免请求重叠。
客户身份识别
使用以下方法之一识别客户:
// By email (most common)
<RecurProvider customer={{ email: '[email protected]' }}>
// By your system's user ID
<RecurProvider customer={{ externalId: 'user_123' }}>
// By Recur customer ID
<RecurProvider customer={{ id: 'cus_xxx' }}>
检查访问权限
同步检查(缓存)
速度快,使用缓存数据。适合 UI 渲染。
const { check } = useCustomer()
// Check by product slug
const { allowed, entitlement } = check('pro-plan')
// Check by product ID (a CUID from list_products, e.g. 'cmfxq8n2a0001l8yz3k5p9t7d'; 'prod_xxx' is a placeholder)
const { allowed } = check('prod_xxx')
if (allowed) {
// User has access
// entitlement contains details like status, expiresAt
}
异步检查(实时)
回答前会重新获取数据。这个方式很有用,但并非权威结果——请参阅下方警告。
const { check } = useCustomer()
// Refetches, then answers
const { allowed, entitlement } = await check('pro-plan', { live: true })
// Good for:
// - Refreshing the UI after checkout
// - When cached data is probably stale
{ live: true }可能会根据先前的快照作答。 在当前 SDK 中,它会等待重新获取数据,然后调用其所属渲染过程捕获的同步检查函数,因此,最新数据已在缓存中,但答案并非基于最新数据计算。购买完成后,它仍可能返回false。绝不要将它用作重要事项的访问控制条件,例如授予访问权限、开放下载或开始付费工作。应改用密钥在服务器上检查。在客户端,
{ live: true }只是刷新用户所见内容的方式,而不是决策依据。
手动重新获取
const { refetch } = useCustomer()
// After checkout completion
onPaymentComplete: async () => {
await refetch() // Refresh entitlements
router.push('/dashboard')
}
权益响应结构
interface CheckResult { // what check() returns
allowed: boolean
reason?: 'no_customer' | 'no_entitlement' | 'not_found' | 'expired' | 'insufficient_balance'
entitlement?: Entitlement
balance?: number // CREDITS products
unlimited?: boolean
subscription?: { id: string; status: string; product: { id: string; slug: string; name: string }; currentPeriodEnd: string }
}
interface Entitlement {
product: string // Product slug
productId: string // Product ID (CUID)
status: EntitlementStatus
source: 'subscription' | 'order' // How they got access
sourceId: string // Subscription/Order ID
grantedAt: string // When access was granted
expiresAt: string | null // When access expires (null = permanent)
subscriptionId?: string
}
type EntitlementStatus =
| 'active' // Subscription active
| 'trialing' // In trial period
| 'past_due' // Payment failed, in grace period
| 'canceled' // Cancelled but access until period end
| 'purchased' // One-time purchase (permanent)
服务器端检查
使用服务器 SDK
import { Recur } from 'recur-tw/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
// In API route or server action
async function checkAccess(userEmail: string) {
// Server EntitlementCheckResult is { allowed, subscription? } — there is no
// `entitlement` field here (that one belongs to the React check()).
const { allowed, subscription } = await recur.entitlements.check({
product: 'pro-plan',
customer: { email: userEmail },
})
if (!allowed) {
throw new Error('Upgrade required')
}
return subscription
}
直接使用 REST API
// GET /v1/customers/entitlements
const response = await fetch(
`https://api.recur.tw/v1/customers/entitlements?email=${encodeURIComponent(email)}`,
{
headers: {
'X-Recur-Secret-Key': process.env.RECUR_SECRET_KEY!,
},
}
)
// REST responses are snake_case (the API wrapper converts them), unlike the SDK's
// camelCase types: customer { id, email, name, external_id }, subscription | null,
// entitlements[] with product_id / granted_at / expires_at / source_id.
const { customer, subscription, entitlements } = await response.json()
常见模式
付费墙组件
function Paywall({
children,
product,
customerKey,
fallback
}: {
children: React.ReactNode
product: string
/** The identifier passed to RecurProvider, so the guard closes on a customer switch. */
customerKey: string | null | undefined
fallback?: React.ReactNode
}) {
const { check } = useCustomer()
const ready = useEntitlementsReady(customerKey)
if (!ready) {
return <div>Loading...</div>
}
const { allowed } = check(product)
if (!allowed) {
return fallback || <UpgradePrompt product={product} />
}
return <>{children}</>
}
// Usage
<Paywall product="pro-plan" customerKey={customerEmail}>
<PremiumDashboard />
</Paywall>
功能标志模式
function useFeature(featureProduct: string, customerKey: string | null | undefined) {
const { check, error } = useCustomer()
const ready = useEntitlementsReady(customerKey)
// `isLoading` alone is not enough here either: it starts false, so the first paint
// would read an empty cache and report the feature as off.
if (!ready) {
return { enabled: false, loading: !error, error, entitlement: undefined }
}
const { allowed, entitlement } = check(featureProduct)
return {
enabled: allowed,
loading: false,
error: null,
entitlement,
isTrial: entitlement?.status === 'trialing',
isPastDue: entitlement?.status === 'past_due',
}
}
// Usage
function MyComponent() {
const { enabled, isTrial } = useFeature('pro-plan', customerEmail)
if (!enabled) return <UpgradeButton />
return (
<>
{isTrial && <TrialBanner />}
<ProFeature />
</>
)
}
API 中间件
// middleware/requireSubscription.ts
import { Recur } from 'recur-tw/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
export async function requireSubscription(
req: Request,
product: string
) {
const userEmail = await getUserEmail(req) // Your auth logic
const { allowed } = await recur.entitlements.check({
product,
customer: { email: userEmail },
})
if (!allowed) {
throw new Response(JSON.stringify({ error: 'Subscription required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
}
// Usage in API route
export async function GET(req: Request) {
await requireSubscription(req, 'pro-plan')
// User has access, continue...
return Response.json({ data: 'premium content' })
}
多个产品层级
function PricingGate({ customerKey }: { customerKey: string | null | undefined }) {
const { check } = useCustomer()
const ready = useEntitlementsReady(customerKey)
if (!ready) return <div>Loading...</div>
const hasPro = check('pro-plan').allowed
const hasEnterprise = check('enterprise-plan').allowed
if (hasEnterprise) {
return <EnterpriseDashboard />
}
if (hasPro) {
return <ProDashboard />
}
return <FreeDashboard />
}
处理边缘情况
逾期订阅
const { allowed, entitlement } = check('pro-plan')
if (allowed && entitlement?.status === 'past_due') {
// Show warning but allow access during grace period
return (
<>
<PaymentFailedBanner />
<PremiumContent />
</>
)
}
试用订阅
const { entitlement } = check('pro-plan')
if (entitlement?.status === 'trialing') {
const trialEnds = new Date(entitlement.expiresAt!)
const daysLeft = Math.ceil((trialEnds.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return <TrialBanner daysLeft={daysLeft} />
}
已取消但仍有效
const { entitlement } = check('pro-plan')
if (entitlement?.status === 'canceled') {
// User cancelled but still has access until period end
return (
<>
<ResubscribeBanner expiresAt={entitlement.expiresAt} />
<PremiumContent />
</>
)
}
拒绝原因
当 allowed 为 false 时,CheckResult.reason 会说明原因('no_customer' | 'no_entitlement' | 'not_found' | 'expired' | 'insufficient_balance'):
const { allowed, reason } = check('pro-plan')
if (!allowed) {
switch (reason) {
case 'no_customer':
// Customer not found
return <CreateAccountPrompt />
case 'no_entitlement':
// No subscription to this product
return <SubscribePrompt />
case 'expired':
// Subscription/access expired
return <RenewPrompt />
case 'insufficient_balance':
// For credit-based products
return <BuyCreditsPrompt />
default:
return <GenericUpgradePrompt />
}
}
最佳实践
- 使用缓存检查更新 UI - 渲染快速,用户体验好
- 在服务端做出决定 - 任何授予访问权限、发放文件或启动付费工作的操作,都要使用密钥进行检查。
{ live: true }会刷新 UI;它仍可能根据之前的快照作出响应,因此不能作为授权决策 - 处理所有状态 - active、trialing、past_due、canceled
- 结账后重新获取数据 - 确保购买后 UI 更新
- 实现优雅降级 - 显示升级提示,而不是错误
相关技能
/recur-quickstart- SDK 初始设置/recur-checkout- 实现购买流程/recur-webhooks- 使用 webhook 同步权益