diff --git a/apps/web/src/pages/generate/hooks/pointsCost.ts b/apps/web/src/pages/generate/hooks/pointsCost.ts index 1bb463703..92b390c92 100644 --- a/apps/web/src/pages/generate/hooks/pointsCost.ts +++ b/apps/web/src/pages/generate/hooks/pointsCost.ts @@ -1,58 +1,74 @@ /** * 生成前积分消耗估算与余额校验 - * 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值 + * 用于步骤「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值 + * + * 契约对齐(2026-09-16 最终版): + * - balance 不再包含 free_clips_* 字段,免费额度通过 dailyUsage 传入 + * - 乘数逻辑:非会员 ceil(base × free_user_multiplier),会员 floor(base × points_discount) + * - points_discount 从 subscription.plans.points_discount 获取(mock 阶段用 1 占位) */ -import type { PointsBalance } from "@/api/points/types" +import type { PointsBalance, DailyUsage } from "@/api/points/types" +import type { SubscriptionPlan } from "@/api/subscription/types" -/** 生成单条视频消耗积分(基准) */ +/** 生成单条视频基准积分(ai_video base_points=8,但向导默认使用短片段,先保守按 3 估算) */ export const BASE_VIDEO_POINTS = 3 -/** - * 估算生成任务的积分消耗 - * @param videoCount 视频条数(批量模式) - * @param memberMultiplier 会员倍率(免费用户 1.15) - */ -export function estimateGenerateCost(videoCount: number, memberMultiplier = 1): number { - const raw = BASE_VIDEO_POINTS * videoCount * memberMultiplier - // 向上取整,避免小数 - return Math.ceil(raw) -} +/** 默认免费用户倍率(后端 free_user_multiplier,mock 默认 1.15) */ +const DEFAULT_FREE_MULTIPLIER = 1.15 -/** - * 判断积分是否充足(含每日免费额度) - * @returns sufficient=true 表示可以继续生成;false 需要提示充值 - */ -export function hasEnoughPoints( - balance: PointsBalance | null, - videoCount: number, -): { +export interface HasEnoughPointsResult { sufficient: boolean cost: number reason?: string freeClipsUsed?: number freeClipsRemaining?: number -} { +} + +/** + * 判断积分是否充足(含每日免费额度) + * @param balance 积分余额 + * @param videoCount 视频条数(批量模式下为 variant 数) + * @param dailyUsage 每日免费额度(可选;不传视为 0) + * @param plans 当前可用订阅计划列表(用于计算会员积分折扣;mock 传 []) + * @param currentPlanId 当前用户 plan_id(free/monthly/quarterly/yearly) + * @param freeMultiplier 免费用户倍率,默认 1.15 + */ +export function hasEnoughPoints( + balance: PointsBalance | null, + videoCount: number, + dailyUsage?: DailyUsage | null, + plans: SubscriptionPlan[] = [], + currentPlanId: string = "free", + freeMultiplier: number = DEFAULT_FREE_MULTIPLIER, +): HasEnoughPointsResult { if (!balance) { - // 未登录或未加载:不拦截,后端会校验 - return { sufficient: true, cost: estimateGenerateCost(videoCount) } + return { sufficient: true, cost: estimateGenerateCost(videoCount, 1) } } - const isMember = balance.is_member - const multiplier = isMember ? 1 : 1.15 - const cost = estimateGenerateCost(videoCount, multiplier) + const isMember = balance.is_member && currentPlanId !== "free" + const plan = plans.find((p) => p.plan_id === currentPlanId) + const multiplier = isMember ? (plan?.points_discount ?? 1) : freeMultiplier + const raw = BASE_VIDEO_POINTS * videoCount * multiplier + const cost = isMember ? Math.floor(raw) : Math.ceil(raw) - // 免费用户优先使用每日免费额度 - if (!isMember && balance.free_clips_remaining && balance.free_clips_remaining > 0) { - const freeUsed = Math.min(balance.free_clips_remaining, videoCount) - const remainingAfterFree = videoCount - freeUsed - const paidCost = estimateGenerateCost(remainingAfterFree, multiplier) + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + + // 非会员优先用每日免费额度 + if (!isMember && freeRemain > 0) { + const freeUsed = Math.min(freeRemain, videoCount) + const afterFree = videoCount - freeUsed + const paidCost = + afterFree === 0 + ? 0 + : isMember + ? Math.floor(BASE_VIDEO_POINTS * afterFree * multiplier) + : Math.ceil(BASE_VIDEO_POINTS * afterFree * multiplier) if (paidCost === 0) { - // 完全用免费额度 return { sufficient: true, cost: 0, freeClipsUsed: freeUsed, - freeClipsRemaining: balance.free_clips_remaining - freeUsed, + freeClipsRemaining: freeRemain - freeUsed, } } if (balance.balance >= paidCost) { @@ -60,24 +76,28 @@ export function hasEnoughPoints( sufficient: true, cost: paidCost, freeClipsUsed: freeUsed, - freeClipsRemaining: balance.free_clips_remaining - freeUsed, + freeClipsRemaining: freeRemain - freeUsed, } } return { sufficient: false, cost: paidCost, - reason: `积分不足:本次需 ${paidCost} 积分(使用 ${freeUsed} 次免费额度后),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`, + reason: `积分不足:本次需 ${paidCost} 积分(已用 ${freeUsed} 次免费额度),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`, freeClipsUsed: freeUsed, } } - // 付费会员或免费额度用完 - if (balance.balance >= cost) { - return { sufficient: true, cost } - } + if (balance.balance >= cost) return { sufficient: true, cost } return { sufficient: false, cost, reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`, } } + +/** + * 估算生成任务的积分消耗(导出给 UI 直接使用) + */ +export function estimateGenerateCost(videoCount: number, multiplier = 1): number { + return Math.ceil(BASE_VIDEO_POINTS * videoCount * multiplier) +}