PromotionFacts.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. <?php
  2. namespace App\Services;
  3. use App\Services\Sharing\EffectiveBet;
  4. use Carbon\Carbon;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Schema;
  7. use Illuminate\Validation\ValidationException;
  8. /** Promotion eligibility is independent of commission accrual and never uses raw stake as effective stake. */
  9. class PromotionFacts
  10. {
  11. private ?array $snapshotCache = null;
  12. /** Share repeated reads only within one refresh; never retain eligibility across requests or accounts. */
  13. public function snapshot(callable $work): mixed
  14. {
  15. if ($this->snapshotCache !== null) return $work();
  16. $this->snapshotCache = [];
  17. try { return $work(); }
  18. finally { $this->snapshotCache = null; }
  19. }
  20. private function remember(string $method, array $arguments, callable $read): mixed
  21. {
  22. if ($this->snapshotCache === null) return $read();
  23. $key = $method . ':' . json_encode($arguments, JSON_THROW_ON_ERROR);
  24. return $this->snapshotCache[$key] ??= $read();
  25. }
  26. public static function now(): string { return now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
  27. public function policy(): array
  28. {
  29. $row = DB::table('share_policies')->where('effective_date', '<=', substr(self::now(), 0, 10))->orderByDesc('effective_date')->first();
  30. if (!$row || $row->valid_member_min_deposit === null || $row->valid_member_min_effective_bet === null) {
  31. throw ValidationException::withMessages(['policy' => '请先配置已生效的有效会员充值和有效投注门槛']);
  32. }
  33. return ['id' => $row->id, 'effective_date' => $row->effective_date,
  34. 'min_deposit' => (string)$row->valid_member_min_deposit, 'min_effective_bet' => (string)$row->valid_member_min_effective_bet];
  35. }
  36. public function children(int $userId): array
  37. {
  38. return $this->remember(__FUNCTION__, func_get_args(), fn () => $this->readChildren($userId));
  39. }
  40. private function readChildren(int $userId): array
  41. {
  42. return DB::table('share_members as m')->join('users as u', 'u.id', '=', 'm.user_id')
  43. ->where('m.parent_user_id', $userId)->where('u.from', '<>', 2)->where('m.bound_at', '<=', self::now())
  44. ->get(['u.id', 'u.member_id', 'u.created_at', 'm.bound_at'])->map(fn ($r) => (array)$r)->all();
  45. }
  46. public function deposits(array $members, string $from, ?string $until = null, int $cursor = 0): array
  47. {
  48. return $this->remember(__FUNCTION__, [$members, $from, $until, $cursor], fn () => $this->readDeposits($members, $from, $until, $cursor));
  49. }
  50. private function readDeposits(array $members, string $from, ?string $until, int $cursor): array
  51. {
  52. $totals = [];
  53. if (!$members) return $totals;
  54. $q = DB::table('balance_logs')->whereIn('member_id', $members)->where('type', 1)->where('amount', '>', 0)
  55. ->whereIn('change_type', ['充值', '三方充值'])->where('created_at', '>=', $from)->where('created_at', '<=', self::now())->where('id', '>', $cursor);
  56. if ($until !== null) $q->where('created_at', '<', $until);
  57. if ($this->usesDecimalAggregates()) {
  58. foreach ($q->selectRaw('SUBSTR(created_at, 1, 10) AS day, SUM(amount) AS total')->groupByRaw('SUBSTR(created_at, 1, 10)')->get() as $row) {
  59. $totals[$row->day] = bcadd((string) $row->total, '0', 10);
  60. }
  61. return $totals;
  62. }
  63. foreach ($q->orderBy('id')->cursor() as $row) {
  64. $day = substr($row->created_at, 0, 10);
  65. $totals[$day] = bcadd($totals[$day] ?? '0', (string)$row->amount, 10);
  66. }
  67. return $totals;
  68. }
  69. public function cursors(): array
  70. {
  71. $result = [];
  72. foreach (['share_bet_facts', 'lhc_order_item', 'sport_order', 'sport_game_order', 'third_game_orders', 'bets', 'jisu_game_order'] as $table) {
  73. if (Schema::hasTable($table)) $result[$table] = (int)DB::table($table)->max('id');
  74. }
  75. return $result;
  76. }
  77. public function betting(array $ids, string $from, ?string $until = null, array $cursors = []): array
  78. {
  79. return $this->remember(__FUNCTION__, [$ids, $from, $until, $cursors], fn () => $this->readBetting($ids, $from, $until, $cursors));
  80. }
  81. private function readBetting(array $ids, string $from, ?string $until, array $cursors): array
  82. {
  83. $totals = ['effective' => [], 'profit' => '0'];
  84. if (!$ids) return $totals;
  85. $currency = (string)config('third_game.currency', 'CNY');
  86. $q = DB::table('share_bet_facts')->whereIn('user_id', $ids)->where('bet_at', '>=', $from)
  87. ->where('bet_at', '<=', self::now())->where('currency', $currency)->where('settled', 1)->where('voided', 0);
  88. if ($until !== null) $q->where('bet_at', '<', $until);
  89. if (isset($cursors['third_game_orders'])) {
  90. // One indexed semijoin replaces a separate original-order lookup for every fact.
  91. $q->where(function ($q) use ($cursors) {
  92. $q->where('source', '<>', 'third_game_orders')->orWhereExists(function ($origin) use ($cursors) {
  93. $origin->selectRaw('1')->from('third_game_orders as promotion_origin')
  94. ->whereColumn('promotion_origin.order_key', 'share_bet_facts.source_id')->where('promotion_origin.id', '>', $cursors['third_game_orders']);
  95. });
  96. });
  97. }
  98. if (!$cursors && $this->usesDecimalAggregates()) {
  99. $this->aggregateBets($q, 'bet_at', 'bet_amount', 'net_profit', $totals);
  100. } else {
  101. foreach ($q->cursor() as $row) {
  102. if (!$this->afterCursor($row, $cursors)) continue;
  103. $this->addBet($totals, $row->bet_at, (string)$row->bet_amount, (string)$row->net_profit);
  104. }
  105. }
  106. // The legacy Telegram games are not imported into sharing facts. Read only their own sources.
  107. $members = DB::table('users')->whereIn('id', $ids)->pluck('member_id')->all();
  108. foreach (['bets', 'jisu_game_order'] as $table) {
  109. if (!Schema::hasTable($table)) continue;
  110. $q = DB::table($table)->whereIn('member_id', $members)->where('created_at', '>=', $from)->where('created_at', '<=', self::now())
  111. ->where('id', '>', $cursors[$table] ?? 0)->whereIn('status', $table === 'bets' ? [2] : [1, 2]);
  112. if ($until !== null) $q->where('created_at', '<', $until);
  113. if ($this->usesDecimalAggregates()) {
  114. $this->aggregateBets($q, 'created_at', 'amount', ($table === 'bets' ? 'profit' : 'win_amount') . ' - amount', $totals);
  115. } else {
  116. foreach ($q->cursor() as $row) {
  117. $profit = bcsub((string)($table === 'bets' ? $row->profit : $row->win_amount), (string)$row->amount, 10);
  118. $this->addBet($totals, $row->created_at, (string)$row->amount, $profit);
  119. }
  120. }
  121. }
  122. return $totals;
  123. }
  124. private function afterCursor(object $fact, array $cursors): bool
  125. {
  126. if (!$cursors) return true;
  127. if ($fact->source === 'third_game_orders' && isset($cursors['third_game_orders'])) {
  128. return true; // Already checked in the source query.
  129. }
  130. if (isset($cursors[$fact->source])) return ctype_digit((string)$fact->source_id) && (int)$fact->source_id > $cursors[$fact->source];
  131. return (int)$fact->id > ($cursors['share_bet_facts'] ?? 0);
  132. }
  133. private function addBet(array &$totals, string $at, string $stake, string $profit): void
  134. {
  135. $day = substr($at, 0, 10);
  136. $totals['effective'][$day] = bcadd($totals['effective'][$day] ?? '0', EffectiveBet::amount($stake, $profit), 10);
  137. $totals['profit'] = bcadd($totals['profit'], $profit, 10);
  138. }
  139. private function usesDecimalAggregates(): bool
  140. {
  141. // SQLite SUM uses floating point; keep BCMath there so isolated tests preserve exact amounts.
  142. return in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true);
  143. }
  144. private function aggregateBets($query, string $time, string $stake, string $profit, array &$totals): void
  145. {
  146. foreach ($query->selectRaw("SUBSTR($time, 1, 10) AS day, SUM(CASE WHEN $stake < ABS($profit) THEN $stake ELSE ABS($profit) END) AS effective_total, SUM($profit) AS profit_total, SUM(CASE WHEN $stake < 0 THEN 1 ELSE 0 END) AS invalid_stakes, SUM(CASE WHEN $stake IS NULL OR ($profit) IS NULL THEN 1 ELSE 0 END) AS invalid_amounts")
  147. ->groupByRaw("SUBSTR($time, 1, 10)")->get() as $row) {
  148. if ($row->invalid_amounts > 0) throw new \InvalidArgumentException('Expected a plain decimal amount');
  149. if ($row->invalid_stakes > 0) throw new \InvalidArgumentException('Bet amount cannot be negative');
  150. $totals['effective'][$row->day] = bcadd($totals['effective'][$row->day] ?? '0', (string) $row->effective_total, 10);
  151. $totals['profit'] = bcadd($totals['profit'], (string) $row->profit_total, 10);
  152. }
  153. }
  154. public function qualified(array $child, array $policy, ?string $before = null): bool
  155. {
  156. $deposits = $this->deposits([(string)$child['member_id']], '1970-01-01 00:00:00', $before);
  157. $bet = $this->betting([(int)$child['id']], '1970-01-01 00:00:00', $before);
  158. return bccomp($this->total($deposits), $policy['min_deposit'], 10) >= 0
  159. && bccomp($this->total($bet['effective']), $policy['min_effective_bet'], 10) >= 0;
  160. }
  161. public function total(array $amounts): string
  162. {
  163. $total = '0'; foreach ($amounts as $amount) $total = bcadd($total, (string)$amount, 10); return $total;
  164. }
  165. public function logins(string $memberId, string $from): array
  166. {
  167. return $this->remember(__FUNCTION__, func_get_args(), fn () => $this->readLogins($memberId, $from));
  168. }
  169. private function readLogins(string $memberId, string $from): array
  170. {
  171. $days = [];
  172. foreach (DB::table('user_login')->where('user_id', $memberId)->where('status', 1)->where('created_at', '>=', $from)
  173. ->where('created_at', '<=', self::now())->cursor() as $row) $days[substr($row->created_at, 0, 10)] = true;
  174. $userId = DB::table('users')->where('member_id', $memberId)->value('id');
  175. foreach (['promotion_presence', 'reporting_activity'] as $table) {
  176. if (!Schema::hasTable($table)) continue;
  177. foreach (DB::table($table)->where('user_id', $userId)->where('observed_at', '>=', $from)->where('observed_at', '<=', self::now())->cursor() as $row) $days[substr($row->observed_at, 0, 10)] = true;
  178. }
  179. return $days;
  180. }
  181. public function online(int $userId, string $from): array
  182. {
  183. return $this->remember(__FUNCTION__, func_get_args(), fn () => $this->readOnline($userId, $from));
  184. }
  185. private function readOnline(int $userId, string $from): array
  186. {
  187. $last = []; $intervals = []; $observations = [];
  188. foreach (['reporting_activity', 'promotion_presence'] as $table) {
  189. if (!Schema::hasTable($table)) continue;
  190. foreach (DB::table($table)->where('user_id', $userId)->where('observed_at', '>=', $from)->where('observed_at', '<=', self::now())->cursor() as $row) $observations[] = $row;
  191. }
  192. usort($observations, fn ($a, $b) => strcmp($a->observed_at, $b->observed_at));
  193. foreach ($observations as $row) {
  194. $at = Carbon::parse($row->observed_at, 'Asia/Shanghai')->timestamp;
  195. $key = $row->session_hash;
  196. if (isset($last[$key]) && $at > $last[$key] && $at - $last[$key] <= 300) {
  197. $start = $last[$key];
  198. while ($start < $at) {
  199. $day = Carbon::createFromTimestamp($start, 'Asia/Shanghai')->toDateString();
  200. $end = min($at, Carbon::parse($day, 'Asia/Shanghai')->addDay()->timestamp);
  201. $intervals[$day][] = [$start, $end]; $start = $end;
  202. }
  203. }
  204. $last[$key] = $at;
  205. }
  206. $result = [];
  207. foreach ($intervals as $day => $list) {
  208. usort($list, fn ($a, $b) => $a[0] <=> $b[0]); $end = 0; $seconds = 0;
  209. foreach ($list as [$a, $b]) { if ($b > max($a, $end)) $seconds += $b - max($a, $end); $end = max($end, $b); }
  210. $result[$day] = $seconds;
  211. }
  212. return $result;
  213. }
  214. }