ship-credits

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
Scaffold a full credits system — database schema, backend middleware, payment webhooks, frontend state, and UI components. Reads the project first, builds on top of what's already there.
为应用搭建完整的积分系统——包含数据库 schema、后端中间件、支付 webhook、前端状态管理及 UI 组件。会先分析现有项目,在已有基础上进行构建。

Phase 1: Understand the Project

第一阶段:了解项目

Before writing any code, answer these questions by reading the codebase:
在编写任何代码之前,先通过阅读代码库回答以下问题:

1.1 Detect the Stack

1.1 检测技术栈

  • Backend: Next.js API routes / FastAPI / Express / Django / Rails?
  • Database: Supabase / Postgres / PlanetScale / MongoDB / Prisma?
  • Auth: Clerk / NextAuth / Supabase Auth / Firebase Auth / custom JWT?
  • Frontend state: Zustand / Redux / React Context / Jotai / vanilla?
  • Existing payments?: Check for Stripe / Lemon Squeezy / Dodo / Paddle imports
  • 后端:使用 Next.js API 路由 / FastAPI / Express / Django / Rails?
  • 数据库:使用 Supabase / Postgres / PlanetScale / MongoDB / Prisma?
  • 认证:使用 Clerk / NextAuth / Supabase Auth / Firebase Auth / 自定义 JWT?
  • 前端状态管理:使用 Zustand / Redux / React Context / Jotai / 原生 JS?
  • 是否已有支付功能?:检查是否引入 Stripe / Lemon Squeezy / Dodo / Paddle

1.2 Ask the User

1.2 询问用户

Before scaffolding, confirm these decisions:
I'll set up credits for your [framework] app with [database].

Quick decisions needed:

1. What costs credits? (e.g., "AI generation = 5, image gen = 4, export = 2")
2. Free tier: How many credits on signup? (e.g., 50)
3. Payment provider preference? (Stripe / Lemon Squeezy / Dodo / manual only)
4. Credit pack pricing? (e.g., "$5 = 100 credits, $10 = 250 credits")

Defaults: 50 free credits, per-action costs you define, Stripe.
在搭建前,先确认以下决策:
我将为你的 [框架] 应用搭建积分系统,使用 [数据库]。

需要快速确认以下事项:

1. 哪些操作消耗积分?(例如:"AI 生成 = 5 积分,图片生成 = 4 积分,导出 = 2 积分")
2. 免费额度:新用户注册赠送多少积分?(例如:50)
3. 支付服务商偏好?(Stripe / Lemon Squeezy / Dodo / 仅手动充值)
4. 积分套餐定价?(例如:"5 美元 = 100 积分,10 美元 = 250 积分")

默认设置:注册赠送 50 积分,操作消耗规则由你定义,使用 Stripe。

Phase 2: Database Schema

第二阶段:数据库Schema

Create the schema that matches their database.
创建与用户数据库匹配的Schema。

For SQL databases (Supabase / Postgres / PlanetScale):

适用于 SQL 数据库(Supabase / Postgres / PlanetScale):

Users table — add credits column if it doesn't exist:
sql
-- Add to existing users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS credits integer DEFAULT [FREE_CREDITS] NOT NULL;
If no users table exists, create one with the minimum needed:
sql
CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  auth_id text UNIQUE NOT NULL,        -- from auth provider (clerk_id, supabase uid, etc.)
  email text UNIQUE,
  credits integer DEFAULT [FREE_CREDITS] NOT NULL,
  created_at timestamptz DEFAULT now()
);
Credit transactions table — this is the audit trail. Non-negotiable:
sql
CREATE TABLE credit_transactions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES users(id) NOT NULL,
  amount integer NOT NULL,              -- positive = add, negative = spend
  reason text NOT NULL,                 -- 'signup_bonus', 'purchase', 'generation', 'refund', 'admin_grant', 'promo_code'
  metadata jsonb DEFAULT '{}',          -- payment_id, action details, admin notes
  created_at timestamptz DEFAULT now()
);

CREATE INDEX idx_credit_tx_user ON credit_transactions(user_id);
CREATE INDEX idx_credit_tx_created ON credit_transactions(created_at);
Promo codes table (optional but recommended):
sql
CREATE TABLE promo_codes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  code text UNIQUE NOT NULL,
  credits_amount integer NOT NULL,
  max_uses integer DEFAULT 1,
  times_used integer DEFAULT 0,
  email text,                           -- NULL = anyone can use, set = restricted to this email
  expires_at timestamptz,               -- NULL = never expires
  created_at timestamptz DEFAULT now()
);
用户表——如果不存在则添加 credits 字段:
sql
-- 添加到现有用户表
ALTER TABLE users ADD COLUMN IF NOT EXISTS credits integer DEFAULT [FREE_CREDITS] NOT NULL;
如果没有用户表,则创建包含必要字段的基础表:
sql
CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  auth_id text UNIQUE NOT NULL,        -- 来自认证服务商(clerk_id、supabase uid 等)
  email text UNIQUE,
  credits integer DEFAULT [FREE_CREDITS] NOT NULL,
  created_at timestamptz DEFAULT now()
);
积分交易表——这是审计追踪表,必须创建:
sql
CREATE TABLE credit_transactions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES users(id) NOT NULL,
  amount integer NOT NULL,              -- 正数 = 增加,负数 = 消耗
  reason text NOT NULL,                 -- 'signup_bonus'、'purchase'、'generation'、'refund'、'admin_grant'、'promo_code'
  metadata jsonb DEFAULT '{}',          -- 支付 ID、操作详情、管理员备注
  created_at timestamptz DEFAULT now()
);

CREATE INDEX idx_credit_tx_user ON credit_transactions(user_id);
CREATE INDEX idx_credit_tx_created ON credit_transactions(created_at);
优惠码表(可选但推荐):
sql
CREATE TABLE promo_codes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  code text UNIQUE NOT NULL,
  credits_amount integer NOT NULL,
  max_uses integer DEFAULT 1,
  times_used integer DEFAULT 0,
  email text,                           -- NULL = 所有人可用,指定值 = 仅限该邮箱使用
  expires_at timestamptz,               -- NULL = 永不过期
  created_at timestamptz DEFAULT now()
);

For Prisma:

适用于 Prisma:

prisma
model User {
  id        String   @id @default(uuid())
  authId    String   @unique @map("auth_id")
  email     String?  @unique
  credits   Int      @default([FREE_CREDITS])
  createdAt DateTime @default(now()) @map("created_at")

  transactions CreditTransaction[]

  @@map("users")
}

model CreditTransaction {
  id        String   @id @default(uuid())
  userId    String   @map("user_id")
  amount    Int                          // positive = add, negative = spend
  reason    String                       // signup_bonus, purchase, generation, etc.
  metadata  Json     @default("{}")
  createdAt DateTime @default(now()) @map("created_at")

  user User @relation(fields: [userId], references: [id])

  @@index([userId])
  @@index([createdAt])
  @@map("credit_transactions")
}
prisma
model User {
  id        String   @id @default(uuid())
  authId    String   @unique @map("auth_id")
  email     String?  @unique
  credits   Int      @default([FREE_CREDITS])
  createdAt DateTime @default(now()) @map("created_at")

  transactions CreditTransaction[]

  @@map("users")
}

model CreditTransaction {
  id        String   @id @default(uuid())
  userId    String   @map("user_id")
  amount    Int                          // 正数 = 增加,负数 = 消耗
  reason    String                       // signup_bonus、purchase、generation 等
  metadata  Json     @default("{}")
  createdAt DateTime @default(now()) @map("created_at")

  user User @relation(fields: [userId], references: [id])

  @@index([userId])
  @@index([createdAt])
  @@map("credit_transactions")
}

For MongoDB / Mongoose:

适用于 MongoDB / Mongoose:

javascript
const creditTransactionSchema = new Schema({
  userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
  amount: { type: Number, required: true },
  reason: { type: String, required: true },
  metadata: { type: Schema.Types.Mixed, default: {} },
  createdAt: { type: Date, default: Date.now, index: true },
});
Tell the user: "Created credit_transactions table. Every credit change is logged — you'll never wonder where credits went."
javascript
const creditTransactionSchema = new Schema({
  userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
  amount: { type: Number, required: true },
  reason: { type: String, required: true },
  metadata: { type: Schema.Types.Mixed, default: {} },
  createdAt: { type: Date, default: Date.now, index: true },
});
告知用户:"已创建 credit_transactions 表。每一次积分变动都会被记录——你永远不会疑惑积分的去向。"

Phase 3: Backend Credit Service

第三阶段:后端积分服务

Create a credit service module with these core functions. Adapt to the project's language and framework.
创建包含以下核心功能的积分服务模块,适配项目的语言和框架。

Core Functions

核心功能

get_credits(user_identifier) — Get current balance:
Input: auth_id or email (whatever the project uses to identify users)
Output: integer (credit balance) or null (user not found)
Logic: Query users table, return credits column
deduct_credits(user_identifier, amount, reason) — Spend credits:
Input: user identifier, amount to deduct, reason string
Output: boolean (success/failure)
Logic:
  1. Get current credits
  2. If credits < amount → return false (DO NOT go negative)
  3. Update users.credits = credits - amount
  4. Insert credit_transactions record with negative amount
  5. Return true
add_credits(user_identifier, amount, reason, metadata) — Add credits:
Input: user identifier, amount to add, reason, optional metadata (payment_id, promo_code, etc.)
Output: new balance
Logic:
  1. Update users.credits = credits + amount
  2. Insert credit_transactions record with positive amount
  3. Return new balance
Important implementation details:
  • The deduct function MUST check balance before deducting — never trust the frontend
  • Use database-level constraints or transactions to prevent race conditions
  • For high-throughput: use
    UPDATE users SET credits = credits - $amount WHERE id = $id AND credits >= $amount
    and check affected rows
  • Always log to credit_transactions — this is your financial audit trail
get_credits(user_identifier) —— 获取当前余额:
输入:auth_id 或 email(项目用于识别用户的标识)
输出:整数(积分余额)或 null(未找到用户)
逻辑:查询用户表,返回 credits 字段
deduct_credits(user_identifier, amount, reason) —— 消耗积分:
输入:用户标识、要扣除的积分数量、原因字符串
输出:布尔值(成功/失败)
逻辑:
  1. 获取当前积分余额
  2. 如果积分 < 要扣除的数量 → 返回 false(禁止余额为负)
  3. 更新 users.credits = credits - amount
  4. 在 credit_transactions 表中插入一条负数值的记录
  5. 返回 true
add_credits(user_identifier, amount, reason, metadata) —— 增加积分:
输入:用户标识、要添加的积分数量、原因、可选元数据(支付 ID、优惠码等)
输出:新的余额
逻辑:
  1. 更新 users.credits = credits + amount
  2. 在 credit_transactions 表中插入一条正数值的记录
  3. 返回新的余额
重要实现细节:
  • 扣除功能必须在扣除前检查余额——永远不要信任前端
  • 使用数据库级约束或事务防止竞态条件
  • 高并发场景:使用
    UPDATE users SET credits = credits - $amount WHERE id = $id AND credits >= $amount
    并检查受影响的行数
  • 始终记录到 credit_transactions 表——这是你的财务审计追踪依据

Credit Check Middleware

积分校验中间件

Create middleware that runs before any credit-consuming endpoint:
For Next.js API routes:
typescript
// middleware pattern — adapt to project's auth
export function withCredits(handler, cost) {
  return async (req, res) => {
    const user = await getAuthUser(req); // from project's auth
    const credits = await getCredits(user.id);

    if (credits < cost) {
      return res.status(402).json({
        error: 'Insufficient credits',
        required: cost,
        balance: credits,
      });
    }

    // Attach to request for handler to use
    req.creditsCost = cost;
    req.deductCredits = () => deductCredits(user.id, cost, 'api_action');

    return handler(req, res);
  };
}
For FastAPI:
python
async def require_credits(amount: int):
    async def dependency(request: Request):
        user = await get_current_user(request)  # from project's auth
        credits = get_credits(user.id)
        if credits is None or credits < amount:
            raise HTTPException(
                status_code=402,
                detail={"error": "Insufficient credits", "required": amount, "balance": credits or 0}
            )
        return user
    return Depends(dependency)
For Express:
javascript
function requireCredits(cost) {
  return async (req, res, next) => {
    const credits = await getCredits(req.user.id);
    if (credits < cost) {
      return res.status(402).json({ error: 'Insufficient credits', required: cost, balance: credits });
    }
    req.creditsCost = cost;
    next();
  };
}
HTTP 402 is the right status code for insufficient credits. It literally means "Payment Required." Handle it explicitly in error handlers — don't let it get caught by generic 4xx/5xx handlers.
创建在所有消耗积分的接口前执行的中间件:
适用于 Next.js API 路由:
typescript
// 中间件模式——适配项目的认证逻辑
export function withCredits(handler, cost) {
  return async (req, res) => {
    const user = await getAuthUser(req); // 来自项目的认证逻辑
    const credits = await getCredits(user.id);

    if (credits < cost) {
      return res.status(402).json({
        error: '积分不足',
        required: cost,
        balance: credits,
      });
    }

    // 附加到 request 对象供处理器使用
    req.creditsCost = cost;
    req.deductCredits = () => deductCredits(user.id, cost, 'api_action');

    return handler(req, res);
  };
}
适用于 FastAPI:
python
async def require_credits(amount: int):
    async def dependency(request: Request):
        user = await get_current_user(request)  # 来自项目的认证逻辑
        credits = get_credits(user.id)
        if credits is None or credits < amount:
            raise HTTPException(
                status_code=402,
                detail={"error": "积分不足", "required": amount, "balance": credits or 0}
            )
        return user
    return Depends(dependency)
适用于 Express:
javascript
function requireCredits(cost) {
  return async (req, res, next) => {
    const credits = await getCredits(req.user.id);
    if (credits < cost) {
      return res.status(402).json({ error: '积分不足', required: cost, balance: credits });
    }
    req.creditsCost = cost;
    next();
  };
}
HTTP 402 是积分不足的正确状态码,它的字面意思是“需要支付”。在错误处理器中显式处理该状态码——不要让它被通用的 4xx/5xx 处理器捕获。

Credit Costs Config

积分消耗配置

Create a single source of truth for what things cost:
typescript
// config/credits.ts (or equivalent)
export const CREDIT_COSTS = {
  // Define based on user's answer from Phase 1
  GENERATE: 5,
  REGENERATE: 1,
  IMAGE: 4,
  EXPORT: 2,
} as const;

export const FREE_CREDITS = 50;

export const CREDIT_PACKS = [
  { credits: 100, price_cents: 500, label: '$5' },
  { credits: 250, price_cents: 1000, label: '$10' },
  { credits: 600, price_cents: 2000, label: '$20' },
] as const;
创建统一的积分消耗规则配置文件:
typescript
// config/credits.ts(或对应格式的文件)
export const CREDIT_COSTS = {
  // 根据第一阶段用户的回答定义
  GENERATE: 5,
  REGENERATE: 1,
  IMAGE: 4,
  EXPORT: 2,
} as const;

export const FREE_CREDITS = 50;

export const CREDIT_PACKS = [
  { credits: 100, price_cents: 500, label: '$5' },
  { credits: 250, price_cents: 1000, label: '$10' },
  { credits: 600, price_cents: 2000, label: '$20' },
] as const;

Phase 4: Payment Integration

第四阶段:支付集成

Wire up the payment provider the user chose. Each provider follows the same pattern:
  1. Create a checkout session with credits amount in metadata
  2. Redirect user to hosted checkout page
  3. Receive webhook when payment succeeds
  4. Add credits to user's account
对接用户选择的支付服务商。所有服务商的集成流程遵循以下模式:
  1. 创建结账会话,在元数据中包含积分数量
  2. 重定向用户到托管结账页面
  3. 支付成功时接收 webhook
  4. 为用户账户增加积分

Stripe Integration

Stripe 集成

Create checkout endpoint:
POST /api/payments/create-checkout
Body: { credits: number, price_id: string }

Logic:
  1. Get authenticated user
  2. Create Stripe Checkout Session:
     - line_items: the selected credit pack
     - metadata: { user_id, credits_amount }
     - success_url: /checkout/success?session_id={CHECKOUT_SESSION_ID}
     - cancel_url: /pricing
  3. Return { url: session.url }
Webhook handler:
POST /api/webhooks/stripe
Headers: stripe-signature

Logic:
  1. Verify webhook signature using STRIPE_WEBHOOK_SECRET
  2. Handle event type: checkout.session.completed
  3. Extract metadata.user_id and metadata.credits_amount
  4. IDEMPOTENCY CHECK: query credit_transactions for this payment_id
     - If found → return 200 (already processed)
  5. Call add_credits(user_id, credits_amount, 'purchase', { payment_id: session.id })
  6. Return 200

CRITICAL: Always return 200 to prevent retries, even on errors. Log the error instead.
Environment variables needed:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID_100=price_...    # $5 = 100 credits
STRIPE_PRICE_ID_250=price_...    # $10 = 250 credits
创建结账接口:
POST /api/payments/create-checkout
请求体:{ credits: number, price_id: string }

逻辑:
  1. 获取已认证用户
  2. 创建 Stripe 结账会话:
     - line_items: 选中的积分套餐
     - metadata: { user_id, credits_amount }
     - success_url: /checkout/success?session_id={CHECKOUT_SESSION_ID}
     - cancel_url: /pricing
  3. 返回 { url: session.url }
Webhook 处理器:
POST /api/webhooks/stripe
请求头:stripe-signature

逻辑:
  1. 使用 STRIPE_WEBHOOK_SECRET 验证 webhook 签名
  2. 处理事件类型:checkout.session.completed
  3. 提取 metadata.user_id 和 metadata.credits_amount
  4. **幂等性检查**:查询 credit_transactions 表中是否存在该 payment_id
     - 如果存在 → 返回 200(已处理过)
  5. 调用 add_credits(user_id, credits_amount, 'purchase', { payment_id: session.id })
  6. 返回 200

**关键注意事项**:无论是否出错,始终返回 200,避免重复重试。改为记录错误即可。
所需环境变量:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID_100=price_...    # 5 美元 = 100 积分
STRIPE_PRICE_ID_250=price_...    # 10 美元 = 250 积分

Lemon Squeezy Integration

Lemon Squeezy 集成

Create checkout:
POST /api/payments/create-checkout
Body: { variant_id: string, credits: number }

Logic:
  1. POST to https://api.lemonsqueezy.com/v1/checkouts
  2. Include custom_data: { user_id, credits }
  3. Return { url: checkout_url }
Webhook:
POST /api/webhooks/lemonsqueezy
Headers: x-signature (HMAC hex)

Logic:
  1. Verify HMAC-SHA256 signature
  2. Handle event: order_created
  3. Extract custom_data.user_id, custom_data.credits
  4. Idempotency check → add_credits
创建结账:
POST /api/payments/create-checkout
请求体:{ variant_id: string, credits: number }

逻辑:
  1. 向 https://api.lemonsqueezy.com/v1/checkouts 发送 POST 请求
  2. 包含 custom_data: { user_id, credits }
  3. 返回 { url: checkout_url }
Webhook:
POST /api/webhooks/lemonsqueezy
请求头:x-signature (HMAC hex)

逻辑:
  1. 验证 HMAC-SHA256 签名
  2. 处理事件:order_created
  3. 提取 custom_data.user_id、custom_data.credits
  4. 幂等性检查 → 调用 add_credits

Dodo Payments Integration

Dodo Payments 集成

Create checkout:
POST /api/payments/create-checkout
Body: { amount_cents: number, credits: number }

Logic:
  1. POST to https://live.dodopayments.com/checkouts
  2. Headers: Authorization: Bearer DODO_API_KEY
  3. Include metadata: { user_id, credits }
  4. Return { checkout_url }
Webhook:
POST /api/webhooks/dodo
Headers: webhook-id, webhook-timestamp, webhook-signature

Logic:
  1. Verify Standard Webhooks signature:
     - Strip "whsec_" prefix from secret
     - Base64 decode the secret
     - HMAC-SHA256 over "{webhook-id}.{webhook-timestamp}.{raw_body}"
     - Compare with webhook-signature header
  2. Handle event: payment.succeeded
  3. Extract metadata → idempotency check → add_credits
创建结账:
POST /api/payments/create-checkout
请求体:{ amount_cents: number, credits: number }

逻辑:
  1. 向 https://live.dodopayments.com/checkouts 发送 POST 请求
  2. 请求头:Authorization: Bearer DODO_API_KEY
  3. 包含 metadata: { user_id, credits }
  4. 返回 { checkout_url }
Webhook:
POST /api/webhooks/dodo
请求头:webhook-id、webhook-timestamp、webhook-signature

逻辑:
  1. 验证标准 Webhook 签名:
     - 从密钥中移除 "whsec_" 前缀
     - 对密钥进行 Base64 解码
     - 对 "{webhook-id}.{webhook-timestamp}.{raw_body}" 进行 HMAC-SHA256 加密
     - 与 webhook-signature 请求头进行比对
  2. 处理事件:payment.succeeded
  3. 提取元数据 → 幂等性检查 → 调用 add_credits

Webhook Security Checklist

Webhook 安全检查清单

Regardless of provider, every webhook handler MUST:
  1. Verify the signature — never trust unverified webhooks
  2. Check idempotency — payment_id should be unique in credit_transactions
  3. Return 200 always — even on errors, to prevent infinite retries
  4. Log everything — payment_id, user_id, amount, timestamp
  5. Use raw body for signature verification — parsed JSON won't match the signature
无论使用哪个服务商,每个 webhook 处理器都必须:
  1. 验证签名——永远不要信任未验证的 webhook
  2. 检查幂等性——payment_id 在 credit_transactions 表中应唯一
  3. 始终返回 200——即使出错,也要避免无限重试
  4. 记录所有内容——payment_id、user_id、金额、时间戳
  5. 使用原始请求体验证签名——解析后的 JSON 无法匹配签名

Phase 5: Frontend Credit State

第五阶段:前端积分状态管理

Zustand Store (recommended for React)

Zustand 状态仓库(React 推荐使用)

typescript
import { create } from 'zustand';

interface CreditsStore {
  credits: number | null;  // null = not loaded yet
  setCredits: (credits: number | null) => void;
  deduct: (amount: number) => void;
}

export const useCreditsStore = create<CreditsStore>((set) => ({
  credits: null,
  setCredits: (credits) => set({ credits }),
  deduct: (amount) =>
    set((state) => ({
      credits: state.credits !== null ? Math.max(0, state.credits - amount) : null,
    })),
}));
typescript
import { create } from 'zustand';

interface CreditsStore {
  credits: number | null;  // null = 尚未加载
  setCredits: (credits: number | null) => void;
  deduct: (amount: number) => void;
}

export const useCreditsStore = create<CreditsStore>((set) => ({
  credits: null,
  setCredits: (credits) => set({ credits }),
  deduct: (amount) =>
    set((state) => ({
      credits: state.credits !== null ? Math.max(0, state.credits - amount) : null,
    })),
}));

Credit Sync Hook

积分同步 Hook

typescript
// hooks/useCredits.ts
export function useCredits() {
  const { credits, setCredits, deduct } = useCreditsStore();

  // Fetch on mount (after auth)
  const fetchCredits = async () => {
    const res = await fetch('/api/user/credits');
    const data = await res.json();
    setCredits(data.credits);
  };

  // Optimistic deduction — update UI immediately, backend confirms async
  const spendCredits = async (amount: number, action: () => Promise<void>) => {
    if (credits !== null && credits < amount) {
      // Trigger low credit UI
      return { success: false, reason: 'insufficient' };
    }

    deduct(amount); // Optimistic UI update
    try {
      await action(); // The actual API call (which also deducts server-side)
      return { success: true };
    } catch (error) {
      // Refund the optimistic deduction
      await fetchCredits();
      return { success: false, reason: 'error' };
    }
  };

  const hasEnough = (amount: number) => credits !== null && credits >= amount;

  return { credits, fetchCredits, spendCredits, hasEnough };
}
typescript
// hooks/useCredits.ts
export function useCredits() {
  const { credits, setCredits, deduct } = useCreditsStore();

  // 挂载时获取积分(认证后)
  const fetchCredits = async () => {
    const res = await fetch('/api/user/credits');
    const data = await res.json();
    setCredits(data.credits);
  };

  // 乐观扣除——立即更新 UI,后端异步确认
  const spendCredits = async (amount: number, action: () => Promise<void>) => {
    if (credits !== null && credits < amount) {
      // 触发积分不足的 UI 提示
      return { success: false, reason: 'insufficient' };
    }

    deduct(amount); // 乐观更新 UI
    try {
      await action(); // 实际的 API 调用(后端也会扣除积分)
      return { success: true };
    } catch (error) {
      // 回滚乐观扣除的积分
      await fetchCredits();
      return { success: false, reason: 'error' };
    }
  };

  const hasEnough = (amount: number) => credits !== null && credits >= amount;

  return { credits, fetchCredits, spendCredits, hasEnough };
}

Key UX Patterns

核心 UX 模式

Credit display in header/nav:
  • Show current balance near user avatar/menu
  • Format: "42 credits" or just "42" with a coin/token icon
  • If null (loading), show skeleton or nothing — never show 0 while loading
Low credit warning thresholds:
typescript
const LOW_CREDIT_THRESHOLD = 20;      // Show gentle reminder
const CRITICAL_THRESHOLD = 10;         // Show prominent warning
const ZERO_THRESHOLD = 0;              // Block action, show buy modal
Pre-action credit check: Before any credit-consuming action, check client-side first:
typescript
if (!hasEnough(CREDIT_COSTS.GENERATE)) {
  openBuyCreditsModal();
  return;
}
This prevents unnecessary API calls. The server still validates — this is just UX.
头部/导航栏中的积分显示:
  • 在用户头像/菜单附近显示当前余额
  • 格式:"42 积分" 或仅显示 "42" 搭配硬币/代币图标
  • 如果为 null(加载中),显示骨架屏或不显示——加载时永远不要显示 0
积分不足警告阈值:
typescript
const LOW_CREDIT_THRESHOLD = 20;      // 显示温和提醒
const CRITICAL_THRESHOLD = 10;         // 显示显眼警告
const ZERO_THRESHOLD = 0;              // 阻止操作,显示购买弹窗
操作前积分校验: 在所有消耗积分的操作前,先进行客户端校验:
typescript
if (!hasEnough(CREDIT_COSTS.GENERATE)) {
  openBuyCreditsModal();
  return;
}
这可以避免不必要的 API 调用。后端仍会进行验证——这只是为了提升 UX。

Phase 6: UI Components

第六阶段:UI 组件

Create these components, matching the project's existing design system.
创建以下组件,匹配项目现有的设计系统。

1. Credit Balance Display

1. 积分余额显示组件

  • Shows in header/nav bar
  • Updates in real-time after actions
  • Subtle pulse animation when credits change
  • Click to open buy modal
  • 在头部/导航栏中显示
  • 操作后实时更新
  • 积分变动时显示微妙的脉冲动画
  • 点击可打开购买弹窗

2. Buy Credits Modal/Slider

2. 积分购买弹窗/滑块

  • Shows credit pack options with pricing
  • Highlights best value pack
  • Shows what credits buy: "100 credits = ~20 generations + 5 images"
  • CTA button that creates checkout and redirects
  • 显示积分套餐选项及定价
  • 突出显示性价比最高的套餐
  • 显示积分可兑换的服务:"100 积分 = 约 20 次生成 + 5 张图片"
  • 包含创建结账会话并重定向的 CTA 按钮

3. Low Credit Warning

3. 积分不足警告组件

  • Appears when balance drops below threshold
  • Non-blocking for LOW (banner/toast)
  • Blocking for ZERO (modal with buy CTA)
  • Shows credit costs as reminder
  • "Buy credits" primary CTA + "I'll be careful" dismiss (if credits > 0)
  • 余额低于阈值时显示
  • 余额 LOW 时显示非阻塞提示(横幅/通知)
  • 余额为 ZERO 时显示阻塞提示(带购买 CTA 的弹窗)
  • 显示积分消耗规则作为提醒
  • 主 CTA "购买积分" + "我会注意的" 关闭按钮(当积分 > 0 时)

4. Credit Cost Indicators

4. 积分消耗指示器

  • Show cost next to every action button: "Generate (5 credits)"
  • Gray out buttons when insufficient credits
  • Tooltip on disabled button: "You need X more credits"
  • 在每个操作按钮旁显示消耗的积分:"生成(5 积分)"
  • 积分不足时按钮置灰
  • 禁用按钮的提示:"你还需要 X 积分"

5. Checkout Success Page

5. 结账成功页面

  • "/checkout/success" route
  • Polls /api/user/credits until balance updates (webhook may take a few seconds)
  • Shows confetti/celebration + new balance
  • CTA back to main app
  • "/checkout/success" 路由
  • 轮询 /api/user/credits 直到余额更新(webhook 可能需要几秒)
  • 显示庆祝动画(如彩屑)+ 新余额
  • 包含返回主应用的 CTA

6. Transaction History (optional but recommended)

6. 交易历史(可选但推荐)

  • Table/list of all credit changes
  • Columns: date, action, amount (+/-), balance after
  • Filter by reason (purchases, spending, bonuses)
  • Useful for user trust — they can see exactly where credits went
  • 所有积分变动的表格/列表
  • 列:日期、操作、金额(+/-)、变动后余额
  • 可按原因筛选(购买、消耗、奖励)
  • 提升用户信任度——用户可以清楚看到积分的去向

Phase 7: Promo Code System

第七阶段:优惠码系统

Redeem Endpoint

兑换接口

POST /api/credits/redeem
Body: { code: string }

Logic:
  1. Get authenticated user
  2. Find promo code by code string
  3. Validate:
     - Code exists
     - Not expired (expires_at is null or > now)
     - times_used < max_uses
     - If email is set on code, must match user's email
  4. Add credits to user
  5. Increment times_used on promo code
  6. Log transaction with reason='promo_code', metadata={ code }
  7. Return { credits_added, new_balance }
POST /api/credits/redeem
请求体:{ code: string }

逻辑:
  1. 获取已认证用户
  2. 根据 code 字符串查找优惠码
  3. 验证:
     - 优惠码存在
     - 未过期(expires_at 为 null 或 > 当前时间)
     - 使用次数 < 最大可用次数
     - 如果优惠码设置了邮箱,必须与用户邮箱匹配
  4. 为用户增加积分
  5. 增加优惠码的使用次数
  6. 记录交易,reason='promo_code',metadata={ code }
  7. 返回 { credits_added, new_balance }

Admin Create Code Endpoint

管理员创建优惠码接口

POST /api/admin/promo-codes
Body: { credits_amount, max_uses?, email?, expires_in_days?, custom_code? }
Auth: Admin only

Logic:
  1. Generate random 8-char code if no custom_code
  2. Insert into promo_codes table
  3. Return { code, credits_amount, expires_at }
Promo codes are essential for:
  • Beta user onboarding
  • Influencer/partner distribution
  • Customer support ("sorry for the issue, here's 50 credits")
  • Marketing campaigns
POST /api/admin/promo-codes
请求体:{ credits_amount, max_uses?, email?, expires_in_days?, custom_code? }
认证:仅限管理员(检查管理员密钥请求头或管理员角色)

逻辑:
  1. 如果未提供 custom_code,生成 8 位随机码
  2. 插入到 promo_codes 表
  3. 返回 { code, credits_amount, expires_at }
优惠码系统对以下场景至关重要:
  • 测试用户/ beta 用户入职
  • 网红/合作伙伴分销
  • 客户支持("抱歉给你带来困扰,这里有 50 积分")
  • 营销活动

Phase 8: Admin Tools

第八阶段:管理员工具

Add Credits Endpoint

增加积分接口

POST /api/admin/add-credits
Body: { email: string, amount: number, reason?: string }
Auth: Admin only (check admin secret header or admin role)

Logic:
  1. Find user by email
  2. Add credits
  3. Log transaction with reason (default: 'admin_grant')
  4. Return { email, credits_added, new_balance }
POST /api/admin/add-credits
请求体:{ email: string, amount: number, reason?: string }
认证:仅限管理员(检查管理员密钥请求头或管理员角色)

逻辑:
  1. 根据邮箱查找用户
  2. 为用户增加积分
  3. 记录交易,使用指定 reason(默认:'admin_grant')
  4. 返回 { email, credits_added, new_balance }

Credit Analytics (read from credit_transactions)

积分分析(从 credit_transactions 表读取)

Useful queries to surface in an admin panel:
  • Total credits purchased (sum where reason='purchase')
  • Total credits spent (sum where amount < 0)
  • Credits outstanding (sum of all users.credits)
  • Revenue (count purchases * price)
  • Most active users (group by user_id, sum spending)
  • Spend by action type (group by reason)
以下查询可在管理员面板中展示:
  • 总购买积分(reason='purchase' 的总和)
  • 总消耗积分(amount < 0 的总和)
  • 未使用积分(所有用户 credits 字段的总和)
  • 收入(购买次数 * 价格)
  • 活跃用户排名(按 user_id 分组,统计消耗总额)
  • 按操作类型统计消耗(按 reason 分组)

Phase 9: Wire It All Together

第九阶段:整合所有模块

After creating all the pieces, connect them:
  1. Auth sync: When a user signs in, fetch their credit balance and hydrate the frontend store
  2. Every protected endpoint: Add credit check middleware with the appropriate cost
  3. Every credit-consuming UI action: Add client-side hasEnough check + server-side middleware
  4. After every action: Optimistically deduct in frontend, confirm via API response
  5. Webhook route: Register with payment provider, test with CLI tools
  6. Success page: Create /checkout/success with polling logic
创建完所有模块后,将它们连接起来:
  1. 认证同步:用户登录时,获取其积分余额并更新前端状态仓库
  2. 所有受保护接口:添加对应消耗金额的积分校验中间件
  3. 所有消耗积分的 UI 操作:添加客户端 hasEnough 检查 + 服务端中间件
  4. 每次操作后:前端乐观扣除积分,通过 API 响应确认
  5. Webhook 路由:在支付服务商处注册,使用 CLI 工具测试
  6. 成功页面:创建 /checkout/success 页面并添加轮询逻辑

Verification Checklist

验证检查清单

After scaffolding, walk through these flows:
Flow 1: New User Signup
[ ] User signs up → gets FREE_CREDITS
[ ] Credit balance shows in UI
[ ] Transaction logged: reason='signup_bonus'

Flow 2: Spend Credits
[ ] User triggers action → credits deducted
[ ] UI updates optimistically
[ ] Server validates balance before processing
[ ] 402 returned if insufficient
[ ] Transaction logged with correct reason

Flow 3: Buy Credits
[ ] User clicks buy → redirected to checkout
[ ] Payment succeeds → webhook received
[ ] Webhook verified → credits added
[ ] Idempotent (double webhook doesn't double-credit)
[ ] UI refreshes with new balance
[ ] Transaction logged: reason='purchase'

Flow 4: Promo Code
[ ] User enters code → credits added
[ ] Code usage incremented
[ ] Expired/used codes rejected
[ ] Email-restricted codes enforced

Flow 5: Edge Cases
[ ] Insufficient credits → clear error, buy CTA shown
[ ] Concurrent requests → no negative balance (DB constraint or check)
[ ] Webhook arrives before redirect → still works
[ ] Network error during action → optimistic deduction rolled back
Tell the user which flows are wired and which need manual testing.
搭建完成后,测试以下流程:
流程 1:新用户注册
[ ] 用户注册 → 获得 FREE_CREDITS
[ ] 积分余额在 UI 中显示
[ ] 交易记录:reason='signup_bonus'

流程 2:消耗积分
[ ] 用户触发操作 → 积分被扣除
[ ] UI 乐观更新
[ ] 服务器在处理前验证余额
[ ] 积分不足时返回 402
[ ] 交易记录中包含正确的 reason

流程 3:购买积分
[ ] 用户点击购买 → 重定向到结账页面
[ ] 支付成功 → 接收 webhook
[ ] Webhook 验证通过 → 积分增加
[ ] 幂等性(重复 webhook 不会重复增加积分)
[ ] UI 刷新显示新余额
[ ] 交易记录:reason='purchase'

流程 4:优惠码
[ ] 用户输入优惠码 → 积分增加
[ ] 优惠码使用次数增加
[ ] 过期/已使用的优惠码被拒绝
[ ] 邮箱限制的优惠码生效

流程 5:边缘情况
[ ] 积分不足 → 显示清晰错误及购买 CTA
[ ] 并发请求 → 不会出现负余额(数据库约束或校验)
[ ] Webhook 在重定向前到达 → 仍能正常工作
[ ] 操作时网络错误 → 乐观扣除的积分被回滚
告知用户哪些流程已对接完成,哪些需要手动测试。

Important Notes

重要注意事项

  • Never trust the frontend balance. Always validate server-side. The frontend balance is for UX only.
  • Log every credit change. The credit_transactions table is your source of truth, not the credits column. If they ever disagree, transactions win.
  • Idempotency on webhooks is non-negotiable. Payment providers retry. Double-crediting loses you money.
  • HTTP 402 is the correct status code. Not 403, not 400. 402 means "Payment Required."
  • Start simple. One credit pack at one price. Add tiers later when you have data on what users actually buy.
  • 永远不要信任前端余额。始终在服务端验证。前端余额仅用于 UX。
  • 记录每一次积分变动。credit_transactions 表是你的唯一可信来源,而不是 credits 字段。如果两者不一致,以交易记录为准。
  • Webhook 的幂等性是必须的。支付服务商会重试。重复增加积分会导致你损失资金。
  • HTTP 402 是正确的状态码。不是 403,也不是 400。402 表示“需要支付”。
  • 从简单开始。先提供一个价格的一个积分套餐。等有了用户实际购买数据后再添加更多层级。