chore(points): align types/api with final backend contract - apps/web/src/pages/generate/hooks/pointsCost.ts
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user