feat: enable video points billing and admin video cost config
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-21 02:01:29 +08:00
parent 1fe2c3ba8a
commit ab779c57ed
5 changed files with 169 additions and 11 deletions

View File

@ -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() {
</div>
{/* 🆕 图片生成模型特性配置 */}
{modelForm.supportsImageGen && (
{(modelForm.supportsImageGen || isVideoProviderForModelConfig) && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
<div className="font-medium text-sm">图片生成配置</div>
<div className="font-medium text-sm">生成配置</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
@ -5948,6 +5960,29 @@ export function AdminSettingsClient() {
开启积分系统后,将按“单价 × 实际出图张数”扣除积分。
</p>
</div>
<div className="space-y-2">
<Label>每条视频积分消耗</Label>
<Input
type="number"
min={0}
step={1}
value={modelForm.imageGenConfig?.creditCostPerVideo ?? 0}
onChange={(e) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
creditCostPerVideo: Math.max(
0,
Number.parseInt(e.target.value, 10) || 0
),
},
})}
/>
<p className="text-xs text-muted-foreground">
视频生成成功后按每条视频扣除积分。未配置时默认使用“每张图片积分”作为兼容值。
</p>
</div>
<div className="space-y-2">
<Label>占位图颜色主题</Label>
@ -6100,6 +6135,7 @@ export function AdminSettingsClient() {
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostPerVideo: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},

View File

@ -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<any | undefined> {
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<string | null | undefined>;
}) {
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",

View File

@ -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();

View File

@ -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 "积分调整";

View File

@ -1339,6 +1339,7 @@ interface ImageGenConfig {
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostPerVideo?: number;
enableLoadingPlaceholder?: boolean;
loadingColor?: string;
}