Sistine Starter

自定义指南

如何定制 Sistine Starter 以满足您的业务需求

自定义指南

本文档将指导您如何定制 Sistine Starter 的各个核心功能,使其符合您的业务需求。

修改定价计划

位置

主要配置文件: constants/billing.ts

订阅计划结构

export const subscriptionPlans: Record<PlanKey, SubscriptionPlan> = {
  starter_monthly: {
    key: "starter_monthly",
    kind: "subscription",
    priceCents: 2900,        // 价格(美分): $29.00
    currency: "usd",
    creditsPerCycle: 1000,   // 每个周期的积分数
    cycle: "month",          // 周期: month 或 year
    creemPriceId: "prod_xxx", // Creem 产品 ID
    grantSchedule: { mode: "per_cycle" }, // 发放模式
  },
  // ... 其他计划
};

修改步骤

1. 在 Creem Dashboard 创建产品

访问 Creem Dashboard 创建对应的订阅产品,获取 product_id

2. 更新 billing.ts 配置

// 示例: 添加新的 Premium 计划
pro_monthly: {
  key: "pro_monthly",
  kind: "subscription",
  priceCents: 9900,        // $99.00/月
  currency: "usd",
  creditsPerCycle: 10000,  // 每月 10000 积分
  cycle: "month",
  creemPriceId: "prod_5Xzh9qV5TWeTQtRxjZPEHM", // 从 Creem 获取
  grantSchedule: { mode: "per_cycle" },
},

3. 年付计划的分期发放

如果希望年付计划分期发放积分(防止滥用):

starter_yearly: {
  key: "starter_yearly",
  kind: "subscription",
  priceCents: 29000,       // $290/年
  currency: "usd",
  creditsPerCycle: 12000,  // 全年总积分
  cycle: "year",
  creemPriceId: "prod_2V1LbGt2bLmZpKgmASTiCN",
  grantSchedule: {
    mode: "installments",  // 分期发放模式
    grantsPerCycle: 12,    // 分 12 次发放
    intervalMonths: 1,     // 每月发放一次
    creditsPerGrant: 1000, // 每次发放 1000 积分
    initialGrants: 1,      // 首次购买立即发放 1 次
  },
},

4. 一次性积分包

export const oneTimePacks: Record<PackKey, OneTimePack> = {
  pack_200: {
    key: "pack_200",
    kind: "one_time",
    priceCents: 500,       // $5.00
    currency: "usd",
    credits: 200,          // 200 积分
    creemPriceId: "prod_3SiroZeMbMQidMVFDMUzKy",
  },
};

注意事项

  • 数据库字段长度: user.planKey 字段定义为 varchar(50),如果计划名称超过 50 个字符,需要修改数据库 Schema
  • Type 定义: 添加新计划时,需要在 PlanKeyPackKey 类型中添加对应的字符串字面量
  • Webhook 处理: app/api/payments/creem/webhook/route.ts 会自动处理新计划,无需修改

修改积分消耗规则

AI 对话积分消耗

位置: lib/credits.ts:6

const CHAT_CREDIT_COST = 10; // 每次对话消耗 10 积分

修改此常量即可调整对话的积分消耗。该常量被以下文件使用:

  • app/api/chat/route.ts - 非流式对话
  • app/api/chat/stream/route.ts - 流式对话

AI 图像生成积分消耗

位置: app/api/image/generate/route.ts:36

// 当前配置: 每次图像生成消耗 20 积分
const creditsNeeded = 20;
const hasCredits = await canUserAfford(userId, creditsNeeded);

修改步骤:

  1. 修改 creditsNeeded 的值
  2. 更新前端显示的积分消耗提示(如果有)

AI 视频生成积分消耗

位置: app/api/video/generate/route.ts:36

// 当前配置: 每次视频生成消耗 50 积分
const creditsNeeded = 50;
const hasCredits = await canUserAfford(userId, creditsNeeded);

动态定价策略

如果需要根据不同参数动态调整积分消耗:

// 示例: 根据视频时长调整积分
const baseCost = 50;
const durationMultiplier = duration === "5s" ? 1 : 2; // 5秒视频 1x,10秒视频 2x
const creditsNeeded = baseCost * durationMultiplier;

// 示例: 根据图像尺寸调整积分
const sizePricing = {
  "512x512": 20,
  "1024x1024": 40,
  "2048x2048": 80,
};
const creditsNeeded = sizePricing[size] || 20;

积分消耗规则文档

修改积分消耗后,需要同步更新:

  1. 定价页面 (app/[locale]/(marketing)/pricing/page.tsx)
  2. 用户仪表板提示
  3. API 文档(如果有)

替换 AI 提供商

当前项目使用火山引擎(豆包 API)作为 AI 服务提供商。以下是切换到其他提供商的详细步骤。

从火山引擎切换到 OpenAI

1. 安装 OpenAI SDK

pnpm add openai

2. 添加环境变量

# .env.local
OPENAI_API_KEY="sk-proj-xxx"
OPENAI_ORG_ID="org-xxx" # 可选

3. 创建 OpenAI 客户端

创建 lib/openai/index.ts:

import OpenAI from "openai";

if (!process.env.OPENAI_API_KEY) {
  throw new Error("OPENAI_API_KEY is not set");
}

export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  organization: process.env.OPENAI_ORG_ID,
});

4. 修改对话 API

文件: app/api/chat/stream/route.ts

替换火山引擎调用:

// 原来的代码 (火山引擎)
// import { volcanoEngine } from "@/lib/volcano-engine";
// const stream = await volcanoEngine.chat(messages, model);

// 新代码 (OpenAI)
import { openai } from "@/lib/openai";

const stream = await openai.chat.completions.create({
  model: "gpt-4-turbo-preview", // 或其他模型
  messages: messages.map(msg => ({
    role: msg.role as "system" | "user" | "assistant",
    content: msg.content,
  })),
  stream: true,
});

// 转换流式响应
const encoder = new TextEncoder();
const readable = new ReadableStream({
  async start(controller) {
    for await (const chunk of stream) {
      const content = chunk.choices[0]?.delta?.content || "";
      if (content) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content })}\n\n`));
      }
    }
    controller.enqueue(encoder.encode("data: [DONE]\n\n"));
    controller.close();
  },
});

return new Response(readable, {
  headers: {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  },
});

5. 修改图像生成 API

文件: app/api/image/generate/route.ts

import { openai } from "@/lib/openai";

// 替换图像生成逻辑
const response = await openai.images.generate({
  model: "dall-e-3",
  prompt: prompt,
  n: 1,
  size: size as "1024x1024" | "1792x1024" | "1024x1792",
  quality: "standard", // 或 "hd"
});

const imageUrl = response.data[0]?.url;
if (!imageUrl) {
  throw new Error("No image generated");
}

// 后续的存储逻辑保持不变
const r2Url = await uploadImageFromUrl(imageUrl, userId, "image");

6. 修改视频生成(如果支持)

OpenAI 目前不直接支持视频生成。如需保留视频功能,可以:

  • 保留火山引擎用于视频生成
  • 切换到 Runway ML、Stability AI 等视频生成服务
  • 移除视频生成功能

7. 更新模型配置

创建 constants/models.ts:

export const AI_MODELS = {
  chat: {
    default: "gpt-4-turbo-preview",
    fast: "gpt-3.5-turbo",
    smart: "gpt-4-turbo-preview",
  },
  image: {
    default: "dall-e-3",
  },
} as const;

切换到 Anthropic Claude

1. 安装 SDK

pnpm add @anthropic-ai/sdk

2. 配置环境变量

ANTHROPIC_API_KEY="sk-ant-xxx"

3. 创建客户端

// lib/anthropic/index.ts
import Anthropic from "@anthropic-ai/sdk";

export const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!,
});

4. 修改对话 API

const stream = await anthropic.messages.create({
  model: "claude-3-5-sonnet-20241022",
  max_tokens: 4096,
  messages: messages.map(msg => ({
    role: msg.role === "system" ? "user" : msg.role,
    content: msg.content,
  })),
  stream: true,
});

// 处理流式响应
const readable = new ReadableStream({
  async start(controller) {
    for await (const event of stream) {
      if (event.type === "content_block_delta") {
        const content = event.delta.text;
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content })}\n\n`));
      }
    }
    controller.enqueue(encoder.encode("data: [DONE]\n\n"));
    controller.close();
  },
});

需要修改的文件清单

无论切换到哪个提供商,以下文件需要修改:

  1. API 路由:

    • app/api/chat/route.ts - 非流式对话
    • app/api/chat/stream/route.ts - 流式对话
    • app/api/image/generate/route.ts - 图像生成
    • app/api/video/generate/route.ts - 视频生成(如保留)
  2. 环境变量:

    • .env.example - 更新示例环境变量
    • CLAUDE.md - 更新文档
  3. 前端配置:

    • constants/models.ts - 模型配置(如果创建)
    • 前端模型选择器组件(如有)
  4. 数据库 (可选):

    • 如果需要跟踪不同提供商的使用情况,可在 chatSession.model 字段中记录

添加新的支付网关

当前项目集成了 Creem 支付。以下是添加 Stripe 作为额外支付网关的示例。

项目支付架构

支付相关代码位于:

  • lib/payments/creem.ts - Creem 支付客户端
  • app/api/payments/creem/checkout/route.ts - 创建支付会话
  • app/api/payments/creem/webhook/route.ts - 处理支付回调

添加 Stripe 支付

1. 安装 Stripe SDK

pnpm add stripe

2. 创建 Stripe 客户端

创建 lib/payments/stripe.ts:

import Stripe from "stripe";
import crypto from "node:crypto";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY is not set");
}

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-12-18.acacia",
});

type CreateCheckoutParams = {
  userId: string;
  key: string;
  kind: "subscription" | "one_time";
  successUrl: string;
  cancelUrl: string;
  stripePriceId?: string;
};

export async function createStripeCheckoutSession(
  params: CreateCheckoutParams
): Promise<{ url: string }> {
  const session = await stripe.checkout.sessions.create({
    mode: params.kind === "subscription" ? "subscription" : "payment",
    line_items: [
      {
        price: params.stripePriceId,
        quantity: 1,
      },
    ],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: {
      userId: params.userId,
      key: params.key,
      kind: params.kind,
    },
  });

  if (!session.url) {
    throw new Error("Stripe checkout session missing URL");
  }

  return { url: session.url };
}

export function constructWebhookEvent(
  rawBody: string,
  signature: string
): Stripe.Event {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
  return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
}

3. 创建 Checkout API

创建 app/api/payments/stripe/checkout/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { createStripeCheckoutSession } from "@/lib/payments/stripe";
import { subscriptionPlans, oneTimePacks } from "@/constants/billing";

export async function POST(req: NextRequest) {
  try {
    const session = await auth.api.getSession({ headers: req.headers });
    if (!session?.session?.userId) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const { key } = await req.json();

    // 查找对应的 Stripe Price ID
    const plan = subscriptionPlans[key as keyof typeof subscriptionPlans];
    const pack = oneTimePacks[key as keyof typeof oneTimePacks];

    if (!plan && !pack) {
      return NextResponse.json({ error: "Invalid plan key" }, { status: 400 });
    }

    const stripePriceId = plan?.stripePriceId || pack?.stripePriceId;
    if (!stripePriceId) {
      return NextResponse.json({
        error: "Stripe price ID not configured"
      }, { status: 400 });
    }

    const result = await createStripeCheckoutSession({
      userId: session.session.userId,
      key,
      kind: plan ? "subscription" : "one_time",
      successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?payment=success`,
      cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?payment=cancelled`,
      stripePriceId,
    });

    return NextResponse.json({ url: result.url });
  } catch (error) {
    console.error("Stripe checkout error:", error);
    return NextResponse.json({
      error: "Failed to create checkout session"
    }, { status: 500 });
  }
}

4. 创建 Webhook 处理

创建 app/api/payments/stripe/webhook/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { constructWebhookEvent } from "@/lib/payments/stripe";
import { db } from "@/lib/db";
import { payment, subscription, user } from "@/lib/db/schema";
import { randomUUID } from "crypto";
import { eq, sql } from "drizzle-orm";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "No signature" }, { status: 400 });
  }

  try {
    const event = constructWebhookEvent(rawBody, signature);

    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
        const metadata = session.metadata;

        if (!metadata?.userId || !metadata?.key) {
          console.error("Missing metadata in webhook");
          return NextResponse.json({ error: "Invalid metadata" }, { status: 400 });
        }

        const { userId, key, kind } = metadata;

        // 检查幂等性
        const existingPayment = await db
          .select()
          .from(payment)
          .where(eq(payment.providerPaymentId, session.id));

        if (existingPayment.length > 0) {
          return NextResponse.json({ received: true }); // 已处理
        }

        // 创建支付记录
        const paymentId = randomUUID();
        await db.insert(payment).values({
          id: paymentId,
          userId,
          provider: "stripe",
          providerPaymentId: session.id,
          amountCents: session.amount_total || 0,
          currency: session.currency || "usd",
          status: "completed",
          planKey: kind === "subscription" ? key : null,
          packKey: kind === "one_time" ? key : null,
        });

        // 发放积分和创建订阅的逻辑...
        // (参考 creem webhook 的实现)

        return NextResponse.json({ received: true });
      }

      default:
        console.log(`Unhandled event type: ${event.type}`);
        return NextResponse.json({ received: true });
    }
  } catch (error) {
    console.error("Stripe webhook error:", error);
    return NextResponse.json({ error: "Webhook error" }, { status: 400 });
  }
}

export const runtime = "nodejs";

5. 扩展 billing.ts 配置

constants/billing.ts 中添加 Stripe Price ID:

export const subscriptionPlans: Record<PlanKey, SubscriptionPlan> = {
  starter_monthly: {
    // ... 现有配置
    creemPriceId: "prod_6oSIwPL8m6scklr3fwdkC9",
    stripePriceId: "price_xxx", // 添加 Stripe Price ID
  },
  // ... 其他计划
};

6. 扩展 payment 表

如果需要支持多个支付网关,可以在数据库 Schema 中添加 provider 字段(已存在):

// lib/db/schema.ts
export const payment = pgTable("payment", {
  // ...
  provider: varchar("provider", { length: 50 }).notNull().default("creem"),
  // "creem", "stripe", "paypal", etc.
});

7. 前端选择支付方式

在定价页面添加支付方式选择:

// 示例: 支付按钮组件
const [paymentProvider, setPaymentProvider] = useState<"creem" | "stripe">("creem");

async function handleCheckout(planKey: string) {
  const endpoint = paymentProvider === "stripe"
    ? "/api/payments/stripe/checkout"
    : "/api/payments/creem/checkout";

  const response = await fetch(endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key: planKey }),
  });

  const { url } = await response.json();
  window.location.href = url;
}

支付网关对比

特性CreemStripePayPal
订阅支持
一次性购买
Webhook
国际支付
费率较低2.9% + $0.302.9% + $0.30
集成难度简单中等中等

添加新功能模块

示例: 添加 "文档总结" 功能

1. 创建数据库 Schema

lib/db/schema.ts 中添加:

export const documentSummary = pgTable("document_summary", {
  id: varchar("id", { length: 36 }).primaryKey(),
  userId: varchar("user_id", { length: 255 }).notNull().references(() => user.id, { onDelete: "cascade" }),
  title: varchar("title", { length: 500 }).notNull(),
  originalText: text("original_text").notNull(),
  summary: text("summary"),
  status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, processing, completed, failed
  creditsUsed: integer("credits_used").notNull().default(0),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

运行数据库迁移:

pnpm db:generate
pnpm db:migrate

2. 创建 API 端点

创建 app/api/document/summarize/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { documentSummary } from "@/lib/db/schema";
import { canUserAfford, deductCredits } from "@/lib/credits";
import { openai } from "@/lib/openai"; // 假设使用 OpenAI
import { randomUUID } from "crypto";
import { eq } from "drizzle-orm";

export async function POST(req: NextRequest) {
  try {
    const session = await auth.api.getSession({ headers: req.headers });
    if (!session?.session?.userId) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const { title, text } = await req.json();

    if (!text || text.length < 100) {
      return NextResponse.json({
        error: "Text must be at least 100 characters"
      }, { status: 400 });
    }

    // 根据文本长度计算积分消耗
    const wordCount = text.split(/\s+/).length;
    const creditsNeeded = Math.ceil(wordCount / 100); // 每 100 词 1 积分

    const hasCredits = await canUserAfford(session.session.userId, creditsNeeded);
    if (!hasCredits) {
      return NextResponse.json({
        error: "Insufficient credits",
        creditsNeeded
      }, { status: 402 });
    }

    // 创建记录
    const summaryId = randomUUID();
    await db.insert(documentSummary).values({
      id: summaryId,
      userId: session.session.userId,
      title: title || "Untitled Document",
      originalText: text,
      status: "processing",
      creditsUsed: creditsNeeded,
    });

    // 扣除积分
    const deductResult = await deductCredits(
      session.session.userId,
      creditsNeeded,
      "document_summary",
      summaryId
    );

    if (!deductResult.success) {
      await db.update(documentSummary)
        .set({ status: "failed", summary: deductResult.error })
        .where(eq(documentSummary.id, summaryId));

      return NextResponse.json({ error: deductResult.error }, { status: 402 });
    }

    try {
      // 调用 AI 生成摘要
      const completion = await openai.chat.completions.create({
        model: "gpt-4-turbo-preview",
        messages: [
          {
            role: "system",
            content: "You are a professional document summarizer. Provide concise, accurate summaries.",
          },
          {
            role: "user",
            content: `Please summarize the following document:\n\n${text}`,
          },
        ],
      });

      const summary = completion.choices[0]?.message?.content;

      if (!summary) {
        throw new Error("No summary generated");
      }

      // 更新记录
      await db.update(documentSummary)
        .set({
          status: "completed",
          summary,
          updatedAt: new Date(),
        })
        .where(eq(documentSummary.id, summaryId));

      return NextResponse.json({
        id: summaryId,
        summary,
        creditsUsed: creditsNeeded,
        remainingCredits: deductResult.remainingCredits,
      });

    } catch (error: any) {
      await db.update(documentSummary)
        .set({
          status: "failed",
          summary: error.message,
          updatedAt: new Date(),
        })
        .where(eq(documentSummary.id, summaryId));

      throw error;
    }

  } catch (error: any) {
    console.error("Document summarization error:", error);
    return NextResponse.json({
      error: error.message || "Failed to summarize document"
    }, { status: 500 });
  }
}

3. 创建前端页面

创建 app/[locale]/(protected)/document-summary/page.tsx:

"use client";

import { useState } from "react";
import { useSession } from "@/lib/auth-client";

export default function DocumentSummaryPage() {
  const { data: session } = useSession();
  const [title, setTitle] = useState("");
  const [text, setText] = useState("");
  const [summary, setSummary] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError("");
    setSummary("");

    try {
      const response = await fetch("/api/document/summarize", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, text }),
      });

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || "Failed to summarize");
      }

      const data = await response.json();
      setSummary(data.summary);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="container mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Document Summarization</h1>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-2">
            Document Title (Optional)
          </label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full p-2 border rounded"
            placeholder="My Document"
          />
        </div>

        <div>
          <label className="block text-sm font-medium mb-2">
            Document Text *
          </label>
          <textarea
            value={text}
            onChange={(e) => setText(e.target.value)}
            className="w-full p-2 border rounded h-64"
            placeholder="Paste your document text here..."
            required
            minLength={100}
          />
        </div>

        <button
          type="submit"
          disabled={loading || !text}
          className="px-6 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
        >
          {loading ? "Summarizing..." : "Generate Summary"}
        </button>
      </form>

      {error && (
        <div className="mt-4 p-4 bg-red-100 text-red-700 rounded">
          {error}
        </div>
      )}

      {summary && (
        <div className="mt-6">
          <h2 className="text-2xl font-bold mb-4">Summary</h2>
          <div className="p-4 bg-gray-100 rounded whitespace-pre-wrap">
            {summary}
          </div>
        </div>
      )}
    </div>
  );
}

4. 添加权限控制

如果需要限制某些用户访问此功能,可以在页面中添加:

import { SessionGuard } from "@/features/auth/components/session-guard";
import { redirect } from "next/navigation";

export default function DocumentSummaryPage() {
  const { data: session } = useSession();

  // 只有 Pro 用户可以访问
  if (session?.user?.planKey !== "pro_monthly" && session?.user?.planKey !== "pro_yearly") {
    redirect("/pricing?upgrade=required");
  }

  // ... 页面内容
}

5. 添加导航链接

features/navigation/components/navbar.tsx 中添加链接:

const navigationLinks = [
  { href: "/dashboard", label: "Dashboard" },
  { href: "/chat", label: "Chat" },
  { href: "/document-summary", label: "Document Summary" }, // 新增
  // ...
];

修改 UI 主题

Tailwind 配置

主题配置位于 tailwind.config.ts:

export default {
  theme: {
    extend: {
      colors: {
        // 自定义品牌颜色
        brand: {
          50: "#f0f9ff",
          100: "#e0f2fe",
          500: "#0ea5e9",
          600: "#0284c7",
          700: "#0369a1",
        },
      },
      fontFamily: {
        sans: ["var(--font-geist-sans)", ...fontFamily.sans],
        mono: ["var(--font-geist-mono)", ...fontFamily.mono],
      },
    },
  },
};

深色/浅色模式

项目使用 next-themes 管理主题。配置位于:

  • app/providers.tsx - ThemeProvider 配置
  • components/theme-toggle.tsx - 主题切换按钮

自定义深色模式样式:

/* globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    /* ... 其他变量 */
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    /* ... 其他变量 */
  }
}

自定义组件样式

所有 UI 组件位于 components/ui/。修改示例:

// components/ui/button.tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
  {
    variants: {
      variant: {
        default: "bg-brand-600 text-white hover:bg-brand-700", // 使用品牌色
        destructive: "bg-red-600 text-white hover:bg-red-700",
        outline: "border border-gray-300 hover:bg-gray-100",
        // ... 添加新变体
      },
    },
  }
);

添加新的分析工具

当前集成的工具

项目已集成:

  • PostHog (用户行为分析)
  • Google Analytics (流量分析)
  • Microsoft Clarity (会话录制)

配置位于 app/providers.tsxlib/analytics/

添加 Mixpanel

1. 安装 SDK

pnpm add mixpanel-browser

2. 创建 Mixpanel 客户端

创建 lib/analytics/mixpanel.ts:

import mixpanel from "mixpanel-browser";

const MIXPANEL_TOKEN = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN;

if (MIXPANEL_TOKEN) {
  mixpanel.init(MIXPANEL_TOKEN, {
    track_pageview: true,
    persistence: "localStorage",
  });
}

export function trackEvent(eventName: string, properties?: Record<string, any>) {
  if (!MIXPANEL_TOKEN) return;
  mixpanel.track(eventName, properties);
}

export function identifyUser(userId: string, traits?: Record<string, any>) {
  if (!MIXPANEL_TOKEN) return;
  mixpanel.identify(userId);
  if (traits) {
    mixpanel.people.set(traits);
  }
}

3. 在应用中使用

// 在组件中
import { trackEvent } from "@/lib/analytics/mixpanel";

function handlePurchase(planKey: string) {
  trackEvent("Purchase Initiated", {
    plan: planKey,
    timestamp: Date.now(),
  });

  // ... 购买逻辑
}

4. 添加到 Providers

app/providers.tsx 中初始化:

"use client";

import { useEffect } from "react";
import { useSession } from "@/lib/auth-client";
import { identifyUser } from "@/lib/analytics/mixpanel";

export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession();

  useEffect(() => {
    if (session?.user) {
      identifyUser(session.user.id, {
        email: session.user.email,
        planKey: session.user.planKey,
        credits: session.user.credits,
      });
    }
  }, [session]);

  return <>{children}</>;
}

性能优化建议

1. Redis 缓存积分查询

当前每次操作都查询数据库获取积分,高并发场景下可能成为瓶颈。

安装 Redis

pnpm add ioredis

创建 Redis 客户端

// lib/redis.ts
import Redis from "ioredis";

export const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");

const CREDIT_CACHE_TTL = 60; // 60 秒过期

export async function getCachedCredits(userId: string): Promise<number | null> {
  const cached = await redis.get(`credits:${userId}`);
  return cached ? parseInt(cached, 10) : null;
}

export async function setCachedCredits(userId: string, credits: number) {
  await redis.setex(`credits:${userId}`, CREDIT_CACHE_TTL, credits);
}

export async function invalidateCreditCache(userId: string) {
  await redis.del(`credits:${userId}`);
}

修改 credits.ts

// lib/credits.ts
import { redis, getCachedCredits, setCachedCredits, invalidateCreditCache } from "./redis";

export async function getUserCredits(userId: string): Promise<number> {
  // 先尝试从缓存获取
  const cached = await getCachedCredits(userId);
  if (cached !== null) {
    return cached;
  }

  // 缓存未命中,查询数据库
  const users = await db
    .select({ credits: userTable.credits })
    .from(userTable)
    .where(eq(userTable.id, userId));

  const credits = users[0]?.credits ?? 0;

  // 更新缓存
  await setCachedCredits(userId, credits);

  return credits;
}

export async function deductCredits(...) {
  // ... 扣除逻辑

  // 事务成功后,清除缓存
  await invalidateCreditCache(userId);

  // ...
}

2. AI API 速率限制

防止滥用,添加基于用户的速率限制。

安装 Rate Limiter

pnpm add @upstash/ratelimit @upstash/redis

创建速率限制器

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL!,
  token: process.env.UPSTASH_REDIS_TOKEN!,
});

export const chatRateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "1 m"), // 每分钟 10 次
  analytics: true,
});

export const imageRateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, "1 m"), // 每分钟 5 次
});

在 API 中使用

// app/api/chat/route.ts
import { chatRateLimiter } from "@/lib/rate-limit";

export async function POST(req: NextRequest) {
  const session = await auth.api.getSession({ headers: req.headers });
  const userId = session?.session?.userId;

  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 检查速率限制
  const { success, remaining } = await chatRateLimiter.limit(userId);

  if (!success) {
    return NextResponse.json({
      error: "Rate limit exceeded",
      retryAfter: "60 seconds",
      remaining: 0,
    }, { status: 429 });
  }

  // ... 正常处理逻辑
}

3. 数据库索引

确保关键字段已添加索引:

// lib/db/schema.ts
export const creditLedger = pgTable("credit_ledger", {
  // ...
}, (table) => ({
  userIdIdx: index("credit_ledger_user_id_idx").on(table.userId),
  createdAtIdx: index("credit_ledger_created_at_idx").on(table.createdAt),
}));

export const subscriptionCreditSchedule = pgTable("subscription_credit_schedule", {
  // ...
}, (table) => ({
  nextGrantAtIdx: index("subscription_credit_schedule_next_grant_at_idx").on(table.nextGrantAt),
}));

运行迁移应用索引:

pnpm db:generate
pnpm db:migrate

4. CDN 配置

将生成的图片/视频分发到 CDN。

使用 Cloudflare R2 + CDN

当前项目已集成 R2 存储 (lib/r2-storage.ts)。配置 CDN:

  1. 在 Cloudflare 中为 R2 存储桶绑定自定义域名
  2. 更新 r2-storage.ts 返回 CDN URL:
// lib/r2-storage.ts
export async function uploadImageFromUrl(...) {
  // ... 上传逻辑

  const cdnDomain = process.env.R2_CDN_DOMAIN || process.env.R2_PUBLIC_URL;
  return `${cdnDomain}/${key}`;
}
  1. 配置环境变量:
R2_CDN_DOMAIN="https://cdn.yourdomain.com"

5. 数据库连接池

确保 Drizzle 使用连接池:

// lib/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // 最大连接数
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

export const db = drizzle(pool, { schema });

常见定制场景

场景 1: 修改注册赠送积分

位置: lib/auth.ts:44-49

// 当前: 注册赠送 300 积分
const initialCredits = 300;

// 修改为 500 积分
const initialCredits = 500;

场景 2: 添加推荐奖励系统

1. 扩展数据库 Schema

// lib/db/schema.ts
export const referral = pgTable("referral", {
  id: varchar("id", { length: 36 }).primaryKey(),
  referrerId: varchar("referrer_id", { length: 255 }).notNull().references(() => user.id),
  refereeId: varchar("referee_id", { length: 255 }).notNull().references(() => user.id),
  rewardCredits: integer("reward_credits").notNull().default(100),
  status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, claimed
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

// 在 user 表中添加推荐码
export const user = pgTable("user", {
  // ... 现有字段
  referralCode: varchar("referral_code", { length: 20 }).unique(),
});

2. 创建推荐 API

// app/api/referral/claim/route.ts
export async function POST(req: NextRequest) {
  const { referralCode } = await req.json();

  // 查找推荐人
  const referrer = await db
    .select()
    .from(user)
    .where(eq(user.referralCode, referralCode));

  // 发放奖励积分给推荐人
  // ...
}

场景 3: 添加积分过期机制

// lib/db/schema.ts
export const creditLedger = pgTable("credit_ledger", {
  // ... 现有字段
  expiresAt: timestamp("expires_at"), // 积分过期时间
  expired: boolean("expired").default(false),
});

// 创建定时任务定期清理过期积分
// app/api/cron/expire-credits/route.ts

场景 4: 多语言支持扩展

添加日语支持:

  1. 复制 messages/en/messages/ja/
  2. 翻译所有 JSON 文件
  3. 更新 middleware.ts:
export const locales = ["en", "zh", "ja"] as const;
  1. 更新 lib/i18n/request.ts

总结

本文档涵盖了 Sistine Starter 的主要定制场景。关键原则:

  1. 模块化: 所有核心功能都是独立模块,可以单独替换
  2. 可扩展: 通过添加新的 API 端点和数据库表轻松扩展功能
  3. 类型安全: 修改配置时注意更新 TypeScript 类型定义
  4. 测试: 每次定制后在本地测试,确保不影响现有功能

如需更多帮助:

  • 查看项目 CLAUDE.md 了解详细架构
  • 参考现有代码作为实现模板
  • 提交 Issue 获取社区支持

On this page

自定义指南修改定价计划位置订阅计划结构修改步骤1. 在 Creem Dashboard 创建产品2. 更新 billing.ts 配置3. 年付计划的分期发放4. 一次性积分包注意事项修改积分消耗规则AI 对话积分消耗AI 图像生成积分消耗AI 视频生成积分消耗动态定价策略积分消耗规则文档替换 AI 提供商从火山引擎切换到 OpenAI1. 安装 OpenAI SDK2. 添加环境变量3. 创建 OpenAI 客户端4. 修改对话 API5. 修改图像生成 API6. 修改视频生成(如果支持)7. 更新模型配置切换到 Anthropic Claude1. 安装 SDK2. 配置环境变量3. 创建客户端4. 修改对话 API需要修改的文件清单添加新的支付网关项目支付架构添加 Stripe 支付1. 安装 Stripe SDK2. 创建 Stripe 客户端3. 创建 Checkout API4. 创建 Webhook 处理5. 扩展 billing.ts 配置6. 扩展 payment 表7. 前端选择支付方式支付网关对比添加新功能模块示例: 添加 "文档总结" 功能1. 创建数据库 Schema2. 创建 API 端点3. 创建前端页面4. 添加权限控制5. 添加导航链接修改 UI 主题Tailwind 配置深色/浅色模式自定义组件样式添加新的分析工具当前集成的工具添加 Mixpanel1. 安装 SDK2. 创建 Mixpanel 客户端3. 在应用中使用4. 添加到 Providers性能优化建议1. Redis 缓存积分查询安装 Redis创建 Redis 客户端修改 credits.ts2. AI API 速率限制安装 Rate Limiter创建速率限制器在 API 中使用3. 数据库索引4. CDN 配置使用 Cloudflare R2 + CDN5. 数据库连接池常见定制场景场景 1: 修改注册赠送积分场景 2: 添加推荐奖励系统1. 扩展数据库 Schema2. 创建推荐 API场景 3: 添加积分过期机制场景 4: 多语言支持扩展总结