where('type', Record::TYPE_MANUAL_FREEZE)->sum('amount'); $unfrozen = (string)Record::where('member_id', $memberId)->where('type', Record::TYPE_MANUAL_UNFREEZE)->sum('amount'); $left = bcsub(bcadd($frozen, '0', 10), bcadd($unfrozen, '0', 10), 10); return bccomp($left, '0', 10) > 0 ? $left : '0'; } private static function apply(bool $isFreeze, string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array { $type = $isFreeze ? Record::TYPE_MANUAL_FREEZE : Record::TYPE_MANUAL_UNFREEZE; $amount = bcadd($amount, '0', 10); $wagerAmount = bcadd($wagerAmount === '' ? '0' : $wagerAmount, '0', 10); if (bccomp($amount, '0', 10) <= 0) throw new Exception('金额必须大于0', HttpStatus::CUSTOM_ERROR); if (bccomp($wagerAmount, '0', 10) < 0) throw new Exception('打码量不能小于0', HttpStatus::CUSTOM_ERROR); $replay = fn () => self::replay($requestId, $type, $memberId, $amount, $wagerAmount); if ($existing = $replay()) return $existing; try { $record = DB::transaction(function () use ($isFreeze, $type, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason) { $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first(); if (!$wallet) throw new Exception('用户钱包不存在', HttpStatus::CUSTOM_ERROR); $user = User::where('member_id', $memberId)->first(['id']); if (!$user) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR); $beforeAvailable = bcadd((string)$wallet->available_balance, '0', 10); $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10); if ($isFreeze) { if (bccomp($beforeAvailable, $amount, 10) < 0) throw new Exception('可用余额不足', HttpStatus::CUSTOM_ERROR); $afterAvailable = bcsub($beforeAvailable, $amount, 10); $afterFrozen = bcadd($beforeFrozen, $amount, 10); } else { $limit = self::manualFrozen($memberId); if (bccomp($beforeFrozen, $limit, 10) < 0) $limit = $beforeFrozen; if (bccomp($limit, $amount, 10) < 0) throw new Exception('可解冻金额不足,最多可解冻:' . bcadd($limit, '0', 2), HttpStatus::CUSTOM_ERROR); $afterAvailable = bcadd($beforeAvailable, $amount, 10); $afterFrozen = bcsub($beforeFrozen, $amount, 10); } $wallet->available_balance = $afterAvailable; $wallet->frozen_balance = $afterFrozen; if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR); $record = Record::create([ 'user_id' => $user->id, 'member_id' => $memberId, 'type' => $type, 'amount' => $amount, 'wager_amount' => $wagerAmount, 'reason' => mb_substr($reason, 0, 255), 'status' => $isFreeze ? Record::STATUS_FROZEN : Record::STATUS_DONE, 'request_id' => $requestId, 'admin_id' => $adminId, 'before_available' => $beforeAvailable, 'after_available' => $afterAvailable, 'before_frozen' => $beforeFrozen, 'after_frozen' => $afterFrozen, ]); BalanceLogService::addLog($memberId, $isFreeze ? bcsub('0', $amount, 10) : $amount, $beforeAvailable, $afterAvailable, $isFreeze ? '人工冻结' : '人工解冻', $record->id, $reason); WageringService::addRequirement($wallet, $isFreeze ? bcsub('0', $wagerAmount, 10) : $wagerAmount, $isFreeze ? 'freeze' : 'unfreeze', $record->id, $reason, $adminId); return $record; }, 3); } catch (QueryException $e) { // 同一 request_id 并发提交:唯一索引冲突后返回已成功的那一笔 if ($existing = $replay()) return $existing; throw $e; } return self::format($record); } private static function replay(string $requestId, string $type, string $memberId, string $amount, string $wagerAmount): ?array { $existing = Record::where('request_id', $requestId)->first(); if (!$existing) return null; if ($existing->type !== $type || (string)$existing->member_id !== $memberId || bccomp((string)$existing->amount, $amount, 10) !== 0 || bccomp((string)$existing->wager_amount, $wagerAmount, 10) !== 0) { throw new Exception('request_id 已被其他冻结操作使用', HttpStatus::CUSTOM_ERROR); } return self::format($existing); } /** 冻结记录列表(后台)。 */ public static function list(array $params): array { $page = max(1, (int)($params['page'] ?? 1)); $limit = min(200, max(1, (int)($params['limit'] ?? 20))); $query = Record::query()->with('member'); if (!empty($params['member_id'])) $query->where('member_id', (string)$params['member_id']); if (!empty($params['account'])) { $query->whereIn('member_id', User::select('member_id')->where('account', 'like', '%' . trim((string)$params['account']) . '%')); } if (!empty($params['type'])) $query->where('type', (string)$params['type']); if (!empty($params['status'])) $query->where('status', (string)$params['status']); if (!empty($params['start_date']) && !empty($params['end_date'])) { $query->whereBetween('created_at', [ Carbon::parse($params['start_date'], 'Asia/Shanghai')->startOfDay(), Carbon::parse($params['end_date'], 'Asia/Shanghai')->endOfDay(), ]); } $total = (clone $query)->count('id'); $rows = $query->orderByDesc('id')->forPage($page, $limit)->get(); $admins = DB::table('admin')->whereIn('id', $rows->pluck('admin_id')->filter()->unique()->all() ?: [0])->pluck('username', 'id'); return [ 'total' => $total, 'data' => $rows->map(fn (Record $row) => self::format($row, $admins->all()))->all(), 'options' => [ 'types' => self::options(Record::TYPE_LABELS), 'statuses' => self::options(Record::STATUS_LABELS), ], ]; } public static function format(Record $row, array $admins = []): array { $member = $row->relationLoaded('member') ? $row->member : null; return [ 'id' => (int)$row->id, 'member_id' => (string)$row->member_id, 'account' => (string)($member->account ?? ''), 'username' => (string)($member->username ?? ''), 'first_name' => (string)($member->first_name ?? ''), 'type' => (string)$row->type, 'type_text' => Record::TYPE_LABELS[$row->type] ?? $row->type, 'amount' => bcadd((string)$row->amount, '0', 2), 'wager_amount' => bcadd((string)$row->wager_amount, '0', 2), 'reason' => (string)$row->reason, 'status' => (string)$row->status, 'status_text' => Record::STATUS_LABELS[$row->status] ?? $row->status, 'related_type' => $row->related_type, 'related_id' => $row->related_id === null ? null : (int)$row->related_id, 'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id, 'admin_name' => $row->admin_id === null ? '' : (string)($admins[$row->admin_id] ?? ''), 'before_available' => bcadd((string)$row->before_available, '0', 2), 'after_available' => bcadd((string)$row->after_available, '0', 2), 'before_frozen' => bcadd((string)$row->before_frozen, '0', 2), 'after_frozen' => bcadd((string)$row->after_frozen, '0', 2), 'finished_at' => $row->finished_at ? (string)$row->finished_at : null, 'created_at' => (string)$row->created_at, ]; } private static function options(array $labels): array { $result = []; foreach ($labels as $value => $label) $result[] = ['label' => $label, 'value' => $value]; return $result; } }