BetFactSynchronizer.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. <?php
  2. namespace App\Services\Sharing;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Schema;
  5. use InvalidArgumentException;
  6. use RuntimeException;
  7. /** The only importer of current real-money orders into the new sharing facts. */
  8. class BetFactSynchronizer
  9. {
  10. private array $users = [];
  11. private const LOTTERY_NAMES = [1 => '新澳门六合彩', 2 => '香港六合彩', 3 => '澳门六合彩', 4 => '极速六合彩',
  12. 5 => '加拿大28', 6 => '极速28', 7 => '极速赛车', 8 => '极速时时彩', 9 => '极速飞艇',
  13. 10 => 'SG时时彩', 11 => 'SG飞艇', 12 => '极速快3', 13 => 'SG快3'];
  14. /** Read-only projection shared with reports; does not publish or alter sharing facts. */
  15. public function projectForReporting(string $source, object $order): array
  16. {
  17. if (!in_array($source, ['lhc_order', 'sport_order', 'sport_game_order', 'third_game_orders'], true)) throw new InvalidArgumentException('Unsupported reporting source');
  18. if ((int) ($order->is_faker ?? 0) === 1) return [];
  19. $this->users = [];
  20. return $source === 'lhc_order' ? $this->lotteryOrder($order, false)['facts'] : array_values(array_filter([$this->singleOrder($source, $order)]));
  21. }
  22. public function sync(?string $from = null, ?string $to = null, bool $write = false): array
  23. {
  24. foreach ([$from, $to] as $date) {
  25. if ($date !== null && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)
  26. || \Carbon\Carbon::parse($date)->toDateString() !== $date)) {
  27. throw new InvalidArgumentException('Dates must use Y-m-d');
  28. }
  29. }
  30. if ($from !== null && $to !== null && $from > $to) throw new InvalidArgumentException('Invalid date range');
  31. $result = ['scanned' => 0, 'created' => 0, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0,
  32. 'errors' => [], 'changed_dates' => [], 'write' => $write];
  33. $this->users = [];
  34. foreach (['lhc_order', 'sport_game_order', 'sport_order', 'third_game_orders'] as $table) {
  35. if (!Schema::hasTable($table)) continue;
  36. $cursorColumn = $table === 'sport_order' ? 'update_time' : 'updated_at';
  37. $cursorEnabled = $write && $from === null && $to === null && Schema::hasColumn($table, $cursorColumn);
  38. $startedAt = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  39. $highestIdAtStart = $cursorEnabled ? (int) DB::table($table)->max('id') : 0;
  40. $errorCount = count($result['errors']);
  41. $query = DB::table($table);
  42. if ($cursorEnabled) {
  43. $lastRun = DB::table('share_system_state')->where('key', 'facts_cursor:' . $table)->value('value');
  44. if ($lastRun) {
  45. $cursor = json_decode($lastRun, true, 512, JSON_THROW_ON_ERROR);
  46. $cutoff = EffectiveBet::time($cursor['updated_at'])->subMinutes(5);
  47. $cutoffValue = in_array($table, ['lhc_order', 'sport_order'], true)
  48. ? $cutoff->timestamp : $cutoff->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  49. $query->where(function ($query) use ($cursorColumn, $cutoffValue, $cursor) {
  50. $query->where($cursorColumn, '>=', $cutoffValue)->orWhereNull($cursorColumn)
  51. ->orWhere('id', '>', (int) $cursor['last_id']);
  52. });
  53. }
  54. }
  55. $query->orderBy('id')->chunkById(200, function ($orders) use ($table, $from, $to, $write, &$result) {
  56. foreach ($orders as $order) {
  57. $result['scanned']++;
  58. try {
  59. $changes = $this->synchronizeOrder($table, (int) $order->id, $from, $to, $write);
  60. foreach (['created', 'updated', 'unchanged', 'skipped'] as $key) $result[$key] += $changes[$key];
  61. foreach ($changes['changed_dates'] as $date) $result['changed_dates'][$date] = $date;
  62. $result['errors'] = array_merge($result['errors'], $changes['errors']);
  63. } catch (\Throwable $e) {
  64. $result['skipped']++;
  65. $result['errors'][] = ['source' => $table, 'source_id' => (string) $order->id, 'reason' => $e->getMessage()];
  66. }
  67. }
  68. });
  69. if ($cursorEnabled && count($result['errors']) === $errorCount) {
  70. DB::table('share_system_state')->updateOrInsert(['key' => 'facts_cursor:' . $table],
  71. ['value' => json_encode(['updated_at' => $startedAt, 'last_id' => $highestIdAtStart]),
  72. 'updated_at' => $startedAt, 'created_at' => $startedAt]);
  73. }
  74. }
  75. $result['changed_dates'] = array_values($result['changed_dates']);
  76. sort($result['changed_dates']);
  77. return $result;
  78. }
  79. /** A source order and all of its child facts are one publication unit. */
  80. private function synchronizeOrder(string $table, int $id, ?string $from, ?string $to, bool $write): array
  81. {
  82. $operation = function () use ($table, $id, $from, $to, $write) {
  83. if ($write) {
  84. // Use the same first lock as settlement, relation binding and policy changes.
  85. $guard = DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first();
  86. if (!$guard) throw new RuntimeException('Sharing migration/relations lock is missing');
  87. $this->users = [];
  88. }
  89. // The chunk row may predate a lock wait or a concurrent source correction.
  90. $query = DB::table($table)->where('id', $id);
  91. $order = ($write ? $query->lockForUpdate() : $query)->first();
  92. if (!$order) throw new RuntimeException('Source order disappeared before synchronization');
  93. $result = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0, 'changed_dates' => [], 'errors' => []];
  94. if ((int) ($order->is_faker ?? 0) === 1) { $result['skipped']++; return $result; }
  95. try {
  96. $facts = $table === 'lhc_order' ? $this->lotteryOrder($order, $write)['facts'] : [$this->singleOrder($table, $order)];
  97. } catch (\Throwable $e) {
  98. $isolated = $this->quarantine($table, $order, $e->getMessage(), $write);
  99. $result['updated'] = $isolated['updated'];
  100. $result['changed_dates'] = $isolated['changed_dates'];
  101. $result['skipped']++;
  102. $result['errors'][] = ['source' => $table, 'source_id' => (string) $id, 'reason' => $e->getMessage(),
  103. 'isolated_facts' => $isolated['updated']];
  104. return $result;
  105. }
  106. if ($facts === []) { $result['skipped']++; return $result; }
  107. foreach ($facts as $fact) {
  108. if ($fact === null || ($from !== null && $fact['bet_date'] < $from)
  109. || ($to !== null && $fact['bet_date'] > $to)) { $result['skipped']++; continue; }
  110. $change = $this->persist($fact, $write);
  111. $result[$change['status']]++;
  112. $result['changed_dates'] = array_merge($result['changed_dates'], $change['dates']);
  113. }
  114. return $result;
  115. };
  116. return $write ? DB::transaction($operation, 3) : $operation();
  117. }
  118. /** Also used by the guarded historical PC28 rebate recalculation. */
  119. public function lotteryOrder(object $order, bool $lock = false): array
  120. {
  121. $type = (int) ($order->type ?? 0);
  122. if (!isset(self::LOTTERY_NAMES[$type])) throw new RuntimeException('Unknown lottery type');
  123. $betAt = $this->time($order->created_at ?? null);
  124. $base = ['member_id' => (string) $order->member_id, 'bet_at' => $betAt, 'date' => EffectiveBet::newYorkDate($order->created_at ?? null),
  125. 'total_bet' => EffectiveBet::zero(), 'facts' => [], 'unsettled' => false];
  126. if (trim((string) ($order->ordernum ?? '')) === '') throw new RuntimeException('Missing lottery order number');
  127. if (DB::table('lhc_order')->where('ordernum', $order->ordernum)->count() !== 1) {
  128. throw new RuntimeException('Ambiguous lottery parent order number');
  129. }
  130. $itemQuery = DB::table('lhc_order_item')->where('ordernum', $order->ordernum)->orderBy('id');
  131. $items = ($lock ? $itemQuery->lockForUpdate() : $itemQuery)->get();
  132. if ($items->isEmpty()) throw new RuntimeException('Lottery order has no identifiable child bets');
  133. $bet = EffectiveBet::decimal($order->amount);
  134. if (bccomp($bet, '0', EffectiveBet::SCALE) <= 0) throw new RuntimeException('Lottery stake is not positive');
  135. $total = bcmul($bet, (string) $items->count(), EffectiveBet::SCALE);
  136. if (isset($order->total_amount) && bccomp((string) $order->total_amount, $total, EffectiveBet::SCALE) !== 0) {
  137. throw new RuntimeException('Lottery total does not match child stakes');
  138. }
  139. $base['total_bet'] = $total;
  140. $refund = $this->refundAmount($order, [self::LOTTERY_NAMES[$type] . '退款', '投注退分']);
  141. if (bccomp($refund, '0', EffectiveBet::SCALE) > 0 && bccomp($refund, $total, EffectiveBet::SCALE) !== 0
  142. && !in_array((int) $order->lottery_status, [3, 4], true)) {
  143. throw new RuntimeException('Partial or inconsistent lottery refund requires review');
  144. }
  145. $parentVoid = in_array((int) $order->lottery_status, [3, 4], true) || bccomp($refund, '0', EffectiveBet::SCALE) > 0;
  146. $user = $this->user((string) $order->member_id);
  147. if ($user === null) return $base;
  148. foreach ($items as $item) {
  149. if ((string) ($item->member_id ?? '') !== (string) $order->member_id) throw new RuntimeException('Lottery child member mismatch');
  150. $void = $parentVoid || in_array((int) $item->lottery_status, [3, 4], true);
  151. $settled = !$void && in_array((int) $order->lottery_status, [1, 2], true)
  152. && in_array((int) $item->lottery_status, [1, 2], true);
  153. $base['unsettled'] = $base['unsettled'] || (!$settled && !$void);
  154. $profit = $settled ? ((int) $item->lottery_status === 1 ? bcsub('0', $bet, EffectiveBet::SCALE)
  155. : bcsub(EffectiveBet::decimal($item->win_amount ?? null), $bet, EffectiveBet::SCALE)) : EffectiveBet::zero();
  156. $base['facts'][] = $this->fact('lhc_order_item', (string) $item->id, $user, 'self:lottery', $betAt,
  157. $settled || $void ? $this->time($item->updated_at ?? $order->updated_at ?? null) : null,
  158. $bet, $profit, $settled, $void, $this->nativeCurrency());
  159. }
  160. return $base;
  161. }
  162. private function singleOrder(string $table, object $order): ?array
  163. {
  164. $previous = null;
  165. if ($table === 'third_game_orders' && !empty($order->order_key) && Schema::hasTable('share_bet_facts')) {
  166. $previous = DB::table('share_bet_facts')->where('source', $table)->where('source_id', $order->order_key)->first();
  167. }
  168. $member = (string) ($table === 'sport_order' ? $order->user_id : ($order->member_id ?? ''));
  169. $user = $this->user($member, $table === 'third_game_orders' ? ($order->user_id ?? $previous->user_id ?? null) : null);
  170. if ($user === null) return null;
  171. $sourceId = (string) $order->id;
  172. $void = false;
  173. if ($table === 'third_game_orders') {
  174. $sourceId = (string) ($order->order_key ?? '');
  175. if (!preg_match('/^[a-f0-9]{64}$/D', $sourceId)) throw new RuntimeException('Third-party order key is invalid');
  176. $type = (int) $order->game_type;
  177. $settled = (int) $order->status === 1;
  178. $void = in_array((int) $order->status, [2, 3], true);
  179. if (($type < 1 || $type > 7) && !$previous) throw new RuntimeException('Unknown third-party category');
  180. $category = $type >= 1 && $type <= 7 ? 'third:' . $type : $previous->category;
  181. $bet = EffectiveBet::decimal($order->bet_amount ?? (!$settled ? ($previous->bet_amount ?? '0') : null));
  182. $profit = $settled ? EffectiveBet::decimal($order->settled_amount) : EffectiveBet::zero();
  183. $betAt = $this->time($order->bet_time ?? $previous->bet_at ?? null);
  184. $settledAt = $settled || $void ? $this->time($order->last_update_time ?? $order->updated_at ?? null) : null;
  185. $currency = strtoupper(trim((string) ($order->currency ?? $previous->currency ?? '')));
  186. if ($currency === '') throw new RuntimeException('Third-party currency is missing');
  187. } else {
  188. $category = 'self:sport';
  189. $bet = EffectiveBet::decimal($order->amount);
  190. $betAt = $this->time($table === 'sport_order' ? ($order->create_time ?? null) : ($order->created_at ?? null));
  191. if ($table === 'sport_order') {
  192. $void = (int) $order->status === 2 || (int) ($order->return_status ?? 0) === 2
  193. || bccomp($this->refundAmount($order, ['体彩退款'], $member), '0', EffectiveBet::SCALE) > 0;
  194. $settled = !$void && (int) $order->status === 1 && (int) ($order->pay_status ?? 0) === 1
  195. && (int) ($order->settlement_status ?? 0) === 1;
  196. $profit = $settled ? bcsub(bcadd(EffectiveBet::decimal($order->win_amount ?? '0'),
  197. EffectiveBet::decimal($order->return_amount ?? '0'), EffectiveBet::SCALE), $bet, EffectiveBet::SCALE) : EffectiveBet::zero();
  198. $settledAt = $settled || $void ? $this->time($void
  199. ? (($order->return_operation_time ?? null) ?: ($order->update_time ?? null)) : ($order->settlement_time ?? null)) : null;
  200. } else {
  201. $void = (int) $order->status === 3 || bccomp($this->refundAmount($order, ['足球游戏退款']), '0', EffectiveBet::SCALE) > 0;
  202. $settled = !$void && in_array((int) $order->status, [1, 2], true);
  203. $profit = !$settled ? EffectiveBet::zero() : ((int) $order->status === 1 ? bcsub('0', $bet, EffectiveBet::SCALE)
  204. : bcsub(EffectiveBet::decimal($order->win_amount), $bet, EffectiveBet::SCALE));
  205. $settledAt = $settled || $void ? $this->time($order->updated_at ?? null) : null;
  206. }
  207. $currency = $this->nativeCurrency();
  208. }
  209. return $this->fact($table, $sourceId, $user, $category, $betAt, $settledAt, $bet, $profit, $settled, $void, $currency);
  210. }
  211. private function fact(string $source, string $id, object $user, string $category, string $betAt, ?string $settledAt,
  212. string $bet, string $profit, bool $settled, bool $void, string $currency): array
  213. {
  214. if (bccomp($bet, '0', EffectiveBet::SCALE) < 0) throw new RuntimeException('Negative stake');
  215. $fact = ['source' => $source, 'source_id' => $id, 'user_id' => (int) $user->id,
  216. 'member_id' => (string) $user->member_id, 'category' => $category, 'bet_at' => $betAt,
  217. 'bet_date' => substr($betAt, 0, 10), 'settled_at' => $settledAt, 'bet_amount' => $bet,
  218. 'net_profit' => $profit, 'effective_amount' => EffectiveBet::amount($bet, $profit, $settled, $void),
  219. 'settled' => $settled, 'voided' => $void, 'currency' => $currency];
  220. $fact['source_version'] = hash('sha256', json_encode($fact, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
  221. return $fact;
  222. }
  223. private function user(string $member, $userId = null): ?object
  224. {
  225. $key = $userId !== null ? 'id:' . $userId : 'member:' . $member;
  226. if (array_key_exists($key, $this->users)) return $this->users[$key];
  227. $query = DB::table('users');
  228. $rows = ($userId !== null ? $query->where('id', $userId) : $query->where('member_id', $member))->limit(2)->get();
  229. if ($rows->count() !== 1) throw new RuntimeException('User mapping is missing or ambiguous');
  230. $user = $rows->first();
  231. if ($member !== '' && (string) $user->member_id !== $member) throw new RuntimeException('User identity mismatch');
  232. return $this->users[$key] = ((int) ($user->from ?? 0) === 2 ? null : $user);
  233. }
  234. private function refundAmount(object $order, array $types, ?string $member = null): string
  235. {
  236. if (!Schema::hasTable('balance_logs')) return EffectiveBet::zero();
  237. return EffectiveBet::decimal(DB::table('balance_logs')->where('member_id', $member ?? $order->member_id)
  238. ->where('related_id', $order->id)->whereIn('change_type', $types)->where('amount', '>', 0)->sum('amount'));
  239. }
  240. private function time($value): string
  241. {
  242. return EffectiveBet::time($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  243. }
  244. private function nativeCurrency(): string
  245. {
  246. return strtoupper(trim((string) config('third_game.currency', 'CNY')));
  247. }
  248. private function persist(array $fact, bool $write): array
  249. {
  250. if ($write && DB::transactionLevel() === 0) {
  251. throw new RuntimeException('Fact publication requires its source order transaction');
  252. }
  253. $query = DB::table('share_bet_facts')->where('source', $fact['source'])->where('source_id', $fact['source_id']);
  254. $existing = ($write ? $query->lockForUpdate() : $query)->first();
  255. if ($existing && hash_equals((string) $existing->source_version, $fact['source_version'])) {
  256. return ['status' => 'unchanged', 'dates' => []];
  257. }
  258. $dates = array_values(array_unique(array_filter([$existing->bet_date ?? null, $fact['bet_date']])));
  259. if ($write) {
  260. $now = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  261. if ($existing) DB::table('share_bet_facts')->where('id', $existing->id)->update($fact + ['updated_at' => $now]);
  262. else DB::table('share_bet_facts')->insert($fact + ['created_at' => $now, 'updated_at' => $now]);
  263. $this->markDirty($dates, $now);
  264. }
  265. return ['status' => $existing ? 'updated' : 'created', 'dates' => $dates];
  266. }
  267. /** Only quarantine previously published facts identifiable from the current source row. */
  268. private function quarantine(string $table, object $order, string $reason, bool $write): array
  269. {
  270. if ($table === 'lhc_order') {
  271. if (trim((string) ($order->ordernum ?? '')) === '' || !Schema::hasTable('lhc_order_item')) {
  272. return ['updated' => 0, 'changed_dates' => []];
  273. }
  274. $items = DB::table('lhc_order_item')->where('ordernum', $order->ordernum)->orderBy('id');
  275. $ids = ($write ? $items->lockForUpdate() : $items)->pluck('id')->map(fn ($id) => (string) $id)->all();
  276. $source = 'lhc_order_item';
  277. } else {
  278. $ids = [(string) ($table === 'third_game_orders' ? ($order->order_key ?? '') : $order->id)];
  279. $source = $table;
  280. }
  281. $ids = array_values(array_filter($ids, fn ($id) => $id !== ''));
  282. if ($ids === []) return ['updated' => 0, 'changed_dates' => []];
  283. $query = DB::table('share_bet_facts')->where('source', $source)->whereIn('source_id', $ids)->orderBy('id');
  284. $facts = ($write ? $query->lockForUpdate() : $query)->get();
  285. $now = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  286. $updated = 0; $dates = [];
  287. foreach ($facts as $fact) {
  288. $version = hash('sha256', json_encode(['invalid_source' => $source, 'source_id' => $fact->source_id,
  289. 'reason' => $reason, 'order' => $order], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR));
  290. if ((string) $fact->source_version === $version && !(bool) $fact->settled
  291. && bccomp((string) $fact->effective_amount, '0', EffectiveBet::SCALE) === 0) continue;
  292. $updated++; $dates[] = $fact->bet_date;
  293. if ($write) DB::table('share_bet_facts')->where('id', $fact->id)->update([
  294. 'settled' => false, 'effective_amount' => EffectiveBet::zero(), 'source_version' => $version, 'updated_at' => $now]);
  295. }
  296. $dates = array_values(array_unique($dates));
  297. if ($write && $dates !== []) $this->markDirty($dates, $now);
  298. return ['updated' => $updated, 'changed_dates' => $dates];
  299. }
  300. private function markDirty(array $dates, string $now): void
  301. {
  302. DB::table('share_system_state')->insertOrIgnore(['key' => 'facts_dirty_dates', 'value' => '[]', 'created_at' => $now, 'updated_at' => $now]);
  303. $state = DB::table('share_system_state')->where('key', 'facts_dirty_dates')->lockForUpdate()->first();
  304. $dirty = json_decode($state->value, true, 512, JSON_THROW_ON_ERROR);
  305. $dirty = array_values(array_unique(array_merge($dirty, $dates)));
  306. sort($dirty);
  307. DB::table('share_system_state')->where('key', 'facts_dirty_dates')->update(['value' => json_encode($dirty), 'updated_at' => $now]);
  308. }
  309. }