积分系统
配置 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.ts、apps/server/src/routers/web/credits.ts |
| Web UI | apps/web/src/routes/_authed/(dashboard)/credits/*、apps/web/src/hooks/use-credits.ts |
积分相关数据表:
| 表 | 用途 |
|---|---|
credit_account | 用户当前余额和累计值 |
credit_transaction | 不可变流水;(sourceProvider, sourceType, sourceId) 元组即幂等键 |
credit_order | Web 购买订单生命周期 |
credit_signup_grant_claim | 注册赠送的资格与防滥用校验 |
1. 配置积分包
所有配置都在 packages/app-config/src/app-config.ts。
开启 Web 积分
web: {
credits: {
enabled: true,
signupGrant: creditSignupGrant,
packages: webCreditPackages,
},
},配置注册赠送
新用户首次读取余额时赠送积分。expiresInDays: null(或省略)表示永不过期。
const creditSignupGrant = {
enabled: true,
amount: 100, // 注册赠送的积分数
expiresInDays: 30, // null = 永不过期
} satisfies NonNullable<AppCreditsConfig["signupGrant"]>;创建一次性购买产品
积分包是一次性付款,绝不是订阅。先在支付商后台创建产品,再把价格 / 产品 ID 填到下一步。
Stripe
- Dashboard → Products → Add product,命名(如
100 Credits)。 - 在 Pricing 里选 One time(一次性)(不是 Recurring),设置金额和币种,保存。
- 打开该价格,复制 Price ID(
price_xxx)。 - 切换 Test mode 开 / 关,在两个环境各建一个价格——
test和prod各需要一个 ID。
Creem
- Dashboard → Products → Create product,命名(如
100 Credits)。 - 计费类型选 One time,设置金额。
- 保存并复制 Product ID(
prod_xxx)——Creem 用 Product ID 作为价格 id。 - 在测试和正式两个环境各建一次。
配置 Web 积分包
把上一步的价格 / 产品 ID 填进每个积分包的 web。运行时按 NODE_ENV 自动选用环境。
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 才能正确渲染。
"credits": {
"packages": {
"starter": {
"title": "入门积分包",
"description": "{count} 积分,适合轻量使用。"
}
}
}配置规则
amount与amountCents必须是正整数。test和prod价格 ID 都必须填写,且各自唯一。- 用
status: "archived"可隐藏积分包而不删除历史记录。 - 若同一个积分包也在 App 售卖,复用相同的
id(amount和status须一致)——见 Mobile · 积分系统。
2. 准备服务端
执行迁移
pnpm db:migrate:local # 本地 D1
pnpm db:migrate # 远程 D1配置支付密钥
积分复用 Web 支付商,除了 Stripe / Creem 已要求的密钥外,无需额外配置:
| 支付商 | 密钥 |
|---|---|
| Stripe | STRIPE_SECRET_KEY、STRIPE_WEBHOOK_SECRET |
| Creem | CREEM_API_KEY、CREEM_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 },
});从浏览器则直接调用 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过期,由每日定时任务清理。 - 消耗时优先扣最快过期的积分,再扣永久付费积分。
- 退款只回收原购买记录中尚未消耗的剩余额度。