From bc209d0203a0eb6455e3e1fa210ef94c5c7f1b05 Mon Sep 17 00:00:00 2001 From: songtianlun Date: Wed, 30 Jul 2025 23:13:38 +0800 Subject: [PATCH] Add subscribe --- CLAUDE.md | 5 +- messages/en.json | 16 +- messages/zh.json | 16 +- prisma/schema.prisma | 19 +++ src/app/api/users/credits/route.ts | 29 ++++ src/app/api/users/profile/route.ts | 4 + src/app/api/users/sync/route.ts | 4 + src/app/profile/page.tsx | 115 +++++++++++++- src/app/studio/page.tsx | 1 - src/lib/credits.ts | 233 +++++++++++++++++++++++++++++ src/lib/subscription.ts | 35 ++++- 11 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 src/app/api/users/credits/route.ts create mode 100644 src/lib/credits.ts diff --git a/CLAUDE.md b/CLAUDE.md index 051feaf..dc20a45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,11 +147,12 @@ Required environment variables: - [ ] Free - [ ] 20 Prompt Limit - [ ] 3 Version Each Prompt Limit - - [ ] 5 USD Credit Once Time + - [ ] 5 USD Credit Once Time. Timeout After 1 Mounth. + - [ ] 0 USD Credit Every Month. - [ ] 19.9 USD Paid Monthly - [ ] 500 Prompt Limit - [ ] 10 Version Each Prompt Limit - - [ ] 20 USD Credit Every Month + - [ ] 20 USD Credit Every Month. Valid 1 Month. - [ ] AI Prompt Showcase - [ ] User Can Share - [ ] Admin Can Review diff --git a/messages/en.json b/messages/en.json index bbf7e83..bf197aa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -90,7 +90,15 @@ "confirmNewPassword": "Confirm new password", "updatePassword": "Update Password", "english": "English", - "chinese": "中文" + "chinese": "中文", + "loadingStudio": "Loading Profile...", + "subscription": "Subscription", + "creditBalance": "Credit Balance", + "freePlan": "Free Plan", + "proPlan": "Pro Plan", + "usdCredit": "USD", + "subscriptionInfo": "Subscription Info", + "currentPlan": "Current Plan" }, "studio": { "title": "AI Prompt Studio", @@ -184,7 +192,8 @@ "features": [ "20 Prompt Limit", "3 Versions per Prompt", - "$5 AI Credit Monthly" + "$5 One-time Credit (Expires in 1 Month)", + "$0 Monthly Credit" ] }, "pro": { @@ -193,7 +202,8 @@ "features": [ "500 Prompt Limit", "10 Versions per Prompt", - "$20 AI Credit Monthly" + "$20 Monthly Credit", + "Purchase Permanent Credits" ] }, "getStartedFree": "Get Started Free", diff --git a/messages/zh.json b/messages/zh.json index a218537..937cbb0 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -90,7 +90,15 @@ "confirmNewPasswordLabel": "确认新密码", "updatePassword": "更新密码", "english": "English", - "chinese": "中文" + "chinese": "中文", + "loadingStudio": "加载个人资料中...", + "subscription": "订阅状态", + "creditBalance": "信用余额", + "freePlan": "免费版", + "proPlan": "专业版", + "usdCredit": "美元", + "subscriptionInfo": "订阅信息", + "currentPlan": "当前方案" }, "studio": { "title": "AI 提示词工作室", @@ -184,7 +192,8 @@ "features": [ "20 个提示词限制", "每个提示词 3 个版本", - "每月 5 美元 AI 积分" + "注册赠送 5 美元积分(1个月后过期)", + "每月 0 美元积分" ] }, "pro": { @@ -193,7 +202,8 @@ "features": [ "500 个提示词限制", "每个提示词 10 个版本", - "每月 20 美元 AI 积分" + "每月 20 美元 AI 积分", + "可购买永久积分" ] }, "getStartedFree": "免费开始", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index def4488..5771980 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,10 +23,13 @@ model User { versionLimit Int @default(3) // 版本数量限制,可在用户配置中设置 subscribePlan String @default("free") // 订阅计划: "free", "pro" maxVersionLimit Int @default(3) // 基于订阅的最大版本限制 + promptLimit Int @default(20) // 提示词数量限制 + creditBalance Float @default(5.0) // 信用余额,单位:美元 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt prompts Prompt[] + credits Credit[] @@map("users") } @@ -100,3 +103,19 @@ model PromptTestRun { @@map("prompt_test_runs") } + +model Credit { + id String @id @default(cuid()) + userId String + amount Float // 信用额度数量 + type String // "system_gift", "subscription_monthly", "user_purchase" + note String? // 备注说明 + expiresAt DateTime? // 过期时间,null表示永久有效 + isActive Boolean @default(true) // 是否激活状态 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("credits") +} diff --git a/src/app/api/users/credits/route.ts b/src/app/api/users/credits/route.ts new file mode 100644 index 0000000..60e6c82 --- /dev/null +++ b/src/app/api/users/credits/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getUserCreditSummary, checkAndRefreshProMonthlyCredit } from '@/lib/credits' + +// GET /api/users/credits - 获取用户信用额度概览 +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ error: 'User ID is required' }, { status: 401 }) + } + + // 检查Pro用户是否需要刷新月度额度 + await checkAndRefreshProMonthlyCredit(userId) + + // 获取信用额度概览 + const creditSummary = await getUserCreditSummary(userId) + + return NextResponse.json(creditSummary) + + } catch (error) { + console.error('Error fetching user credits:', error) + return NextResponse.json( + { error: 'Failed to fetch user credits' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/users/profile/route.ts b/src/app/api/users/profile/route.ts index 138b50f..7cfb2ce 100644 --- a/src/app/api/users/profile/route.ts +++ b/src/app/api/users/profile/route.ts @@ -24,6 +24,8 @@ export async function GET(request: NextRequest) { versionLimit: true, subscribePlan: true, maxVersionLimit: true, + promptLimit: true, + creditBalance: true, createdAt: true, updatedAt: true } @@ -107,6 +109,8 @@ export async function PUT(request: NextRequest) { versionLimit: true, subscribePlan: true, maxVersionLimit: true, + promptLimit: true, + creditBalance: true, createdAt: true, updatedAt: true } diff --git a/src/app/api/users/sync/route.ts b/src/app/api/users/sync/route.ts index fd61267..a2ff81a 100644 --- a/src/app/api/users/sync/route.ts +++ b/src/app/api/users/sync/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { createServerSupabaseClient } from '@/lib/supabase-server' +import { addSystemGiftCredit } from '@/lib/credits' // POST /api/users/sync - 同步Supabase用户到Prisma数据库 export async function POST(_request: NextRequest) { @@ -48,6 +49,9 @@ export async function POST(_request: NextRequest) { } }) + // 为新用户添加系统赠送的5USD信用额度(1个月后过期) + await addSystemGiftCredit(newUser.id) + return NextResponse.json({ message: 'User created successfully', user: newUser diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 7949e9b..9bff7f2 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -13,7 +13,7 @@ import { Avatar } from '@/components/ui/avatar' import { LoadingSpinner, LoadingOverlay } from '@/components/ui/loading-spinner' import { FullScreenLoading } from '@/components/ui/full-screen-loading' import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton' -import { Camera, Save, Eye, EyeOff, Globe } from 'lucide-react' +import { Camera, Save, Eye, EyeOff, Globe, CreditCard, Crown, Star } from 'lucide-react' interface UserProfile { id: string @@ -25,10 +25,32 @@ interface UserProfile { versionLimit: number subscribePlan: string maxVersionLimit: number + promptLimit?: number + creditBalance: number createdAt?: string updatedAt?: string } +interface CreditInfo { + totalBalance: number + activeCredits: { + id: string + amount: number + type: string + note: string | null + expiresAt: Date | null + createdAt: Date + }[] + expiredCredits: { + id: string + amount: number + type: string + note: string | null + expiresAt: Date | null + createdAt: Date + }[] +} + export default function ProfilePage() { const { user, loading } = useAuth() const t = useTranslations('profile') @@ -62,6 +84,7 @@ export default function ProfilePage() { const [profileLoading, setProfileLoading] = useState(true) const [fieldLoading, setFieldLoading] = useState<{ [key: string]: boolean }>({}) const [avatarUploading, setAvatarUploading] = useState(false) + const [creditInfo, setCreditInfo] = useState(null) const supabase = createClient() @@ -71,12 +94,16 @@ export default function ProfilePage() { setProfileLoading(true) try { // Get profile data from our API - const response = await fetch(`/api/users/profile?userId=${user.id}`) - if (!response.ok) { + const [profileResponse, creditsResponse] = await Promise.all([ + fetch(`/api/users/profile?userId=${user.id}`), + fetch(`/api/users/credits?userId=${user.id}`) + ]) + + if (!profileResponse.ok) { throw new Error('Failed to fetch profile') } - const profileData: UserProfile = await response.json() + const profileData: UserProfile = await profileResponse.json() setProfile(profileData) setFormData({ @@ -89,6 +116,12 @@ export default function ProfilePage() { language: profileData.language || 'en', versionLimit: profileData.versionLimit }) + + // Load credit information + if (creditsResponse.ok) { + const creditData: CreditInfo = await creditsResponse.json() + setCreditInfo(creditData) + } } catch (error) { console.error('Error loading profile:', error) setSaveStatus({ type: 'error', message: t('failedToLoadProfile') }) @@ -271,9 +304,10 @@ export default function ProfilePage() { )} -
- {/* Avatar Section */} -
+
+ {/* Avatar and Subscription Section */} +
+ {/* Profile Picture */} {profileLoading ? ( ) : ( @@ -310,6 +344,73 @@ export default function ProfilePage() {
)} + + {/* Subscription Status */} + {profileLoading ? ( + + ) : ( +
+
+
+
+ {profile?.subscribePlan === 'pro' ? ( + + ) : ( + + )} +
+
+

{t('currentPlan')}

+

+ {profile?.subscribePlan === 'pro' ? t('proPlan') : t('freePlan')} +

+
+
+
+ +
+ {/* Credit Balance */} +
+
+
+ + {t('creditBalance')} +
+
+
+ ${(creditInfo?.totalBalance || 0).toFixed(2)} +
+
{t('usdCredit')}
+
+
+
+ + {/* Plan Details */} +
+
+
+ Max Prompts: + + {profile?.subscribePlan === 'pro' ? '500' : '20'} + +
+
+ Max Versions: + + {profile?.maxVersionLimit} + +
+
+ Monthly Credit: + + ${profile?.subscribePlan === 'pro' ? '20.00' : '0.00'} + +
+
+
+
+
+ )}
{/* Profile Information */} diff --git a/src/app/studio/page.tsx b/src/app/studio/page.tsx index d97d636..392d2cb 100644 --- a/src/app/studio/page.tsx +++ b/src/app/studio/page.tsx @@ -7,7 +7,6 @@ import { useRouter } from 'next/navigation' import { Header } from '@/components/layout/Header' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { LoadingSpinner } from '@/components/ui/loading-spinner' import { FullScreenLoading } from '@/components/ui/full-screen-loading' import { EditPromptModal } from '@/components/studio/EditPromptModal' import { PromptDetailModal } from '@/components/studio/PromptDetailModal' diff --git a/src/lib/credits.ts b/src/lib/credits.ts new file mode 100644 index 0000000..9bd3676 --- /dev/null +++ b/src/lib/credits.ts @@ -0,0 +1,233 @@ +import { prisma } from '@/lib/prisma' + +export type CreditType = 'system_gift' | 'subscription_monthly' | 'user_purchase' + +export interface CreditSummary { + totalBalance: number + activeCredits: { + id: string + amount: number + type: CreditType + note: string | null + expiresAt: Date | null + createdAt: Date + }[] + expiredCredits: { + id: string + amount: number + type: CreditType + note: string | null + expiresAt: Date | null + createdAt: Date + }[] +} + +/** + * 获取用户的信用额度总览 + */ +export async function getUserCreditSummary(userId: string): Promise { + const now = new Date() + + // 获取所有信用记录 + const allCredits = await prisma.credit.findMany({ + where: { userId, isActive: true }, + orderBy: { createdAt: 'desc' } + }) + + const activeCredits = [] + const expiredCredits = [] + let totalBalance = 0 + + for (const credit of allCredits) { + const creditData = { + id: credit.id, + amount: credit.amount, + type: credit.type as CreditType, + note: credit.note, + expiresAt: credit.expiresAt, + createdAt: credit.createdAt + } + + // 检查是否过期 + if (credit.expiresAt && credit.expiresAt < now) { + expiredCredits.push(creditData) + // 标记过期的信用为非激活状态 + await prisma.credit.update({ + where: { id: credit.id }, + data: { isActive: false } + }) + } else { + activeCredits.push(creditData) + totalBalance += credit.amount + } + } + + return { + totalBalance, + activeCredits, + expiredCredits + } +} + +/** + * 为新用户添加系统赠送的5USD信用额度(1个月后过期) + */ +export async function addSystemGiftCredit(userId: string): Promise { + const expiresAt = new Date() + expiresAt.setMonth(expiresAt.getMonth() + 1) // 1个月后过期 + + await prisma.credit.create({ + data: { + userId, + amount: 5.0, + type: 'system_gift', + note: '系统赠送 - 新用户礼包', + expiresAt, + isActive: true + } + }) +} + +/** + * 为Pro用户添加月度信用额度 + */ +export async function addMonthlySubscriptionCredit(userId: string): Promise { + const expiresAt = new Date() + expiresAt.setMonth(expiresAt.getMonth() + 1) // 1个月后过期 + + await prisma.credit.create({ + data: { + userId, + amount: 20.0, + type: 'subscription_monthly', + note: 'Pro套餐月度额度', + expiresAt, + isActive: true + } + }) +} + +/** + * 用户购买信用额度(永久有效) + */ +export async function addUserPurchaseCredit( + userId: string, + amount: number, + note?: string +): Promise { + await prisma.credit.create({ + data: { + userId, + amount, + type: 'user_purchase', + note: note || '用户充值', + expiresAt: null, // 永久有效 + isActive: true + } + }) +} + +/** + * 检查Pro用户是否需要刷新月度额度 + */ +export async function checkAndRefreshProMonthlyCredit(userId: string): Promise { + // 获取用户信息 + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { subscribePlan: true } + }) + + if (!user || user.subscribePlan !== 'pro') { + return false + } + + // 查找最近的月度订阅额度 + const lastMonthlyCredit = await prisma.credit.findFirst({ + where: { + userId, + type: 'subscription_monthly', + isActive: true + }, + orderBy: { createdAt: 'desc' } + }) + + const now = new Date() + + // 如果没有月度额度记录,或者最近的已过期,则添加新的 + if (!lastMonthlyCredit || (lastMonthlyCredit.expiresAt && lastMonthlyCredit.expiresAt < now)) { + await addMonthlySubscriptionCredit(userId) + return true + } + + return false +} + +/** + * 消费信用额度 + */ +export async function consumeCredit(userId: string, amount: number): Promise { + const creditSummary = await getUserCreditSummary(userId) + + if (creditSummary.totalBalance < amount) { + return false // 余额不足 + } + + // 按优先级消费额度:先消费即将过期的,再消费永久的 + const sortedCredits = creditSummary.activeCredits.sort((a, b) => { + // 有过期时间的排在前面,没有过期时间的排在后面 + if (a.expiresAt && !b.expiresAt) return -1 + if (!a.expiresAt && b.expiresAt) return 1 + if (a.expiresAt && b.expiresAt) { + return a.expiresAt.getTime() - b.expiresAt.getTime() + } + return a.createdAt.getTime() - b.createdAt.getTime() + }) + + let remainingAmount = amount + + for (const credit of sortedCredits) { + if (remainingAmount <= 0) break + + const consumeFromThis = Math.min(credit.amount, remainingAmount) + const newAmount = credit.amount - consumeFromThis + + if (newAmount <= 0) { + // 这个额度用完了,标记为非激活 + await prisma.credit.update({ + where: { id: credit.id }, + data: { isActive: false } + }) + } else { + // 更新剩余额度 + await prisma.credit.update({ + where: { id: credit.id }, + data: { amount: newAmount } + }) + } + + remainingAmount -= consumeFromThis + } + + return true +} + +/** + * 获取订阅计划的限制 + */ +export function getSubscriptionLimits(plan: string) { + switch (plan) { + case 'pro': + return { + promptLimit: 500, + maxVersionLimit: 10, + monthlyCredit: 20.0 + } + case 'free': + default: + return { + promptLimit: 20, + maxVersionLimit: 3, + monthlyCredit: 0.0 // 免费版没有月度额度 + } + } +} \ No newline at end of file diff --git a/src/lib/subscription.ts b/src/lib/subscription.ts index 751c9ad..4f46a40 100644 --- a/src/lib/subscription.ts +++ b/src/lib/subscription.ts @@ -4,13 +4,17 @@ export const SUBSCRIPTION_PLANS = { name: 'Free', maxVersionLimit: 3, promptLimit: 20, - credit: 5 + monthlyCredit: 0, // 免费版没有月度额度 + initialCredit: 5, // 注册时一次性赠送5USD,1个月后过期 + price: 0 }, pro: { name: 'Pro', maxVersionLimit: 10, promptLimit: 500, - credit: 20 + monthlyCredit: 20, // 每月20USD额度 + initialCredit: 0, // Pro用户不需要初始额度 + price: 19.9 } } as const @@ -66,4 +70,31 @@ export function getVersionsToDelete( ): number { const versionLimit = getUserVersionLimit(user) return Math.max(0, currentVersionCount - versionLimit + 1) // +1 因为要创建新版本 +} + +// 获取用户的提示词限制 +export function getPromptLimit(subscribePlan: string): number { + const plan = subscribePlan as SubscriptionPlan + const planConfig = SUBSCRIPTION_PLANS[plan] + + return planConfig?.promptLimit || SUBSCRIPTION_PLANS.free.promptLimit +} + +// 检查用户是否可以创建新提示词 +export async function canCreateNewPrompt(userId: string): Promise { + const { prisma } = await import('@/lib/prisma') + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { subscribePlan: true } + }) + + if (!user) return false + + const promptLimit = getPromptLimit(user.subscribePlan) + const currentPromptCount = await prisma.prompt.count({ + where: { userId } + }) + + return currentPromptCount < promptLimit } \ No newline at end of file