自定义指南
如何定制 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 定义: 添加新计划时,需要在
PlanKey
或PackKey
类型中添加对应的字符串字面量 - 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);
修改步骤:
- 修改
creditsNeeded
的值 - 更新前端显示的积分消耗提示(如果有)
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;
积分消耗规则文档
修改积分消耗后,需要同步更新:
- 定价页面 (
app/[locale]/(marketing)/pricing/page.tsx
) - 用户仪表板提示
- 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();
},
});
需要修改的文件清单
无论切换到哪个提供商,以下文件需要修改:
-
API 路由:
app/api/chat/route.ts
- 非流式对话app/api/chat/stream/route.ts
- 流式对话app/api/image/generate/route.ts
- 图像生成app/api/video/generate/route.ts
- 视频生成(如保留)
-
环境变量:
.env.example
- 更新示例环境变量CLAUDE.md
- 更新文档
-
前端配置:
constants/models.ts
- 模型配置(如果创建)- 前端模型选择器组件(如有)
-
数据库 (可选):
- 如果需要跟踪不同提供商的使用情况,可在
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;
}
支付网关对比
特性 | Creem | Stripe | PayPal |
---|---|---|---|
订阅支持 | ✅ | ✅ | ✅ |
一次性购买 | ✅ | ✅ | ✅ |
Webhook | ✅ | ✅ | ✅ |
国际支付 | ✅ | ✅ | ✅ |
费率 | 较低 | 2.9% + $0.30 | 2.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.tsx
和 lib/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:
- 在 Cloudflare 中为 R2 存储桶绑定自定义域名
- 更新
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}`;
}
- 配置环境变量:
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: 多语言支持扩展
添加日语支持:
- 复制
messages/en/
到messages/ja/
- 翻译所有 JSON 文件
- 更新
middleware.ts
:
export const locales = ["en", "zh", "ja"] as const;
- 更新
lib/i18n/request.ts
总结
本文档涵盖了 Sistine Starter 的主要定制场景。关键原则:
- 模块化: 所有核心功能都是独立模块,可以单独替换
- 可扩展: 通过添加新的 API 端点和数据库表轻松扩展功能
- 类型安全: 修改配置时注意更新 TypeScript 类型定义
- 测试: 每次定制后在本地测试,确保不影响现有功能
如需更多帮助:
- 查看项目 CLAUDE.md 了解详细架构
- 参考现有代码作为实现模板
- 提交 Issue 获取社区支持