EasyStarter logoEasyStarter

积分系统

配置 Web 积分包、Stripe / Creem 购买、消耗和账本规则

积分系统

EasyStarter 内置一套由服务端账本支撑的积分系统。在 Web 端,用户通过 Web 支付商(Stripe 或 Creem)购买积分包:

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

账本是唯一数据源。客户端永远不会直接修改余额——只有支付 webhook(购买入账)和服务端消耗会写入账本。

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

架构

位置
配置packages/app-config/src/app-config.ts
服务端apps/server/src/credits/*
API 路由apps/server/src/routers/common/credits.tsapps/server/src/routers/web/credits.ts
Web UIapps/web/src/routes/_authed/(dashboard)/credits/*apps/web/src/hooks/use-credits.ts

积分相关数据表:

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

1. 配置积分包

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

开启 Web 积分

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

配置注册赠送

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

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

创建一次性购买产品

积分包是一次性付款,绝不是订阅。先在支付商后台创建产品,再把价格 / 产品 ID 填到下一步。

Stripe

  1. Dashboard → Products → Add product,命名(如 100 Credits)。
  2. Pricing 里选 One time(一次性)(不是 Recurring),设置金额和币种,保存。
  3. 打开该价格,复制 Price ID(price_xxx)。
  4. 切换 Test mode 开 / 关,在两个环境各建一个价格——testprod 各需要一个 ID。

Creem

  1. Dashboard → Products → Create product,命名(如 100 Credits)。
  2. 计费类型选 One time,设置金额。
  3. 保存并复制 Product ID(prod_xxx)——Creem 用 Product ID 作为价格 id。
  4. 在测试和正式两个环境各建一次。

支付商的完整配置(API 密钥、webhook)见 StripeCreem 文档。

配置 Web 积分包

把上一步的价格 / 产品 ID 填进每个积分包的 web。运行时按 NODE_ENV 自动选用环境。

packages/app-config/src/app-config.ts
const webCreditPackages = [
  {
    id: "starter",       // 内部积分包 id
    amount: 100,         // 购买后到账的积分数
    web: {
      provider: "stripe", // "stripe" | "creem"
      test: { providerPriceId: "price_xxx" },
      prod: { providerPriceId: "price_xxx" },
      currency: "usd",
      amountCents: 499,  // $4.99
      status: "active",
    },
  },
] satisfies AppCreditsConfig["packages"];

补充积分包文案

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

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

配置规则

  • amountamountCents 必须是正整数。
  • testprod 价格 ID 都必须填写,且各自唯一。
  • status: "archived" 可隐藏积分包而不删除历史记录。
  • 若同一个积分包也在 App 售卖,复用相同的 id(amountstatus 须一致)——见 Mobile · 积分系统

2. 准备服务端

执行迁移

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

配置支付密钥

积分复用 Web 支付商,除了 Stripe / Creem 已要求的密钥外,无需额外配置:

支付商密钥
StripeSTRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRET
CreemCREEM_API_KEYCREEM_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 },
});

从浏览器则直接调用 API:

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

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

账本规则

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