doge пре 2 недеља
родитељ
комит
7313cb14bc

+ 180 - 0
app/Console/Commands/ThirdGameBalanceUsers.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\BalanceLog;
+use App\Services\ThirdGameBalanceService;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\File;
+
+class ThirdGameBalanceUsers extends Command
+{
+    protected $signature = 'third-game:balance-users
+        {--from= : 开始时间,例如 2026-07-14 10:00:00}
+        {--to= : 结束时间,例如 2026-07-14 23:59:59}
+        {--member_id=* : 只查询指定 member_id,可重复传入}
+        {--csv= : CSV 输出路径,默认保存到 storage/app}
+        {--show=100 : 控制台最多展示条数}';
+
+    protected $description = '导出有三方游戏转入、失败退回或转出流水的玩家(只读,不回收余额)';
+
+    public function handle(ThirdGameBalanceService $service): int
+    {
+        try {
+            $from = $this->parseTimeOption('from');
+            $to = $this->parseTimeOption('to');
+        } catch (\InvalidArgumentException $e) {
+            $this->error($e->getMessage());
+            return self::FAILURE;
+        }
+
+        if ($from !== null && $to !== null && $from->greaterThan($to)) {
+            $this->error('--from 不能晚于 --to');
+            return self::FAILURE;
+        }
+
+        $transferInType = '三方游戏转入';
+        $transferOutType = '三方游戏转出';
+        $query = BalanceLog::query()
+            ->whereNotNull('member_id')
+            ->where('member_id', '<>', '')
+            ->whereIn('change_type', [$transferInType, $transferOutType])
+            ->select('member_id')
+            ->selectRaw(
+                'SUM(CASE WHEN change_type = ? AND amount < 0 THEN 1 ELSE 0 END) AS transfer_in_count',
+                [$transferInType]
+            )
+            ->selectRaw(
+                'SUM(CASE WHEN change_type = ? AND amount < 0 THEN -amount ELSE 0 END) AS transfer_in_total',
+                [$transferInType]
+            )
+            ->selectRaw(
+                'SUM(CASE WHEN change_type = ? AND amount > 0 THEN 1 ELSE 0 END) AS refund_count',
+                [$transferInType]
+            )
+            ->selectRaw(
+                'SUM(CASE WHEN change_type = ? AND amount > 0 THEN amount ELSE 0 END) AS refund_total',
+                [$transferInType]
+            )
+            ->selectRaw(
+                'SUM(CASE WHEN change_type = ? AND amount > 0 THEN 1 ELSE 0 END) AS transfer_out_count',
+                [$transferOutType]
+            )
+            ->selectRaw(
+                'SUM(CASE WHEN change_type = ? AND amount > 0 THEN amount ELSE 0 END) AS transfer_out_total',
+                [$transferOutType]
+            )
+            ->selectRaw('MIN(created_at) AS first_at, MAX(created_at) AS last_at');
+
+        if ($from !== null) {
+            $query->where('created_at', '>=', $from->format('Y-m-d H:i:s'));
+        }
+        if ($to !== null) {
+            $query->where('created_at', '<=', $to->format('Y-m-d H:i:s'));
+        }
+
+        $memberIds = array_values(array_filter(array_map('strval', (array) $this->option('member_id'))));
+        if ($memberIds !== []) {
+            $query->whereIn('member_id', $memberIds);
+        }
+
+        $rows = $query->groupBy('member_id')->orderBy('member_id')->get();
+        $report = [];
+        foreach ($rows as $row) {
+            $transferInCount = (int) $row->transfer_in_count;
+            $refundCount = (int) $row->refund_count;
+            $transferOutCount = (int) $row->transfer_out_count;
+            $flowTypes = [];
+            if ($transferInCount > 0) {
+                $flowTypes[] = '转入';
+            }
+            if ($refundCount > 0) {
+                $flowTypes[] = '失败退款';
+            }
+            if ($transferOutCount > 0) {
+                $flowTypes[] = '转出';
+            }
+            $report[] = [
+                'member_id' => (string) $row->member_id,
+                'player_id' => $service->playerId((string) $row->member_id),
+                'flow_type' => implode('+', $flowTypes),
+                'transfer_in_count' => $transferInCount,
+                'transfer_in_total' => $this->decimal($row->transfer_in_total),
+                'refund_count' => $refundCount,
+                'refund_total' => $this->decimal($row->refund_total),
+                'transfer_out_count' => $transferOutCount,
+                'transfer_out_total' => $this->decimal($row->transfer_out_total),
+                'first_at' => (string) $row->first_at,
+                'last_at' => (string) $row->last_at,
+            ];
+        }
+
+        $csvPath = (string) $this->option('csv');
+        if ($csvPath === '') {
+            $csvPath = storage_path('app/third_game_balance_users_' . now()->format('Ymd_His') . '.csv');
+        }
+        if (!$this->writeCsv($csvPath, $report)) {
+            return self::FAILURE;
+        }
+
+        $show = max(0, (int) $this->option('show'));
+        if ($show > 0 && $report !== []) {
+            $this->table(array_keys($report[0]), array_slice($report, 0, $show));
+        }
+
+        $this->info('共找到 ' . count($report) . ' 个玩家');
+        $this->info('CSV:' . $csvPath);
+        $this->comment('本命令只读:未请求三方接口,未修改钱包或流水。');
+        return self::SUCCESS;
+    }
+
+    private function parseTimeOption(string $name): ?Carbon
+    {
+        $value = trim((string) $this->option($name));
+        if ($value === '') {
+            return null;
+        }
+
+        try {
+            return Carbon::createFromFormat('Y-m-d H:i:s', $value);
+        } catch (\Throwable $e) {
+            throw new \InvalidArgumentException('--' . $name . ' 格式必须为 YYYY-MM-DD HH:MM:SS');
+        }
+    }
+
+    private function decimal($value): string
+    {
+        return bcadd(is_numeric($value) ? (string) $value : '0', '0', 10);
+    }
+
+    private function writeCsv(string $path, array $rows): bool
+    {
+        try {
+            File::ensureDirectoryExists(dirname($path));
+            $handle = fopen($path, 'wb');
+            if ($handle === false) {
+                throw new \RuntimeException('无法创建 CSV');
+            }
+
+            fwrite($handle, "\xEF\xBB\xBF");
+            $headers = $rows !== [] ? array_keys($rows[0]) : [
+                'member_id', 'player_id', 'flow_type', 'transfer_in_count', 'transfer_in_total',
+                'refund_count', 'refund_total', 'transfer_out_count', 'transfer_out_total',
+                'first_at', 'last_at',
+            ];
+            fputcsv($handle, $headers);
+            foreach ($rows as $row) {
+                fputcsv($handle, $row);
+            }
+            fclose($handle);
+            return true;
+        } catch (\Throwable $e) {
+            if (isset($handle) && is_resource($handle)) {
+                fclose($handle);
+            }
+            $this->error('写入 CSV 失败:' . $e->getMessage());
+            return false;
+        }
+    }
+}

+ 58 - 15
app/Services/ThirdGameBalanceService.php

@@ -13,7 +13,7 @@ class ThirdGameBalanceService
     private const CODE_SUCCESS = 10000;
 
     /**
-     * 批量查询用户的三方游戏总余额,返回值已按 GAME_RATE 换算为主钱包货币
+     * 批量查询用户的三方游戏总余额,三方与主钱包金额按 1:1 处理
      *
      * @param array<int, string|int> $memberIds
      * @return array<string, float|null>
@@ -132,6 +132,9 @@ class ThirdGameBalanceService
      */
     public function recycle(string $memberId): array
     {
+        // 必须在调用 transferAll 前校验,避免三方已转出后才因本地配置错误中断。
+        $this->recycleRate();
+
         $response = $this->request('/api/server/transferAll', [
             'playerId' => $this->playerId($memberId),
             'currency' => config('third_game.currency', 'CNY'),
@@ -160,7 +163,14 @@ class ThirdGameBalanceService
                     'msg' => '三方游戏回收成功响应缺少有效 balanceAll,回收结果待核对',
                 ];
             }
-            $gameBalance = (float) $data['balanceAll'];
+            $gameBalance = $this->decimalAmount($data['balanceAll']);
+            if ($gameBalance === null) {
+                return [
+                    'ok' => false,
+                    'uncertain' => true,
+                    'msg' => '三方游戏回收金额格式异常,回收结果待核对',
+                ];
+            }
             $this->clearCache($memberId);
             return [
                 'ok' => true,
@@ -171,7 +181,7 @@ class ThirdGameBalanceService
 
         if ($this->isPlayerNotFound((string) ($body['msg'] ?? ''))) {
             $this->clearCache($memberId);
-            return ['ok' => true, 'game_balance' => 0.0, 'wallet_balance' => 0.0];
+            return ['ok' => true, 'game_balance' => '0.0000000000', 'wallet_balance' => '0.0000000000'];
         }
 
         return [
@@ -181,9 +191,24 @@ class ThirdGameBalanceService
         ];
     }
 
-    public function toWalletBalance($gameBalance): float
+    public function toWalletBalance($gameBalance): string
     {
-        return round((float) $gameBalance / $this->rate(), 2);
+        $balance = $this->decimalAmount($gameBalance);
+        if ($balance === null) {
+            throw new \InvalidArgumentException('三方游戏余额格式异常');
+        }
+
+        return bcdiv($balance, $this->recycleRate(), 10);
+    }
+
+    private function recycleRate(): string
+    {
+        $rate = trim((string) config('third_game.recycle_rate', '1'));
+        if (!preg_match('/^\d+(?:\.\d+)?$/D', $rate) || bccomp($rate, '0', 10) <= 0) {
+            throw new \RuntimeException('GAME_RECYCLE_RATE 必须是大于 0 的数字');
+        }
+
+        return $rate;
     }
 
     private function balancesFromResponse($response): ?array
@@ -214,18 +239,18 @@ class ThirdGameBalanceService
 
     private function detailResult(string $memberId, array $balances): array
     {
-        $gameBalance = array_sum(array_filter($balances, 'is_numeric'));
+        $gameBalance = $this->sumBalances($balances);
         $walletBalances = [];
         $gameBalances = [];
         foreach ($balances as $platform => $balance) {
             if (is_numeric($balance)) {
                 $walletBalances[] = [
                     'platform' => (string) $platform,
-                    'balance' => round((float) $balance / $this->rate(), 2),
+                    'balance' => (float) $balance,
                 ];
                 $gameBalances[] = [
                     'platform' => (string) $platform,
-                    'balance' => round((float) $balance, 2),
+                    'balance' => (float) $balance,
                 ];
             }
         }
@@ -234,24 +259,42 @@ class ThirdGameBalanceService
             'member_id' => $memberId,
             'player_id' => $this->playerId($memberId),
             'currency' => (string) config('third_game.currency', 'CNY'),
-            'rate' => $this->rate(),
+            'rate' => 1,
             'list' => $walletBalances,
             'game_list' => $gameBalances,
-            'total_game_balance' => round($gameBalance, 2),
+            'total_game_balance' => (float) $gameBalance,
             'total_balance' => $this->totalFromBalances($balances),
         ];
     }
 
     private function totalFromBalances(array $balances): float
     {
-        $gameBalance = array_sum(array_filter($balances, 'is_numeric'));
-        return round($gameBalance / $this->rate(), 2);
+        return (float) $this->sumBalances($balances);
+    }
+
+    private function sumBalances(array $balances): string
+    {
+        $total = '0.0000000000';
+        foreach ($balances as $balance) {
+            $amount = $this->decimalAmount($balance);
+            if ($amount !== null) {
+                $total = bcadd($total, $amount, 10);
+            }
+        }
+        return $total;
     }
 
-    private function rate(): float
+    private function decimalAmount($value): ?string
     {
-        $rate = (float) config('third_game.rate', 10);
-        return $rate > 0 ? $rate : 1;
+        if (!is_int($value) && !is_float($value) && !is_string($value)) {
+            return null;
+        }
+
+        $value = trim((string) $value);
+        if (!preg_match('/^\d+(?:\.\d+)?$/D', $value)) {
+            return null;
+        }
+        return bcadd($value, '0', 10);
     }
 
     private function request(string $path, array $data, bool $transfer = false)

+ 2 - 1
config/third_game.php

@@ -7,8 +7,9 @@ return [
     'proxy' => trim((string) env('GAME_PROXY', '')),
     'ca_bundle' => trim((string) env('GAME_CA_BUNDLE', '')),
     'currency' => trim((string) env('GAME_CURRENCY', 'CNY')),
-    'rate' => (float) env('GAME_RATE', 10),
     'timeout' => (int) env('GAME_TIMEOUT', 10),
     'transfer_timeout' => (int) env('GAME_TRANSFER_TIMEOUT', 65),
     'cache_seconds' => (int) env('GAME_BALANCE_CACHE_SECONDS', 30),
+    // 仅用于旧比例余额迁移时,将 transferAll 返回的游戏分换算为主钱包金额。
+    'recycle_rate' => trim((string) env('GAME_RECYCLE_RATE', '1')),
 ];

+ 2 - 1
example.env

@@ -36,10 +36,11 @@ GAME_PROXY=
 # 代理使用自签发 CA 时填写 CA 证书绝对路径;留空使用系统 CA
 GAME_CA_BUNDLE=
 GAME_CURRENCY=CNY
-GAME_RATE=10
 GAME_TIMEOUT=10
 GAME_TRANSFER_TIMEOUT=65
 GAME_BALANCE_CACHE_SECONDS=30
+# 旧 1:10 游戏余额迁移期间设为 10,全部回收完成后改回 1
+GAME_RECYCLE_RATE=1
 
 # 短信宝
 SMS_USERNAME=