ReportContext.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. <?php
  2. namespace App\Services\Reporting;
  3. use Carbon\Carbon;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Schema;
  6. /** Captures attribution at creation, never retrospectively guesses from the latest login. */
  7. class ReportContext
  8. {
  9. public const SOURCES = ['bets', 'lhc_order', 'jisu_game_order', 'sport_game_order', 'sport_order', 'recharges', 'withdraws', 'payment_orders', 'balance_logs', 'agent_withdrawals', 'share_claims'];
  10. /** Bulk lottery updates bypass model events. Call at the actual terminal transition. */
  11. public static function lotterySettled(int $orderId): void
  12. {
  13. try {
  14. if (!Schema::hasTable('reporting_contexts')) return;
  15. $order = DB::table('lhc_order')->where('id', $orderId)->first();
  16. if (!$order || (int) ($order->is_faker ?? 0) === 1 || !in_array((int) $order->lottery_status, [1, 2, 3, 4], true)) return;
  17. $at = \App\Services\Sharing\EffectiveBet::time($order->created_at)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  18. $identity = self::identity((array) $order, $at, 'lhc_order');
  19. DB::table('reporting_contexts')->insertOrIgnore($identity + [
  20. 'source' => 'lhc_order', 'source_id' => (string) $orderId, 'occurred_at' => $at, 'succeeded_at' => null,
  21. ]);
  22. DB::table('reporting_contexts')->where('source', 'lhc_order')->where('source_id', (string) $orderId)
  23. ->whereNull('succeeded_at')->update(['succeeded_at' => now('Asia/Shanghai')->format('Y-m-d H:i:s')]);
  24. } catch (\Throwable $e) {
  25. // Reporting must never change whether an already settled bet pays out.
  26. \Illuminate\Support\Facades\Log::error('reporting_lottery_settlement_failed', ['order_id' => $orderId, 'exception' => get_class($e)]);
  27. }
  28. }
  29. /** Capture the loss batch while the settlement job still holds its source locks. */
  30. public static function sportGamesSettled(array $orderIds): void
  31. {
  32. try {
  33. if (!$orderIds || !Schema::hasTable('reporting_contexts')) return;
  34. $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
  35. foreach (array_chunk($orderIds, 200) as $chunk) {
  36. $rows = DB::table('sport_game_order')->whereIn('id', $chunk)->where('status', 1)->get();
  37. $ids = $rows->map(fn ($row) => (string) $row->id)->all();
  38. $known = DB::table('reporting_contexts')->where('source', 'sport_game_order')->whereIn('source_id', $ids)->pluck('source_id')->all();
  39. foreach ($rows as $row) {
  40. if (in_array((string) $row->id, $known, true)) continue;
  41. try {
  42. $at = \App\Services\Sharing\EffectiveBet::time($row->created_at)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  43. $identity = self::identity((array) $row, $at, 'sport_game_order');
  44. DB::table('reporting_contexts')->insertOrIgnore($identity + [
  45. 'source' => 'sport_game_order', 'source_id' => (string) $row->id, 'occurred_at' => $at, 'succeeded_at' => null,
  46. ]);
  47. } catch (\Throwable $e) {
  48. \Illuminate\Support\Facades\Log::error('reporting_sport_settlement_identity_failed', ['order_id' => $row->id, 'exception' => get_class($e)]);
  49. }
  50. }
  51. DB::table('reporting_contexts')->where('source', 'sport_game_order')->whereIn('source_id', $ids)
  52. ->whereNull('succeeded_at')->update(['succeeded_at' => $now]);
  53. }
  54. } catch (\Throwable $e) {
  55. \Illuminate\Support\Facades\Log::error('reporting_sport_settlement_failed', ['exception' => get_class($e)]);
  56. }
  57. }
  58. public static function captureModel($model): void
  59. {
  60. $source = $model->getTable();
  61. if (!in_array($source, self::SOURCES, true) || !Schema::hasTable('reporting_contexts')) return;
  62. $row = $model->getAttributes(); $id = (string) $model->getKey();
  63. if ($id === '') return;
  64. $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
  65. $identity = self::identity($row, $now, $source);
  66. $platform = 'unknown';
  67. if ($model->wasRecentlyCreated && !app()->runningInConsole() && (str_starts_with(request()->path(), 'api/') || str_starts_with(request()->path(), 'agent/')) && request()->path() !== 'api/onMessage') {
  68. $value = request()->header('Platform', request()->input('platform'));
  69. $platform = is_string($value) && in_array(strtoupper($value), ['PC', 'H5', 'APP'], true) ? strtoupper($value) : 'unknown';
  70. }
  71. DB::table('reporting_contexts')->insertOrIgnore(['source' => $source, 'source_id' => $id,
  72. 'user_id' => $identity['user_id'], 'agent_id' => $identity['agent_id'], 'inviter_id' => $identity['inviter_id'],
  73. 'client_platform' => $platform, 'attribution_quality' => $model->wasRecentlyCreated ? 'event_snapshot' : 'historical_current_agent', 'occurred_at' => $now, 'succeeded_at' => null]);
  74. $success = ($source === 'withdraws' && (int) ($row['status'] ?? -1) === 1)
  75. || ($source === 'payment_orders' && (int) ($row['status'] ?? -1) === 2)
  76. || ($source === 'agent_withdrawals' && ($row['status'] ?? '') === 'paid')
  77. || ($source === 'lhc_order' && in_array((int) ($row['lottery_status'] ?? 0), [1, 2, 3, 4], true))
  78. || (in_array($source, ['sport_game_order', 'jisu_game_order'], true) && in_array((int) ($row['status'] ?? 0), [1, 2, 3], true))
  79. || ($source === 'bets' && in_array((int) ($row['status'] ?? 0), [2, 3], true))
  80. || ($source === 'sport_order' && ((int) ($row['settlement_status'] ?? 0) === 1 || (int) ($row['status'] ?? 0) === 2));
  81. if ($success && ($model->wasRecentlyCreated || $model->wasChanged(['status', 'lottery_status', 'settlement_status']))) {
  82. DB::table('reporting_contexts')->where('source', $source)->where('source_id', $id)->whereNull('succeeded_at')->update(['succeeded_at' => $now]);
  83. }
  84. }
  85. public static function identity(array $row, string $at, string $source = ''): array
  86. {
  87. $context = isset($row['id']) ? DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first() : null;
  88. if ($context) return ['user_id' => $context->user_id === null ? null : (int) $context->user_id,
  89. 'agent_id' => $context->agent_id === null ? null : (int) $context->agent_id, 'inviter_id' => $context->inviter_id === null ? null : (int) $context->inviter_id,
  90. 'client_platform' => $context->client_platform, 'attribution_quality' => $context->attribution_quality];
  91. $member = $row['member_id'] ?? ($source === 'sport_order' ? ($row['user_id'] ?? '') : '');
  92. $query = DB::table('users');
  93. if (!empty($row['report_user_id'])) $query->where('id', $row['report_user_id']);
  94. elseif ($member !== '') $query->where('member_id', (string) $member);
  95. else return ['user_id' => null, 'agent_id' => $source === 'agent_withdrawals' ? ($row['agent_id'] ?? null) : null,
  96. 'inviter_id' => null, 'client_platform' => 'unknown', 'attribution_quality' => 'unknown'];
  97. $users = $query->limit(2)->get();
  98. if ($users->count() !== 1 || (int) ($users[0]->from ?? 0) === 2) throw new \RuntimeException('真实会员身份不明确');
  99. $user = (array) $users[0]; $inviter = null;
  100. if (Schema::hasTable('share_members')) {
  101. $relation = DB::table('share_members')->where('user_id', $user['id'])->where('bound_at', '<=', $at)->first();
  102. $inviter = $relation && $relation->parent_user_id !== null ? (int) $relation->parent_user_id : null;
  103. }
  104. return ['user_id' => (int) $user['id'], 'agent_id' => isset($user['agent_id']) ? (int) $user['agent_id'] : null,
  105. 'inviter_id' => $inviter, 'client_platform' => 'unknown', 'attribution_quality' => 'historical_current_agent'];
  106. }
  107. }