count('id'); $rows = $query ->orderByDesc('id') ->forPage($page, $limit) ->get(); return [ 'total' => $total, 'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row))->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], ], ], ]; } 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); } public static function credit( string $requestId, int $adminId, string $memberId, string $amount, string $changeType, string $remark ): array { return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false); } public static function debit( string $requestId, int $adminId, string $memberId, string $amount, string $remark ): array { return self::adjust( $requestId, $adminId, $memberId, $amount, self::DEBIT_CHANGE_TYPE, $remark, true ); } private static function adjust( string $requestId, int $adminId, string $memberId, string $amount, string $changeType, string $remark, bool $isDebit ): array { $signedAmount = bcadd($amount, '0', 10); if ($isDebit) { $signedAmount = bcmul($signedAmount, '-1', 10); } $transactionType = $isDebit ? 'debit' : 'credit'; $requestHash = self::requestHash( $adminId, $memberId, $signedAmount, $transactionType, $changeType, $remark ); $existing = ManualAuditRecord::where('request_id', $requestId)->first(); if ($existing) { self::assertRequestMatches($existing, $requestHash); return self::format($existing); } $created = false; try { $audit = DB::transaction(function () use ( $requestId, $requestHash, $adminId, $memberId, $signedAmount, $transactionType, $changeType, $remark ): ManualAuditRecord { $audit = ManualAuditRecord::create([ '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' => '', ]); // 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, '可用余额不足'); } $log = BalanceLogService::addLog( $memberId, $signedAmount, $beforeBalance, $afterBalance, $changeType, null, $remark ); $wallet->available_balance = $afterBalance; if (!$wallet->save()) { throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR); } $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); } 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['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 { $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, '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 ?? ''), '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 ): string { $payload = json_encode([ 'admin_id' => $adminId, 'member_id' => $memberId, 'amount' => $signedAmount, 'transaction_type' => $transactionType, 'change_type' => $changeType, 'remark' => $remark, ], 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(), ]; } }