积分系统
配置 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 UI | apps/native/app/(tabs)/(profile)/credits*.tsx、apps/native/hooks/use-credits.ts |
| 购买 | apps/native/lib/payments/revenuecat.ts |
积分相关数据表:
| 表 | 用途 |
|---|---|
credit_account | 用户当前余额和累计值 |
credit_transaction | 不可变流水;(sourceProvider, sourceType, sourceId) 元组即幂等键 |
credit_order | App 购买订单生命周期 |
credit_signup_grant_claim | 注册赠送的资格与防滥用校验 |
1. 配置积分包
所有配置都在 packages/app-config/src/app-config.ts。
开启 App 积分
native: {
credits: {
enabled: true,
signupGrant: creditSignupGrant,
packages: nativeCreditPackages,
},
},配置注册赠送
新用户首次读取余额时赠送积分。expiresInDays: null(或省略)表示永不过期。
const creditSignupGrant = {
enabled: true,
amount: 100, // 注册赠送的积分数
expiresInDays: 30, // null = 永不过期
} satisfies NonNullable<AppCreditsConfig["signupGrant"]>;创建消耗型产品
积分包是消耗型(Consumable)应用内购买产品——可以重复购买,并由账本"消耗"掉。这和永久解锁(Non-Consumable)不同。
iOS —— App Store Connect
- Monetization → In-App Purchases → +。
- 选择 Consumable(消耗型)。
- 填写 Reference Name(如
100 Credits)和 Product ID(如com.yourapp.credits.starter)。 - 添加价格和本地化信息,点 Save。
Android —— Google Play Console
- Monetize → In-app products → Create product。
- 填写 Product ID(如
credits_starter)、名称、描述和默认价格。 - Save 后点 Activate(未激活的产品 RevenueCat 看不到)。
RevenueCat
- Product catalog → Products → Import,选中这些消耗型产品并 Import。
- 积分只需做到这一步——不需要 Offering 或 Entitlement。App 通过产品 ID 直接拉取(
Purchases.getProducts(..., NON_SUBSCRIPTION)),服务端收到NON_RENEWING_PURCHASEwebhook 后按产品 id 匹配发放积分。Offering 和 Entitlement 只用于订阅和永久解锁。
RevenueCat 的完整配置(App 配置、商店凭证、webhook)见 RevenueCat 和 商店产品 文档。
配置 App 积分包
把上一步的产品 id 填进每个平台的 native。providerProductId 必须与商店产品 id 完全一致。
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 添加标题和描述,购买页才能正确渲染。
"credits": {
"packages": {
"starter": {
"title": "入门积分包",
"description": "{count} 积分,适合轻量使用。"
}
}
}配置规则
amount与amountCents必须是正整数。providerProductId必填、唯一,且必须与 RevenueCat 产品 id 一致。- 在 RevenueCat 中将积分产品配置为非订阅(消耗型)产品。
- 用
status: "archived"可隐藏积分包而不删除历史记录。 - 若同一个积分包也在 Web 售卖,复用相同的
id(amount和status须一致)——见 Web · 积分系统。
2. 准备服务端
执行迁移
pnpm db:migrate:local # 本地 D1
pnpm db:migrate # 远程 D1配置 RevenueCat webhook 密钥
积分复用 RevenueCat 集成,除了 应用内购买 已要求的密钥外,无需额外配置:
| 支付商 | 密钥 |
|---|---|
| RevenueCat | REVENUECAT_WEBHOOK_SECRET |
确认维护定时任务
apps/server/src/index.ts 会按 apps/server/wrangler.jsonc 中的每日计划运行 runCreditMaintenance,用于过期赠送积分、清理过期的待支付订单。
"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过期,由每日定时任务清理。 - 消耗时优先扣最快过期的积分,再扣永久付费积分。
- 退款只回收原购买记录中尚未消耗的剩余额度。