paywall.js 6.75 KB
/**
 * `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<string[]>}
   */
  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<import('./quota.js').PaywallRules>}
 */
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}`
}