/** * `paywall.js` —— 付费墙判定与解锁。 * * `check` 是数据驱动的纯判定:取规则(`listPaywallRules`)+ 已解锁记录 + 免费用量, * 调 `quota.js` 算出 `{ unlocked, reason, price_points?, free_quota_left? }`。 * `unlock` 走 `deduct` 完成扣分并落「已解锁」记录,重复解锁幂等(已解锁不再扣)。 * * target 形态(§3):`{ kind:'episode'|'branch'|'ending'|'continue', key }`。 * `key` 对 episode 是集号字符串,对 branch/ending 是分支/结局 id,对 continue 是本次续写 id。 * * @typedef {import('./repo/index.js').BillingRepo} BillingRepo * @typedef {{ kind:'episode'|'branch'|'ending'|'continue', key:string|number }} Target */ import { BillingError, BillingErrorCode } from './errors.js' import { evaluateEpisodeQuota, evaluateContinueQuota, priceForUnlock, day_window_start, } from './quota.js' /** 解锁判定原因(稳定枚举,供前端文案/埋点分流)。 */ export const PaywallReason = Object.freeze({ ALREADY_UNLOCKED: 'already_unlocked', FREE_EPISODE: 'free_episode', FREE_CONTINUE: 'free_continue', PAYMENT_REQUIRED: 'payment_required', }) /** target.kind 与不需要持久解锁记录的「免费即放行」类型。 */ const QUOTA_KINDS = new Set(['episode', 'continue']) /** * @param {object} deps * @param {BillingRepo} deps.billingRepo * @param {(p:any)=>Promise<{balance_after:number,ledger_id:string}>} deps.deduct ledger.deduct 注入 */ export function createPaywall({ billingRepo, deduct }) { /** * 判定 target 对该用户是否解锁。 * @param {object} p * @param {string} p.user_id * @param {string} p.content_id * @param {Target} p.target * @returns {Promise<{ unlocked:boolean, reason:string, price_points?:number, free_quota_left?:number }>} */ async function check({ user_id, content_id, target }) { const { kind, key } = normalizeTarget(target) const rules = await loadRules({ billingRepo, content_id }) // 已显式解锁过(持久记录)→ 直接放行(branch/ending/已付费 episode 都走这)。 const target_key = toTargetKey(kind, key) const already = await billingRepo.findUnlock({ user_id, content_id, target_key }) if (already) { return { unlocked: true, reason: PaywallReason.ALREADY_UNLOCKED } } if (kind === 'episode') { const ep = evaluateEpisodeQuota({ rules, episode_number: Number(key) }) return ep.free ? { unlocked: true, reason: PaywallReason.FREE_EPISODE, free_quota_left: ep.free_quota_left } : { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points: ep.price_points, free_quota_left: ep.free_quota_left } } if (kind === 'continue') { const used_today = await billingRepo.getFreeQuotaUsage({ user_id, content_id, kind: 'continue', window: day_window_start(), }) const c = evaluateContinueQuota({ rules, used_today }) return c.free ? { unlocked: true, reason: PaywallReason.FREE_CONTINUE, free_quota_left: c.free_quota_left } : { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points: c.price_points, free_quota_left: c.free_quota_left } } // branch / ending:无免费额度概念,未解锁即需付费。 const { price_points } = priceForUnlock({ rules, kind: /** @type {'branch'|'ending'} */ (kind) }) return { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points } } /** * 解锁:扣分 + 落已解锁记录,幂等(已解锁不再扣)。 * @param {object} p * @param {string} p.user_id * @param {string} p.content_id * @param {Target} p.target * @param {string} p.idempotency_key * @returns {Promise<{ unlocked:true, ledger_id?:string }>} */ async function unlock({ user_id, content_id, target, idempotency_key }) { const { kind, key } = normalizeTarget(target) const target_key = toTargetKey(kind, key) const already = await billingRepo.findUnlock({ user_id, content_id, target_key }) if (already) { // 幂等:已解锁不再扣分。 return { unlocked: true, ledger_id: already.ledger_id ?? undefined } } const verdict = await check({ user_id, content_id, target }) if (verdict.unlocked) { // 落在免费区间:直接记一条免费解锁,不扣分。 if (QUOTA_KINDS.has(kind)) { await billingRepo.bumpFreeQuota({ user_id, content_id, kind, window: day_window_start() }) } await billingRepo.insertUnlock({ user_id, content_id, target_key, ledger_id: null }) return { unlocked: true } } const price = verdict.price_points if (typeof price !== 'number' || price <= 0) { throw new BillingError({ code: BillingErrorCode.RULE_NOT_FOUND, message: `target ${target_key} 无可用价目,无法解锁`, retryable: false, }) } const { ledger_id } = await deduct({ user_id, amount: price, reason: `unlock_${kind}`, ref_type: 'unlock', ref_id: `${content_id}:${target_key}`, idempotency_key, }) await billingRepo.insertUnlock({ user_id, content_id, target_key, ledger_id }) return { unlocked: true, ledger_id } } /** * 列出某剧下该用户已解锁的 target keys。 * @param {object} p * @param {string} p.user_id * @param {string} p.content_id * @returns {Promise} */ async function listUnlocked({ user_id, content_id }) { const rows = await billingRepo.listUnlocks(user_id, content_id) return rows.map((r) => r.target_key) } return { check, unlock, listUnlocked } } /** * @param {object} p * @param {BillingRepo} p.billingRepo * @param {string} p.content_id * @returns {Promise} */ async function loadRules({ billingRepo, content_id }) { const rules = await billingRepo.listPaywallRules(content_id) if (!rules) { throw new BillingError({ code: BillingErrorCode.RULE_NOT_FOUND, message: `content_id ${content_id} 无付费墙规则`, retryable: false, }) } return rules } /** * @param {Target} target * @returns {{ kind:string, key:string|number }} */ function normalizeTarget(target) { if (!target || typeof target.kind !== 'string') { throw new BillingError({ code: BillingErrorCode.RULE_NOT_FOUND, message: 'target 需含 kind 与 key', retryable: false, }) } if (!['episode', 'branch', 'ending', 'continue'].includes(target.kind)) { throw new BillingError({ code: BillingErrorCode.RULE_NOT_FOUND, message: `未知 target.kind: ${target.kind}`, retryable: false, }) } return { kind: target.kind, key: target.key } } /** * @param {string} kind * @param {string|number} key */ function toTargetKey(kind, key) { return `${kind}:${key}` }