tips.js 2.68 KB
import { randomUUID } from 'node:crypto'
import { createBillingRepo, createLedger } from '@drama/billing'

const TIPS = 'vk_tips'

/**
 * 打赏:任何成员可给已结算任务的赢家打赏,从自己的可用积分划转,不可撤回。
 * 打赏人扣款与收款人入账在同一事务;余额不足时账本抛 INSUFFICIENT_BALANCE,事务回滚。
 *
 * @param {{ db: import('knex').Knex }} deps
 */
export function createTips({ db }) {
  if (typeof db !== 'function') throw new Error('createTips: db is required')

  /**
   * @param {object} _ctx
   * @param {{
   *   task_id: string,
   *   from_agent_id: string,
   *   from_wallet: string,
   *   amount: number,
   *   client_request_key: string,
   *   onSent(trx: import('knex').Knex, sent: { to_wallet: string }): Promise<void>,
   * }} input `onSent` 在同一事务里执行(写事件);重放时不再调用。
   * @returns {Promise<{ ok: true, tip_id: string, replayed: boolean } | { ok: false, code: 'state_conflict'|'rule_violation', hint: string }>}
   */
  async function send(_ctx, { task_id, from_agent_id, from_wallet, amount, client_request_key, onSent }) {
    return db.transaction(async (trx) => {
      // 锁打赏人的钱包行:同一打赏人的打赏串行,重放在首个事务提交后读到已有记录。
      await trx('users').where({ id: from_wallet }).forUpdate().first('id')
      const existing = await trx(TIPS).where({ from_agent_id, client_request_key }).first('id')
      if (existing !== undefined) return { ok: true, tip_id: existing.id, replayed: true }
      const winner = await trx('vk_escrow_payouts').where({ task_id, kind: 'winner' }).first('recipient_wallet')
      if (winner === undefined) return { ok: false, code: 'state_conflict', hint: 'Only settled tasks can be tipped.' }
      const to_wallet = winner.recipient_wallet
      if (to_wallet === from_wallet) return { ok: false, code: 'rule_violation', hint: 'You cannot tip yourself.' }
      const id = `tp_${randomUUID()}`
      const ledger = createLedger(createBillingRepo(db, trx))
      const base = { amount, source: 'voko', reason: 'vk_tip', ref_type: 'vk_tip', ref_id: id }
      const out = await ledger.deduct({ ...base, user_id: from_wallet, idempotency_key: `vk:tip:${id}:out` })
      const received = await ledger.grant({ ...base, user_id: to_wallet, credit_kind: 'earned', idempotency_key: `vk:tip:${id}:in` })
      await trx(TIPS).insert({ id, task_id, from_agent_id, from_wallet, to_wallet, amount, out_ledger_id: out.ledger_id, in_ledger_id: received.ledger_id, client_request_key, created_at: new Date() })
      await onSent(trx, { to_wallet })
      return { ok: true, tip_id: id, replayed: false }
    })
  }

  return { send }
}