EasyStarter logoEasyStarter

积分系统

配置 App 积分包、RevenueCat 购买、消耗和账本规则

积分系统

EasyStarter 内置一套由服务端账本支撑的积分系统。在 App 端,用户通过 RevenueCat 应用内购买积分包:

  • 售卖积分包:走 RevenueCat(iOS / Android)
  • 注册赠送积分,可选过期时间
  • 防薅羊毛:注册赠送需邮箱验证,并按邮箱、IP、User-Agent 限流
  • 按功能消耗积分,幂等且并发安全
  • 开箱即用的 UI:余额、购买页、流水记录

账本是唯一数据源。客户端永远不会在本地发放积分——只有 RevenueCat webhook(购买入账)和服务端消耗会写入账本。

同时也在做 Web?积分共用同一套服务端和配置——Web 端的配置见 Web · 积分系统

架构

位置
配置packages/app-config/src/app-config.ts
服务端apps/server/src/credits/*
API 路由apps/server/src/routers/common/credits.ts
App UIapps/native/app/(tabs)/(profile)/credits*.tsxapps/native/hooks/use-credits.ts
购买apps/native/lib/payments/revenuecat.ts

积分相关数据表:

用途
credit_account用户当前余额和累计值
credit_transaction不可变流水;(sourceProvider, sourceType, sourceId) 元组即幂等键
credit_orderApp 购买订单生命周期
credit_signup_grant_claim注册赠送的资格与防滥用校验

1. 配置积分包

所有配置都在 packages/app-config/src/app-config.ts

开启 App 积分

packages/app-config/src/app-config.ts
native: {
  credits: {
    enabled: true,
    signupGrant: creditSignupGrant,
    packages: nativeCreditPackages,
  },
},

配置注册赠送

新用户首次读取余额时赠送积分。expiresInDays: null(或省略)表示永不过期。

packages/app-config/src/app-config.ts
const creditSignupGrant = {
  enabled: true,
  amount: 100,          // 注册赠送的积分数
  expiresInDays: 30,    // null = 永不过期
} satisfies NonNullable<AppCreditsConfig["signupGrant"]>;

创建消耗型产品

积分包是消耗型(Consumable)应用内购买产品——可以重复购买,并由账本"消耗"掉。这和永久解锁(Non-Consumable)不同。

iOS —— App Store Connect

  1. Monetization → In-App Purchases → +
  2. 选择 Consumable(消耗型)
  3. 填写 Reference Name(如 100 Credits)和 Product ID(如 com.yourapp.credits.starter)。
  4. 添加价格和本地化信息,点 Save

Android —— Google Play Console

  1. Monetize → In-app products → Create product
  2. 填写 Product ID(如 credits_starter)、名称、描述和默认价格。
  3. Save 后点 Activate(未激活的产品 RevenueCat 看不到)。

RevenueCat

  1. Product catalog → Products → Import,选中这些消耗型产品并 Import
  2. 积分只需做到这一步——不需要 Offering 或 Entitlement。App 通过产品 ID 直接拉取(Purchases.getProducts(..., NON_SUBSCRIPTION)),服务端收到 NON_RENEWING_PURCHASE webhook 后按产品 id 匹配发放积分。Offering 和 Entitlement 只用于订阅和永久解锁。

RevenueCat 的完整配置(App 配置、商店凭证、webhook)见 RevenueCat商店产品 文档。

配置 App 积分包

把上一步的产品 id 填进每个平台的 nativeproviderProductId 必须与商店产品 id 完全一致。

packages/app-config/src/app-config.ts
const nativeCreditPackages = [
  {
    id: "starter",       // 内部积分包 id
    amount: 100,         // 购买后到账的积分数
    native: {
      ios: {
        provider: "revenuecat",
        providerProductId: "easystarter_credits_starter_ios",
        currency: "usd",
        amountCents: 499,
        status: "active",
      },
      android: {
        provider: "revenuecat",
        providerProductId: "easystarter_credits_starter_android",
        currency: "usd",
        amountCents: 499,
        status: "active",
      },
    },
  },
] satisfies AppCreditsConfig["packages"];

补充积分包文案

为每个积分包 id 添加标题和描述,购买页才能正确渲染。

packages/i18n/src/messages/native/zh.json
"credits": {
  "packages": {
    "starter": {
      "title": "入门积分包",
      "description": "{count} 积分,适合轻量使用。"
    }
  }
}

配置规则

  • amountamountCents 必须是正整数。
  • providerProductId 必填、唯一,且必须与 RevenueCat 产品 id 一致。
  • 在 RevenueCat 中将积分产品配置为非订阅(消耗型)产品。
  • status: "archived" 可隐藏积分包而不删除历史记录。
  • 若同一个积分包也在 Web 售卖,复用相同的 id(amountstatus 须一致)——见 Web · 积分系统

2. 准备服务端

执行迁移

pnpm db:migrate:local   # 本地 D1
pnpm db:migrate         # 远程 D1

配置 RevenueCat webhook 密钥

积分复用 RevenueCat 集成,除了 应用内购买 已要求的密钥外,无需额外配置:

支付商密钥
RevenueCatREVENUECAT_WEBHOOK_SECRET

确认维护定时任务

apps/server/src/index.ts 会按 apps/server/wrangler.jsonc 中的每日计划运行 runCreditMaintenance,用于过期赠送积分、清理过期的待支付订单。

apps/server/wrangler.jsonc
"triggers": {
  "crons": ["10 16 * * *"]
}

3. 消耗积分

消耗积分是你需要接入到自己业务里的部分。优先在服务端路由中调用,避免客户端绕过余额校验。

服务端路由
await context.credits.consumeCredits({
  user: { userId: context.session.user.id },
  amount: 1,
  idempotencyKey: `image-generate:${recordId}`,
  metadata: { feature: "image-generate", recordId },
});

从 App 则使用 useCredits hook:

const credits = useCredits();

await credits.consume({
  amount: 1,
  idempotencyKey: `image-generate:${recordId}`,
  metadata: { feature: "image-generate", recordId },
});

idempotencyKey 必须对应一次真实的消耗事件(8–120 字符)。用相同 key 重试只会返回当前余额,不会重复扣费。消耗时优先扣最快过期的积分;余额不足时抛出 Insufficient credits

账本规则

  • 注册赠送积分在首次读取余额 / 流水或消耗时懒发放。需邮箱已验证,并按邮箱、IP、User-Agent 限流。
  • 购买的积分永不过期(expiresAt = null)。
  • 赠送积分按 expiresInDays 过期,由每日定时任务清理。
  • 消耗时优先扣最快过期的积分,再扣永久付费积分。
  • 退款只回收原购买记录中尚未消耗的剩余额度。