ThirdGameBalanceUsers.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Models\BalanceLog;
  4. use App\Services\ThirdGameBalanceService;
  5. use Carbon\Carbon;
  6. use Illuminate\Console\Command;
  7. use Illuminate\Support\Facades\File;
  8. class ThirdGameBalanceUsers extends Command
  9. {
  10. protected $signature = 'third-game:balance-users
  11. {--from= : 开始时间,例如 2026-07-14 10:00:00}
  12. {--to= : 结束时间,例如 2026-07-14 23:59:59}
  13. {--member_id=* : 只查询指定 member_id,可重复传入}
  14. {--csv= : CSV 输出路径,默认保存到 storage/app}
  15. {--show=100 : 控制台最多展示条数}
  16. {--skip-provider : 仅导出本地流水,不请求三方当前余额}';
  17. protected $description = '导出有三方游戏流水的玩家,并查询三方当前余额(只读,不回收余额)';
  18. public function handle(ThirdGameBalanceService $service): int
  19. {
  20. try {
  21. $from = $this->parseTimeOption('from');
  22. $to = $this->parseTimeOption('to');
  23. } catch (\InvalidArgumentException $e) {
  24. $this->error($e->getMessage());
  25. return self::FAILURE;
  26. }
  27. if ($from !== null && $to !== null && $from->greaterThan($to)) {
  28. $this->error('--from 不能晚于 --to');
  29. return self::FAILURE;
  30. }
  31. $transferInType = '三方游戏转入';
  32. $transferOutType = '三方游戏转出';
  33. $query = BalanceLog::query()
  34. ->whereNotNull('member_id')
  35. ->where('member_id', '<>', '')
  36. ->whereIn('change_type', [$transferInType, $transferOutType])
  37. ->select('member_id')
  38. ->selectRaw(
  39. 'SUM(CASE WHEN change_type = ? AND amount < 0 THEN 1 ELSE 0 END) AS transfer_in_count',
  40. [$transferInType]
  41. )
  42. ->selectRaw(
  43. 'SUM(CASE WHEN change_type = ? AND amount < 0 THEN -amount ELSE 0 END) AS transfer_in_total',
  44. [$transferInType]
  45. )
  46. ->selectRaw(
  47. 'SUM(CASE WHEN change_type = ? AND amount > 0 THEN 1 ELSE 0 END) AS refund_count',
  48. [$transferInType]
  49. )
  50. ->selectRaw(
  51. 'SUM(CASE WHEN change_type = ? AND amount > 0 THEN amount ELSE 0 END) AS refund_total',
  52. [$transferInType]
  53. )
  54. ->selectRaw(
  55. 'SUM(CASE WHEN change_type = ? AND amount > 0 THEN 1 ELSE 0 END) AS transfer_out_count',
  56. [$transferOutType]
  57. )
  58. ->selectRaw(
  59. 'SUM(CASE WHEN change_type = ? AND amount > 0 THEN amount ELSE 0 END) AS transfer_out_total',
  60. [$transferOutType]
  61. )
  62. ->selectRaw('MIN(created_at) AS first_at, MAX(created_at) AS last_at');
  63. if ($from !== null) {
  64. $query->where('created_at', '>=', $from->format('Y-m-d H:i:s'));
  65. }
  66. if ($to !== null) {
  67. $query->where('created_at', '<=', $to->format('Y-m-d H:i:s'));
  68. }
  69. $memberIds = array_values(array_filter(array_map('strval', (array) $this->option('member_id'))));
  70. if ($memberIds !== []) {
  71. $query->whereIn('member_id', $memberIds);
  72. }
  73. $rows = $query->groupBy('member_id')->orderBy('member_id')->get();
  74. $verifyProvider = !$this->option('skip-provider');
  75. $progress = null;
  76. if ($verifyProvider && $rows->isNotEmpty()) {
  77. $this->info('正在逐个查询三方当前余额……');
  78. $progress = $this->output->createProgressBar($rows->count());
  79. $progress->start();
  80. }
  81. $report = [];
  82. foreach ($rows as $row) {
  83. $transferInCount = (int) $row->transfer_in_count;
  84. $refundCount = (int) $row->refund_count;
  85. $transferOutCount = (int) $row->transfer_out_count;
  86. $flowTypes = [];
  87. if ($transferInCount > 0) {
  88. $flowTypes[] = '转入';
  89. }
  90. if ($refundCount > 0) {
  91. $flowTypes[] = '失败退款';
  92. }
  93. if ($transferOutCount > 0) {
  94. $flowTypes[] = '转出';
  95. }
  96. $item = [
  97. 'member_id' => (string) $row->member_id,
  98. 'player_id' => $service->playerId((string) $row->member_id),
  99. 'flow_type' => implode('+', $flowTypes),
  100. 'transfer_in_count' => $transferInCount,
  101. 'transfer_in_total' => $this->decimal($row->transfer_in_total),
  102. 'refund_count' => $refundCount,
  103. 'refund_total' => $this->decimal($row->refund_total),
  104. 'transfer_out_count' => $transferOutCount,
  105. 'transfer_out_total' => $this->decimal($row->transfer_out_total),
  106. 'first_at' => (string) $row->first_at,
  107. 'last_at' => (string) $row->last_at,
  108. ];
  109. if ($verifyProvider) {
  110. $detail = $service->detail((string) $row->member_id, true);
  111. $item['provider_status'] = ($detail['ok'] ?? false) ? '成功' : '失败';
  112. $item['provider_total_balance'] = ($detail['ok'] ?? false)
  113. ? $this->decimal($detail['total_game_balance'] ?? 0)
  114. : '';
  115. $item['provider_balances'] = ($detail['ok'] ?? false)
  116. ? $this->providerBalances($detail['game_list'] ?? [])
  117. : '';
  118. $item['provider_message'] = ($detail['ok'] ?? false)
  119. ? ''
  120. : (string) ($detail['msg'] ?? '查询失败');
  121. }
  122. $report[] = $item;
  123. if ($progress !== null) {
  124. $progress->advance();
  125. }
  126. }
  127. if ($progress !== null) {
  128. $progress->finish();
  129. $this->newLine(2);
  130. }
  131. $csvPath = (string) $this->option('csv');
  132. if ($csvPath === '') {
  133. $csvPath = storage_path('app/third_game_balance_users_' . now()->format('Ymd_His') . '.csv');
  134. }
  135. if (!$this->writeCsv($csvPath, $report)) {
  136. return self::FAILURE;
  137. }
  138. $show = max(0, (int) $this->option('show'));
  139. if ($show > 0 && $report !== []) {
  140. $this->table(array_keys($report[0]), array_slice($report, 0, $show));
  141. }
  142. $this->info('共找到 ' . count($report) . ' 个玩家');
  143. $this->info('CSV:' . $csvPath);
  144. $this->comment($verifyProvider
  145. ? '本命令只读:已请求三方当前余额,未回收余额,未修改钱包或流水。'
  146. : '本命令只读:未请求三方接口,未修改钱包或流水。');
  147. return self::SUCCESS;
  148. }
  149. private function parseTimeOption(string $name): ?Carbon
  150. {
  151. $value = trim((string) $this->option($name));
  152. if ($value === '') {
  153. return null;
  154. }
  155. try {
  156. return Carbon::createFromFormat('Y-m-d H:i:s', $value);
  157. } catch (\Throwable $e) {
  158. throw new \InvalidArgumentException('--' . $name . ' 格式必须为 YYYY-MM-DD HH:MM:SS');
  159. }
  160. }
  161. private function decimal($value): string
  162. {
  163. return bcadd(is_numeric($value) ? (string) $value : '0', '0', 10);
  164. }
  165. private function writeCsv(string $path, array $rows): bool
  166. {
  167. try {
  168. File::ensureDirectoryExists(dirname($path));
  169. $handle = fopen($path, 'wb');
  170. if ($handle === false) {
  171. throw new \RuntimeException('无法创建 CSV');
  172. }
  173. fwrite($handle, "\xEF\xBB\xBF");
  174. $headers = $rows !== [] ? array_keys($rows[0]) : [
  175. 'member_id', 'player_id', 'flow_type', 'transfer_in_count', 'transfer_in_total',
  176. 'refund_count', 'refund_total', 'transfer_out_count', 'transfer_out_total',
  177. 'first_at', 'last_at', 'provider_status', 'provider_total_balance',
  178. 'provider_balances', 'provider_message',
  179. ];
  180. fputcsv($handle, $headers);
  181. foreach ($rows as $row) {
  182. fputcsv($handle, $row);
  183. }
  184. fclose($handle);
  185. return true;
  186. } catch (\Throwable $e) {
  187. if (isset($handle) && is_resource($handle)) {
  188. fclose($handle);
  189. }
  190. $this->error('写入 CSV 失败:' . $e->getMessage());
  191. return false;
  192. }
  193. }
  194. private function providerBalances(array $balances): string
  195. {
  196. $result = [];
  197. foreach ($balances as $balance) {
  198. if (!is_array($balance) || !isset($balance['platform']) || !is_numeric($balance['balance'] ?? null)) {
  199. continue;
  200. }
  201. $result[(string) $balance['platform']] = $this->decimal($balance['balance']);
  202. }
  203. return (string) json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  204. }
  205. }