ReportSync.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. <?php
  2. namespace App\Services\Reporting;
  3. use App\Services\Sharing\BetFactSynchronizer;
  4. use App\Services\Sharing\EffectiveBet as Money;
  5. use Carbon\Carbon;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Schema;
  8. /** Reporting projections only. Never settles orders, moves money or publishes sharing facts. */
  9. class ReportSync
  10. {
  11. public const ORDER_SOURCES = ['lhc_order', 'sport_game_order', 'sport_order', 'third_game_orders', 'bets', 'jisu_game_order'];
  12. public const MONEY_SOURCES = ['balance_logs', 'withdraws', 'payment_orders', 'agent_withdrawals', 'agent_balance_logs', 'share_ledger', 'agent_rebate_records'];
  13. public const RECHECK_BATCH = 200;
  14. public function sync(bool $full = false): array
  15. {
  16. $result = ['scanned' => 0, 'published' => 0, 'errors' => 0, 'sources' => []];
  17. $lotteryParents = [];
  18. foreach (array_merge(self::ORDER_SOURCES, self::MONEY_SOURCES, ['lhc_order_item']) as $source) {
  19. if (!Schema::hasTable($source)) { $result['sources'][$source] = 'absent'; continue; }
  20. $start = now('Asia/Shanghai')->format('Y-m-d H:i:s');
  21. $state = DB::table('reporting_sync_state')->where('source', $source)->first();
  22. $cursor = $state ? json_decode($state->state, true, 512, JSON_THROW_ON_ERROR) : null;
  23. $column = $source === 'sport_order' ? 'update_time' : 'updated_at';
  24. $hasUpdate = Schema::hasColumn($source, $column);
  25. $max = (int) DB::table($source)->max('id');
  26. $scan = function ($query) use ($source, &$result, &$lotteryParents) {
  27. $query->orderBy('id')->chunkById(200, function ($rows) use ($source, &$result, &$lotteryParents) {
  28. $this->processRows($source, $rows, $result, $lotteryParents);
  29. });
  30. };
  31. $recheckId = 0;
  32. if ($full || !$cursor) {
  33. $scan(DB::table($source)->where('id', '<=', $max));
  34. } else {
  35. $lastId = min((int) $cursor['last_id'], $max);
  36. // Keep the ID and time ranges separate so either can use its own index.
  37. $scan(DB::table($source)->where('id', '>', $lastId)->where('id', '<=', $max));
  38. $cut = Carbon::parse($cursor['checked_at'], 'Asia/Shanghai')->subMinutes(5);
  39. $value = in_array($source, ['lhc_order', 'lhc_order_item', 'sport_order'], true) ? $cut->timestamp : $cut->format('Y-m-d H:i:s');
  40. if ($hasUpdate) $scan(DB::table($source)->where('id', '<=', $lastId)->where($column, '>=', $value));
  41. // A bad row must not pin the whole source's high-water mark. Retry errors independently.
  42. $retryIds = DB::table('reporting_errors')->where('source', $source)->orderBy('updated_at')->orderBy('id')
  43. ->limit(self::RECHECK_BATCH)->pluck('source_id')->all();
  44. if ($retryIds) {
  45. // Missing source rows remain visible as errors, but cannot starve the rest of the retry queue.
  46. DB::table('reporting_errors')->where('source', $source)->whereIn('source_id', $retryIds)->update(['updated_at' => $start]);
  47. $retry = DB::table($source)->whereIn('id', $retryIds)->where('id', '<=', $lastId);
  48. if ($hasUpdate) $retry->where(fn ($q) => $q->whereNull($column)->orWhere($column, '<', $value));
  49. $scan($retry);
  50. }
  51. // Missing timestamps still receive eventual correction checks, without a full scan each minute.
  52. $recheck = DB::table($source)->where('id', '>', (int) ($cursor['recheck_id'] ?? 0))->where('id', '<=', $lastId);
  53. if ($hasUpdate) $recheck->whereNull($column);
  54. if ($retryIds) $recheck->whereNotIn('id', $retryIds);
  55. $rows = $recheck->orderBy('id')->limit(self::RECHECK_BATCH)->get();
  56. $this->processRows($source, $rows, $result, $lotteryParents);
  57. $recheckId = $rows->count() === self::RECHECK_BATCH ? (int) $rows->last()->id : 0;
  58. }
  59. DB::table('reporting_sync_state')->updateOrInsert(['source' => $source], ['state' => json_encode([
  60. 'checked_at' => $start, 'last_id' => $max, 'recheck_id' => $recheckId,
  61. ]), 'updated_at' => $start]);
  62. $errors = DB::table('reporting_errors')->where('source', $source)->count();
  63. $result['errors'] += $errors;
  64. $result['sources'][$source] = $errors ? 'errors' : 'ready';
  65. }
  66. DB::table('reporting_sync_state')->updateOrInsert(['source' => '__run__'], ['state' => json_encode(['complete' => $result['errors'] === 0, 'sources' => $result['sources']]), 'updated_at' => now()]);
  67. return $result;
  68. }
  69. private function processRows(string $source, $rows, array &$result, array &$lotteryParents): void
  70. {
  71. if ($rows->isEmpty()) return;
  72. $parents = $source === 'lhc_order_item'
  73. ? DB::table('lhc_order')->whereIn('ordernum', $rows->pluck('ordernum')->unique()->all())->get(['id', 'ordernum'])->groupBy('ordernum') : null;
  74. $successfulItems = [];
  75. foreach ($rows as $row) {
  76. ++$result['scanned'];
  77. try {
  78. $publishSource = $source; $id = (int) $row->id;
  79. if ($source === 'lhc_order_item') {
  80. $matches = $parents->get($row->ordernum);
  81. if (!$matches || $matches->count() !== 1) throw new \RuntimeException('六合彩子单对应的父单不明确');
  82. $publishSource = 'lhc_order'; $id = (int) $matches->first()->id;
  83. }
  84. if ($publishSource === 'lhc_order' && array_key_exists($id, $lotteryParents)) {
  85. if ($lotteryParents[$id] !== null) throw new \RuntimeException($lotteryParents[$id]);
  86. } else {
  87. try { $this->publish($publishSource, $id); }
  88. catch (\Throwable $e) {
  89. if ($publishSource === 'lhc_order') $lotteryParents[$id] = $e->getMessage();
  90. throw $e;
  91. }
  92. if ($publishSource === 'lhc_order') $lotteryParents[$id] = null;
  93. ++$result['published'];
  94. }
  95. if ($source === 'lhc_order_item') $successfulItems[] = (string) $row->id;
  96. } catch (\Throwable $e) {
  97. DB::table('reporting_errors')->updateOrInsert(['source' => $source, 'source_id' => (string) $row->id],
  98. ['candidate_at' => $this->time($row->created_at ?? $row->create_time ?? null), 'reason' => mb_substr($e->getMessage(), 0, 500), 'created_at' => now(), 'updated_at' => now()]);
  99. }
  100. }
  101. if ($successfulItems) DB::table('reporting_errors')->where('source', $source)->whereIn('source_id', $successfulItems)->delete();
  102. }
  103. public function publish(string $source, int $id): void
  104. {
  105. if (!in_array($source, array_merge(self::ORDER_SOURCES, self::MONEY_SOURCES), true)) throw new \InvalidArgumentException('不支持的数据来源');
  106. DB::transaction(function () use ($source, $id) {
  107. $record = DB::table($source)->where('id', $id)->lockForUpdate()->first();
  108. if (!$record) throw new \RuntimeException('原记录不存在');
  109. $row = (array) $record;
  110. $member = $row['member_id'] ?? null;
  111. $virtual = $member !== null && DB::table('users')->where('member_id', (string) $member)->where('from', 2)->exists();
  112. if ($virtual) {
  113. DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $id)->delete();
  114. DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $id)->delete();
  115. DB::table('reporting_errors')->where('source', $source)->where('source_id', (string) $id)->delete(); return;
  116. }
  117. if (in_array($source, self::ORDER_SOURCES, true)) $this->orders($source, $row);
  118. else $this->money($source, $row);
  119. DB::table('reporting_errors')->where('source', $source)->where('source_id', (string) $id)->delete();
  120. });
  121. }
  122. private function orders(string $source, array $row): void
  123. {
  124. $at = $this->time($row['created_at'] ?? $row['create_time'] ?? null);
  125. if ((int) ($row['is_faker'] ?? 0) === 1) { DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->delete(); return; }
  126. $facts = [];
  127. if (in_array($source, ['bets', 'jisu_game_order'], true)) {
  128. $identity = ReportContext::identity($row, $at ?? now()->toDateTimeString(), $source);
  129. if ($identity['user_id'] === null || $at === null) throw new \RuntimeException('旧订单缺少会员或下单时间');
  130. $bet = Money::decimal($row['amount']);
  131. $settled = $source === 'bets' ? (int) $row['status'] === 2 : in_array((int) $row['status'], [1, 2], true);
  132. $void = (int) $row['status'] === 3;
  133. $payout = $settled ? Money::decimal($source === 'bets' ? ($row['profit'] ?? 0) : ($row['win_amount'] ?? 0)) : Money::zero();
  134. $facts[] = ['source' => $source, 'source_id' => (string) $row['id'], 'user_id' => $identity['user_id'],
  135. 'category' => 'self:lottery', 'bet_at' => $at, 'bet_amount' => $bet, 'settled' => $settled, 'voided' => $void,
  136. 'net_profit' => $settled ? bcsub($payout, $bet, 10) : Money::zero(), 'currency' => $this->currency()];
  137. } else $facts = (new BetFactSynchronizer())->projectForReporting($source, (object) $row);
  138. $context = DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first();
  139. $lotteryPayoutAt = $source === 'lhc_order' && empty($context->succeeded_at)
  140. ? $this->lotteryPayoutTime($row, $facts) : null;
  141. $keys = [];
  142. foreach ($facts as $fact) {
  143. $identity = ReportContext::identity($row + ['report_user_id' => $fact['user_id']], $fact['bet_at'], $source);
  144. $settledAt = $context->succeeded_at ?? null;
  145. if (!(bool) ($context->settlement_verified ?? false)) {
  146. if ($source === 'lhc_order') $settledAt = $settledAt ?? $lotteryPayoutAt;
  147. if ($source === 'third_game_orders') $settledAt = $this->time($row['last_update_time'] ?? null);
  148. if ($source === 'sport_order') $settledAt = $this->time($row['settlement_time'] ?? null) ?? $this->time($row['return_operation_time'] ?? null) ?? $settledAt;
  149. }
  150. $settledAt = $fact['settled'] || $fact['voided'] ? $settledAt : null;
  151. $payout = $fact['settled'] ? bcadd($fact['bet_amount'], $fact['net_profit'], 10) : Money::zero();
  152. $refund = $fact['voided'] ? $fact['bet_amount'] : Money::zero();
  153. if ($source === 'sport_order' && $fact['settled']) {
  154. $refund = Money::decimal($row['return_amount'] ?? 0);
  155. if (bccomp($refund, '0', 10) < 0 || bccomp($refund, $fact['bet_amount'], 10) > 0) throw new \RuntimeException('部分退款金额不正确');
  156. $payout = bcsub($payout, $refund, 10);
  157. }
  158. if (bccomp($payout, '0', 10) < 0) throw new \RuntimeException('派奖金额不能为负');
  159. $key = ['source' => $fact['source'], 'source_id' => $fact['source_id']]; $keys[] = $fact['source_id'];
  160. $values = $identity + ['parent_source' => $source, 'parent_id' => (string) $row['id'], 'category' => $fact['category'],
  161. 'currency' => $source === 'third_game_orders' ? $fact['currency'] : $this->currency(), 'bet_at' => $fact['bet_at'], 'settled_at' => $settledAt,
  162. 'settlement_candidate_at' => $this->time($row['updated_at'] ?? $row['update_time'] ?? null),
  163. 'bet_amount' => $fact['bet_amount'], 'payout_amount' => $payout, 'net_profit' => $fact['net_profit'],
  164. 'refund_amount' => $refund, 'settled' => (int) $fact['settled'], 'voided' => (int) $fact['voided'],
  165. 'time_quality' => (!$fact['settled'] && !$fact['voided']) || $settledAt !== null ? 'confirmed' : 'missing_settlement_time'];
  166. $this->writeProjection('reporting_orders', $key, $values);
  167. }
  168. DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->when($keys, fn ($q) => $q->whereNotIn('source_id', $keys))->delete();
  169. }
  170. /** Restore missing win times only from matching, balanced wallet-credit receipts. */
  171. private function lotteryPayoutTime(array $order, array $facts): ?string
  172. {
  173. if (!$facts || !Schema::hasTable('balance_logs') || !isset($order['win_amount'])) return null;
  174. $expected = Money::zero();
  175. foreach ($facts as $fact) {
  176. if (!$fact['settled'] || $fact['voided']) return null;
  177. $expected = bcadd($expected, bcadd($fact['bet_amount'], $fact['net_profit'], 10), 10);
  178. }
  179. if (bccomp($expected, '0', 10) <= 0 || bccomp($expected, (string) $order['win_amount'], 10) !== 0) return null;
  180. $name = \App\Models\LhcOrder::getRemark((int) ($order['type'] ?? 0));
  181. if ($name === '') return null;
  182. $types = [$name . '中奖'];
  183. if (in_array((int) $order['type'], [5, 6], true)) $types[] = '中奖';
  184. $logs = DB::table('balance_logs')->where('member_id', (string) $order['member_id'])
  185. ->where('related_id', $order['id'])->where('type', 1)->whereIn('change_type', $types)->where('amount', '>', 0)->get();
  186. $credited = Money::zero(); $last = null;
  187. foreach ($logs as $log) {
  188. $at = $this->time($log->created_at);
  189. if ($at === null || $at < $facts[0]['bet_at'] || $at > now('Asia/Shanghai')->format('Y-m-d H:i:s')) return null;
  190. if ($log->before_balance === null || $log->after_balance === null
  191. || bccomp(bcsub((string) $log->after_balance, (string) $log->before_balance, 10), (string) $log->amount, 10) !== 0) return null;
  192. $credited = bcadd($credited, (string) $log->amount, 10);
  193. $last = $last === null ? $at : max($last, $at);
  194. }
  195. return bccomp($credited, $expected, 10) === 0 ? $last : null;
  196. }
  197. private function money(string $source, array $row): void
  198. {
  199. $candidate = $this->time($row['created_at'] ?? null);
  200. if ($candidate === null) throw new \RuntimeException('资金记录缺少创建时间');
  201. $context = DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first();
  202. $at = $context->succeeded_at ?? null; $kind = null; $amount = null; $customerFee = Money::zero(); $configuredFee = null;
  203. $category = null; $identityRow = $row;
  204. if ($source === 'balance_logs') {
  205. $amount = Money::decimal($row['amount']); $type = (string) $row['change_type'];
  206. if (bccomp($amount, '0', 10) > 0 && in_array($type, ['充值', '三方充值'], true)) $kind = 'deposit';
  207. elseif (bccomp($amount, '0', 10) > 0 && in_array($type, ['注册赠送', '优惠活动', '即充即送', '充值返现', '老用户回归', '比比返', '笔笔返', '返水', '回水', '余额宝利息', '流水解冻'], true) && (int) ($row['type'] ?? 1) === 1) $kind = 'bonus';
  208. elseif ($type === '人工充值') $kind = isset($row['related_id']) && (string) $row['related_id'] === '0' ? 'bonus' : 'manual_credit';
  209. elseif ($type === '人工扣款') { $kind = 'manual_debit'; $amount = bcsub('0', $amount, 10); }
  210. elseif ($type === '分享佣金领取') {
  211. $claim = DB::table('share_claims')->where('credited_log_id', $row['id'])->where('status', 'approved')->first();
  212. if (!$claim) throw new \RuntimeException('分享领取缺少审核通过的原记录');
  213. $kind = 'share_commission';
  214. }
  215. $at = $candidate;
  216. if ($kind === 'deposit' && ($row['related_id'] ?? null)) {
  217. $origin = $type === '三方充值' ? 'payment_orders' : 'recharges';
  218. $originContext = DB::table('reporting_contexts')->where('source', $origin)->where('source_id', (string) $row['related_id'])->first();
  219. if ($originContext && !$context) $context = $originContext;
  220. if ($origin === 'payment_orders' && Schema::hasTable($origin)) {
  221. $original = DB::table($origin)->where('id', $row['related_id'])->where('member_id', $row['member_id'])->whereIn('type', [1, 3])->where('status', 2)->first();
  222. if ($original) $configuredFee = $original->fee ?? null;
  223. }
  224. }
  225. } elseif ($source === 'withdraws' && (int) $row['status'] === 1) {
  226. $kind = 'withdraw'; $rate = Money::decimal($row['exchange_rate']);
  227. if (bccomp($rate, '0', 10) <= 0) throw new \RuntimeException('提现历史汇率无效');
  228. $amount = isset($row['to_account']) && is_numeric($row['to_account']) ? bcmul((string) $row['to_account'], $rate, 10) : null;
  229. $customerFee = bcmul(Money::decimal($row['service_charge'] ?? 0), $rate, 10);
  230. } elseif ($source === 'payment_orders' && (int) $row['status'] === 2 && in_array((int) $row['type'], [2, 4], true)) {
  231. $kind = 'withdraw'; $amount = (int) $row['type'] === 2 ? Money::decimal($row['amount']) : null; $configuredFee = $row['fee'] ?? null;
  232. } elseif ($source === 'agent_withdrawals' && $row['status'] === 'paid') {
  233. $kind = 'agent_withdraw'; $amount = Money::decimal($row['actual_amount']); $customerFee = Money::decimal($row['fee'] ?? 0); $at = $this->time($row['paid_at'] ?? null);
  234. } elseif ($source === 'agent_balance_logs' && $row['type'] === 'commission' && $row['reference_type'] === 'agent_rebate_record') {
  235. $kind = 'agent_commission'; $amount = Money::decimal($row['amount']); $at = $candidate;
  236. $record = DB::table('agent_rebate_records')->where('id', $row['reference_id'])->first();
  237. if ($record) $category = $this->agentCategory((string) $record->platform, (int) $record->game_type);
  238. } elseif ($source === 'share_ledger') {
  239. $kind = 'share_accrual'; $amount = Money::decimal($row['amount']); $at = $row['bet_date'] . ' 00:00:00'; $category = $row['category'];
  240. $identityRow['report_user_id'] = $row['user_id'];
  241. } elseif ($source === 'agent_rebate_records') {
  242. $kind = 'agent_accrual'; $amount = Money::zero(); $at = $row['settlement_date'] . ' 00:00:00';
  243. foreach (['flow', 'profit'] as $part) $amount = bcadd($amount, (string) $row[$part . '_commission'], 10);
  244. $category = $this->agentCategory((string) $row['platform'], (int) $row['game_type']);
  245. }
  246. if (!$kind) {
  247. $existing = DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $row['id'])->first();
  248. $verified = $existing ? DB::table('reporting_reconciliations')->where('event_id', $existing->id)->first() : null;
  249. if ($verified) {
  250. if (bccomp($verified->actual_amount, '0', 10) !== 0) throw new \RuntimeException('来源状态与已核实的收付款冲突,请重新核对');
  251. return; // Confirmed zero payout may still have an actual external fee; preserve its audit and event identity.
  252. }
  253. DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $row['id'])->delete(); return;
  254. }
  255. $identity = ReportContext::identity($identityRow, $at ?? $candidate, $source);
  256. if ($context) $identity = ['user_id' => $context->user_id, 'agent_id' => $context->agent_id, 'inviter_id' => $context->inviter_id,
  257. 'client_platform' => $context->client_platform, 'attribution_quality' => $context->attribution_quality];
  258. if (in_array($source, ['agent_withdrawals', 'agent_balance_logs', 'agent_rebate_records'], true)) $identity['agent_id'] = (int) $row['agent_id'];
  259. if ($identity['user_id'] === null && $identity['agent_id'] === null) throw new \RuntimeException('资金记录没有可识别的归属');
  260. $key = ['source' => $source, 'source_id' => (string) $row['id'], 'kind' => $kind];
  261. $values = $identity + ['category' => $category, 'currency' => $this->currency(), 'amount' => $amount,
  262. 'customer_fee' => $customerFee, 'configured_fee' => $configuredFee, 'occurred_at' => $at, 'candidate_at' => $candidate];
  263. $this->writeProjection('reporting_events', $key, $values);
  264. }
  265. private function writeProjection(string $table, array $key, array $values): void
  266. {
  267. $old = DB::table($table)->where($key)->first();
  268. if (!$old) { DB::table($table)->insert($key + $values + ['created_at' => now(), 'updated_at' => now()]); return; }
  269. $decimals = ['bet_amount', 'payout_amount', 'net_profit', 'refund_amount', 'amount', 'customer_fee', 'configured_fee'];
  270. foreach ($values as $column => $value) {
  271. $previous = $old->$column;
  272. $same = $value === null || $previous === null ? $value === $previous
  273. : (in_array($column, $decimals, true) ? bccomp((string) $value, (string) $previous, 10) === 0 : (string) $value === (string) $previous);
  274. if (!$same) { DB::table($table)->where($key)->update($values + ['updated_at' => now()]); return; }
  275. }
  276. }
  277. private function agentCategory(string $platform, int $type): ?string
  278. {
  279. if ($platform === 'sport') return 'self:sport';
  280. if (in_array($platform, ['lhc', 'jisu', 'pc28'], true)) return 'self:lottery';
  281. return $type >= 1 && $type <= 7 && $platform !== 'legacy' ? 'third:' . $type : null;
  282. }
  283. public function currency(): string { return strtoupper((string) config('reporting.currency', 'CNY')); }
  284. public function time($value): ?string
  285. {
  286. if ($value === null || $value === '' || $value === 0 || $value === '0') return null;
  287. try { return Money::time($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; }
  288. }
  289. }