'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); } } }