mcpField
R
recur-entitlements

recur-entitlements

github.com/recur-tw开发039 次安装3 周前更新
代码仓库

关于 recur-entitlements

recur-entitlements 是围绕 Recur 订阅和购买权限检查的开发技能,面向在应用中实现访问控制的开发者。内容涵盖客户端状态就绪处理,并强调敏感授权应在服务端校验。

主要功能

  1. 01按产品标识检查权限
  2. 02处理客户端加载就绪状态
  3. 03提供服务端权限检查示例
  4. 04涵盖订阅状态与拒绝原因
  5. 05支持结账后刷新权限

适用场景

  1. 01按订阅等级控制功能入口
  2. 02结账后刷新用户界面权限
  3. 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 />
  }
}

最佳实践

  1. 使用缓存检查更新 UI - 渲染快速,用户体验好
  2. 在服务端做出决定 - 任何授予访问权限、发放文件或启动付费工作的操作,都要使用密钥进行检查。{ live: true } 会刷新 UI;它仍可能根据之前的快照作出响应,因此不能作为授权决策
  3. 处理所有状态 - active、trialing、past_due、canceled
  4. 结账后重新获取数据 - 确保购买后 UI 更新
  5. 实现优雅降级 - 显示升级提示,而不是错误

相关技能

  • /recur-quickstart - SDK 初始设置
  • /recur-checkout - 实现购买流程
  • /recur-webhooks - 使用 webhook 同步权益

评论

支持 Markdown