ThirdGameCommissionService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. <?php
  2. namespace App\Services;
  3. use App\Services\AdminRealtime\AdminRealtime;
  4. use App\Services\Reporting\ReportSync;
  5. use App\Services\Sharing\EffectiveBet as Money;
  6. use Illuminate\Contracts\Cache\LockTimeoutException;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Schema;
  10. use RuntimeException;
  11. class ThirdGameCommissionService
  12. {
  13. public const BACKFILL_STATE = '__third_game_commission_backfill__';
  14. /** A failed rate lookup is retried after this long instead of blocking the rest of the day. */
  15. private const FAILED_RATES_TTL_MINUTES = 30;
  16. public static function result(int $status, ?string $net): ?string
  17. {
  18. if ($status !== 1 || $net === null) {
  19. return null;
  20. }
  21. return match (bccomp($net, '0', 10)) {
  22. -1 => 'loss',
  23. 1 => 'win',
  24. 0 => 'draw',
  25. };
  26. }
  27. public static function charge(string $net, string $ratio): string
  28. {
  29. return bcmul(bcsub('0', $net, 10), $ratio, 10);
  30. }
  31. public static function currency(): string
  32. {
  33. return strtoupper(trim((string) config('third_game.currency', 'CNY')));
  34. }
  35. public static function meta(): array
  36. {
  37. return ['basis' => 'player_net_loss', 'estimated' => true, 'rate_date' => null, 'rates_fetched_at' => null];
  38. }
  39. /** Whether the commission migration has run: the columns exist and the launch range was recorded. */
  40. public function migrated(): bool
  41. {
  42. return Schema::hasTable('third_game_orders') && Schema::hasColumn('third_game_orders', 'third_game_commission_ratio')
  43. && $this->backfillState() !== [];
  44. }
  45. public function backfillState(): array
  46. {
  47. if (!Schema::hasTable('reporting_sync_state')) return [];
  48. $state = DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->value('state');
  49. return $state === null ? [] : json_decode($state, true, 512, JSON_THROW_ON_ERROR);
  50. }
  51. public function isHistorical(?int $id, ?string $settledAt, array $state): bool
  52. {
  53. return $id !== null && $id <= ($state['max_id'] ?? 0)
  54. && ($settledAt === null || $settledAt <= $state['cutoff_at']);
  55. }
  56. public function backfill(bool $retryMissing): array
  57. {
  58. $state = $this->backfillState();
  59. if ($state === []) {
  60. throw new RuntimeException('缺少三方抽成回填范围,请先运行迁移');
  61. }
  62. $orders = DB::table('third_game_orders')->where('status', 1)
  63. ->where('settled_amount', '<', 0)->whereNull('third_game_commission_ratio')
  64. ->whereRaw('UPPER(TRIM(currency)) = ?', [self::currency()])
  65. // Old snapshots of the other environment's players stay untouched, like the order list and finance summary.
  66. ->where('player_id', $this->testPlayerNamespace() ? 'like' : 'not like', 'tu%');
  67. if ($retryMissing) {
  68. $orders->where(function ($query) use ($state) {
  69. $query->where('id', '>', $state['max_id'])->orWhere('last_update_time', '>', $state['cutoff_at']);
  70. });
  71. if (!$orders->exists()) {
  72. return ['updated' => 0, 'remaining' => 0];
  73. }
  74. $snapshot = $this->rates();
  75. } else {
  76. $orders->where('id', '<=', $state['max_id'])->where(function ($query) use ($state) {
  77. $query->whereNull('last_update_time')->orWhere('last_update_time', '<=', $state['cutoff_at']);
  78. });
  79. if (!$orders->exists()) {
  80. return ['updated' => 0, 'remaining' => 0];
  81. }
  82. $snapshot = $state['snapshot'];
  83. if ($snapshot === null) {
  84. $snapshot = $this->rates();
  85. if (!$snapshot['available'] || $snapshot['ratios'] === []) {
  86. throw new RuntimeException('未取得上线费率,请在费率可用时重试历史回填');
  87. }
  88. $state['snapshot'] = $snapshot;
  89. $state['currency'] = self::currency();
  90. DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->update([
  91. 'state' => json_encode($state, JSON_THROW_ON_ERROR),
  92. 'updated_at' => now('Asia/Shanghai')->toDateTimeString(),
  93. ]);
  94. } elseif ($state['currency'] !== self::currency()) {
  95. throw new RuntimeException('当前三方币种与历史回填费率币种不一致');
  96. }
  97. }
  98. $updated = 0;
  99. $ratios = $snapshot['ratios'];
  100. if ($ratios !== []) {
  101. $candidates = (clone $orders)->whereIn(DB::raw('LOWER(TRIM(platform))'), array_keys($ratios));
  102. $candidates->select('id')->chunkById(200, function ($batch) use ($candidates, $ratios, &$updated) {
  103. $ids = DB::transaction(function () use ($candidates, $batch, $ratios) {
  104. $ids = [];
  105. $locked = (clone $candidates)->whereIn('id', $batch->pluck('id'))
  106. ->select('id', 'platform', 'settled_amount')->orderBy('id')->lockForUpdate()->get();
  107. $now = now('Asia/Shanghai')->toDateTimeString();
  108. foreach ($locked as $order) {
  109. $ratio = $ratios[strtolower(trim($order->platform))];
  110. DB::table('third_game_orders')->where('id', $order->id)->update([
  111. 'third_game_commission_ratio' => $ratio,
  112. 'third_game_commission' => self::charge(Money::decimal($order->settled_amount), $ratio),
  113. 'updated_at' => $now,
  114. ]);
  115. $ids[] = $order->id;
  116. }
  117. return $ids;
  118. }, 3);
  119. $updated += count($ids);
  120. AdminRealtime::record('third_game_orders', $ids);
  121. });
  122. }
  123. return ['updated' => $updated, 'remaining' => $orders->count()];
  124. }
  125. private function testPlayerNamespace(): bool
  126. {
  127. return app(ThirdGameBalanceService::class)->playerPrefix() === 'tu';
  128. }
  129. /** Finance reads original orders, independently of the reporting projection's sync schedule. */
  130. public function summary(?string $start, ?string $end): array
  131. {
  132. $known = Money::zero(); $gross = Money::zero(); $people = [];
  133. $currency = (new ReportSync())->currency();
  134. $quality = ['missing_ratio' => 0, 'missing_time' => 0, 'missing_amount' => 0, 'foreign_currency' => 0];
  135. if (Schema::hasTable('third_game_orders')) {
  136. $query = DB::table('third_game_orders as o')->join('users as u', 'u.id', '=', 'o.user_id')
  137. ->where(function ($q) { $q->whereNull('u.from')->orWhere('u.from', '<>', 2); })
  138. ->where('o.status', 1);
  139. // Same settlement date as the report: an admin-verified time (结算日核对) wins over the provider time.
  140. $grammar = $query->getGrammar();
  141. $settledAt = $grammar->wrap('o.last_update_time');
  142. if (Schema::hasTable('reporting_contexts')) {
  143. $query->leftJoin('reporting_contexts as c', function ($join) use ($grammar) {
  144. // source_id is a string; cast the order id so the (source, source_id) index is usable.
  145. $join->where('c.source', 'third_game_orders')
  146. ->whereRaw($grammar->wrap('c.source_id') . ' = CAST(' . $grammar->wrap('o.id') . ' AS CHAR)')
  147. ->where('c.settlement_verified', 1);
  148. });
  149. $settledAt = 'COALESCE(' . $grammar->wrap('c.succeeded_at') . ', ' . $settledAt . ')';
  150. }
  151. $query->select('o.*')->selectRaw($settledAt . ' AS report_settled_at');
  152. $query->where('o.player_id', $this->testPlayerNamespace() ? 'like' : 'not like', 'tu%');
  153. if ($start !== null && $end !== null) {
  154. $query->where(function ($q) use ($start, $end, $settledAt) {
  155. $q->whereRaw($settledAt . ' BETWEEN ? AND ?', [$start, $end])
  156. ->orWhere(function ($q) use ($end, $settledAt) { $q->whereRaw($settledAt . ' IS NULL')->where('o.bet_time', '<=', $end); });
  157. });
  158. }
  159. foreach ($query->orderBy('o.id')->cursor() as $row) {
  160. if ($row->report_settled_at === null) { ++$quality['missing_time']; continue; }
  161. if (strtoupper(trim((string) $row->currency)) !== $currency) { ++$quality['foreign_currency']; continue; }
  162. if ($row->settled_amount === null) { ++$quality['missing_amount']; continue; }
  163. $net = Money::decimal($row->settled_amount);
  164. $gross = bcsub($gross, $net, 10);
  165. if (self::result(1, $net) !== 'loss') continue;
  166. $amount = $row->third_game_commission;
  167. if ($amount === null) { ++$quality['missing_ratio']; continue; }
  168. $amount = Money::decimal($amount);
  169. $known = bcadd($known, $amount, 10);
  170. if (bccomp($amount, '0', 10) !== 0) $people[$row->user_id] = true;
  171. }
  172. }
  173. $complete = array_sum($quality) === 0;
  174. return ['amount' => $complete ? $known : null, 'known_amount' => $known, 'people' => count($people),
  175. 'complete' => $complete, 'quality' => $quality, 'net_profit' => $complete ? bcsub($gross, $known, 10) : null]
  176. + self::meta();
  177. }
  178. /** One successful quota request per merchant/currency/Beijing day; failures are retried every 30 minutes. */
  179. public function rates(): array
  180. {
  181. $now = now('Asia/Shanghai');
  182. $unavailable = ['date' => $now->toDateString(), 'fetched_at' => null, 'available' => false, 'ratios' => []];
  183. if (trim((string) config('third_game.sn')) === '' || trim((string) config('third_game.key')) === '') return $unavailable;
  184. $merchant = hash('sha256', json_encode([
  185. config('third_game.api_url'), config('third_game.sn'), config('third_game.key'), config('third_game.currency', 'CNY'),
  186. ]));
  187. $key = 'third_game:commission_rates:' . $merchant . ':' . $now->toDateString();
  188. $cached = Cache::get($key);
  189. if (is_array($cached)) return $cached;
  190. try {
  191. return Cache::lock($key . ':lock', max(30, (int) config('third_game.timeout', 10) + 10))->block(2, function () use ($key, $now, $unavailable) {
  192. $cached = Cache::get($key);
  193. if (is_array($cached)) return $cached;
  194. // Reserve before HTTP so concurrent requests never retry in a loop; a failure is retried after a short pause.
  195. $retryAt = $now->copy()->addMinutes(self::FAILED_RATES_TTL_MINUTES)->min($now->copy()->endOfDay());
  196. Cache::put($key, $unavailable, $retryAt);
  197. $result = app(ThirdGameBalanceService::class)->quotaRatios();
  198. $snapshot = ['date' => $now->toDateString(), 'fetched_at' => now('Asia/Shanghai')->toDateTimeString(),
  199. 'available' => (bool) $result['ok'], 'ratios' => $result['ratios']];
  200. Cache::put($key, $snapshot, $snapshot['available'] ? $now->copy()->endOfDay() : $retryAt);
  201. return $snapshot;
  202. });
  203. } catch (LockTimeoutException $e) {
  204. return Cache::get($key, $unavailable);
  205. }
  206. }
  207. }