RewardBaseSyncService.php 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. <?php
  2. namespace App\Services;
  3. use App\Models\Backflow;
  4. use App\Services\Sharing\EffectiveBet;
  5. use Carbon\Carbon;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. use InvalidArgumentException;
  9. use Throwable;
  10. /**
  11. * Keeps unpaid rebates (New York day) and backflow (Shanghai month) rows in step with share_bet_facts,
  12. * so admin lists show every game category. Paid rows are never touched; payout recomputes on its own.
  13. */
  14. class RewardBaseSyncService
  15. {
  16. private const CURSOR = 'reward_bases_cursor';
  17. public function run(?string $from = null, ?string $to = null): array
  18. {
  19. foreach ([$from, $to] as $date) {
  20. if ($date !== null && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date) || Carbon::parse($date)->toDateString() !== $date)) {
  21. throw new InvalidArgumentException('Dates must use Y-m-d');
  22. }
  23. }
  24. if (($from === null) !== ($to === null) || ($from !== null && $from > $to)) throw new InvalidArgumentException('Invalid date range');
  25. $result = ['rebates' => 0, 'backflow' => 0, 'failed' => 0];
  26. $startedAt = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  27. $query = DB::table('share_bet_facts')->select(['id', 'user_id', 'bet_at', 'bet_date']);
  28. if ($from !== null) {
  29. // A New York day spans two Shanghai dates.
  30. $query->whereBetween('bet_date', [$from, Carbon::parse($to)->addDay()->toDateString()]);
  31. } else {
  32. $cursor = DB::table('share_system_state')->where('key', self::CURSOR)->value('value');
  33. $since = $cursor ? EffectiveBet::time(json_decode($cursor, true, 512, JSON_THROW_ON_ERROR)['updated_at'])->subMinutes(5)
  34. : now()->subDays(2);
  35. $query->where('updated_at', '>=', $since->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
  36. }
  37. $days = $months = [];
  38. $query->orderBy('id')->chunkById(500, function ($facts) use (&$days, &$months, $from, $to) {
  39. foreach ($facts as $fact) {
  40. $day = Carbon::parse($fact->bet_at, 'Asia/Shanghai')->setTimezone('America/New_York')->toDateString();
  41. if ($from === null || ($day >= $from && $day <= $to)) $days[$fact->user_id . '|' . $day] = [$fact->user_id, $day];
  42. $months[$fact->user_id . '|' . substr((string) $fact->bet_date, 0, 7)] = [$fact->user_id, substr((string) $fact->bet_date, 0, 7)];
  43. }
  44. });
  45. $users = [];
  46. foreach (['rebate' => $days, 'backflow' => $months] as $type => $keys) {
  47. foreach ($keys as [$userId, $period]) {
  48. try {
  49. $user = $users[$userId] ??= DB::table('users')->where('id', $userId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
  50. if (!$user) continue;
  51. if ($type === 'rebate' ? $this->rebate($user, $period) : $this->backflow($user, $period)) {
  52. ++$result[$type === 'rebate' ? 'rebates' : 'backflow'];
  53. }
  54. } catch (Throwable $e) {
  55. ++$result['failed'];
  56. Log::error('reward_base_sync_failed', ['type' => $type, 'user_id' => $userId, 'period' => $period, 'exception' => $e]);
  57. }
  58. }
  59. }
  60. if ($from === null && $result['failed'] === 0) {
  61. DB::table('share_system_state')->updateOrInsert(['key' => self::CURSOR],
  62. ['value' => json_encode(['updated_at' => $startedAt]), 'updated_at' => $startedAt, 'created_at' => $startedAt]);
  63. }
  64. return $result;
  65. }
  66. private function rebate(object $user, string $date): bool
  67. {
  68. return DB::transaction(function () use ($user, $date) {
  69. $row = DB::table('rebates')->where('member_id', $user->member_id)->where('date', $date)->lockForUpdate()->first();
  70. if ($row && (int) $row->status !== 0) return false;
  71. $reward = RewardBasisService::rebateReward(RewardBasisService::rebateBasis($user, $date), (int) ($user->level ?? 0));
  72. if (!$reward['has_facts'] && !$row) return false;
  73. $values = ['betting_amount' => $reward['betting_amount'], 'effective_betting_amount' => $reward['effective_amount'],
  74. 'profit' => $reward['profit'], 'amount' => $reward['amount'], 'need_flow' => $reward['need_flow'],
  75. 'details' => json_encode($reward['details'], JSON_UNESCAPED_UNICODE),
  76. 'username' => (string) ($user->username ?? ''), 'first_name' => (string) ($user->first_name ?? ''),
  77. 'updated_at' => now()];
  78. if ($row) {
  79. // Rows without facts are legacy PC28 days; their stored basis stays the payout basis.
  80. if (!$reward['has_facts']) return false;
  81. return DB::table('rebates')->where('id', $row->id)->where('status', 0)->update($values) > 0;
  82. }
  83. DB::table('rebates')->insert($values + ['member_id' => $user->member_id, 'date' => $date, 'status' => 0, 'created_at' => now()]);
  84. return true;
  85. }, 3);
  86. }
  87. private function backflow(object $user, string $month): bool
  88. {
  89. return DB::transaction(function () use ($user, $month) {
  90. $row = DB::table('backflow')->where('member_id', $user->member_id)->where('date', $month)->lockForUpdate()->first();
  91. // 部分发放(status 2)的记录继续累计,之后可再发差额
  92. if ($row && !in_array((int) $row->status, Backflow::OPEN_STATUSES, true)) return false;
  93. $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $month));
  94. if (!$reward['has_facts'] && !$row) return false;
  95. $values = ['loss_amount' => $reward['loss_amount'], 'amount' => $reward['amount'], 'need_flow' => $reward['need_flow'],
  96. 'details' => json_encode($reward['details'], JSON_UNESCAPED_UNICODE), 'updated_at' => now()];
  97. if ($row) return DB::table('backflow')->where('id', $row->id)->whereIn('status', Backflow::OPEN_STATUSES)->update($values) > 0;
  98. DB::table('backflow')->insert($values + ['member_id' => $user->member_id, 'date' => $month, 'status' => 0, 'created_at' => now()]);
  99. return true;
  100. }, 3);
  101. }
  102. }