/** * `quota.js` —— 免费额度切点判定(纯逻辑,零 IO)。 * * 两类切点,皆数据驱动(规则从 `billingRepo.listPaywallRules` 取,用量从 * `getFreeQuotaUsage` 取,本文件只做纯计算): * 1. **前 N 集免费**:第 1..N 集(key 为序号)免费,N+1 起收费。 * 2. **每日 K 次免费续写**:当日窗口内前 K 次 `continue` 免费,第 K+1 次起收费。 * * 设计成纯函数让全表可直接单测:给定规则 + 当前用量,返回是否免费、剩余额度、价目。 */ /** * @typedef {object} PaywallRules * @property {number} [free_episodes] 前 N 集免费(N) * @property {number} [free_continue_per_day] 每日免费续写次数(K) * @property {Record} [prices] 各 kind 的解锁价目(积分),如 { episode:30, branch:50, ending:80, continue:5 } */ /** * 判定「第 N 集」是否落在免费区间。 * @param {object} p * @param {PaywallRules} p.rules * @param {number} p.episode_number 1-based 集号 * @returns {{ free:boolean, price_points:number, free_quota_left:number }} */ export function evaluateEpisodeQuota({ rules, episode_number }) { const free_episodes = numOr(rules.free_episodes, 0) const price = priceFor(rules, 'episode') const free = episode_number <= free_episodes // 剩余免费集数:还能免费看到第几集(相对当前集),不为负。 const free_quota_left = Math.max(0, free_episodes - episode_number + 1) return { free, price_points: free ? 0 : price, free_quota_left } } /** * 判定一次续写是否落在「每日 K 次免费」区间。 * @param {object} p * @param {PaywallRules} p.rules * @param {number} p.used_today 当日已用免费续写次数(窗口内) * @returns {{ free:boolean, price_points:number, free_quota_left:number }} */ export function evaluateContinueQuota({ rules, used_today }) { const k = numOr(rules.free_continue_per_day, 0) const price = priceFor(rules, 'continue') const used = numOr(used_today, 0) const free_quota_left = Math.max(0, k - used) const free = free_quota_left > 0 return { free, price_points: free ? 0 : price, free_quota_left } } /** * 解锁价目(无免费额度概念的 branch / ending)。 * @param {object} p * @param {PaywallRules} p.rules * @param {'branch'|'ending'} p.kind * @returns {{ price_points:number }} */ export function priceForUnlock({ rules, kind }) { return { price_points: priceFor(rules, kind) } } /** * 计算「日窗口」起点(UTC 当日 0 点),供 repo 按窗口聚合用量(DATETIME(3) 列,Date 出入)。 * @param {Date} [now] * @returns {Date} */ export function day_window_start(now = new Date()) { const d = new Date(now) d.setUTCHours(0, 0, 0, 0) return d } /** * @param {PaywallRules} rules * @param {string} kind */ function priceFor(rules, kind) { const prices = rules.prices || {} return numOr(prices[kind], 0) } /** * @param {unknown} v * @param {number} fallback */ function numOr(v, fallback) { return typeof v === 'number' && Number.isFinite(v) ? v : fallback }