From ab779c57edb7e1b86612069e003e29ad09191979 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Feb 2026 02:01:29 +0800 Subject: [PATCH] feat: enable video points billing and admin video cost config --- .../settings/admin-settings-client.tsx | 40 ++++- app/(chat)/api/chat/route.ts | 137 ++++++++++++++++-- app/api/admin/ai-models/route.ts | 1 + app/api/user/account-center/route.ts | 1 + lib/db/queries.ts | 1 + 5 files changed, 169 insertions(+), 11 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index a2196d1c8..107afe156 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -137,6 +137,7 @@ type ImageGenConfig = { supportsWatermark?: boolean; // 是否支持水印开关 supportsSeed?: boolean; // 是否支持随机种子 creditCostPerImage?: number; // 每张图片积分消耗 + creditCostPerVideo?: number; // 每条视频积分消耗 enableLoadingPlaceholder?: boolean; // 是否启用加载占位图 loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green" }; @@ -1025,6 +1026,7 @@ export function AdminSettingsClient() { supportsWatermark: false, supportsSeed: false, creditCostPerImage: 0, + creditCostPerVideo: 0, enableLoadingPlaceholder: true, loadingColor: "purple", } as ImageGenConfig, @@ -1067,6 +1069,15 @@ export function AdminSettingsClient() { } }, [modelForm.providerId, providersData?.providers, volcanoVideoModels.length]); + const selectedModelProviderType = + providersData?.providers?.find((p) => p.id === modelForm.providerId)?.type || ""; + const isVideoProviderForModelConfig = [ + "volcano-video", + "doubao-video", + "qianwen-video", + "fal-video", + ].includes(selectedModelProviderType); + // 存储配置状态 const [storageForm, setStorageForm] = useState({ id: "", @@ -1883,6 +1894,7 @@ export function AdminSettingsClient() { supportsWatermark: false, supportsSeed: false, creditCostPerImage: 0, + creditCostPerVideo: 0, enableLoadingPlaceholder: true, loadingColor: "purple", }, @@ -5903,9 +5915,9 @@ export function AdminSettingsClient() { {/* 🆕 图片生成模型特性配置 */} - {modelForm.supportsImageGen && ( + {(modelForm.supportsImageGen || isVideoProviderForModelConfig) && (
-
图片生成配置
+
生成配置
@@ -5948,6 +5960,29 @@ export function AdminSettingsClient() { 开启积分系统后,将按“单价 × 实际出图张数”扣除积分。

+ +
+ + setModelForm({ + ...modelForm, + imageGenConfig: { + ...modelForm.imageGenConfig, + creditCostPerVideo: Math.max( + 0, + Number.parseInt(e.target.value, 10) || 0 + ), + }, + })} + /> +

+ 视频生成成功后按每条视频扣除积分。未配置时默认使用“每张图片积分”作为兼容值。 +

+
@@ -6100,6 +6135,7 @@ export function AdminSettingsClient() { supportsWatermark: false, supportsSeed: false, creditCostPerImage: 0, + creditCostPerVideo: 0, enableLoadingPlaceholder: true, loadingColor: "purple", }, diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 434feed7d..f08a38fa5 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1460,14 +1460,8 @@ async function saveGeneratedImages( const pointsConfig = await getPointsSystemConfig(); if (!pointsConfig.enabled) return; - const isUuidModelId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - modelId - ); - if (!isUuidModelId) return; - - const modelConfig = await getAiModelById({ id: modelId }); - const rawUnitCost = Number((modelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0); - const unitCost = Number.isFinite(rawUnitCost) ? Math.max(0, Math.round(rawUnitCost)) : 0; + const modelConfig = await resolveBillingModelByReference(modelId); + const unitCost = getModelCreditCost(modelConfig, "image"); if (unitCost <= 0) return; const totalCost = unitCost * savedImageCount; @@ -1475,7 +1469,7 @@ async function saveGeneratedImages( userId, amount: totalCost, reason: "image_generation", - modelId, + modelId: modelConfig?.id || modelId, chatId, messageId, note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`, @@ -1495,6 +1489,81 @@ async function saveGeneratedImages( } } +const MODEL_UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function getModelCreditCost(modelConfig: any, target: "image" | "video"): number { + const config = (modelConfig as any)?.imageGenConfig || {}; + const sourceValue = + target === "video" + ? config.creditCostPerVideo ?? config.creditCostPerImage ?? 0 + : config.creditCostPerImage ?? 0; + const normalized = Number(sourceValue); + return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; +} + +async function resolveBillingModelByReference(modelRef: string): Promise { + if (!modelRef) return undefined; + + if (MODEL_UUID_REGEX.test(modelRef)) { + return await getAiModelById({ id: modelRef }).catch(() => undefined); + } + + const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []); + const matched = allModels.find( + ({ model }: any) => model.id === modelRef || model.modelId === modelRef + ); + return matched?.model; +} + +async function consumeGeneratedVideoPoints(params: { + userId: string; + chatId: string; + messageId: string; + modelRefs: Array; +}) { + try { + const pointsConfig = await getPointsSystemConfig(); + if (!pointsConfig.enabled) return; + + let billingModel: any | undefined; + for (const ref of params.modelRefs) { + if (!ref) continue; + const resolved = await resolveBillingModelByReference(ref); + if (!resolved) continue; + billingModel = resolved; + if (getModelCreditCost(resolved, "video") > 0) break; + } + + if (!billingModel) return; + + const unitCost = getModelCreditCost(billingModel, "video"); + if (unitCost <= 0) return; + + const consumeResult = await consumeUserPoints({ + userId: params.userId, + amount: unitCost, + reason: "video_generation", + modelId: billingModel.id, + chatId: params.chatId, + messageId: params.messageId, + note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条`, + }); + + if (!consumeResult.ok) { + console.warn( + `[Points] Failed to consume video points. user=${params.userId} model=${billingModel.id} cost=${unitCost}` + ); + } else { + console.log( + `[Points] Consumed ${unitCost} points for video. balance=${consumeResult.balance}` + ); + } + } catch (error) { + console.error("[Points] Failed to consume points after video generation:", error); + } +} + export const maxDuration = 600; // 10 分钟,用于支持图片编辑等耗时任务 let globalStreamContext: ResumableStreamContext | null = null; @@ -2643,6 +2712,7 @@ export async function POST(request: Request) { const modelCreditCostPerImage = Number( (currentModelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0 ); + const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video"); if (isImageGenerationModel) { const quota = await checkUserImageQuota(session.user.id); @@ -2695,6 +2765,43 @@ export async function POST(request: Request) { } } + const isVideoGenerationModel = [ + "qianwen-video", + "fal-video", + "volcano-video", + "doubao-video", + ].includes(String(resolvedProviderType || (languageModel as any).provider || "")); + + if (isVideoGenerationModel) { + const pointsConfig = await getPointsSystemConfig(); + const normalizedVideoUnitCost = Number.isFinite(modelCreditCostPerVideo) + ? Math.max(0, Math.round(modelCreditCostPerVideo)) + : 0; + + if ( + pointsConfig.enabled && + normalizedVideoUnitCost > 0 && + pointsConfig.blockOnInsufficient !== false + ) { + const expectedCost = normalizedVideoUnitCost; + const userData = await getUserById({ id: session.user.id }); + const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0)); + + if (currentBalance < expectedCost) { + return NextResponse.json( + { + error: "insufficient_points", + message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${expectedCost}(${normalizedVideoUnitCost} x 1 条视频)。`, + balance: currentBalance, + expectedCost, + unitCost: normalizedVideoUnitCost, + }, + { status: 402 } + ); + } + } + } + // ========== Gemini 图片生成独立处理 ========== // 支持 gemini-image 和 huanke-gemini 两种图片生成 provider const isGeminiImageProvider = ["gemini-image", "huanke-gemini"].includes((languageModel as any).provider); @@ -3834,6 +3941,12 @@ export async function POST(request: Request) { ], }); console.log(`[Chat API] ✅ ${currentVideoProvider} video message saved successfully!`); + await consumeGeneratedVideoPoints({ + userId: session.user.id, + chatId: id, + messageId, + modelRefs: [finalModelId], + }); if (tempVideoUrl) { scheduleVideoArchive({ @@ -4421,6 +4534,12 @@ export async function POST(request: Request) { archivedAt: null, videoMetadata: successMetadata as any, }); + await consumeGeneratedVideoPoints({ + userId: session.user.id, + chatId: id, + messageId: videoMessageId, + modelRefs: [finalVideoModelData.id, providerModelData.id, finalModelId], + }); dataStream.write({ type: "data-appendMessage", diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 6b91eb0ff..fad003f98 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -21,6 +21,7 @@ const imageGenConfigSchema = z.object({ supportsWatermark: z.boolean().optional(), supportsSeed: z.boolean().optional(), creditCostPerImage: z.number().min(0).max(100000).optional(), + creditCostPerVideo: z.number().min(0).max(100000).optional(), enableLoadingPlaceholder: z.boolean().optional(), loadingColor: z.string().optional(), }).optional(); diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts index e9344d7df..e13672adf 100644 --- a/app/api/user/account-center/route.ts +++ b/app/api/user/account-center/route.ts @@ -22,6 +22,7 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides"; function mapLedgerReasonLabel(reason: string) { if (reason === "image_generation") return "图片生成"; + if (reason === "video_generation") return "视频生成"; if (reason === "gift") return "管理员赠送"; if (reason === "recharge") return "积分充值"; if (reason === "adjust") return "积分调整"; diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 4dc3ca463..40459ba8f 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1339,6 +1339,7 @@ interface ImageGenConfig { supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditCostPerVideo?: number; enableLoadingPlaceholder?: boolean; loadingColor?: string; }