feat: split 3d model and world point costs

This commit is contained in:
Your Name 2026-04-05 21:54:11 +08:00
parent 607fd3bc3e
commit 5c2ece9fb6
5 changed files with 96 additions and 46 deletions

View File

@ -7413,7 +7413,7 @@ export function AdminSettingsClient() {
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="pointsEnabled">启用积分消耗</Label> <Label htmlFor="pointsEnabled">启用积分消耗</Label>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
开启后按模型单价和图片张数扣除积分 开启后按不同功能配置的单价扣除积分
</div> </div>
</div> </div>
<Switch <Switch
@ -7435,10 +7435,10 @@ export function AdminSettingsClient() {
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="pointsBlockOnInsufficient"> <Label htmlFor="pointsBlockOnInsufficient">
余额不足时阻止生图 余额不足时阻止生成
</Label> </Label>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
开启后积分不足直接返回错误,不执行生图任务 开启后积分不足直接返回错误,不执行生成任务
</div> </div>
</div> </div>
<Switch <Switch
@ -7459,35 +7459,72 @@ export function AdminSettingsClient() {
/> />
</div> </div>
</div> </div>
<div className="rounded-lg border p-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-1"> <div className="rounded-lg border p-3">
<Label htmlFor="points3dCost">3D生成单次积分消耗</Label> <div className="space-y-1">
<div className="text-xs text-muted-foreground"> <Label htmlFor="points3dModelCost">
作用于 3D 生成功能(文生3D / 图生3D / 3D模型单次积分消耗
多视图3D),每次提交任务扣除一次。 </Label>
<div className="text-xs text-muted-foreground">
作用于 3D模型模式(文生3D / 图生3D / 多视图3D),每次提交任务扣除一次。
</div>
</div>
<div className="mt-2 w-full max-w-xs">
<Input
id="points3dModelCost"
min={0}
type="number"
value={
siteSettings.pointsSystemConfig?.creditCostPer3DModel ??
0
}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
creditCostPer3DModel: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
),
},
})
}
/>
</div> </div>
</div> </div>
<div className="mt-2 w-full max-w-xs"> <div className="rounded-lg border p-3">
<Input <div className="space-y-1">
id="points3dCost" <Label htmlFor="points3dWorldCost">
min={0} 3D世界单次积分消耗
type="number" </Label>
value={ <div className="text-xs text-muted-foreground">
siteSettings.pointsSystemConfig?.creditCostPer3D ?? 0 作用于 3D世界模式(文本 / 图片 / 多图 / 视频生成世界),每次提交任务扣除一次。
} </div>
onChange={(e) => </div>
setSiteSettings({ <div className="mt-2 w-full max-w-xs">
...siteSettings, <Input
pointsSystemConfig: { id="points3dWorldCost"
...siteSettings.pointsSystemConfig, min={0}
creditCostPer3D: Math.max( type="number"
0, value={
Number.parseInt(e.target.value || "0", 10) || 0 siteSettings.pointsSystemConfig?.creditCostPer3DWorld ??
), 0
}, }
}) onChange={(e) =>
} setSiteSettings({
/> ...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
creditCostPer3DWorld: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
),
},
})
}
/>
</div>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -341,8 +341,8 @@ export async function POST(request: Request) {
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(taskType); const shouldChargePoints = shouldCharge3DPoints(taskType);
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3D)) const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3D))) ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
: 0; : 0;
if ( if (
@ -399,7 +399,7 @@ export async function POST(request: Request) {
amount: unitCost, amount: unitCost,
reason: "model3d_generation", reason: "model3d_generation",
modelId: "tripo-3d", modelId: "tripo-3d",
note: "3D生成消耗积分", note: "3D模型生成消耗积分",
}); });
if (!consumeResult.ok) { if (!consumeResult.ok) {
@ -412,7 +412,7 @@ export async function POST(request: Request) {
); );
} else { } else {
console.log( console.log(
`[Points] Consumed ${unitCost} points for 3D generation. user=${session.user.id} task=${taskId} balance=${consumeResult.balance}` `[Points] Consumed ${unitCost} points for 3D model generation. user=${session.user.id} task=${taskId} balance=${consumeResult.balance}`
); );
} }
} catch (error) { } catch (error) {

View File

@ -143,8 +143,8 @@ export async function POST(request: Request) {
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode); const shouldChargePoints = shouldCharge3DPoints(mode);
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3D)) const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DWorld))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3D))) ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DWorld)))
: 0; : 0;
if ( if (
@ -209,7 +209,7 @@ export async function POST(request: Request) {
await consumeUserPoints({ await consumeUserPoints({
userId: session.user.id, userId: session.user.id,
amount: unitCost, amount: unitCost,
reason: "model3d_generation", reason: "world3d_generation",
modelId: "worldlabs-3d", modelId: "worldlabs-3d",
note: "3D世界生成消耗积分", note: "3D世界生成消耗积分",
}); });

View File

@ -22,7 +22,8 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
function mapLedgerReasonLabel(reason: string) { function mapLedgerReasonLabel(reason: string) {
if (reason === "image_generation") return "图片生成"; if (reason === "image_generation") return "图片生成";
if (reason === "model3d_generation") return "3D生成"; if (reason === "model3d_generation") return "3D模型生成";
if (reason === "world3d_generation") return "3D世界生成";
if (reason === "video_generation") return "视频生成"; if (reason === "video_generation") return "视频生成";
if (reason === "deduct") return "管理员扣减"; if (reason === "deduct") return "管理员扣减";
if (reason === "gift") return "管理员赠送"; if (reason === "gift") return "管理员赠送";

View File

@ -4,7 +4,8 @@ export const POINTS_PACKAGES_KEY = "points_packages";
export type PointsSystemConfig = { export type PointsSystemConfig = {
enabled: boolean; enabled: boolean;
blockOnInsufficient: boolean; blockOnInsufficient: boolean;
creditCostPer3D: number; creditCostPer3DModel: number;
creditCostPer3DWorld: number;
}; };
export type PointsPackage = { export type PointsPackage = {
@ -19,7 +20,8 @@ export type PointsPackage = {
export const defaultPointsSystemConfig: PointsSystemConfig = { export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false, enabled: false,
blockOnInsufficient: true, blockOnInsufficient: true,
creditCostPer3D: 0, creditCostPer3DModel: 0,
creditCostPer3DWorld: 0,
}; };
export const defaultPointsPackages: PointsPackage[] = [ export const defaultPointsPackages: PointsPackage[] = [
@ -46,15 +48,25 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
return defaultPointsSystemConfig; return defaultPointsSystemConfig;
} }
const raw = value as Partial<PointsSystemConfig>; const raw = value as Partial<PointsSystemConfig> & {
const parsed3DCost = Number(raw.creditCostPer3D); creditCostPer3D?: number;
};
const normalizeCost = (input: unknown) => {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(100000, Math.round(parsedValue))
: 0;
};
const legacy3DCost = normalizeCost(raw.creditCostPer3D);
return { return {
enabled: raw.enabled === true, enabled: raw.enabled === true,
blockOnInsufficient: raw.blockOnInsufficient !== false, blockOnInsufficient: raw.blockOnInsufficient !== false,
creditCostPer3D: creditCostPer3DModel: normalizeCost(
Number.isFinite(parsed3DCost) && parsed3DCost > 0 raw.creditCostPer3DModel ?? legacy3DCost
? Math.min(100000, Math.round(parsed3DCost)) ),
: 0, creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
),
}; };
} }