| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- <?php
- namespace App\Services;
- use App\Services\AdminRealtime\AdminRealtime;
- use App\Services\Reporting\ReportSync;
- use App\Services\Sharing\EffectiveBet as Money;
- use Illuminate\Contracts\Cache\LockTimeoutException;
- use Illuminate\Support\Facades\Cache;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Schema;
- use RuntimeException;
- class ThirdGameCommissionService
- {
- public const BACKFILL_STATE = '__third_game_commission_backfill__';
- /** A failed rate lookup is retried after this long instead of blocking the rest of the day. */
- private const FAILED_RATES_TTL_MINUTES = 30;
- public static function result(int $status, ?string $net): ?string
- {
- if ($status !== 1 || $net === null) {
- return null;
- }
- return match (bccomp($net, '0', 10)) {
- -1 => 'loss',
- 1 => 'win',
- 0 => 'draw',
- };
- }
- public static function charge(string $net, string $ratio): string
- {
- return bcmul(bcsub('0', $net, 10), $ratio, 10);
- }
- public static function currency(): string
- {
- return strtoupper(trim((string) config('third_game.currency', 'CNY')));
- }
- public static function meta(): array
- {
- return ['basis' => 'player_net_loss', 'estimated' => true, 'rate_date' => null, 'rates_fetched_at' => null];
- }
- /** Whether the commission migration has run: the columns exist and the launch range was recorded. */
- public function migrated(): bool
- {
- return Schema::hasTable('third_game_orders') && Schema::hasColumn('third_game_orders', 'third_game_commission_ratio')
- && $this->backfillState() !== [];
- }
- public function backfillState(): array
- {
- if (!Schema::hasTable('reporting_sync_state')) return [];
- $state = DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->value('state');
- return $state === null ? [] : json_decode($state, true, 512, JSON_THROW_ON_ERROR);
- }
- public function isHistorical(?int $id, ?string $settledAt, array $state): bool
- {
- return $id !== null && $id <= ($state['max_id'] ?? 0)
- && ($settledAt === null || $settledAt <= $state['cutoff_at']);
- }
- public function backfill(bool $retryMissing): array
- {
- $state = $this->backfillState();
- if ($state === []) {
- throw new RuntimeException('缺少三方抽成回填范围,请先运行迁移');
- }
- $orders = DB::table('third_game_orders')->where('status', 1)
- ->where('settled_amount', '<', 0)->whereNull('third_game_commission_ratio')
- ->whereRaw('UPPER(TRIM(currency)) = ?', [self::currency()])
- // Old snapshots of the other environment's players stay untouched, like the order list and finance summary.
- ->where('player_id', $this->testPlayerNamespace() ? 'like' : 'not like', 'tu%');
- if ($retryMissing) {
- $orders->where(function ($query) use ($state) {
- $query->where('id', '>', $state['max_id'])->orWhere('last_update_time', '>', $state['cutoff_at']);
- });
- if (!$orders->exists()) {
- return ['updated' => 0, 'remaining' => 0];
- }
- $snapshot = $this->rates();
- } else {
- $orders->where('id', '<=', $state['max_id'])->where(function ($query) use ($state) {
- $query->whereNull('last_update_time')->orWhere('last_update_time', '<=', $state['cutoff_at']);
- });
- if (!$orders->exists()) {
- return ['updated' => 0, 'remaining' => 0];
- }
- $snapshot = $state['snapshot'];
- if ($snapshot === null) {
- $snapshot = $this->rates();
- if (!$snapshot['available'] || $snapshot['ratios'] === []) {
- throw new RuntimeException('未取得上线费率,请在费率可用时重试历史回填');
- }
- $state['snapshot'] = $snapshot;
- $state['currency'] = self::currency();
- DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->update([
- 'state' => json_encode($state, JSON_THROW_ON_ERROR),
- 'updated_at' => now('Asia/Shanghai')->toDateTimeString(),
- ]);
- } elseif ($state['currency'] !== self::currency()) {
- throw new RuntimeException('当前三方币种与历史回填费率币种不一致');
- }
- }
- $updated = 0;
- $ratios = $snapshot['ratios'];
- if ($ratios !== []) {
- $candidates = (clone $orders)->whereIn(DB::raw('LOWER(TRIM(platform))'), array_keys($ratios));
- $candidates->select('id')->chunkById(200, function ($batch) use ($candidates, $ratios, &$updated) {
- $ids = DB::transaction(function () use ($candidates, $batch, $ratios) {
- $ids = [];
- $locked = (clone $candidates)->whereIn('id', $batch->pluck('id'))
- ->select('id', 'platform', 'settled_amount')->orderBy('id')->lockForUpdate()->get();
- $now = now('Asia/Shanghai')->toDateTimeString();
- foreach ($locked as $order) {
- $ratio = $ratios[strtolower(trim($order->platform))];
- DB::table('third_game_orders')->where('id', $order->id)->update([
- 'third_game_commission_ratio' => $ratio,
- 'third_game_commission' => self::charge(Money::decimal($order->settled_amount), $ratio),
- 'updated_at' => $now,
- ]);
- $ids[] = $order->id;
- }
- return $ids;
- }, 3);
- $updated += count($ids);
- AdminRealtime::record('third_game_orders', $ids);
- });
- }
- return ['updated' => $updated, 'remaining' => $orders->count()];
- }
- private function testPlayerNamespace(): bool
- {
- return app(ThirdGameBalanceService::class)->playerPrefix() === 'tu';
- }
- /** Finance reads original orders, independently of the reporting projection's sync schedule. */
- public function summary(?string $start, ?string $end): array
- {
- $known = Money::zero(); $gross = Money::zero(); $people = [];
- $currency = (new ReportSync())->currency();
- $quality = ['missing_ratio' => 0, 'missing_time' => 0, 'missing_amount' => 0, 'foreign_currency' => 0];
- if (Schema::hasTable('third_game_orders')) {
- $query = DB::table('third_game_orders as o')->join('users as u', 'u.id', '=', 'o.user_id')
- ->where(function ($q) { $q->whereNull('u.from')->orWhere('u.from', '<>', 2); })
- ->where('o.status', 1);
- // Same settlement date as the report: an admin-verified time (结算日核对) wins over the provider time.
- $grammar = $query->getGrammar();
- $settledAt = $grammar->wrap('o.last_update_time');
- if (Schema::hasTable('reporting_contexts')) {
- $query->leftJoin('reporting_contexts as c', function ($join) use ($grammar) {
- // source_id is a string; cast the order id so the (source, source_id) index is usable.
- $join->where('c.source', 'third_game_orders')
- ->whereRaw($grammar->wrap('c.source_id') . ' = CAST(' . $grammar->wrap('o.id') . ' AS CHAR)')
- ->where('c.settlement_verified', 1);
- });
- $settledAt = 'COALESCE(' . $grammar->wrap('c.succeeded_at') . ', ' . $settledAt . ')';
- }
- $query->select('o.*')->selectRaw($settledAt . ' AS report_settled_at');
- $query->where('o.player_id', $this->testPlayerNamespace() ? 'like' : 'not like', 'tu%');
- if ($start !== null && $end !== null) {
- $query->where(function ($q) use ($start, $end, $settledAt) {
- $q->whereRaw($settledAt . ' BETWEEN ? AND ?', [$start, $end])
- ->orWhere(function ($q) use ($end, $settledAt) { $q->whereRaw($settledAt . ' IS NULL')->where('o.bet_time', '<=', $end); });
- });
- }
- foreach ($query->orderBy('o.id')->cursor() as $row) {
- if ($row->report_settled_at === null) { ++$quality['missing_time']; continue; }
- if (strtoupper(trim((string) $row->currency)) !== $currency) { ++$quality['foreign_currency']; continue; }
- if ($row->settled_amount === null) { ++$quality['missing_amount']; continue; }
- $net = Money::decimal($row->settled_amount);
- $gross = bcsub($gross, $net, 10);
- if (self::result(1, $net) !== 'loss') continue;
- $amount = $row->third_game_commission;
- if ($amount === null) { ++$quality['missing_ratio']; continue; }
- $amount = Money::decimal($amount);
- $known = bcadd($known, $amount, 10);
- if (bccomp($amount, '0', 10) !== 0) $people[$row->user_id] = true;
- }
- }
- $complete = array_sum($quality) === 0;
- return ['amount' => $complete ? $known : null, 'known_amount' => $known, 'people' => count($people),
- 'complete' => $complete, 'quality' => $quality, 'net_profit' => $complete ? bcsub($gross, $known, 10) : null]
- + self::meta();
- }
- /** One successful quota request per merchant/currency/Beijing day; failures are retried every 30 minutes. */
- public function rates(): array
- {
- $now = now('Asia/Shanghai');
- $unavailable = ['date' => $now->toDateString(), 'fetched_at' => null, 'available' => false, 'ratios' => []];
- if (trim((string) config('third_game.sn')) === '' || trim((string) config('third_game.key')) === '') return $unavailable;
- $merchant = hash('sha256', json_encode([
- config('third_game.api_url'), config('third_game.sn'), config('third_game.key'), config('third_game.currency', 'CNY'),
- ]));
- $key = 'third_game:commission_rates:' . $merchant . ':' . $now->toDateString();
- $cached = Cache::get($key);
- if (is_array($cached)) return $cached;
- try {
- return Cache::lock($key . ':lock', max(30, (int) config('third_game.timeout', 10) + 10))->block(2, function () use ($key, $now, $unavailable) {
- $cached = Cache::get($key);
- if (is_array($cached)) return $cached;
- // Reserve before HTTP so concurrent requests never retry in a loop; a failure is retried after a short pause.
- $retryAt = $now->copy()->addMinutes(self::FAILED_RATES_TTL_MINUTES)->min($now->copy()->endOfDay());
- Cache::put($key, $unavailable, $retryAt);
- $result = app(ThirdGameBalanceService::class)->quotaRatios();
- $snapshot = ['date' => $now->toDateString(), 'fetched_at' => now('Asia/Shanghai')->toDateTimeString(),
- 'available' => (bool) $result['ok'], 'ratios' => $result['ratios']];
- Cache::put($key, $snapshot, $snapshot['available'] ? $now->copy()->endOfDay() : $retryAt);
- return $snapshot;
- });
- } catch (LockTimeoutException $e) {
- return Cache::get($key, $unavailable);
- }
- }
- }
|