Add subscribe

This commit is contained in:
songtianlun 2025-07-30 23:13:38 +08:00
parent 7e337cd7fa
commit bc209d0203
11 changed files with 459 additions and 18 deletions

View File

@ -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

View File

@ -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",

View File

@ -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": "免费开始",

View File

@ -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")
}

View File

@ -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 }
)
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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<CreditInfo | null>(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() {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Avatar Section */}
<div className="lg:col-span-1">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
{/* Avatar and Subscription Section */}
<div className="lg:col-span-1 space-y-6">
{/* Profile Picture */}
{profileLoading ? (
<AvatarSkeleton />
) : (
@ -310,6 +344,73 @@ export default function ProfilePage() {
</div>
</LoadingOverlay>
)}
{/* Subscription Status */}
{profileLoading ? (
<FormFieldSkeleton />
) : (
<div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border-b border-border">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${profile?.subscribePlan === 'pro' ? 'bg-gradient-to-r from-yellow-400 to-orange-400' : 'bg-gray-100 dark:bg-gray-700'}`}>
{profile?.subscribePlan === 'pro' ? (
<Crown className="w-4 h-4 text-white" />
) : (
<Star className="w-4 h-4 text-gray-600 dark:text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base">{t('currentPlan')}</h3>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{profile?.subscribePlan === 'pro' ? t('proPlan') : t('freePlan')}
</p>
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Credit Balance */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<CreditCard className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-foreground">{t('creditBalance')}</span>
</div>
<div className="text-right">
<div className="text-xl font-bold text-green-600 dark:text-green-400">
${(creditInfo?.totalBalance || 0).toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">{t('usdCredit')}</div>
</div>
</div>
</div>
{/* Plan Details */}
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2">
<div className="flex justify-between py-1">
<span className="text-muted-foreground text-xs sm:text-sm">Max Prompts:</span>
<span className="font-medium text-foreground text-xs sm:text-sm">
{profile?.subscribePlan === 'pro' ? '500' : '20'}
</span>
</div>
<div className="flex justify-between py-1">
<span className="text-muted-foreground text-xs sm:text-sm">Max Versions:</span>
<span className="font-medium text-foreground text-xs sm:text-sm">
{profile?.maxVersionLimit}
</span>
</div>
<div className="flex justify-between py-1 col-span-2 sm:col-span-1">
<span className="text-muted-foreground text-xs sm:text-sm">Monthly Credit:</span>
<span className="font-medium text-foreground text-xs sm:text-sm">
${profile?.subscribePlan === 'pro' ? '20.00' : '0.00'}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Profile Information */}

View File

@ -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'

233
src/lib/credits.ts Normal file
View File

@ -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<CreditSummary> {
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<void> {
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<void> {
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<void> {
await prisma.credit.create({
data: {
userId,
amount,
type: 'user_purchase',
note: note || '用户充值',
expiresAt: null, // 永久有效
isActive: true
}
})
}
/**
* Pro用户是否需要刷新月度额度
*/
export async function checkAndRefreshProMonthlyCredit(userId: string): Promise<boolean> {
// 获取用户信息
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<boolean> {
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 // 免费版没有月度额度
}
}
}

View File

@ -4,13 +4,17 @@ export const SUBSCRIPTION_PLANS = {
name: 'Free',
maxVersionLimit: 3,
promptLimit: 20,
credit: 5
monthlyCredit: 0, // 免费版没有月度额度
initialCredit: 5, // 注册时一次性赠送5USD1个月后过期
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<boolean> {
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
}