count('id'); $rows = $query ->orderByDesc('id') ->forPage($page, $limit) ->get(); $accounts = self::accounts($rows->pluck('member_id')->all()); // 会员当前打码量(所需/当前/剩余),便于上下分、冻结时参考 $wallets = WageringService::enabled() ? Wallet::whereIn('member_id', $rows->pluck('member_id')->unique()->values()->all() ?: [''])->get()->keyBy('member_id') : collect(); return [ 'total' => $total, 'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row, $accounts) + ['member_wager' => WageringService::summary($wallets->get((string)$row->member_id))])->all(), 'options' => [ 'transaction_types' => [ ['label' => '人工上分', 'value' => 'credit'], ['label' => '人工下分', 'value' => 'debit'], ], 'credit_types' => array_map(static fn(string $type): array => [ 'label' => $type, 'value' => $type, ], BalanceLogService::$manualRecharge), 'statuses' => [ ['label' => '失败', 'value' => self::STATUS_FAILED], ['label' => '成功', 'value' => self::STATUS_SUCCESS], ], // 人工充值按平台提现打码倍数自动增减打码量,其他类型需填写 wager_amount 'platform_wager_type' => self::PLATFORM_WAGER_TYPE, 'withdraw_wager_multiple' => WageringService::multiple(), ], ]; } public static function detail(int $id): array { $row = self::query(['id' => $id])->first(); if (!$row) { throw new Exception('人工稽查记录不存在', HttpStatus::CUSTOM_ERROR); } return self::format($row, self::accounts([$row->member_id])); } /** 会员ID和账号可二选一,也可同时填写,但必须指向同一会员 */ public static function resolveMemberId(?string $memberId, ?string $account): string { $memberId = trim((string)$memberId); $account = trim((string)$account); if ($account === '') { if ($memberId === '') throw new Exception('请填写会员ID或账号', HttpStatus::CUSTOM_ERROR); return $memberId; } $matches = User::where('account', $account)->limit(2)->pluck('member_id'); if ($matches->isEmpty()) throw new Exception('账号不存在', HttpStatus::CUSTOM_ERROR); if ($matches->count() > 1) throw new Exception('该账号对应多个会员,请填写会员ID', HttpStatus::CUSTOM_ERROR); $resolved = (string)$matches->first(); if ($memberId !== '' && $memberId !== $resolved) throw new Exception('会员ID与账号不一致', HttpStatus::CUSTOM_ERROR); return $resolved; } /** @return array member_id => account */ private static function accounts(array $memberIds): array { $memberIds = array_values(array_unique(array_filter(array_map('strval', $memberIds), 'strlen'))); if (!$memberIds) return []; return User::whereIn('member_id', $memberIds)->pluck('account', 'member_id') ->map(static fn($account): string => (string)($account ?? ''))->all(); } /** * @param string|null $wagerAmount 非“人工充值”类型时填写的打码量(为空视为0,可直接提现) */ public static function credit( string $requestId, int $adminId, string $memberId, string $amount, string $changeType, string $remark, ?string $wagerAmount = null ): array { return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false, null, $wagerAmount); } /** * @param string|null $rechargeType 下分对应的充值类型:人工充值按平台倍数扣除打码量,其他类型扣除 $wagerAmount;为空不调整打码量 */ public static function debit( string $requestId, int $adminId, string $memberId, string $amount, string $remark, ?string $rechargeType = null, ?string $wagerAmount = null ): array { return self::adjust( $requestId, $adminId, $memberId, $amount, self::DEBIT_CHANGE_TYPE, $remark, true, $rechargeType, $wagerAmount ); } /** * 本次上下分对打码量的有符号调整。上分“人工充值”由充值钩子自动按平台倍数增加,这里只返回展示值。 * @return array{0:string,1:bool} [有符号打码量, 是否需要在此处手动写入] */ public static function wagerDelta(bool $isDebit, string $amount, string $changeType, ?string $rechargeType, ?string $wagerAmount): array { $custom = bcadd((string)($wagerAmount ?? '0'), '0', 10); if (!$isDebit) { if ($changeType === self::PLATFORM_WAGER_TYPE) return [WageringService::platformRequirement($amount), false]; return [$custom, true]; } if ($rechargeType === null || $rechargeType === '') return ['0', false]; $value = $rechargeType === self::PLATFORM_WAGER_TYPE ? WageringService::platformRequirement($amount) : $custom; return [bcsub('0', $value, 10), true]; } private static function adjust( string $requestId, int $adminId, string $memberId, string $amount, string $changeType, string $remark, bool $isDebit, ?string $rechargeType = null, ?string $wagerAmount = null ): array { $signedAmount = bcadd($amount, '0', 10); if ($isDebit) { $signedAmount = bcmul($signedAmount, '-1', 10); } $transactionType = $isDebit ? 'debit' : 'credit'; [$wagerDelta, $writeWager] = self::wagerDelta($isDebit, $amount, $changeType, $rechargeType, $wagerAmount); $requestHash = self::requestHash( $adminId, $memberId, $signedAmount, $transactionType, $changeType, $remark, // 只摘要请求原始参数,平台倍数调整后同一 request_id 重试仍能识别为同一请求 $rechargeType === null && $wagerAmount === null ? null : ['recharge_type' => $rechargeType, 'wager_amount' => $wagerAmount === null ? null : bcadd($wagerAmount, '0', 10)] ); $existing = ManualAuditRecord::where('request_id', $requestId)->first(); if ($existing) { self::assertRequestMatches($existing, $requestHash); return self::format($existing, self::accounts([$memberId])); } $created = false; try { $audit = DB::transaction(function () use ( $requestId, $requestHash, $adminId, $memberId, $signedAmount, $transactionType, $changeType, $remark, $wagerDelta, $writeWager ): ManualAuditRecord { $audit = ManualAuditRecord::create(self::withWager([ 'request_id' => $requestId, 'request_hash' => $requestHash, 'admin_id' => $adminId, 'member_id' => $memberId, 'username' => '', 'first_name' => '', 'transaction_type' => $transactionType, 'change_type' => $changeType, 'amount' => $signedAmount, 'level_before' => 0, 'level_before_name' => '普通会员', 'level_after' => 0, 'level_after_name' => '普通会员', 'status' => self::STATUS_FAILED, 'remark' => $remark, 'failure_reason' => '', ], $wagerDelta)); // Keep the same lock order as payment callbacks: wallet first, user second. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first(); $user = User::where('member_id', $memberId)->lockForUpdate()->first(); [$level, $levelName] = self::levelSnapshot($user); $audit->fill([ 'username' => (string)($user->username ?? ''), 'first_name' => (string)($user->first_name ?? ''), 'level_before' => $level, 'level_before_name' => $levelName, 'level_after' => $level, 'level_after_name' => $levelName, ]); if (!$user) { return self::fail($audit, '用户不存在'); } if (!$wallet) { return self::fail($audit, '用户钱包不存在'); } $beforeBalance = bcadd((string)$wallet->available_balance, '0', 10); $afterBalance = bcadd($beforeBalance, $signedAmount, 10); $audit->before_balance = $beforeBalance; if (bccomp($afterBalance, '0', 10) < 0) { $audit->after_balance = $beforeBalance; return self::fail($audit, '可用余额不足'); } // 先保存余额再写日志:充值钩子(首充奖励、即充即送、打码量)会在同一事务内重新锁定并更新钱包。 $wallet->available_balance = $afterBalance; if (!$wallet->save()) { throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR); } $log = BalanceLogService::addLog( $memberId, $signedAmount, $beforeBalance, $afterBalance, $changeType, null, $remark ); if ($writeWager) { $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first(); WageringService::addRequirement($wallet, $wagerDelta, bccomp($signedAmount, '0', 10) < 0 ? 'manual_debit' : 'manual_credit', $audit->id, $changeType . ($remark !== '' ? ';' . $remark : ''), $adminId); } $audit->balance_log_id = $log->id; $audit->after_balance = $afterBalance; $audit->status = self::STATUS_SUCCESS; $audit->failure_reason = ''; $audit->save(); return $audit; }, 3); $created = true; } catch (QueryException $e) { $audit = ManualAuditRecord::where('request_id', $requestId)->first(); if (!$audit) { throw $e; } self::assertRequestMatches($audit, $requestHash); } if ($created && (int)$audit->status === self::STATUS_SUCCESS) { self::notifyUser($memberId, (string)$audit->amount, (string)$audit->after_balance); } return self::format($audit, self::accounts([$memberId])); } private static ?bool $hasWagerColumn = null; private static function withWager(array $data, string $wagerDelta): array { self::$hasWagerColumn ??= DB::connection()->getSchemaBuilder()->hasColumn('manual_audits', 'wager_amount'); if (self::$hasWagerColumn) $data['wager_amount'] = $wagerDelta; return $data; } private static function fail(ManualAuditRecord $audit, string $reason): ManualAuditRecord { $audit->status = self::STATUS_FAILED; $audit->failure_reason = $reason; $audit->save(); return $audit; } private static function query(array $params = []): Builder { $query = ManualAuditRecord::query(); if (!empty($params['id'])) { $query->where('id', (int)$params['id']); } 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['first_name'])) { $query->where('first_name', 'like', '%' . $params['first_name'] . '%'); } if (!empty($params['transaction_type'])) { $query->where('transaction_type', (string)$params['transaction_type']); } if (!empty($params['change_type'])) { $query->where('change_type', (string)$params['change_type']); } if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') { $query->where('status', (int)$params['status']); } if (!empty($params['start_date']) && !empty($params['end_date'])) { [$start, $end] = self::dateRange($params['start_date'], $params['end_date']); $query->whereBetween('created_at', [$start, $end]); } return $query; } private static function format(ManualAuditRecord $row, array $accounts = []): array { $amount = (string)$row->amount; $status = (int)$row->status; return [ 'id' => (int)$row->id, 'request_id' => (string)$row->request_id, 'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id, 'balance_log_id' => $row->balance_log_id === null ? null : (int)$row->balance_log_id, 'member_id' => (string)$row->member_id, 'account' => $accounts[(string)$row->member_id] ?? '', 'username' => (string)($row->username ?? ''), 'first_name' => (string)($row->first_name ?? ''), 'transaction_type' => (string)$row->transaction_type, 'transaction_type_text' => $row->transaction_type === 'debit' ? '人工下分' : '人工上分', 'change_type' => (string)$row->change_type, 'level_before' => (int)$row->level_before, 'level_before_name' => (string)$row->level_before_name, 'level_after' => (int)$row->level_after, 'level_after_name' => (string)$row->level_after_name, 'amount' => self::money(abs((float)$amount)), 'signed_amount' => self::money((float)$amount), 'before_balance' => self::nullableMoney($row->before_balance), 'after_balance' => self::nullableMoney($row->after_balance), 'status' => $status, 'status_text' => $status === self::STATUS_SUCCESS ? '成功' : '失败', 'remark' => (string)($row->remark ?? ''), 'wager_amount' => self::nullableMoney($row->getAttribute('wager_amount')) ?? '0.00', 'failure_reason' => (string)($row->failure_reason ?? ''), 'created_at' => (string)$row->created_at, ]; } /** * @return array{0:int,1:string} */ private static function levelSnapshot(?User $user): array { if (!$user) { return [0, '普通会员']; } $level = (int)($user->level ?? 0); $levelName = (string)(Level::where('level', $level)->value('level_name') ?? ''); if ($levelName === '') { $levelName = $level > 0 ? '等级' . $level : '普通会员'; } return [$level, $levelName]; } private static function requestHash( int $adminId, string $memberId, string $signedAmount, string $transactionType, string $changeType, string $remark, ?array $wager = null ): string { $data = [ 'admin_id' => $adminId, 'member_id' => $memberId, 'amount' => $signedAmount, 'transaction_type' => $transactionType, 'change_type' => $changeType, 'remark' => $remark, ]; // 未传打码量参数的请求保持旧摘要,避免升级前后重放同一 request_id 被判为冲突 if ($wager !== null) $data['wager'] = $wager; $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); return hash('sha256', $payload); } private static function assertRequestMatches(ManualAuditRecord $audit, string $requestHash): void { if (!hash_equals((string)$audit->request_hash, $requestHash)) { throw new Exception('request_id 已被其他人工操作使用', HttpStatus::CUSTOM_ERROR); } } private static function notifyUser(string $memberId, string $amount, string $afterBalance): void { try { $formattedAmount = self::money((float)$amount); $formattedBalance = self::money((float)$afterBalance); TopUpService::notifyTransferSuccess( $memberId, '您的账户余额更新:' . ((float)$amount > 0 ? '+' : '') . $formattedAmount . " \n总余额为:" . $formattedBalance ); } catch (\Throwable $e) { Log::warning('manual_audit_notify_failed', [ 'member_id' => $memberId, 'error' => $e->getMessage(), ]); } } private static function money(float $amount): string { return number_format($amount, 2, '.', ''); } private static function nullableMoney($amount): ?string { return $amount === null || $amount === '' ? null : self::money((float)$amount); } /** * @return array{0: Carbon, 1: Carbon} */ private static function dateRange(string $startDate, string $endDate): array { $timezone = config('app.timezone', 'Asia/Shanghai'); return [ Carbon::createFromFormat('Y-m-d', $startDate, $timezone)->startOfDay(), Carbon::createFromFormat('Y-m-d', $endDate, $timezone)->endOfDay(), ]; } }