Add subscribe admin
This commit is contained in:
parent
ada862704b
commit
1d6b1ae0e1
@ -58,6 +58,7 @@ model SubscriptionPlan {
|
||||
stripePriceId String? @unique // Stripe 价格 ID
|
||||
isActive Boolean @default(true) // 是否激活
|
||||
sortOrder Int @default(0) // 排序顺序
|
||||
costMultiplier Float @default(1.0) // 费用倍率,默认1倍
|
||||
|
||||
// 权益配置 (JSON 格式存储)
|
||||
features Json // 功能特性配置
|
||||
@ -213,11 +214,11 @@ model Subscription {
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
// AI 模型配置表 - 每个模型记录直接关联一个套餐
|
||||
// AI 模型配置表 - 所有模型默认可用于所有套餐
|
||||
model Model {
|
||||
id String @id @default(cuid())
|
||||
subscriptionPlanId String // 关联的套餐 ID
|
||||
modelId String // OpenRouter 的模型 ID,如 "openai/gpt-4"
|
||||
subscriptionPlanId String @default("free") // 默认关联到 free 套餐
|
||||
modelId String @unique // 全局唯一的模型 ID,如 "openai/gpt-4"
|
||||
name String // 显示名称,如 "GPT-4"
|
||||
provider String // 提供商,如 "OpenAI"
|
||||
serviceProvider String @default("openrouter") // 服务提供者,如 "openrouter", "replicate"
|
||||
@ -235,12 +236,8 @@ model Model {
|
||||
|
||||
// 关联关系
|
||||
subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade)
|
||||
|
||||
// 关联关系
|
||||
simulatorRuns SimulatorRun[]
|
||||
|
||||
// 同一个套餐内不能有相同的模型ID
|
||||
@@unique([subscriptionPlanId, modelId])
|
||||
@@map("models")
|
||||
}
|
||||
|
||||
|
@ -173,6 +173,25 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/plans"
|
||||
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-orange-50 dark:bg-orange-900/30 group-hover:bg-orange-100 dark:group-hover:bg-orange-900/50 transition-colors">
|
||||
<Users className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-foreground group-hover:text-orange-600 transition-colors">
|
||||
Subscription Plans
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-0.5">
|
||||
Manage subscription plans and cost multipliers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/debug/cache"
|
||||
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||
|
461
src/app/admin/plans/page.tsx
Normal file
461
src/app/admin/plans/page.tsx
Normal file
@ -0,0 +1,461 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthUser } from '@/hooks/useAuthUser'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Trash2, Edit, Plus, Save, X } from 'lucide-react'
|
||||
|
||||
interface SubscriptionPlan {
|
||||
id: string
|
||||
name: string
|
||||
displayName: string
|
||||
description?: string
|
||||
price: number
|
||||
currency: string
|
||||
interval: string
|
||||
stripePriceId?: string
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
costMultiplier: number
|
||||
features: Record<string, unknown>
|
||||
limits: Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count?: {
|
||||
users: number
|
||||
subscriptions: number
|
||||
models: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminPlansPage() {
|
||||
const { userData, loading: authLoading } = useAuthUser()
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
stripePriceId: '',
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
costMultiplier: 1.0,
|
||||
features: '{}',
|
||||
limits: '{}'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// AdminLayout已经处理了权限验证,这里只需要在认证完成后获取数据
|
||||
if (!authLoading && userData && userData.isAdmin) {
|
||||
fetchPlans()
|
||||
}
|
||||
}, [userData, authLoading])
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/subscription-plans')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch plans')
|
||||
}
|
||||
const data = await response.json()
|
||||
setPlans(data.plans || [])
|
||||
} catch (error) {
|
||||
alert('Failed to fetch subscription plans')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
id: '',
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
stripePriceId: '',
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
costMultiplier: 1.0,
|
||||
features: '{}',
|
||||
limits: '{}'
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (plan: SubscriptionPlan) => {
|
||||
setEditingPlan(plan)
|
||||
setFormData({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
displayName: plan.displayName,
|
||||
description: plan.description || '',
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
interval: plan.interval,
|
||||
stripePriceId: plan.stripePriceId || '',
|
||||
isActive: plan.isActive,
|
||||
sortOrder: plan.sortOrder,
|
||||
costMultiplier: plan.costMultiplier,
|
||||
features: JSON.stringify(plan.features, null, 2),
|
||||
limits: JSON.stringify(plan.limits, null, 2)
|
||||
})
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setIsCreating(true)
|
||||
setEditingPlan(null)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingPlan(null)
|
||||
setIsCreating(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 验证 JSON 格式
|
||||
let features, limits
|
||||
try {
|
||||
features = JSON.parse(formData.features)
|
||||
limits = JSON.parse(formData.limits)
|
||||
} catch {
|
||||
alert('Invalid JSON format in features or limits')
|
||||
return
|
||||
}
|
||||
|
||||
const planData = {
|
||||
...formData,
|
||||
features,
|
||||
limits
|
||||
}
|
||||
|
||||
const url = isCreating
|
||||
? '/api/admin/subscription-plans'
|
||||
: `/api/admin/subscription-plans?id=${editingPlan?.id}`
|
||||
|
||||
const method = isCreating ? 'POST' : 'PUT'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(planData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to save plan')
|
||||
}
|
||||
|
||||
alert(isCreating ? 'Plan created successfully' : 'Plan updated successfully')
|
||||
|
||||
fetchPlans()
|
||||
handleCancel()
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to save plan')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (planId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this plan?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/subscription-plans?id=${planId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete plan')
|
||||
}
|
||||
|
||||
alert('Plan deleted successfully')
|
||||
|
||||
fetchPlans()
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete plan')
|
||||
}
|
||||
}
|
||||
|
||||
// AdminLayout已经处理了认证和权限检查
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果没有权限,AdminLayout会重定向,这里不需要额外处理
|
||||
if (!userData || !userData.isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Subscription Plans</h1>
|
||||
<p className="text-muted-foreground">Manage subscription plans and pricing</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Plan Editor Form */}
|
||||
{(isCreating || editingPlan) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{isCreating ? 'Create New Plan' : 'Edit Plan'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isCreating ? 'Create a new subscription plan' : 'Update subscription plan details'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="id">Plan ID</Label>
|
||||
<Input
|
||||
id="id"
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, id: e.target.value }))}
|
||||
placeholder="e.g., free, pro, premium"
|
||||
disabled={!isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Internal Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Internal plan name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, displayName: e.target.value }))}
|
||||
placeholder="User-facing plan name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Price</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: parseFloat(e.target.value) || 0 }))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input
|
||||
id="currency"
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, currency: e.target.value }))}
|
||||
placeholder="usd"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="interval">Billing Interval</Label>
|
||||
<Input
|
||||
id="interval"
|
||||
value={formData.interval}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, interval: e.target.value }))}
|
||||
placeholder="month, year"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="costMultiplier">Cost Multiplier</Label>
|
||||
<Input
|
||||
id="costMultiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.costMultiplier}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, costMultiplier: parseFloat(e.target.value) || 1.0 }))}
|
||||
placeholder="1.0"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">AI usage cost multiplier (e.g., 10.0 for free, 3.0 for pro)</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, sortOrder: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Plan description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stripePriceId">Stripe Price ID</Label>
|
||||
<Input
|
||||
id="stripePriceId"
|
||||
value={formData.stripePriceId}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, stripePriceId: e.target.value }))}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="features">Features (JSON)</Label>
|
||||
<Textarea
|
||||
id="features"
|
||||
value={formData.features}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, features: e.target.value }))}
|
||||
placeholder='{"promptLimit": 20, "versionLimit": 3}'
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="limits">Limits (JSON)</Label>
|
||||
<Textarea
|
||||
id="limits"
|
||||
value={formData.limits}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, limits: e.target.value }))}
|
||||
placeholder='{"maxPromptsPerDay": 100, "maxAPICallsPerMonth": 1000}'
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, isActive: e.target.checked }))}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={handleSave} className="flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isCreating ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} variant="outline" className="flex items-center gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plans List */}
|
||||
<div className="grid gap-4">
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.id} className="relative">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{plan.displayName}
|
||||
<Badge variant={plan.isActive ? 'default' : 'secondary'}>
|
||||
{plan.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{plan.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => handleEdit(plan)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(plan.id)}
|
||||
disabled={plan._count && (plan._count.users > 0 || plan._count.subscriptions > 0)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">ID</p>
|
||||
<p className="text-muted-foreground">{plan.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Price</p>
|
||||
<p className="text-muted-foreground">
|
||||
{plan.price === 0 ? 'Free' : `$${plan.price}/${plan.interval}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Cost Multiplier</p>
|
||||
<p className="text-muted-foreground">{plan.costMultiplier}x</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Sort Order</p>
|
||||
<p className="text-muted-foreground">{plan.sortOrder}</p>
|
||||
</div>
|
||||
{plan._count && (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium">Users</p>
|
||||
<p className="text-muted-foreground">{plan._count.users}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Subscriptions</p>
|
||||
<p className="text-muted-foreground">{plan._count.subscriptions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Models</p>
|
||||
<p className="text-muted-foreground">{plan._count.models}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -5,7 +5,7 @@ import { ReplicateService } from '@/lib/replicate'
|
||||
import { UniAPIService } from '@/lib/uniapi'
|
||||
import { FalService } from '@/lib/fal'
|
||||
|
||||
// GET /api/admin/models - 获取所有模型(按套餐分组)
|
||||
// GET /api/admin/models - 获取所有模型
|
||||
export async function GET() {
|
||||
try {
|
||||
const models = await prisma.model.findMany({
|
||||
@ -19,7 +19,6 @@ export async function GET() {
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ subscriptionPlan: { sortOrder: 'asc' } },
|
||||
{ provider: 'asc' },
|
||||
{ name: 'asc' }
|
||||
]
|
||||
@ -35,30 +34,12 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/models - 为指定套餐添加模型
|
||||
// POST /api/admin/models - 同步和添加模型
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { action, planId, selectedModels, serviceProvider } = await request.json()
|
||||
const { action, selectedModels, serviceProvider } = await request.json()
|
||||
|
||||
if (action === 'sync') {
|
||||
if (!planId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Plan ID is required for sync action' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证套餐是否存在
|
||||
const plan = await prisma.subscriptionPlan.findUnique({
|
||||
where: { id: planId }
|
||||
})
|
||||
|
||||
if (!plan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Subscription plan not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const provider = serviceProvider || 'openrouter'
|
||||
let availableModels: Array<Record<string, unknown>> = []
|
||||
@ -116,74 +97,62 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
message: 'Models fetched successfully',
|
||||
availableModels,
|
||||
planId,
|
||||
serviceProvider: provider
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
if (!planId || !selectedModels || !Array.isArray(selectedModels)) {
|
||||
if (!selectedModels || !Array.isArray(selectedModels)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Plan ID and selected models are required' },
|
||||
{ error: 'Selected models are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查模型 ID 的唯一性
|
||||
// 检查模型 ID 的全局唯一性
|
||||
const modelIds = selectedModels.map(model => model.modelId)
|
||||
const existingModels = await prisma.model.findMany({
|
||||
where: {
|
||||
modelId: {
|
||||
in: modelIds
|
||||
},
|
||||
subscriptionPlanId: {
|
||||
not: planId // 排除当前套餐,允许同一套餐内更新
|
||||
}
|
||||
},
|
||||
include: {
|
||||
subscriptionPlan: {
|
||||
select: {
|
||||
displayName: true
|
||||
}
|
||||
}
|
||||
modelId: true,
|
||||
serviceProvider: true,
|
||||
name: true
|
||||
}
|
||||
})
|
||||
|
||||
if (existingModels.length > 0) {
|
||||
const conflicts = existingModels.map(model => ({
|
||||
modelId: model.modelId,
|
||||
existingPlan: model.subscriptionPlan.displayName,
|
||||
existingServiceProvider: model.serviceProvider
|
||||
existingServiceProvider: model.serviceProvider,
|
||||
modelName: model.name
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Model ID conflicts detected',
|
||||
conflicts: conflicts,
|
||||
message: `The following model IDs are already used by other service providers: ${conflicts.map(c => `${c.modelId} (used by ${c.existingServiceProvider} in ${c.existingPlan})`).join(', ')}`
|
||||
message: `The following model IDs already exist: ${conflicts.map(c => `${c.modelId} (${c.modelName} - ${c.existingServiceProvider})`).join(', ')}`
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
// 批量创建模型记录
|
||||
// 批量创建模型记录 - 所有模型默认绑定到 free 套餐
|
||||
const results = []
|
||||
for (const modelData of selectedModels) {
|
||||
const result = await prisma.model.upsert({
|
||||
where: {
|
||||
subscriptionPlanId_modelId: {
|
||||
subscriptionPlanId: planId,
|
||||
modelId: modelData.modelId
|
||||
}
|
||||
},
|
||||
update: {
|
||||
const result = await prisma.model.create({
|
||||
data: {
|
||||
...modelData,
|
||||
subscriptionPlanId: planId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
create: {
|
||||
...modelData,
|
||||
subscriptionPlanId: planId
|
||||
subscriptionPlanId: 'free' // 默认绑定到 free 套餐,所有套餐都可使用
|
||||
},
|
||||
include: {
|
||||
subscriptionPlan: true
|
||||
subscriptionPlan: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -191,7 +160,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Successfully added ${results.length} models to ${planId} plan`,
|
||||
message: `Successfully added ${results.length} models`,
|
||||
models: results
|
||||
})
|
||||
}
|
||||
|
@ -71,6 +71,7 @@ export async function POST(request: NextRequest) {
|
||||
stripePriceId,
|
||||
isActive = true,
|
||||
sortOrder = 0,
|
||||
costMultiplier = 1.0,
|
||||
features,
|
||||
limits
|
||||
} = await request.json()
|
||||
@ -96,6 +97,7 @@ export async function POST(request: NextRequest) {
|
||||
stripePriceId,
|
||||
isActive,
|
||||
sortOrder,
|
||||
costMultiplier,
|
||||
features,
|
||||
limits
|
||||
}
|
||||
@ -130,6 +132,7 @@ export async function PUT(request: NextRequest) {
|
||||
stripePriceId,
|
||||
isActive,
|
||||
sortOrder,
|
||||
costMultiplier,
|
||||
features,
|
||||
limits
|
||||
} = await request.json()
|
||||
@ -154,6 +157,7 @@ export async function PUT(request: NextRequest) {
|
||||
...(stripePriceId !== undefined && { stripePriceId }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
...(sortOrder !== undefined && { sortOrder }),
|
||||
...(costMultiplier !== undefined && { costMultiplier }),
|
||||
...(features && { features }),
|
||||
...(limits && { limits })
|
||||
}
|
||||
|
@ -28,6 +28,16 @@ export async function POST(
|
||||
prompt: true,
|
||||
promptVersion: true, // 内部使用,用于获取内容
|
||||
model: true,
|
||||
user: {
|
||||
include: {
|
||||
subscriptionPlan: {
|
||||
select: {
|
||||
costMultiplier: true,
|
||||
displayName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -41,7 +51,8 @@ export async function POST(
|
||||
|
||||
// Check user's credit balance before execution
|
||||
const userBalance = await getUserBalance(user.id);
|
||||
const estimatedCost = calculateCost(0, 100, run.model); // Rough estimate
|
||||
const costMultiplier = run.user.subscriptionPlan.costMultiplier || 1.0;
|
||||
const estimatedCost = calculateCost(0, 100, run.model, costMultiplier); // Rough estimate
|
||||
|
||||
if (userBalance < estimatedCost) {
|
||||
return NextResponse.json(
|
||||
@ -292,7 +303,7 @@ export async function POST(
|
||||
if (data === '[DONE]') {
|
||||
// 计算最终数据并更新数据库
|
||||
const duration = Date.now() - startTime;
|
||||
const actualCost = calculateCost(inputTokens, outputTokens, run.model);
|
||||
const actualCost = calculateCost(inputTokens, outputTokens, run.model, costMultiplier);
|
||||
|
||||
// Consume credits for this simulation
|
||||
let creditTransaction;
|
||||
|
@ -5,7 +5,7 @@ interface FalModel {
|
||||
category?: string
|
||||
type?: 'image' | 'video' | 'audio' | 'text'
|
||||
input_schema?: {
|
||||
properties?: Record<string, any>
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
pricing?: {
|
||||
per_request?: number
|
||||
@ -14,7 +14,7 @@ interface FalModel {
|
||||
}
|
||||
max_resolution?: string
|
||||
supported_formats?: string[]
|
||||
[key: string]: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface FalModelsResponse {
|
||||
|
@ -44,18 +44,36 @@ export function getOriginalPromptContent(run: Pick<SimulatorRun, 'prompt' | 'pro
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算实际费用基于模型的定价
|
||||
* 计算实际费用基于模型的定价和用户套餐的费用倍率
|
||||
*/
|
||||
export function calculateCost(inputTokens: number, outputTokens: number, model: {
|
||||
export function calculateCost(
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
model: {
|
||||
inputCostPer1k?: number | null
|
||||
outputCostPer1k?: number | null
|
||||
}): number {
|
||||
},
|
||||
costMultiplier: number = 1.0
|
||||
): number {
|
||||
const inputCostPer1k = model.inputCostPer1k || 0
|
||||
const outputCostPer1k = model.outputCostPer1k || 0
|
||||
|
||||
// 计算费用:token数除以1000再乘以每1k的费用
|
||||
// 计算基础费用:token数除以1000再乘以每1k的费用
|
||||
const inputCost = (inputTokens / 1000) * inputCostPer1k
|
||||
const outputCost = (outputTokens / 1000) * outputCostPer1k
|
||||
const baseCost = inputCost + outputCost
|
||||
|
||||
return inputCost + outputCost
|
||||
// 应用套餐费用倍率
|
||||
return baseCost * costMultiplier
|
||||
}
|
||||
|
||||
/**
|
||||
* 向后兼容的函数,不使用费用倍率
|
||||
* @deprecated 请使用带有 costMultiplier 参数的新版本
|
||||
*/
|
||||
export function calculateBaseCost(inputTokens: number, outputTokens: number, model: {
|
||||
inputCostPer1k?: number | null
|
||||
outputCostPer1k?: number | null
|
||||
}): number {
|
||||
return calculateCost(inputTokens, outputTokens, model, 1.0)
|
||||
}
|
@ -38,7 +38,7 @@ export class UniAPIService {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
await response.text() // Read error text but don't use it
|
||||
throw new Error(`UniAPI error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user