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