*/ private static array $enabled = []; public static function enabled(): bool { if (!WageringService::enabled()) return false; $key = spl_object_id(DB::connection()->getPdo()); if (!array_key_exists($key, self::$enabled)) { $schema = DB::connection()->getSchemaBuilder(); self::$enabled[$key] = $schema->hasTable('wallet_freeze_records') && $schema->hasColumn('withdraws', 'frozen_amount') && $schema->hasColumn('payment_orders', 'frozen_amount'); } return self::$enabled[$key]; } public static function flushSchemaCache(): void { self::$enabled = []; } /** * @param callable(string $beforeAvailable, string $afterAvailable): Model $createOrder 创建提现单 * @return array{0: Model, 1: Wallet} */ public static function hold(string $memberId, $amount, string $changeType, string $remark, string $relatedType, callable $createOrder, ?string $logAmount = null): array { // 先在锁外重算当前打码量(耗时查询不占用钱包行锁);锁内只用缓存值判断。 // 之后才结算的注单只会让缓存偏低,不会让未完成打码量的会员提前提现。 $unlocked = WageringService::enabled() ? Wallet::where('member_id', $memberId)->first() : null; if ($unlocked && bccomp(WageringService::remaining($unlocked->wager_required, $unlocked->wager_completed), '0', 10) > 0) { WageringService::refresh($unlocked); } return DB::transaction(function () use ($memberId, $amount, $changeType, $remark, $relatedType, $createOrder, $logAmount) { $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first(); if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR); $amount = bcadd((string)$amount, '0', 10); if (bccomp($amount, '0', 10) <= 0) throw new Exception('提现金额错误', HttpStatus::CUSTOM_ERROR); $beforeAvailable = bcadd((string)$wallet->available_balance, '0', 10); if (bccomp($beforeAvailable, $amount, 10) < 0) throw new Exception('您的钱包余额不足!', HttpStatus::CUSTOM_ERROR); WageringService::assertWithdrawable($wallet, false); $enabled = self::enabled(); $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10); $afterAvailable = bcsub($beforeAvailable, $amount, 10); $afterFrozen = $enabled ? bcadd($beforeFrozen, $amount, 10) : $beforeFrozen; $wallet->available_balance = $afterAvailable; $wallet->frozen_balance = $afterFrozen; if (!$wallet->save()) throw new Exception('钱包更新失败!', HttpStatus::CUSTOM_ERROR); $order = $createOrder($beforeAvailable, $afterAvailable); if ($enabled) { $order->frozen_amount = $amount; $order->save(); } BalanceLogService::addLog($memberId, $logAmount ?? bcsub('0', $amount, 10), $beforeAvailable, $afterAvailable, $changeType, $order->id, $remark); if ($enabled) { Record::create([ 'user_id' => User::where('member_id', $memberId)->value('id'), 'member_id' => $memberId, 'type' => Record::TYPE_WITHDRAW_HOLD, 'amount' => $amount, 'wager_amount' => '0', 'reason' => $changeType . '申请冻结', 'status' => Record::STATUS_HOLDING, 'related_type' => $relatedType, 'related_id' => $order->id, 'before_available' => $beforeAvailable, 'after_available' => $afterAvailable, 'before_frozen' => $beforeFrozen, 'after_frozen' => $afterFrozen, ]); } WageringService::resetCycle($wallet, 'reset', $relatedType . ':' . $order->id); return [$order, $wallet]; }, 3); } /** * 退回提现资金到可用余额(拒绝 / 失败 / 撤回)。调用方负责先把提现单改为终态,避免重复退回。 * @param string $legacyAmount 旧单(frozen_amount=0)应退回的金额 * @return array{before: string, after: string, amount: string}|null 已处理过返回 null */ public static function release(string $relatedType, Model $order, $legacyAmount, string $changeType, string $remark): ?array { return DB::transaction(function () use ($relatedType, $order, $legacyAmount, $changeType, $remark) { $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first(); if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR); $frozenAmount = self::frozenAmount($order); $record = null; if (bccomp($frozenAmount, '0', 10) > 0) { $record = self::holdRecord($relatedType, (int)$order->id); if ($record && $record->status !== Record::STATUS_HOLDING) return null; $amount = $frozenAmount; $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10); if (bccomp($beforeFrozen, $amount, 10) < 0) throw new Exception('冻结余额不足,无法退回提现', HttpStatus::CUSTOM_ERROR); $wallet->frozen_balance = bcsub($beforeFrozen, $amount, 10); } else { $amount = bcadd((string)$legacyAmount, '0', 10); } $before = bcadd((string)$wallet->available_balance, '0', 10); $after = bcadd($before, $amount, 10); $wallet->available_balance = $after; if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR); BalanceLogService::addLog($order->member_id, $amount, $before, $after, $changeType, $order->id, $remark); $record?->update(['status' => Record::STATUS_RELEASED, 'finished_at' => PromotionFacts::now(), 'after_available' => $after, 'after_frozen' => $wallet->frozen_balance]); return ['before' => $before, 'after' => $after, 'amount' => $amount]; }, 3); } /** 提现成功:扣除冻结余额。旧单无需处理。 */ public static function settle(string $relatedType, Model $order): void { $frozenAmount = self::frozenAmount($order); if (bccomp($frozenAmount, '0', 10) <= 0) return; DB::transaction(function () use ($relatedType, $order, $frozenAmount) { $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first(); if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR); $record = self::holdRecord($relatedType, (int)$order->id); if ($record && $record->status !== Record::STATUS_HOLDING) return; $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10); if (bccomp($beforeFrozen, $frozenAmount, 10) < 0) throw new Exception('冻结余额不足,无法完成提现', HttpStatus::CUSTOM_ERROR); $wallet->frozen_balance = bcsub($beforeFrozen, $frozenAmount, 10); if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR); $record?->update(['status' => Record::STATUS_SETTLED, 'finished_at' => PromotionFacts::now(), 'after_frozen' => $wallet->frozen_balance]); }, 3); } private static function frozenAmount(Model $order): string { return bcadd((string)($order->getAttribute('frozen_amount') ?? '0'), '0', 10); } private static function holdRecord(string $relatedType, int $id): ?Record { if (!self::enabled()) return null; return Record::where('related_type', $relatedType)->where('related_id', $id) ->where('type', Record::TYPE_WITHDRAW_HOLD)->lockForUpdate()->first(); } }