doge 1 день назад
Родитель
Сommit
31f4d183b4

+ 15 - 5
app/Http/Controllers/admin/Balance.php

@@ -10,6 +10,7 @@ use App\Models\BalanceLog;
 use App\Services\BalanceLogService;
 use Carbon\Carbon;
 use Illuminate\Validation\ValidationException;
+use App\Services\MemberBonusLogService;
 use Exception;
 
 class Balance extends Controller
@@ -23,7 +24,8 @@ class Balance extends Controller
                 'type' => ['nullable', 'integer'],
                 'member_id' => ['nullable', 'string', 'min:1'],
                 'related_id' => ['nullable', 'string', 'min:1'],
-                'change_type' => ['nullable', 'string', 'in:' . implode(',', BalanceLogService::$RW)],
+                'change_type' => ['nullable', 'string', 'in:' . implode(',', request()->input('is_fancai') == 1
+                    ? MemberBonusLogService::types() : BalanceLogService::$RW)],
                 'start_time' => ['nullable', 'date', 'date_format:Y-m-d', 'required_with:end_time'],
                 'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'required_with:start_time'],
                 'account' => ['nullable', 'string', 'max:64'],
@@ -34,12 +36,19 @@ class Balance extends Controller
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
+            // 旧版彩金页面固定传 type=2;彩金现在涵盖可用余额及历史冻结奖励。
+            if (!empty($params['is_fancai'])) {
+                $changeType = $params['change_type'] ?? null;
+                unset($params['type'], $params['change_type']);
+            }
             $query = BalanceLog::join('users', 'users.member_id', '=', 'balance_logs.member_id')
                         ->where(BalanceLogService::getWhere($params));
             if (!empty($params['is_fancai'])) {
-                $data['change_types'] = ['即充即送','老用户回归','余额宝利息'];
-                $query = $query->whereIn('balance_logs.change_type', $data['change_types'])
-                    ->where('balance_logs.type', 2);
+                $bonusLogs = new MemberBonusLogService();
+                $data['change_types'] = MemberBonusLogService::types();
+                $query = $query->whereIn('balance_logs.change_type', MemberBonusLogService::LEDGER_TYPES)
+                    ->whereIn('balance_logs.type', [1, 2])->where('balance_logs.amount', '>', 0);
+                $bonusLogs->filter($query, $changeType);
             } else {
                 $data['change_types'] = BalanceLogService::$RW;
             }
@@ -52,6 +61,7 @@ class Balance extends Controller
                 ->with(['member'])
                 ->get()
                 ->toArray();
+            if (isset($bonusLogs)) $data['data'] = $bonusLogs->decorate($data['data']);
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
         } catch (Exception $e) {
@@ -59,4 +69,4 @@ class Balance extends Controller
         }
         return $this->success($data);
     }
-}
+}

+ 7 - 5
app/Http/Controllers/admin/User.php

@@ -111,8 +111,10 @@ class User extends Controller
                 'like_first_name' => ['nullable', 'string', 'min:1'],
                 'username' => ['nullable', 'string', 'min:1'],
                 'register_ip' => ['nullable', 'string', 'min:1'],
-                'order' => ["nullable", 'string', "in:asc,desc"],
-                'by' => ['nullable', 'string', "in:available_balance,created_at,last_active_time"],
+                'order' => ['nullable', 'string', 'in:asc,desc'],
+                'by' => ['nullable', 'string', 'in:available_balance,created_at,last_active_time,frozen_balance,yuebao,total_consume,wager_required,wager_completed,wager_remaining,third_game_total_balance'],
+                'sort_field' => ['nullable', 'string', 'in:available_balance,created_at,last_active_time,frozen_balance,yuebao,total_consume,wager_required,wager_completed,wager_remaining,third_game_total_balance'],
+                'sort_order' => ['nullable', 'string', 'in:asc,desc'],
                 'user_code' => ['nullable'],
                 'agent_user_code' => ['nullable'],
                 'level' => ['nullable'],
@@ -121,10 +123,10 @@ class User extends Controller
                 'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'required_with:start_time'],
                 'recharge_channel_group_id' => ['nullable'],
             ]);
-            $order = request()->input('order', 'desc');
-            $by = request()->input('by', 'available_balance');
+            $sortField = $search['sort_field'] ?? $search['by'] ?? 'created_at';
+            $sortOrder = $search['sort_order'] ?? $search['order'] ?? 'desc';
 
-            $result = UserService::paginate($search,$order,$by);
+            $result = UserService::paginate($search, $sortOrder, $sortField);
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first().'ssss');
         } catch (Exception $e) {

+ 14 - 5
app/Services/FundsSummaryService.php

@@ -16,6 +16,7 @@ class FundsSummaryService
         $withdraw = self::withdrawAggregates($start, $end);
         $paymentOrder = self::paymentOrderAggregates($start, $end);
         $combinedPeople = self::combinedPeople($start, $end);
+        $commission = app(ThirdGameCommissionService::class)->summary($start?->toDateTimeString(), $end?->toDateTimeString());
 
         $income = [
             self::item('company_deposit', '公司入款', $balance->company_deposit_amount, $balance->company_deposit_people),
@@ -69,6 +70,9 @@ class FundsSummaryService
                 $paymentOrder->auto_withdrawal_fee_amount,
                 $paymentOrder->auto_withdrawal_fee_people
             ),
+            ['key' => 'third_game_commission', 'label' => '第三方游戏平台抽成',
+                'amount' => $commission['amount'] === null ? null : self::commissionMoney($commission['amount']),
+                'people' => $commission['people']],
         ];
 
         return [
@@ -77,7 +81,10 @@ class FundsSummaryService
             'income' => $income,
             'expense' => $expense,
             'income_total' => self::sumItems($income),
-            'expense_total' => self::sumItems($expense),
+            'expense_total' => $commission['complete'] ? self::sumItems($expense) : null,
+            'known_expense_total' => bcadd(self::sumItems($expense),
+                $commission['complete'] ? '0.00' : self::commissionMoney($commission['known_amount']), 2),
+            'third_game_commission_meta' => $commission,
         ];
     }
 
@@ -274,11 +281,13 @@ class FundsSummaryService
 
     private static function sumItems(array $items): string
     {
-        $total = array_reduce($items, static function (float $sum, array $item): float {
-            return $sum + (float)$item['amount'];
-        }, 0.0);
+        return array_reduce($items, static fn (string $sum, array $item): string
+            => bcadd($sum, $item['amount'] ?? '0.00', 2), '0.00');
+    }
 
-        return self::money($total);
+    private static function commissionMoney(string $amount): string
+    {
+        return bcadd($amount, bccomp($amount, '0', 10) < 0 ? '-0.005' : '0.005', 2);
     }
 
     private static function money(float $amount): string

+ 93 - 0
app/Services/MemberBonusLogService.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Services;
+
+use App\Support\PromotionRules;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 会员彩金的展示类型与筛选共用映射,资金账本仍保留原记账类型。 */
+final class MemberBonusLogService
+{
+    public const LEDGER_TYPES = ['即充即送', '老用户回归', '余额宝利息', '注册赠送', '优惠活动', '充值返现'];
+    private const PROMOTIONS = [
+        PromotionRules::LOSS => '净亏邀请',
+        PromotionRules::FIRST_DEPOSIT => '首充奖励',
+        PromotionRules::WHEEL => '转盘',
+        PromotionRules::UPGRADE_INVITE => '邀请升级奖励',
+    ];
+
+    private bool $hasPromotions;
+    private bool $hasRechargeBonuses;
+
+    public function __construct()
+    {
+        $this->hasPromotions = Schema::hasTable('promotion_rewards') && Schema::hasTable('promotion_activities');
+        $this->hasRechargeBonuses = Schema::hasTable('recharge_bonus_rewards');
+    }
+
+    public static function types(): array
+    {
+        return ['即充即送', '转盘', '老用户回归', '余额宝利息', '净亏邀请', '首充奖励',
+            '邀请升级奖励', '充值渠道赠送', '注册赠送', '优惠活动', '充值返现'];
+    }
+
+    private function promotionLogs(?int $type = null): QueryBuilder
+    {
+        return DB::table('promotion_rewards as pr')
+            ->join('promotion_activities as pa', 'pa.id', '=', 'pr.activity_id')
+            ->whereIn('pa.type', $type === null ? array_keys(self::PROMOTIONS) : [$type])
+            ->whereNotNull('pr.credited_log_id')->select('pr.credited_log_id');
+    }
+
+    private function rechargeLogs(): QueryBuilder
+    {
+        return DB::table('recharge_bonus_rewards')->whereNotNull('credited_log_id')->select('credited_log_id');
+    }
+
+    public function filter(Builder $query, ?string $type): void
+    {
+        if ($type === null || $type === '') return;
+        $promotionType = array_search($type, self::PROMOTIONS, true);
+        if ($promotionType !== false) {
+            $query->where('balance_logs.change_type', '优惠活动');
+            if ($this->hasPromotions) $query->whereIn('balance_logs.id', $this->promotionLogs($promotionType));
+            else $query->whereRaw('1 = 0');
+            return;
+        }
+        if ($type === '充值渠道赠送') {
+            $query->where('balance_logs.change_type', '优惠活动');
+            if ($this->hasRechargeBonuses) $query->whereIn('balance_logs.id', $this->rechargeLogs());
+            else $query->whereRaw('1 = 0');
+            // 与展示时一致:独立活动关联优先。
+            if ($this->hasPromotions) $query->whereNotIn('balance_logs.id', $this->promotionLogs());
+            return;
+        }
+        $query->where('balance_logs.change_type', $type);
+        if ($type === '优惠活动') {
+            if ($this->hasPromotions) $query->whereNotIn('balance_logs.id', $this->promotionLogs());
+            if ($this->hasRechargeBonuses) $query->whereNotIn('balance_logs.id', $this->rechargeLogs());
+        }
+    }
+
+    public function decorate(array $rows): array
+    {
+        $ids = array_column(array_filter($rows, fn ($row) => $row['change_type'] === '优惠活动'), 'id');
+        if (!$ids) return $rows;
+        $promotions = $this->hasPromotions ? DB::table('promotion_rewards as pr')
+            ->join('promotion_activities as pa', 'pa.id', '=', 'pr.activity_id')
+            ->whereIn('pr.credited_log_id', $ids)->whereIn('pa.type', array_keys(self::PROMOTIONS))
+            ->pluck('pa.type', 'pr.credited_log_id')->all() : [];
+        $recharges = $this->hasRechargeBonuses ? array_fill_keys(DB::table('recharge_bonus_rewards')
+            ->whereIn('credited_log_id', $ids)->pluck('credited_log_id')->all(), true) : [];
+        foreach ($rows as &$row) {
+            if ($row['change_type'] !== '优惠活动') continue;
+            if (isset($promotions[$row['id']])) $row['change_type'] = self::PROMOTIONS[(int)$promotions[$row['id']]];
+            elseif (isset($recharges[$row['id']])) $row['change_type'] = '充值渠道赠送';
+        }
+        unset($row);
+        return $rows;
+    }
+}

+ 27 - 9
app/Services/Reporting/ReportReader.php

@@ -4,6 +4,7 @@ namespace App\Services\Reporting;
 
 use App\Services\Sharing\EffectiveBet as Money;
 use App\Services\Sharing\ShareMemberQualification;
+use App\Services\ThirdGameCommissionService;
 use App\Support\MaintenancePolicy;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
@@ -16,7 +17,7 @@ class ReportReader
 {
     private const AMOUNTS = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
         'customer_fee_income', 'configured_fee', 'bonus_amount', 'agent_commission', 'share_commission', 'agent_accrual', 'share_accrual', 'manual_credit', 'manual_debit',
-        'bet_amount', 'settled_bet_amount', 'payout_amount', 'refund_amount', 'game_gross_profit'];
+        'bet_amount', 'settled_bet_amount', 'payout_amount', 'refund_amount', 'game_gross_profit', 'third_game_commission'];
     private const CASH = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee', 'customer_fee_income', 'configured_fee', 'bonus_amount', 'manual_credit', 'manual_debit'];
 
     public function params(array $input): array
@@ -77,7 +78,7 @@ class ReportReader
         $p = $this->params($input + ['report_type' => 'business']); $p['report_type'] = 'business'; $p['category'] = 'all'; $p['settlement_status'] = 'all';
         $result = $this->compute($p, 'daily'); $all = $result['groups']['all']; unset($result['groups']['all']);
         $days = array_values($result['groups']); usort($days, fn ($a, $b) => strcmp($b['group_id'], $a['group_id']));
-        $keep = array_flip(array_merge(['group_id', 'label', 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit', 'complete', 'quality'], self::AMOUNTS));
+        $keep = array_flip(array_merge(['group_id', 'label', 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit', 'known_third_game_commission', 'complete', 'quality'], self::AMOUNTS));
         $all = array_intersect_key($all, $keep);
         $days = array_map(fn ($row) => ['date' => $row['group_id']] + array_intersect_key($row, $keep), $days);
         return $result['meta'] + ['summary' => $all, 'total' => (int) Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end'])), 'data' => $days];
@@ -117,7 +118,7 @@ class ReportReader
     {
         [$users, $agents] = $this->people($p); $settings = (new ReportAdmin())->settings();
         $qualification = (new ShareMemberQualification())->read($p['end_date'], $mode !== 'daily'); $policy = $qualification['policy_meta'];
-        $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString();
+        $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString(); $rates = null;
         if ($mode === 'summary') foreach (['all' => '所有订单'] + MaintenancePolicy::CATEGORIES as $key => $label) $groups[$key] = $this->empty($key, $label);
         if ($mode === 'daily') {
             $groups['all'] = $this->empty('all', '总计');
@@ -144,7 +145,7 @@ class ReportReader
                 if ($key === null) continue;
                 if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
                 $window = $mode === 'daily' && $key !== 'all' ? ['start' => $key . ' 00:00:00', 'end' => Carbon::parse($key)->addDay()->toDateTimeString()] + $p : $p;
-                $this->addOrder($groups[$key], $row, $window);
+                $this->addOrder($groups[$key], $row, $window, $rates);
                 if ($p['report_type'] === 'promotion' && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
             }
         }
@@ -204,9 +205,12 @@ class ReportReader
             if ($mode === 'daily') $value['quality']['missing_time'] += $key === 'all' ? 0 : $globalMissingTime;
             $complete = !array_sum(array_map('intval', $value['quality'])); $value['complete'] = $complete;
             $value['known_net_cash_flow'] = bcsub(bcsub($value['deposit_amount'], $value['withdraw_amount'], 10), bcadd($value['deposit_fee'], $value['withdraw_fee'], 10), 10);
-            $cost = '0'; foreach (['bonus_amount', 'agent_commission', 'share_commission', 'deposit_fee', 'withdraw_fee'] as $f) $cost = bcadd($cost, $value[$f], 10);
+            $value['known_third_game_commission'] = $value['third_game_commission'];
+            $cost = '0'; foreach (['bonus_amount', 'agent_commission', 'share_commission', 'deposit_fee', 'withdraw_fee', 'third_game_commission'] as $f) $cost = bcadd($cost, $value[$f], 10);
             $value['known_company_profit'] = bcsub($value['game_gross_profit'], $cost, 10);
-            $value['net_cash_flow'] = $complete ? $value['known_net_cash_flow'] : null;
+            if ($value['quality']['missing_platform_ratio']) $value['third_game_commission'] = null;
+            $cashComplete = !array_sum(array_map('intval', array_diff_key($value['quality'], ['missing_platform_ratio' => true])));
+            $value['net_cash_flow'] = $cashComplete ? $value['known_net_cash_flow'] : null;
             $value['company_profit'] = $complete && $p['settlement_status'] !== 'pending' ? $value['known_company_profit'] : null;
             if (($mode === 'summary' && $key !== 'all') || $p['category'] !== 'all') {
                 foreach (self::CASH as $f) $value[$f] = null;
@@ -219,7 +223,9 @@ class ReportReader
             'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'data_updated_at' => $syncAt,
             'activity_started_at' => $settings['activity_started_at'], 'valid_deposit_min' => $policy['min_deposit'],
             'valid_effective_bet_min' => $policy['min_effective_bet'], 'valid_member_policy' => $policy,
-            'commission_balances' => $this->commissionBalances($p, $users, $agents), 'server_time' => $now]];
+            'commission_balances' => $this->commissionBalances($p, $users, $agents),
+            'third_game_commission_meta' => ['basis' => ThirdGameCommissionService::BASIS, 'estimated' => true,
+                'rate_date' => $rates['date'] ?? null, 'rates_fetched_at' => $rates['fetched_at'] ?? null], 'server_time' => $now]];
     }
 
     private function commissionBalances(array $p, array $users, array $agents): array
@@ -259,6 +265,12 @@ class ReportReader
         });
         if ($p['settlement_status'] === 'settled') $q->where('settled', 1)->where('voided', 0);
         if ($p['settlement_status'] === 'pending') $q->where('settled', 0)->where('voided', 0);
+        $q->select('reporting_orders.*');
+        if (Schema::hasTable('third_game_orders')) {
+            $q->selectSub(DB::table('third_game_orders')->select('platform')
+                ->whereColumn('third_game_orders.id', 'reporting_orders.parent_id')
+                ->where('reporting_orders.parent_source', 'third_game_orders')->limit(1), 'provider_platform');
+        }
         return $q;
     }
 
@@ -298,9 +310,9 @@ class ReportReader
     private function empty(string $key, string $label): array
     {
         return ['group_id' => $key, 'label' => $label] + array_fill_keys(self::AMOUNTS, Money::zero()) + ['_orders' => [], '_bettors' => [],
-            'quality' => ['missing_time' => 0, 'missing_amount' => 0, 'unverified_fees' => 0, 'foreign_currency' => 0, 'unverified_attribution' => 0]];
+            'quality' => ['missing_time' => 0, 'missing_amount' => 0, 'unverified_fees' => 0, 'foreign_currency' => 0, 'unverified_attribution' => 0, 'missing_platform_ratio' => 0]];
     }
-    private function addOrder(array &$value, array $row, array $p): void
+    private function addOrder(array &$value, array $row, array $p, ?array &$rates): void
     {
         if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
         if ($this->within($row['bet_at'], $p)) {
@@ -314,6 +326,12 @@ class ReportReader
                 $this->add($value, 'settled_bet_amount', bcsub($row['bet_amount'], $row['refund_amount'], 10)); $this->add($value, 'payout_amount', $row['payout_amount']);
                 $this->add($value, 'refund_amount', $row['refund_amount']);
                 $this->add($value, 'game_gross_profit', bcsub('0', $row['net_profit'], 10));
+                if ($row['parent_source'] === 'third_game_orders' && bccomp($row['net_profit'], '0', 10) !== 0) {
+                    $service = app(ThirdGameCommissionService::class); $rates ??= $service->rates();
+                    $amount = $service->amount((string) ($row['provider_platform'] ?? ''), bcsub('0', $row['net_profit'], 10), $rates);
+                    if ($amount === null) ++$value['quality']['missing_platform_ratio'];
+                    else $this->add($value, 'third_game_commission', $amount);
+                }
             }
         }
     }

+ 32 - 0
app/Services/ThirdGameBalanceService.php

@@ -40,6 +40,38 @@ class ThirdGameBalanceService
         3 => '已撤单',
     ];
 
+    /**
+     * Platform commission rates; `platfrom` is the supplier's documented spelling.
+     * @see https://doc.api-bet.com/docs/api/api-1e413dk22ai38
+     */
+    public function quotaRatios(): array
+    {
+        $response = $this->request('/api/server/quota', [
+            'currency' => (string) config('third_game.currency', 'CNY'),
+        ]);
+        if (!$response instanceof Response || !$response->successful()
+            || (int) $response->json('code') !== self::CODE_SUCCESS
+            || !is_array($response->json('data.ratios'))) {
+            return ['ok' => false, 'ratios' => []];
+        }
+
+        $ratios = []; $invalid = [];
+        foreach ($response->json('data.ratios') as $row) {
+            if (!is_array($row)) continue;
+            $name = $row['platfrom'] ?? $row['platform'] ?? $row['platType'] ?? '';
+            if (!is_string($name)) continue;
+            $platform = strtolower(trim($name));
+            if ($platform === '') continue;
+            $ratio = $this->decimalAmount($row['ratio'] ?? null);
+            if ($ratio === null || bccomp($ratio, '1', 10) > 0
+                || (isset($ratios[$platform]) && bccomp($ratios[$platform], $ratio, 10) !== 0)) {
+                $invalid[$platform] = true;
+            } else $ratios[$platform] = $ratio;
+        }
+
+        return ['ok' => true, 'ratios' => array_diff_key($ratios, $invalid)];
+    }
+
     /**
      * 批量查询用户的三方游戏总余额,三方与主钱包金额按 1:1 处理。
      *

+ 87 - 0
app/Services/ThirdGameCommissionService.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Services;
+
+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;
+
+class ThirdGameCommissionService
+{
+    public const BASIS = 'game_gross_profit';
+
+    public function amount(string $platform, string $grossProfit, array $rates): ?string
+    {
+        if (bccomp($grossProfit, '0', 10) === 0) return Money::zero();
+        $ratio = $rates['ratios'][strtolower(trim($platform))] ?? null;
+        return $ratio === null ? null : bcmul($grossProfit, $ratio, 10);
+    }
+
+    /** Finance reads original orders, independently of the reporting projection's sync schedule. */
+    public function summary(?string $start, ?string $end): array
+    {
+        $known = Money::zero(); $people = []; $rates = null;
+        $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)->select('o.*');
+            $query->where('o.player_id', config('third_game.player_prefix', 'u') === 'tu' ? 'like' : 'not like', 'tu%');
+            if ($start !== null && $end !== null) {
+                $query->where(function ($q) use ($start, $end) {
+                    $q->whereBetween('o.last_update_time', [$start, $end])
+                        ->orWhere(function ($q) use ($end) { $q->whereNull('o.last_update_time')->where('o.bet_time', '<=', $end); });
+                });
+            }
+            foreach ($query->orderBy('o.id')->cursor() as $row) {
+                if ($row->last_update_time === null) { ++$quality['missing_time']; continue; }
+                if (strtoupper(trim((string) $row->currency)) !== config('reporting.currency', 'CNY')) { ++$quality['foreign_currency']; continue; }
+                if ($row->settled_amount === null) { ++$quality['missing_amount']; continue; }
+                $gross = bcsub('0', (string) $row->settled_amount, 10);
+                if (bccomp($gross, '0', 10) === 0) continue;
+                $rates ??= $this->rates();
+                $amount = $this->amount((string) $row->platform, $gross, $rates);
+                if ($amount === null) { ++$quality['missing_ratio']; continue; }
+                $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, 'basis' => self::BASIS, 'estimated' => true,
+            'rate_date' => $rates['date'] ?? null, 'rates_fetched_at' => $rates['fetched_at'] ?? null];
+    }
+
+    /** One quota request per merchant/currency/Beijing day, including failed requests. */
+    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 the day before HTTP so concurrent requests and failures never retry in a loop.
+                Cache::put($key, $unavailable, $now->copy()->endOfDay());
+                $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, $now->copy()->endOfDay());
+                return $snapshot;
+            });
+        } catch (LockTimeoutException $e) {
+            return Cache::get($key, $unavailable);
+        }
+    }
+}

+ 59 - 18
app/Services/UserService.php

@@ -10,7 +10,6 @@ use App\Models\User;
 use App\Models\BalanceLog;
 use Illuminate\Support\Facades\App;
 use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
 use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
@@ -122,36 +121,78 @@ class UserService extends BaseService
      * @param array $search
      * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
      */
-    public static function paginate(array $search = [], $order = 'desc', $by = 'created_at')
+    /** @var list<string> wallets 表排序字段(与 GET /admin/user 的 sort_field 一致) */
+    private const PAGINATE_WALLET_SORT_FIELDS = [
+        'available_balance',
+        'frozen_balance',
+        'yuebao',
+        'wager_required',
+        'wager_completed',
+    ];
+
+    /** @var list<string> users 表排序字段 */
+    private const PAGINATE_USER_SORT_FIELDS = [
+        'created_at',
+        'last_active_time',
+        'third_game_total_balance',
+    ];
+
+    public static function paginate(array $search = [], $sortOrder = 'desc', $sortField = 'created_at')
     {
         $limit = isset($search['limit']) ? $search['limit'] : 15;
         $page = isset($search['page']) ? $search['page'] : 1;
         $where = self::getWhere($search);
         $total = static::$MODEL::where($where)->count();
-        $query = static::$MODEL::with(['wallet','level','agent'])
+        $sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
+
+        $query = static::$MODEL::with(['wallet', 'level', 'agent'])
             ->leftJoin('wallets', 'users.member_id', '=', 'wallets.member_id')
             ->where($where);
-        if ($by == 'available_balance') $by = "wallets.{$by}";
-        else {
-            $by = "users.{$by}";
-        }
-        $list = $query->orderBy($by, $order)
-            ->select("users.*")
-            ->forPage($page, $limit)
-            ->get();
-
-        $thirdGameBalances = app(ThirdGameBalanceService::class)->totals(
-            $list->pluck('member_id')->all()
-        );
-        foreach($list as &$item) {
+        self::applyPaginateSort($query, $sortField, $sortOrder);
+        $list = $query->select('users.*')->forPage($page, $limit)->get();
+
+        foreach ($list as &$item) {
             $item['total_consume'] = BalanceLog::getTotalConsume($item->member_id);//用户累计消费总额
-            $item['third_game_total_balance'] = $thirdGameBalances[(string) $item->member_id] ?? null;
+            $item['third_game_total_balance'] = data_get($item, 'third_game_total_balance');
             // 当前周期打码量:wager_required 所需、wager_completed 当前打码量、wager_remaining 剩余打码量(提现后重置)
-            foreach (WageringService::summary($item->wallet) as $key => $value) $item[$key] = $value;
+            foreach (WageringService::summary($item->wallet) as $key => $value) {
+                $item[$key] = $value;
+            }
         }
         return ['total' => $total, 'data' => $list];
     }
 
+    private static function applyPaginateSort($query, string $sortField, string $sortOrder): void
+    {
+        if ($sortField === 'total_consume') {
+            $query->leftJoinSub(self::totalConsumeSubquery(), 'user_total_consume', 'users.member_id', '=', 'user_total_consume.member_id')
+                ->orderBy('user_total_consume.total_consume', $sortOrder);
+        } elseif ($sortField === 'wager_remaining') {
+            $direction = $sortOrder === 'asc' ? 'ASC' : 'DESC';
+            $query->orderByRaw(
+                "GREATEST(CAST(wallets.wager_required AS DECIMAL(20,4)) - CAST(wallets.wager_completed AS DECIMAL(20,4)), 0) {$direction}"
+            );
+        } elseif (in_array($sortField, self::PAGINATE_WALLET_SORT_FIELDS, true)) {
+            $query->orderBy("wallets.{$sortField}", $sortOrder);
+        } elseif (in_array($sortField, self::PAGINATE_USER_SORT_FIELDS, true)) {
+            $query->orderBy('users.' . $sortField, $sortOrder);
+        } else {
+            $query->orderBy('users.created_at', $sortOrder);
+        }
+        $query->orderByDesc('users.created_at')->orderByDesc('users.id');
+    }
+
+    private static function totalConsumeSubquery()
+    {
+        return BalanceLog::query()
+            ->select('member_id')
+            ->groupBy('member_id')
+            ->selectRaw(
+                "GREATEST(ABS(COALESCE(SUM(CASE WHEN type = 1 AND change_type LIKE '%投注%' THEN amount ELSE 0 END), 0))"
+                . " - COALESCE(SUM(CASE WHEN type = 1 AND change_type LIKE '%彩退款' THEN amount ELSE 0 END), 0), 0) AS total_consume"
+            );
+    }
+
     //设置游戏ID
     public function setGameID($chatId, $gameId)
     {

+ 8 - 1
docs/admin-finance-api.md

@@ -41,7 +41,8 @@ POST 请求使用 `Content-Type: application/json`。统一响应:`code = 0` 
       {"key": "bonus", "label": "优惠金", "amount": "0.00", "people": 0},
       {"key": "third_party_fee", "label": "第三方手续费", "amount": "0.00", "people": 0},
       {"key": "third_party_auto_withdrawal", "label": "第三方支付出款(自动出款)", "amount": "0.00", "people": 0},
-      {"key": "third_party_auto_withdrawal_fee", "label": "第三方支付自动出款手续费", "amount": "0.00", "people": 0}
+      {"key": "third_party_auto_withdrawal_fee", "label": "第三方支付自动出款手续费", "amount": "0.00", "people": 0},
+      {"key": "third_game_commission", "label": "第三方游戏平台抽成", "amount": "0.00", "people": 0}
     ],
     "income_total": "3000.00",
     "expense_total": "0.00"
@@ -51,6 +52,12 @@ POST 请求使用 `Content-Type: application/json`。统一响应:`code = 0` 
 
 前端直接遍历 `income`、`expense`。每项固定返回 `key`、`label`、`amount`、`people`。
 
+`expense` 新增 `third_game_commission`,金额为两位小数字符串,计入 `expense_total`,`people` 为已知非零抽成订单的去重会员数。与支付手续费 `third_party_fee` 分开显示。
+
+按已完成订单结算日期统计:玩家净输金额(`-settled_amount`)乘 AG、PG 等平台对应ratio。玩家净输100元、ratio=0.09时抽成9元;玩家净赢时保留负数抵减。未完成、取消、撤单不计。平台比例三个接口共用,每天查询一次;历史金额使用查询时缓存比例估算。
+
+`third_game_commission_meta` 返回 `amount`、`known_amount`(10位小数)、`people`、`complete`、`quality`、`basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`。日期无可用比例时可为 `null`。比例或订单数据缺失时,抽成项 `amount`、`expense_total` 返回 `null`,显示“—”;`known_expense_total` 返回已知支出合计。真实零比例正常返回0。
+
 不传日期时统计全部历史数据,返回的 `start_date`、`end_date` 均为 `null`。
 
 汇总金额统一按钱包余额单位统计;USDT 会员出款使用订单实际扣减的钱包金额。

+ 2 - 0
docs/前端交付/bot-28/后台/README.md

@@ -17,6 +17,8 @@
 | 报表页面修正:移除三个入口,统一有效会员条件 | [报表页面调整-移除三个入口](报表页面调整-移除三个入口.md) |
 | 报表汇总:营业/推广/分享页签及明细 | [报表汇总-前端接入](报表汇总-前端接入.md) |
 | 提现打码量与冻结金额:会员列表打码量、上下分打码量、冻结/解冻、冻结记录(2026-11-04) | [打码量与冻结金额-后台接口](打码量与冻结金额-后台接口.md) |
+| 会员彩金:统一变动类型筛选、变动前/变动金额/变动后、余额宝利息直接进余额(2026-11-05) | [会员彩金-前端接入](会员彩金-前端接入.md) |
+| 会员列表:默认按注册时间倒序,钱包/三方/冻结/余额宝/投注/打码量列服务端排序(2026-11-05) | [会员列表-默认排序与列排序](会员列表-默认排序与列排序.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |

+ 27 - 0
docs/前端交付/bot-28/后台/会员列表-默认排序与列排序.md

@@ -0,0 +1,27 @@
+# 会员列表:默认排序与列排序
+
+接口:`GET /admin/user`。筛选、分页方式不变。
+
+## 表头排序
+
+与用户报表 `GET /admin/operation/exchangeList` 相同:传 `sort_field`、`sort_order`(`asc` / `desc`)。点击表头切换排序时把 `page` 置为 `1`。未指定方向时默认 `desc`。
+
+| 列表列 | sort_field |
+| --- | --- |
+| 注册时间 | `created_at` |
+| 最后活跃 | `last_active_time` |
+| 钱包余额 | `available_balance` |
+| 三方余额 | `third_game_total_balance` |
+| 冻结金额 | `frozen_balance` |
+| 余额宝金额 | `yuebao` |
+| 历史总投注额 | `total_consume` |
+| 所需打码量 | `wager_required` |
+| 当前打码量 | `wager_completed` |
+| 剩余打码量 | `wager_remaining` |
+
+示例:
+
+```http
+GET /admin/user?page=1&limit=15
+GET /admin/user?page=1&limit=15&sort_field=available_balance&sort_order=desc
+```

+ 96 - 0
docs/前端交付/bot-28/后台/会员彩金-前端接入.md

@@ -0,0 +1,96 @@
+# 会员彩金:前端接入
+
+接口:`GET /admin/balance/log`,使用管理员认证。成功 `code=0`,参数错误 `code=-3`,展示返回的 `msg`。会员彩金页面固定传 `is_fancai=1`。
+
+**当前现金赠送直接进入余额。页面沿用“变动前、变动金额、变动后”三列,不增加资金类型列或资金类型筛选。**
+
+## 1. 查询参数
+
+```http
+GET /admin/balance/log?is_fancai=1&page=1&limit=10
+GET /admin/balance/log?is_fancai=1&change_type=余额宝利息&page=1&limit=10
+GET /admin/balance/log?is_fancai=1&change_type=转盘&page=1&limit=10
+GET /admin/balance/log?is_fancai=1&change_type=净亏邀请&page=1&limit=10
+```
+
+| 参数 | 说明 |
+|---|---|
+| `is_fancai` | 固定传 `1`,查询会员彩金 |
+| `page`、`limit` | 正整数,默认分别为 `1`、`10`;切换筛选时回到第 1 页 |
+| `change_type` | **唯一的变动类型字段**,用于筛选和列表展示。选项取 `data.change_types`,选择哪个中文值就传哪个值;不传则查询全部彩金 |
+| `member_id` | 会员 ID,精确匹配 |
+| `account`、`first_name` | 会员账号、昵称,分别支持模糊匹配 |
+| `from` | 会员来源:`-1` TG、`0` 后台客服、`1` 真实用户、`2` 虚拟用户 |
+| `start_time`、`end_time` | `Y-m-d`,同时传;按记录发生时间筛选,包含起止两天 |
+
+会员彩金页面不需要传 `type`。旧页面固定携带的 `type=2` 可移除;接口在 `is_fancai=1` 时已忽略该参数。
+
+变动类型下拉选项为字符串数组:
+
+```json
+["即充即送", "转盘", "老用户回归", "余额宝利息", "净亏邀请", "首充奖励", "邀请升级奖励", "充值渠道赠送", "注册赠送", "优惠活动", "充值返现"]
+```
+
+下拉显示文字、提交的筛选值、列表返回的 `change_type` 使用同一个中文值。例如选择“转盘”,提交 `change_type=转盘`,返回的每行 `change_type` 也为 `转盘`。
+
+## 2. 返回字段与列表渲染
+
+列表位于 `data.data[]`,按记录 ID 倒序。汇总和选项:
+
+| 字段 | 含义 |
+|---|---|
+| `data.total` | 全部筛选结果的记录数 |
+| `data.total_amount` | 全部筛选结果的赠送金额合计,两位小数字符串,不随分页变化;不是会员当前余额 |
+| `data.change_types` | 变动类型下拉选项,字符串数组,显示文字和提交值相同 |
+
+每行字段与表格列:
+
+| 字段 | 中文列名 / 用途 |
+|---|---|
+| `id` | 记录 ID |
+| `member_id` | 会员 ID |
+| `account`、`first_name` | 会员账号、昵称 |
+| `change_type` | **变动类型**,直接显示,例如“即充即送”“转盘”“净亏邀请” |
+| `before_balance` | **变动前**:该笔赠送入账前的余额 |
+| `amount` | **变动金额**:该笔赠送的金额 |
+| `after_balance` | **变动后**:该笔赠送入账后的余额 |
+| `remark` | 备注 |
+| `created_at` | 发生时间 |
+
+**三列直接读取同一条记录的 `before_balance`、`amount`、`after_balance`,前端不需要按资金类型切换取值字段。**这些是记录发生时的金额,不使用会员当前钱包余额替换,也不在前端根据相邻行重新计算。
+
+金额按现有金额组件格式化,`amount` 保留增减方向;接口可能保留超过两位的小数精度。金额为 `null` 时显示“—”,不要显示为 0。
+
+当前赠送记录示例(仅列出展示字段):
+
+```json
+{
+  "id": 20801,
+  "member_id": "6001",
+  "account": "member6001",
+  "change_type": "转盘",
+  "before_balance": "100.00",
+  "amount": "666.00",
+  "after_balance": "766.00",
+  "created_at": "2026-11-05 12:00:00"
+}
+```
+
+## 3. 彩金范围与余额宝利息
+
+会员彩金展示已入账的正金额赠送记录。转盘、净亏邀请、首充奖励、邀请升级奖励、充值渠道赠送分别按各自名称展示和筛选;其他没有具体来源分类的优惠赠送保留为“优惠活动”。选择“优惠活动”时仅返回这类记录,不混入已经分类为“转盘”“净亏邀请”等类型的记录。
+
+净亏邀请的优惠券发放仍在独立活动的优惠券记录中查询,不计入现金彩金。余额宝本金转回、流水解冻等资金转移不计为再次赠送。
+
+余额宝后续利息在**手动转出、自动到期**两个入口均直接进入余额。本金转回与利息各一条记录。例:操作前余额 50,转出本金 100、利息 3:
+
+| 变动类型 | 变动前 | 变动金额 | 变动后 | 展示位置 |
+|---|---:|---:|---:|---|
+| 余额宝转入 | 50.00 | +100.00 | 150.00 | 普通资金流水,不列入会员彩金 |
+| 余额宝利息 | 150.00 | +3.00 | 153.00 | 普通资金流水及会员彩金 |
+
+因此,利息行的“变动前”显示 150.00,“变动金额”显示 +3.00,“变动后”显示 153.00。
+
+历史已冻结的利息不迁转。接口仍保留历史记录,三列保留当时的原始记账数值,不改算为当前余额;页面无需为历史记录增加取值分支。以上“赠送前后余额”的说明适用于当前直接入余额的赠送记录。
+
+相关规则:[打码量与冻结金额](打码量与冻结金额-后台接口.md)。

+ 2 - 2
docs/前端交付/bot-28/后台/打码量与冻结金额-后台接口.md

@@ -10,7 +10,7 @@
 | 提现 | 剩余打码量大于 0 时不能提现,只要有一项活动打码未完成就会拦截。提现申请成功后重置周期:所需与当前打码量都归 0。 |
 | 有效投注 | 与活动流水同一口径:已结算、未作废的注单,按 min(本金, \|输赢\|) 计算。缓存每分钟刷新一次。 |
 | 平台倍数 | 配置项 `withdraw_wager_multiple`(提现打码倍数),默认 `1`,在现有系统配置列表中修改。 |
-| 活动赠送 | 所有赠送直接进入**可用余额**,不再走冻结金额。配置了打码量的累加打码量;未配置的可直接提现,也不走平台倍数。 |
+| 活动赠送 | 当前赠送直接进入**可用余额**,包括余额宝手动转出和自动到期的利息。配置了打码量的累加打码量;未配置的可直接提现,也不走平台倍数。历史已冻结的余额宝利息保持原状。 |
 | 提现冻结 | 申请提现时,可用余额转入冻结余额。审核拒绝或失败退回可用余额;成功则从冻结余额扣除。 |
 
 ## 1. 会员列表新增字段
@@ -23,7 +23,7 @@
 | `wager_completed` | 当前打码量 |
 | `wager_remaining` | 剩余打码量,为 0 时可以提现 |
 | `total_consume` | 不变:历史总投注额(原“打码量”列) |
-| `wallet.frozen_balance` | 冻结余额:人工冻结 + 审核中的提现 |
+| `wallet.frozen_balance` | 当前冻结余额:人工冻结、审核中的提现,以及尚未解冻的历史冻结奖励(含历史余额宝利息)。当前新发赠送进入可用余额。 |
 
 人工稽查列表 `GET /admin/manualAudit` 的 `data.data[]` 每行新增 `member_wager` 对象(同上三个字段),以及 `wager_amount`:该次操作的打码量变动(有符号)。
 

+ 7 - 1
docs/前端交付/bot-28/后台/报表汇总-前端接入.md

@@ -286,12 +286,18 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 返回金额的含义:
 
 ```text
-公司净收益 = 游戏毛利 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际收款手续费 - 实际出款手续费
+公司净收益 = 游戏毛利 - 第三方游戏平台抽成 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际收款手续费 - 实际出款手续费
 实际净入金 = 成功收款本金 - 实际对外提款净额 - 实际收款手续费 - 实际出款手续费
 ```
 
 优惠、内部佣金、应计金额不直接作为外部现金再次从净入金扣除。客户内扣手续费收入和配置参考费用不额外加入上述公式。
 
+第三方游戏平台抽成使用 `third_game_commission`(10位小数字符串,可为 `null`),`summary` 及每个 `categories[]` 都返回,营业/推广/分享三种 `report_type` 均支持。按 AG、PG 等平台匹配比例,将平台旗下各游戏订单的抽成归入真人、电子、体育等游戏大类;自营游戏为零。
+
+当前计算口径为已结算订单的 `-settled_amount × 平台 ratio`,例如玩家净输100元、ratio=0.09,抽成9元;玩家净赢时保留负值并抵减合计。未完成、取消、撤单不计。按结算日期归属。比例每天共用一次查询;历史区间按查询时缓存的比例估算,不代表历史账单实扣。
+
+`third_game_commission_meta` 返回 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`。无相关结算订单时日期可为 `null`。缺少平台比例时,受影响行的 `third_game_commission`、`company_profit` 为 `null`,`known_third_game_commission` 为已知部分,`quality.missing_platform_ratio` 为缺失订单数;真实零比例按零计算。缺失比例不影响原有实际净入金。
+
 ### 6.2 总卡与分类卡的差异
 
 分类卡保留可归属的游戏指标及分类佣金/应计。以下公共资金字段返回 `null`:`deposit_amount`、`withdraw_amount`、`member_withdraw_amount`、`agent_withdraw_amount`、`deposit_fee`、`withdraw_fee`、`customer_fee_income`、`configured_fee`、`bonus_amount`、`manual_credit`、`manual_debit`。分类卡的 `company_profit`、`net_cash_flow` 也均为 `null`。分类毛利用 `game_gross_profit`。

+ 7 - 2
docs/前端交付/bot-28/后台/资金流动-前端接入.md

@@ -137,6 +137,10 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 
 分页使用请求值或 `data.filters.page`、`data.filters.limit`;顶部总计直接取 `data.summary`。
 
+区间 `summary` 和每日 `data[]` 均新增 `third_game_commission`,10位小数字符串;按结算日期统计,仅包含已完成订单。玩家净输100元、平台ratio=0.09时抽成9元,玩家净赢时以负数抵减。三个接口共用每日平台比例,历史区间使用查询时缓存比例估算。`third_game_commission_meta` 提供 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`,日期可为 `null`。
+
+缺少平台比例时,受影响行的抽成及 `company_profit` 返回 `null`,`known_third_game_commission` 保留已知金额,`quality.missing_platform_ratio` 为缺失订单数。真实零比例按零统计;实际净入金不因缺少游戏抽成比例而置空。
+
 ## 4. 金额字段与展示
 
 总计和每日行使用相同金额字段。金额为十进制字符串,保留原始值,以十进制方式格式化为两位小数;负值保留负号。`null` 显示“—”,数值为零时显示 `0.00`。汇总展示接口返回的 `summary`,不累计当前页行金额。`currency` 仅用于金额格式,不另作说明行。
@@ -156,6 +160,7 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 | `payout_amount` | 派奖总额 | 期间实际结算的派奖,不含退款 |
 | `refund_amount` | 退款总额 | 期间结算或撤销所产生的退款 |
 | `game_gross_profit` | 游戏毛利 | 已结算投注净额减派奖,平台盈利为正 |
+| `third_game_commission` | 第三方游戏平台抽成 | 期间已结算游戏毛利乘 AG、PG 等平台对应比例,保留正负值 |
 | `bonus_amount` | 实发优惠 | 实发笔笔返、回水、可用奖励、利息及冻结奖励实际解冻等 |
 | `agent_commission` | 实发代理佣金 | 已实际计入代理余额的佣金 |
 | `share_commission` | 实发分享佣金 | 已审核通过并实际计入会员钱包的分享佣金 |
@@ -164,7 +169,7 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 | `manual_credit` | 人工上分 | 人工增加余额 |
 | `manual_debit` | 人工扣分 | 人工扣减余额 |
 | `net_cash_flow` | 实际净入金 | 成功存款减实际提款减实际平台手续费 |
-| `company_profit` | 公司净收益 | 游戏毛利减实发优惠、实发代理/分享佣金及实际平台手续费 |
+| `company_profit` | 公司净收益 | 游戏毛利减第三方游戏平台抽成、实发优惠、实发代理/分享佣金及实际平台手续费 |
 
 “总盈利”列使用“游戏毛利”名称并绑定 `game_gross_profit`;“净流入/资金流动”绑定 `net_cash_flow`,展示名称为“实际净入金”;“公司净收益”绑定 `company_profit`。
 
@@ -175,7 +180,7 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 实际平台手续费 = 存款实际平台手续费 + 提款实际平台手续费
 实际净入金 = 成功存款 - 实际提款 - 实际平台手续费
 游戏毛利 = 已结算投注净额 - 派奖总额
-公司净收益 = 游戏毛利 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际平台手续费
+公司净收益 = 游戏毛利 - 第三方游戏平台抽成 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际平台手续费
 ```
 
 客户手续费收入、配置手续费参考值、应计佣金和人工余额调整均单独展示,不重复加入上述计算。投注总额按下单日统计,游戏毛利按实际结算日统计;各结果直接展示对应返回字段。

+ 7 - 1
docs/总后台/报表汇总-前端接入.md

@@ -286,12 +286,18 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 返回金额的含义:
 
 ```text
-公司净收益 = 游戏毛利 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际收款手续费 - 实际出款手续费
+公司净收益 = 游戏毛利 - 第三方游戏平台抽成 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际收款手续费 - 实际出款手续费
 实际净入金 = 成功收款本金 - 实际对外提款净额 - 实际收款手续费 - 实际出款手续费
 ```
 
 优惠、内部佣金、应计金额不直接作为外部现金再次从净入金扣除。客户内扣手续费收入和配置参考费用不额外加入上述公式。
 
+第三方游戏平台抽成使用 `third_game_commission`(10位小数字符串,可为 `null`),`summary` 及每个 `categories[]` 都返回,营业/推广/分享三种 `report_type` 均支持。按 AG、PG 等平台匹配比例,将平台旗下各游戏订单的抽成归入真人、电子、体育等游戏大类;自营游戏为零。
+
+当前计算口径为已结算订单的 `-settled_amount × 平台 ratio`,例如玩家净输100元、ratio=0.09,抽成9元;玩家净赢时保留负值并抵减合计。未完成、取消、撤单不计。按结算日期归属。比例每天共用一次查询;历史区间按查询时缓存的比例估算,不代表历史账单实扣。
+
+`third_game_commission_meta` 返回 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`。无相关结算订单时日期可为 `null`。缺少平台比例时,受影响行的 `third_game_commission`、`company_profit` 为 `null`,`known_third_game_commission` 为已知部分,`quality.missing_platform_ratio` 为缺失订单数;真实零比例按零计算。缺失比例不影响原有实际净入金。
+
 ### 6.2 总卡与分类卡的差异
 
 分类卡保留可归属的游戏指标及分类佣金/应计。以下公共资金字段返回 `null`:`deposit_amount`、`withdraw_amount`、`member_withdraw_amount`、`agent_withdraw_amount`、`deposit_fee`、`withdraw_fee`、`customer_fee_income`、`configured_fee`、`bonus_amount`、`manual_credit`、`manual_debit`。分类卡的 `company_profit`、`net_cash_flow` 也均为 `null`。分类毛利用 `game_gross_profit`。

+ 7 - 2
docs/总后台/资金流动-前端接入.md

@@ -137,6 +137,10 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 
 分页使用请求值或 `data.filters.page`、`data.filters.limit`;顶部总计直接取 `data.summary`。
 
+区间 `summary` 和每日 `data[]` 均新增 `third_game_commission`,10位小数字符串;按结算日期统计,仅包含已完成订单。玩家净输100元、平台ratio=0.09时抽成9元,玩家净赢时以负数抵减。三个接口共用每日平台比例,历史区间使用查询时缓存比例估算。`third_game_commission_meta` 提供 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`,日期可为 `null`。
+
+缺少平台比例时,受影响行的抽成及 `company_profit` 返回 `null`,`known_third_game_commission` 保留已知金额,`quality.missing_platform_ratio` 为缺失订单数。真实零比例按零统计;实际净入金不因缺少游戏抽成比例而置空。
+
 ## 4. 金额字段与展示
 
 总计和每日行使用相同金额字段。金额为十进制字符串,保留原始值,以十进制方式格式化为两位小数;负值保留负号。`null` 显示“—”,数值为零时显示 `0.00`。汇总展示接口返回的 `summary`,不累计当前页行金额。`currency` 仅用于金额格式,不另作说明行。
@@ -156,6 +160,7 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 | `payout_amount` | 派奖总额 | 期间实际结算的派奖,不含退款 |
 | `refund_amount` | 退款总额 | 期间结算或撤销所产生的退款 |
 | `game_gross_profit` | 游戏毛利 | 已结算投注净额减派奖,平台盈利为正 |
+| `third_game_commission` | 第三方游戏平台抽成 | 期间已结算游戏毛利乘 AG、PG 等平台对应比例,保留正负值 |
 | `bonus_amount` | 实发优惠 | 实发笔笔返、回水、可用奖励、利息及冻结奖励实际解冻等 |
 | `agent_commission` | 实发代理佣金 | 已实际计入代理余额的佣金 |
 | `share_commission` | 实发分享佣金 | 已审核通过并实际计入会员钱包的分享佣金 |
@@ -164,7 +169,7 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 | `manual_credit` | 人工上分 | 人工增加余额 |
 | `manual_debit` | 人工扣分 | 人工扣减余额 |
 | `net_cash_flow` | 实际净入金 | 成功存款减实际提款减实际平台手续费 |
-| `company_profit` | 公司净收益 | 游戏毛利减实发优惠、实发代理/分享佣金及实际平台手续费 |
+| `company_profit` | 公司净收益 | 游戏毛利减第三方游戏平台抽成、实发优惠、实发代理/分享佣金及实际平台手续费 |
 
 “总盈利”列使用“游戏毛利”名称并绑定 `game_gross_profit`;“净流入/资金流动”绑定 `net_cash_flow`,展示名称为“实际净入金”;“公司净收益”绑定 `company_profit`。
 
@@ -175,7 +180,7 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 实际平台手续费 = 存款实际平台手续费 + 提款实际平台手续费
 实际净入金 = 成功存款 - 实际提款 - 实际平台手续费
 游戏毛利 = 已结算投注净额 - 派奖总额
-公司净收益 = 游戏毛利 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际平台手续费
+公司净收益 = 游戏毛利 - 第三方游戏平台抽成 - 实发优惠 - 实发代理佣金 - 实发分享佣金 - 实际平台手续费
 ```
 
 客户手续费收入、配置手续费参考值、应计佣金和人工余额调整均单独展示,不重复加入上述计算。投注总额按下单日统计,游戏毛利按实际结算日统计;各结果直接展示对应返回字段。

+ 163 - 0
tests/Integration/AdminBonusLogTest.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Balance;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+class AdminBonusLogTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $translator = new \Illuminate\Translation\Translator(new \Illuminate\Translation\ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new \Illuminate\Validation\Factory($translator, $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        foreach ([
+            'users' => 'id INTEGER PRIMARY KEY,member_id TEXT,account TEXT,username TEXT,first_name TEXT,admin_note TEXT,status INTEGER,phone TEXT,visitor_id TEXT,register_ip TEXT,"from" INTEGER',
+            'balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT',
+            'promotion_activities' => 'id INTEGER PRIMARY KEY,type INTEGER',
+            'promotion_rewards' => 'id INTEGER PRIMARY KEY,activity_id INTEGER,credited_log_id INTEGER',
+            'recharge_bonus_rewards' => 'id INTEGER PRIMARY KEY,credited_log_id INTEGER',
+        ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        DB::table('users')->insert(['id' => 1, 'member_id' => 'm1', 'account' => 'alice', 'from' => 1]);
+        foreach ([[1,2,'余额宝利息','2'],[2,1,'余额宝利息','3'],[3,1,'优惠活动','666'],[4,1,'优惠活动','500'],[5,1,'流水解冻','2'],[6,2,'流水解冻','-2'],[7,1,'充值','100'],[8,1,'即充即送','5']] as [$id,$type,$name,$amount]) {
+            DB::table('balance_logs')->insert(['id'=>$id,'member_id'=>'m1','type'=>$type,'change_type'=>$name,'amount'=>$amount,'created_at'=>'2026-11-05 12:00:00']);
+        }
+        DB::table('promotion_activities')->insert([['id'=>9,'type'=>9],['id'=>7,'type'=>7]]);
+        DB::table('promotion_rewards')->insert([['id'=>1,'activity_id'=>9,'credited_log_id'=>3],['id'=>2,'activity_id'=>7,'credited_log_id'=>4]]);
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function records(array $filters = []): array
+    {
+        $this->app->instance('request', Request::create('/admin/balance/log', 'GET', $filters));
+        $body = (new Balance())->log()->getData(true);
+        $this->assertSame(0, $body['code'], $body['msg']);
+        return $body['data'];
+    }
+
+    public function test_bonus_includes_available_and_legacy_rewards_without_counting_transfers(): void
+    {
+        $data=$this->records(['is_fancai'=>1,'type'=>2,'limit'=>2]);
+        $this->assertSame(5,$data['total']);
+        $this->assertSame('1176.00',$data['total_amount']);
+        $this->assertCount(2,$data['data']);
+        $this->assertSame([8,4],array_column($data['data'],'id'));
+        $this->assertSame('净亏邀请',$data['data'][1]['change_type']);
+        $this->assertArrayNotHasKey('activity_types', $data);
+        foreach (['reward_source', 'activity_type', 'activity_id'] as $field) $this->assertArrayNotHasKey($field, $data['data'][1]);
+        $this->assertSame('1176.00',$this->records(['is_fancai'=>1,'page'=>2,'limit'=>2])['total_amount']);
+    }
+
+    public function test_activity_and_member_filters_apply_to_counts_and_sum(): void
+    {
+        $data=$this->records(['is_fancai'=>1,'change_type'=>'转盘','account'=>'lic']);
+        $this->assertSame(1,$data['total']);$this->assertSame('666.00',$data['total_amount']);
+        $this->assertSame('转盘',$data['data'][0]['change_type']);
+        $this->assertSame(0,$this->records(['is_fancai'=>1,'account'=>'bob'])['total']);
+        $this->assertSame(2,$this->records(['is_fancai'=>1,'change_type'=>'余额宝利息'])['total']);
+    }
+
+    public function test_regular_balance_type_filter_remains_effective(): void
+    {
+        $this->assertSame(2,$this->records(['type'=>2])['total']);
+        $this->assertSame(8,$this->records()['total']);
+        $ordinary = $this->records(['change_type'=>'优惠活动']);
+        $this->assertSame(2, $ordinary['total']);
+        $this->assertSame(['优惠活动'], array_values(array_unique(array_column($ordinary['data'], 'change_type'))));
+    }
+
+    private function log(int $id, string $type, string $amount = '10'): void
+    {
+        DB::table('balance_logs')->insert(['id'=>$id, 'member_id'=>'m1', 'type'=>1, 'change_type'=>$type,
+            'amount'=>$amount, 'before_balance'=>'100.00', 'after_balance'=>bcadd('100', $amount, 2),
+            'related_id'=>1, 'created_at'=>'2026-11-05 12:00:00']);
+    }
+
+    public function test_each_dropdown_value_filters_exactly_the_type_returned_in_rows(): void
+    {
+        DB::table('promotion_activities')->insert([['id'=>8,'type'=>8],['id'=>10,'type'=>10]]);
+        foreach ([80=>8, 100=>10] as $log => $activity) {
+            $this->log($log, '优惠活动');
+            DB::table('promotion_rewards')->insert(['activity_id'=>$activity,'credited_log_id'=>$log]);
+        }
+        $this->log(101, '优惠活动', '6');
+        DB::table('recharge_bonus_rewards')->insert(['credited_log_id'=>101]);
+        $this->log(102, '优惠活动', '4'); // Same related_id as a promotion reward; it is not that reward's log.
+        $this->log(103, '老用户回归');
+        $this->log(104, '注册赠送');
+        $this->log(105, '充值返现');
+        // Unclaimed/coupon rewards and zero-valued channel rewards have no credited log.
+        DB::table('promotion_rewards')->insert(['activity_id'=>9,'credited_log_id'=>null]);
+        DB::table('recharge_bonus_rewards')->insert(['credited_log_id'=>null]);
+        $options = $this->records(['is_fancai'=>1])['change_types'];
+        $this->assertContains('转盘', $options);
+        $this->assertContains('净亏邀请', $options);
+        $this->assertContains('首充奖励', $options);
+        $this->assertContains('邀请升级奖励', $options);
+        foreach ($options as $type) {
+            $filtered = $this->records(['is_fancai'=>1, 'change_type'=>$type, 'limit'=>100]);
+            $this->assertGreaterThan(0, $filtered['total'], $type);
+            $this->assertSame([$type], array_values(array_unique(array_column($filtered['data'], 'change_type'))));
+        }
+        $generic = $this->records(['is_fancai'=>1, 'change_type'=>'优惠活动']);
+        $this->assertSame([102], array_column($generic['data'], 'id'));
+        $this->assertSame('4.00', $generic['total_amount']);
+        $channel = $this->records(['is_fancai'=>1, 'change_type'=>'充值渠道赠送']);
+        $this->assertSame([101], array_column($channel['data'], 'id'));
+        $this->assertSame('6.00', $channel['total_amount']);
+        $this->assertSame('优惠活动', DB::table('balance_logs')->where('id', 3)->value('change_type'));
+    }
+
+    public function test_source_filter_pagination_totals_and_money_fields_remain_consistent(): void
+    {
+        $this->log(200, '优惠活动', '20');
+        DB::table('promotion_rewards')->insert(['activity_id'=>9,'credited_log_id'=>200]);
+        foreach ([1=>200, 2=>3] as $page=>$id) {
+            $data = $this->records(['is_fancai'=>1, 'change_type'=>'转盘', 'limit'=>1, 'page'=>$page]);
+            $this->assertSame(2, $data['total']);
+            $this->assertSame('686.00', $data['total_amount']);
+            $this->assertSame([$id], array_column($data['data'], 'id'));
+        }
+        $row = $this->records(['is_fancai'=>1, 'change_type'=>'转盘', 'limit'=>1])['data'][0];
+        $this->assertSame('100.00', $row['before_balance']);
+        $this->assertSame('20', $row['amount']);
+        $this->assertSame('120.00', $row['after_balance']);
+    }
+
+    public function test_legacy_schema_keeps_generic_rewards_readable_and_specific_filters_empty(): void
+    {
+        DB::statement('DROP TABLE bot_promotion_rewards');
+        DB::statement('DROP TABLE bot_promotion_activities');
+        DB::statement('DROP TABLE bot_recharge_bonus_rewards');
+        $generic = $this->records(['is_fancai'=>1, 'change_type'=>'优惠活动']);
+        $this->assertSame(2, $generic['total']);
+        $this->assertSame('1166.00', $generic['total_amount']);
+        foreach (['转盘', '净亏邀请', '充值渠道赠送'] as $type) {
+            $this->assertSame(0, $this->records(['is_fancai'=>1, 'change_type'=>$type])['total']);
+        }
+    }
+}

+ 133 - 2
tests/Integration/ReportingTest.php

@@ -701,7 +701,7 @@ class ReportingTest extends TestCase
         DB::statement('CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,amount TEXT,total_amount TEXT,type INTEGER,lottery_status INTEGER,is_faker INTEGER,created_at INTEGER,updated_at INTEGER)');
         DB::statement('CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,lottery_status INTEGER,win_amount TEXT,updated_at INTEGER)');
         DB::statement('CREATE TABLE bot_sport_order (id INTEGER PRIMARY KEY,user_id TEXT,amount TEXT,win_amount TEXT,return_amount TEXT,status INTEGER,pay_status INTEGER,settlement_status INTEGER,return_status INTEGER,create_time INTEGER,update_time INTEGER,settlement_time INTEGER)');
-        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
+        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,platform TEXT DEFAULT "pg",player_id TEXT DEFAULT "u2",game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
         $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp; $settledAt = $betAt + 3600;
         DB::table('lhc_order')->insert(['id' => 1, 'member_id' => '200', 'ordernum' => 'L1', 'amount' => '10', 'total_amount' => '20', 'type' => 6, 'lottery_status' => 2, 'is_faker' => 0, 'created_at' => $betAt, 'updated_at' => $settledAt]);
         DB::table('lhc_order_item')->insert([['id' => 1, 'member_id' => '200', 'ordernum' => 'L1', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => $settledAt], ['id' => 2, 'member_id' => '200', 'ordernum' => 'L1', 'lottery_status' => 2, 'win_amount' => '18', 'updated_at' => $settledAt]]);
@@ -738,10 +738,141 @@ class ReportingTest extends TestCase
         return $cases;
     }
 
+    private function commissionOrders(): void
+    {
+        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,
+            platform TEXT,player_id TEXT,game_type INTEGER,game_name TEXT,bet_amount TEXT,valid_amount TEXT,settled_amount TEXT,
+            status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
+        $this->app->instance(\App\Services\ThirdGameCommissionService::class, new class extends \App\Services\ThirdGameCommissionService {
+            public function rates(): array { return ['date' => '2026-10-26', 'fetched_at' => '2026-10-26 08:00:00',
+                'available' => true, 'ratios' => ['pg' => '0.09', 'ag' => '0.20', 'free' => '0']]; }
+        });
+    }
+
+    private function commissionOrder(int $id, array $extra = []): void
+    {
+        $user = $extra['user_id'] ?? 2;
+        DB::table('third_game_orders')->insert($extra + ['id' => $id, 'order_key' => hash('sha256', 'commission-' . $id),
+            'member_id' => (string) ($user * 100), 'user_id' => $user, 'platform' => 'pg', 'player_id' => 'u' . $user,
+            'game_type' => 2, 'game_name' => 'game-' . $id, 'bet_amount' => '100', 'valid_amount' => '95',
+            'settled_amount' => '-100', 'status' => 1, 'currency' => 'CNY', 'bet_time' => '2026-10-25 23:00:00',
+            'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-25 23:00:00', 'updated_at' => '2026-10-26 10:00:00']);
+        $this->sync->publish('third_game_orders', $id);
+    }
+
+    public function test_platform_commissions_sum_multiple_games_by_category_and_respect_all_three_report_scopes(): void
+    {
+        $this->commissionOrders();
+        $this->commissionOrder(1);
+        $this->commissionOrder(2, ['settled_amount' => '40']);
+        $this->commissionOrder(3, ['platform' => 'AG', 'game_type' => 1, 'settled_amount' => '-50']);
+        $this->commissionOrder(4, ['platform' => 'ag', 'user_id' => 3, 'settled_amount' => '-20']);
+        $this->commissionOrder(5, ['user_id' => 1, 'settled_amount' => '-10']);
+        $this->bet();
+        $business = $this->reader->summary($this->today());
+        $this->assertSame('20.3000000000', $business['summary']['third_game_commission']);
+        $this->assertSame('159.7000000000', $business['summary']['company_profit']);
+        $categories = collect($business['categories'])->keyBy('group_id');
+        $this->assertSame('10.3000000000', $categories['third:2']['third_game_commission']);
+        $this->assertSame('10.0000000000', $categories['third:1']['third_game_commission']);
+        $this->assertSame('0.0000000000', $categories['self:lottery']['third_game_commission']);
+        foreach (['promotion', 'sharing'] as $type) {
+            $report = $this->reader->summary($this->today(['report_type' => $type]));
+            $this->assertSame('19.4000000000', $report['summary']['third_game_commission']);
+            $this->assertSame(9, count($report['categories']));
+        }
+        $this->assertSame('15.4000000000', $this->reader->details($this->today(['report_type' => 'promotion', 'group_id' => '10']))['data'][0]['third_game_commission']);
+        $funds = $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1]);
+        $this->assertSame('20.3000000000', $funds['summary']['third_game_commission']);
+        $this->assertSame('20.3000000000', $funds['data'][0]['third_game_commission']);
+        $this->assertSame('0.0000000000', $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1, 'page' => 2])['data'][0]['third_game_commission']);
+        $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
+        $expense = collect($finance['expense'])->keyBy('key');
+        $this->assertSame('20.30', $expense['third_game_commission']['amount']);
+        $this->assertSame(3, $expense['third_game_commission']['people']);
+        $this->assertSame('20.30', $finance['expense_total']);
+        $this->assertSame('0.00', $expense['third_party_fee']['amount']);
+    }
+
+    public function test_missing_platform_rates_are_unknown_zero_rates_are_valid_and_cash_flow_is_preserved(): void
+    {
+        $this->commissionOrders();
+        $this->commissionOrder(1, ['platform' => 'free']);
+        $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['third_game_commission']);
+        $this->commissionOrder(2, ['platform' => 'missing']);
+        $this->commissionOrder(3);
+        $report = $this->reader->summary($this->today());
+        $row = $report['summary'];
+        $this->assertNull($row['third_game_commission']);
+        $this->assertSame('9.0000000000', $row['known_third_game_commission']);
+        $this->assertNull($row['company_profit']);
+        $this->assertSame('0.0000000000', $row['net_cash_flow']);
+        $this->assertSame(1, $row['quality']['missing_platform_ratio']);
+        $finance = \App\Services\FundsSummaryService::summary();
+        $this->assertNull(collect($finance['expense'])->keyBy('key')['third_game_commission']['amount']);
+        $this->assertNull($finance['expense_total']);
+        $this->assertSame('9.00', $finance['known_expense_total']);
+        $this->assertSame('9.0000000000', $finance['third_game_commission_meta']['known_amount']);
+    }
+
+    public function test_commission_excludes_pending_void_virtual_and_foreign_orders_and_uses_settlement_date(): void
+    {
+        $this->commissionOrders();
+        $this->commissionOrder(1);
+        $this->commissionOrder(2, ['status' => 0]);
+        $this->commissionOrder(3, ['status' => 2]);
+        $this->commissionOrder(4, ['status' => 3]);
+        $this->commissionOrder(5, ['user_id' => 4]);
+        $this->commissionOrder(6, ['last_update_time' => '2026-10-25 23:30:00']);
+        $this->assertSame('9.0000000000', $this->reader->summary($this->today())['summary']['third_game_commission']);
+        $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
+        $this->assertSame('9.00', $finance['expense_total']);
+        $this->commissionOrder(7, ['currency' => 'USD']);
+        $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
+        $this->assertNull($finance['expense_total']);
+        $this->assertSame(1, $finance['third_game_commission_meta']['quality']['foreign_currency']);
+        $this->assertSame('9.0000000000', $finance['third_game_commission_meta']['known_amount']);
+    }
+
+    public function test_all_report_types_and_finance_share_one_daily_supplier_request(): void
+    {
+        $this->commissionOrders(); $this->commissionOrder(1);
+        $this->app->instance(\App\Services\ThirdGameCommissionService::class, new \App\Services\ThirdGameCommissionService());
+        $this->app->instance('cache', new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore()));
+        $this->app->instance(\Illuminate\Http\Client\Factory::class, new \Illuminate\Http\Client\Factory());
+        config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test', 'third_game.key' => 'test']);
+        \Illuminate\Support\Facades\Http::preventStrayRequests();
+        \Illuminate\Support\Facades\Http::fake(['https://supplier.test/api/server/quota' => \Illuminate\Support\Facades\Http::response([
+            'code' => 10000, 'data' => ['ratios' => [['platfrom' => 'pg', 'ratio' => 0.09]]],
+        ])]);
+        foreach (['business', 'promotion', 'sharing'] as $type) {
+            $report = $this->reader->summary($this->today(['report_type' => $type]));
+            $this->assertSame('9.0000000000', $report['summary']['third_game_commission']);
+        }
+        $this->assertSame('9.0000000000', $this->reader->funds($this->today())['summary']['third_game_commission']);
+        $this->assertSame('9.00', \App\Services\FundsSummaryService::summary()['expense_total']);
+        \Illuminate\Support\Facades\Http::assertSentCount(1);
+    }
+
+    public function test_finance_commission_keeps_large_decimal_precision_and_reports_missing_source_data(): void
+    {
+        $this->commissionOrders();
+        $this->commissionOrder(1, ['bet_amount' => '99999999999999.1234567891', 'settled_amount' => '-99999999999999.1234567891']);
+        $this->assertSame('8999999999999.92', \App\Services\FundsSummaryService::summary()['expense_total']);
+        DB::table('third_game_orders')->where('id', 1)->update(['settled_amount' => null]);
+        $finance = \App\Services\FundsSummaryService::summary();
+        $this->assertNull($finance['expense_total']);
+        $this->assertSame(1, $finance['third_game_commission_meta']['quality']['missing_amount']);
+        DB::table('third_game_orders')->where('id', 1)->update(['settled_amount' => '-100', 'last_update_time' => null]);
+        $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
+        $this->assertNull($finance['expense_total']);
+        $this->assertSame(1, $finance['third_game_commission_meta']['quality']['missing_time']);
+    }
+
     #[DataProvider('thirdPartyReportCases')]
     public function test_third_party_categories_use_provider_settlement_time_and_preserve_net_payout_math(int $type, int $status, string $net, string $stake, string $payout, string $refund, string $gross): void
     {
-        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
+        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,platform TEXT DEFAULT "pg",player_id TEXT DEFAULT "u2",game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
         DB::table('third_game_orders')->insert(['id' => 1, 'order_key' => str_repeat('b', 64), 'member_id' => '200', 'user_id' => 2, 'game_type' => $type,
             'bet_amount' => '100', 'settled_amount' => $net, 'status' => $status, 'currency' => 'CNY', 'bet_time' => '2026-10-25 23:00:00',
             'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 12:00:00', 'updated_at' => '2026-10-26 13:00:00']);

+ 82 - 0
tests/Integration/ThirdGameCommissionRatesTest.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\ThirdGameCommissionService;
+use Carbon\Carbon;
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Cache\Repository as CacheRepository;
+use Illuminate\Config\Repository;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Client\Factory;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Http;
+use PHPUnit\Framework\TestCase;
+
+class ThirdGameCommissionRatesTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['third_game' => [
+            'api_url' => 'https://supplier.test', 'sn' => 'test-merchant', 'key' => 'test-secret', 'currency' => 'CNY',
+        ]]));
+        $app->instance('cache', new CacheRepository(new ArrayStore()));
+        $app->instance(Factory::class, new Factory());
+        $app->instance('log', new \Psr\Log\NullLogger());
+        Facade::setFacadeApplication($app);
+        Carbon::setTestNow(Carbon::parse('2026-11-04 12:00:00', 'Asia/Shanghai'));
+        Http::preventStrayRequests();
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    public function test_documented_platform_spelling_zero_rates_and_daily_refresh(): void
+    {
+        Http::fake(['https://supplier.test/api/server/quota' => Http::sequence()
+            ->push(['code' => 10000, 'data' => ['ratios' => [
+                ['platfrom' => ' AG ', 'ratio' => 0.09], ['platfrom' => 'pg', 'ratio' => '0'],
+            ]]])
+            ->push(['code' => 10000, 'data' => ['ratios' => [['platfrom' => 'ag', 'ratio' => '0.10']]]])]);
+        $service = new ThirdGameCommissionService();
+        $first = $service->rates();
+        $this->assertSame(['ag' => '0.0900000000', 'pg' => '0.0000000000'], $first['ratios']);
+        $this->assertSame($first, (new ThirdGameCommissionService())->rates());
+        Http::assertSentCount(1);
+        Http::assertSent(fn ($request) => $request->method() === 'POST' && $request['currency'] === 'CNY'
+            && $request->hasHeader('sn', 'test-merchant') && $request->hasHeader('sign'));
+
+        Carbon::setTestNow(Carbon::parse('2026-11-05 00:00:01', 'Asia/Shanghai'));
+        $this->assertSame('0.1000000000', $service->rates()['ratios']['ag']);
+        Http::assertSentCount(2);
+    }
+
+    public function test_provider_failure_is_cached_for_the_day_and_does_not_turn_into_a_zero_rate(): void
+    {
+        Http::fake(['*' => Http::response(['code' => 10404, 'data' => null])]);
+        $service = new ThirdGameCommissionService();
+        $this->assertFalse($service->rates()['available']);
+        $this->assertSame([], $service->rates()['ratios']);
+        Http::assertSentCount(1);
+    }
+
+    public function test_malformed_and_conflicting_rates_are_not_accepted_and_merchant_caches_are_isolated(): void
+    {
+        Http::fake(['*' => Http::response(['code' => 10000, 'data' => ['ratios' => [
+            ['platfrom' => 'negative', 'ratio' => '-0.1'], ['platfrom' => 'large', 'ratio' => '1.01'],
+            ['platfrom' => 'bad', 'ratio' => 'NaN'], ['platfrom' => 'ag', 'ratio' => '.09'],
+            ['platfrom' => 'pg', 'ratio' => '0.1'], ['platfrom' => 'PG', 'ratio' => '0.2'],
+            ['platform' => 'pp', 'ratio' => '0.1234567891'],
+        ]]])]);
+        $service = new ThirdGameCommissionService();
+        $this->assertSame(['pp' => '0.1234567891'], $service->rates()['ratios']);
+        config(['third_game.sn' => 'another-merchant']);
+        $service->rates(); Http::assertSentCount(2);
+        config(['third_game.currency' => 'USD']);
+        $service->rates(); Http::assertSentCount(3);
+    }
+}

+ 176 - 0
tests/Regression/yuebao_available_interest.php

@@ -0,0 +1,176 @@
+<?php
+namespace app\api\controller {
+    class BaseApiController {
+        protected int $userId = 0;
+        protected string $lang = 'zh';
+        public $request;
+        public function __construct($userId = 0) { $this->userId = $userId; $this->request = \request(); }
+        protected function success($message = '', $data = []) { return ['code' => 1, 'data' => $data, 'msg' => $message]; }
+        protected function fail($message = '') { return ['code' => 0, 'msg' => $message]; }
+    }
+}
+namespace app\api\lists {
+    class BaseApiDataLists {
+        protected int $userId = 0;
+        protected string $lang = 'zh';
+        protected array $params = [];
+        protected array $searchWhere = [];
+        public int $limitOffset = 0;
+        public int $limitLength = 1;
+        public function __construct($userId = 0, $type = null) { $this->userId = $userId; $this->params = ['type' => $type]; }
+        public function setParams($params): self { $this->params = $params; return $this; }
+    }
+}
+namespace {
+    function request() { return $GLOBALS['activity_test_request']; }
+    function config($key, $default = null) { return $default; }
+    function env($key, $default = '') { return $key === 'app.bot_host' ? 'https://example.test' : $default; }
+    function ensureUrl($url, $host = '') { return $url; }
+    function json($value) { return $value; }
+    class TestRequest {
+        public array $query = [];
+        public array $postData = [];
+        public array $headers = [];
+        public function param($key, $default = null) { return $this->postData[$key] ?? $this->query[$key] ?? $default; }
+        public function get($key, $default = null) { return $this->query[$key] ?? $default; }
+        public function header($key, $default = null) { return $this->headers[strtolower($key)] ?? $default; }
+        public function post($key, $default = null, $filter = null) { $v = $this->postData[$key] ?? $default; return $filter ? $filter($v) : $v; }
+    }
+    $GLOBALS['activity_test_request'] = new TestRequest();
+    $repo = getenv('ACTIVITY_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
+    $stage = getenv('ACTIVITY_SITE_STAGE') ?: $repo;
+    $loader = require $repo . '/vendor/autoload.php';
+    $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
+    $db = new \think\DbManager();
+    $db->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => false]], 'auto_timestamp' => false]);
+    \think\Container::getInstance()->instance(\think\DbManager::class, $db);
+    \think\Container::getInstance()->instance('log', new \Psr\Log\NullLogger());
+    $tables = [
+        'bot_users' => 'id INTEGER PRIMARY KEY,user_id TEXT,member_id TEXT,created_at TEXT,delete_time INTEGER,recharge_channel_group_id INTEGER',
+        'bot_user_login' => 'id INTEGER PRIMARY KEY,user_id TEXT,status INTEGER,platform TEXT',
+        'bot_balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount TEXT,created_at TEXT,updated_at TEXT,remark TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,frozen_status INTEGER',
+        'bot_recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT',
+        'bot_activity_rewards' => 'id INTEGER PRIMARY KEY,title TEXT,sub_title TEXT,start_time INTEGER,end_time INTEGER,detail_image TEXT,type INTEGER,lang TEXT,game_category TEXT,click_type TEXT DEFAULT "none",link TEXT,link_pc TEXT,link_h5 TEXT,click_position TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,publish_platforms TEXT,budget TEXT,budget_used TEXT DEFAULT "0",quota_enabled INTEGER DEFAULT 0,reward_quota INTEGER,rewarded_count INTEGER DEFAULT 0,sort INTEGER DEFAULT 0,register_time_limited INTEGER,register_start_time TEXT,register_end_time TEXT,register_platforms TEXT,deposit_limited INTEGER,deposit_min TEXT,deposit_max TEXT,show_ineligible INTEGER DEFAULT 1,status INTEGER,params TEXT,created_at TEXT,updated_at TEXT',
+        'bot_activity_reward_recipients' => 'id INTEGER PRIMARY KEY,activity_id INTEGER NOT NULL,member_id TEXT NOT NULL,created_at TEXT,UNIQUE(activity_id,member_id)',
+        'bot_wallets' => 'id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,yuebao TEXT,yuebao_invest TEXT',
+        'bot_yuebao_item' => 'id INTEGER PRIMARY KEY,user_id TEXT,activity_id INTEGER,principal TEXT,surplus_principal TEXT,status INTEGER DEFAULT 1,daily_interest_rate TEXT,expire_time INTEGER,create_time INTEGER DEFAULT 0,update_time INTEGER',
+        'bot_yuebao_log' => 'id INTEGER PRIMARY KEY,user_id TEXT,item_id INTEGER,amount TEXT,type INTEGER,interest TEXT,hold_hours TEXT,create_time INTEGER,update_time INTEGER',
+    ];
+    foreach ($tables as $table => $columns) { $db->execute('CREATE TABLE ' . $table . '(' . $columns . ')'); }
+    $assertions = 0;
+    function check($condition, $message) { global $assertions; ++$assertions; if (!$condition) { throw new \RuntimeException($message); } }
+    function row($table, $data) { \think\facade\Db::table($table)->insert($data); }
+    function activity($id, $override = []) {
+        row('bot_activity_rewards', array_merge(['id' => $id, 'title' => 'Activity ' . $id, 'status' => 1, 'type' => 5, 'lang' => 'zh', 'sort' => 0, 'start_time' => time() - 100, 'end_time' => time() + 10000, 'show_ineligible' => 1, 'budget_used' => '0.00', 'detail_image' => '', 'pc_image' => '', 'detail_banner' => '', 'pc_banner' => ''], $override));
+    }
+    row('bot_users', ['id' => 1, 'user_id' => '1001', 'member_id' => '2001', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_recharge_channel_group', ['id' => 1, 'activity_type' => 'recharge,yuebao']);
+    activity(10, ['sort'=>10, 'type'=>6, 'quota_enabled'=>1, 'reward_quota'=>1, 'params'=>'{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
+    row('bot_wallets', ['id' => 1, 'member_id' => '2001', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
+    row('bot_yuebao_item', ['id' => 1, 'user_id' => '1001', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
+    $command = new \app\api\command\YuebaoInvest();
+    $invest = new \ReflectionMethod($command, 'invest');
+    $invest->invoke($command);
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
+    check(bccomp($wallet['available_balance'], '151.00', 2) === 0, 'Automatic expiry credits principal and interest available');
+    check(bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Automatic expiry leaves frozen balance unchanged');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Automatic expiry records first distinct winner');
+    $invest->invoke($command);
+    check(\think\facade\Db::table('bot_yuebao_log')->count() === 1, 'Repeated expiry does not duplicate settlement');
+    row('bot_users', ['id' => 2, 'user_id' => '1002', 'member_id' => '2002', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_wallets', ['id' => 2, 'member_id' => '2002', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
+    row('bot_yuebao_item', ['id' => 2, 'user_id' => '1002', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
+    $invest->invoke($command);
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
+    check(bccomp($wallet['available_balance'], '150.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Full winner quota returns other member principal without interest');
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 3, 'user_id' => '1001', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
+    $invest->invoke($command);
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
+    check(bccomp($wallet['available_balance'], '252.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Existing winner continues to receive saved-rate interest at full quota');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Second position of same member does not add another winner');
+    $offset = 12 - (int)gmdate('G');
+    date_default_timezone_set('Etc/GMT' . ($offset >= 0 ? '-' : '+') . abs($offset));
+    activity(20, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 4, 'user_id' => '1001', 'activity_id' => 20, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    request()->postData = ['amount' => '100.00'];
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['frozen_balance'=>'7.00']);
+    $yuebao = new \app\api\controller\YuebaoController(1001);
+    $out = $yuebao->out();
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
+    check($out['code'] === 1 && bccomp((string)$out['data']['interest'], '1.00', 2) === 0, 'Manual out awards interest and occupies first-user slot');
+    check(bccomp($wallet['available_balance'], '353.00', 2) === 0 && bccomp($wallet['frozen_balance'], '7.00', 2) === 0, 'Manual out credits principal and interest available and preserves historical frozen funds');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 20)->value('rewarded_count') === 1, 'Manual payout counts one distinct member');
+    check($yuebao->out()['code'] === 0, 'Repeated manual out cannot settle the same position twice');
+    \think\facade\Db::table('bot_wallets')->where('id', 2)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 5, 'user_id' => '1002', 'activity_id' => 20, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    check(bccomp((string)(new \app\api\controller\YuebaoController(1002))->out()['data']['interest'], '0', 2) === 0, 'Manual out denies other-member interest at full quota');
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
+    check(bccomp($wallet['available_balance'], '250.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Manual quota denial preserves principal');
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 6, 'user_id' => '1001', 'activity_id' => null, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    check(bccomp((string)$yuebao->out()['data']['interest'], '1.00', 2) === 0, 'Legacy unlinked position keeps saved rate without invented activity association');
+    check(\app\common\model\ActivityRewards::getYuebaoActivity('2001')['id'] === 10, 'Existing winner remains eligible for new positions when quota is full');
+    row('bot_users', ['id' => 3, 'user_id' => '1003', 'member_id' => '2003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_user_login', ['id' => 4, 'user_id' => '2003', 'status' => 1, 'platform' => 'iOS']);
+    row('bot_wallets', ['id' => 3, 'member_id' => '2003', 'available_balance' => '500.00', 'frozen_balance' => '0.00', 'yuebao' => '0.00', 'yuebao_invest' => '0.00']);
+    row('bot_balance_logs', ['member_id' => '2003', 'type' => 1, 'change_type' => '极速28投注', 'amount' => '-10000.00', 'created_at' => date('Y-m-d H:i:s')]);
+    $newMember = new \app\api\controller\YuebaoController(1003);
+    $beforeItems = \think\facade\Db::table('bot_yuebao_item')->count();
+    check($newMember->add()['code'] === 0, 'Different member cannot open linked position when all eligible configs have full quotas');
+    check(bccomp((string)\think\facade\Db::table('bot_wallets')->where('id', 3)->value('available_balance'), '500.00', 2) === 0 && \think\facade\Db::table('bot_yuebao_item')->count() === $beforeItems, 'Denied new position leaves wallet and positions unchanged');
+    activity(21, ['quota_enabled' => 1, 'type' => 6, 'sort' => 8, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
+    check($newMember->add()['code'] === 1, 'Qualified new position succeeds with user slots available');
+    check((int)\think\facade\Db::table('bot_yuebao_item')->order('id', 'desc')->value('activity_id') === 21, 'New position links selected activity for payout');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 21)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 21)->count() === 0, 'Principal transfer and position creation do not consume a winner slot');
+    activity(22, ['quota_enabled' => 1, 'type' => 6, 'sort' => 9, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00"}']);
+    \think\facade\Db::startTrans();
+    $locked = \app\common\service\ActivityQuotaService::lockActivities([22, 21, 22, null, 0, 999]);
+    check(array_keys($locked) === [21, 22], 'Quota locks use unique ascending IDs and exclude absent rows');
+    \think\facade\Db::commit();
+    foreach ([[4, '3001', [[50,22],[51,21]]], [5, '3002', [[60,21],[61,22]]]] as [$userPk, $userId, $items]) {
+        row('bot_users', ['id' => $userPk, 'user_id' => $userId, 'member_id' => $userId, 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+        row('bot_wallets', ['id' => $userPk, 'member_id' => $userId, 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '200.00', 'yuebao_invest' => '0.00']);
+        foreach ($items as [$itemId, $activityId]) {
+            row('bot_yuebao_item', ['id' => $itemId, 'user_id' => $userId, 'activity_id' => $activityId, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+        }
+        request()->postData = ['amount' => '200.00'];
+        $multi = (new \app\api\controller\YuebaoController((int)$userId))->out();
+        check($multi['code'] === 1 && bccomp((string)$multi['data']['interest'], '2.00', 2) === 0, 'Two linked quota payouts succeed independently of FIFO activity order for ' . $userId);
+    }
+    foreach ([21, 22] as $id) {
+        check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', $id)->value('rewarded_count') === 2, 'Each activity tracks two distinct winning members: ' . $id);
+    }
+    activity(30, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1]);
+    row('bot_users', ['id' => 6, 'user_id' => '3003', 'member_id' => '3003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_wallets', ['id' => 6, 'member_id' => '3003', 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
+    row('bot_yuebao_item', ['id' => 70, 'user_id' => '3003', 'activity_id' => 30, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    $db->execute("CREATE TRIGGER fail_yuebao_log BEFORE INSERT ON bot_yuebao_log WHEN NEW.item_id = 70 BEGIN SELECT RAISE(ABORT, 'simulated payout write failure'); END");
+    request()->postData = ['amount' => '100.00'];
+    $failureController = new \app\api\controller\YuebaoController(3003);
+    check($failureController->out()['code'] === 0, 'Actual settlement write failure rejects payout');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 30)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 30)->count() === 0, 'Failed payout rolls back recipient and distinct-winner slot');
+    $failedWallet = \think\facade\Db::table('bot_wallets')->where('id', 6)->find();
+    check(bccomp($failedWallet['yuebao'], '100.00', 2) === 0 && bccomp($failedWallet['available_balance'], '0.00', 2) === 0 && bccomp($failedWallet['frozen_balance'], '0.00', 2) === 0, 'Failed payout leaves original principal and balances intact');
+    check((int)\think\facade\Db::table('bot_yuebao_item')->where('id', 70)->value('status') === 1 && bccomp((string)\think\facade\Db::table('bot_yuebao_item')->where('id', 70)->value('surplus_principal'), '100.00', 2) === 0, 'Failed payout keeps the position unsettled');
+    $db->execute('DROP TRIGGER fail_yuebao_log');
+    check($failureController->out()['code'] === 1 && (int)\think\facade\Db::table('bot_activity_rewards')->where('id', 30)->value('rewarded_count') === 1, 'Retried successful payout occupies exactly one winner slot');
+    row('bot_users', ['id'=>8, 'user_id'=>'4000', 'member_id'=>'4000']);
+    row('bot_wallets', ['id'=>8, 'member_id'=>'4000', 'available_balance'=>'0.1234567890', 'frozen_balance'=>'9.00', 'yuebao'=>'100', 'yuebao_invest'=>'0']);
+    row('bot_yuebao_item', ['id'=>80, 'user_id'=>'4000', 'principal'=>'100', 'surplus_principal'=>'100', 'daily_interest_rate'=>'1', 'expire_time'=>time()+3600, 'create_time'=>time()-86400]);
+    request()->postData = ['amount'=>'50.00'];
+    $partial = (new \app\api\controller\YuebaoController(4000))->out();
+    $partialWallet = \think\facade\Db::table('bot_wallets')->where('id',8)->find();
+    check($partial['code'] === 1 && bccomp($partialWallet['available_balance'],'50.6234567890',10) === 0, 'Partial payout credits principal plus interest without losing wallet precision');
+    check(bccomp($partialWallet['frozen_balance'],'9',10) === 0 && bccomp($partialWallet['yuebao'],'50',2) === 0, 'Partial payout preserves frozen funds and remaining principal');
+    $payoutLogs = \think\facade\Db::table('bot_balance_logs')->where('member_id','4000')->order('id')->select()->toArray();
+    check(count($payoutLogs) === 2 && bccomp($payoutLogs[0]['after_balance'],$payoutLogs[1]['before_balance'],10) === 0, 'Principal and interest ledger balances are continuous');
+    $logs = \think\facade\Db::table('bot_balance_logs')->where('member_id', '2001')->where('change_type', '余额宝利息')->select()->toArray();
+    check(count($logs) === 4, 'Exactly one interest credit per successful payout');
+    foreach ($logs as $log) {
+        check((int)$log['type'] === 1 && $log['frozen_status'] === null, 'Interest is available and never marked for unfreezing');
+        check(bccomp(bcsub($log['after_balance'], $log['before_balance'], 10), $log['amount'], 10) === 0, 'Interest ledger balances reconcile');
+    }
+    echo 'PASS ' . $assertions . " adapter assertions (actual ThinkORM + isolated in-memory SQLite; no live database)\n";
+}