feat: enable video points billing and admin video cost config
This commit is contained in:
parent
1fe2c3ba8a
commit
ab779c57ed
@ -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",
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 "积分调整";
|
||||
|
||||
@ -1339,6 +1339,7 @@ interface ImageGenConfig {
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
creditCostPerImage?: number;
|
||||
creditCostPerVideo?: number;
|
||||
enableLoadingPlaceholder?: boolean;
|
||||
loadingColor?: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user