doge 1 день назад
Родитель
Сommit
5b5d83e8dd

+ 7 - 4
app/Console/Commands/OperationData.php

@@ -9,6 +9,7 @@ use App\Models\Order;
 use App\Models\Wallet;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
+use App\Support\StatsScope;
 
 class OperationData extends Command
 {
@@ -46,25 +47,27 @@ class OperationData extends Command
             'recharge' => 0,
             'user_total_money' => 0,
         ];
-        $data['recharge'] = FundsRecord::whereIn('change_type', ['充值','人工充值','三方充值'])
+        // 测试代理名下会员不统计
+        $data['recharge'] = StatsScope::excludeTestMembers(FundsRecord::query())->whereIn('change_type', ['充值','人工充值','三方充值'])
             ->where('created_at', '>=', "{$yesterday} 00:00:00")
             ->where('created_at', '<=', "{$yesterday} 23:59:59")
             ->sum('amount');
 
-        $data['withdraw'] = FundsRecord::whereIn('change_type', ['提现','三方提现'])
+        $data['withdraw'] = StatsScope::excludeTestMembers(FundsRecord::query())->whereIn('change_type', ['提现','三方提现'])
             ->where('created_at', '>=', "{$yesterday} 00:00:00")
             ->where('created_at', '<=', "{$yesterday} 23:59:59")
             ->sum('amount');
 
         $data['balance_difference'] = $data['recharge'] - $data['withdraw'];
-        $data['total_price'] = Order::where('create_time', '>=', strtotime($yesterday.' 00:00:00'))
+        // sport_order.user_id 存的是会员 member_id
+        $data['total_price'] = StatsScope::excludeTestMembers(Order::query(), 'user_id')->where('create_time', '>=', strtotime($yesterday.' 00:00:00'))
             ->where('create_time', '<=',  strtotime($yesterday.' 23:59:59'))
             ->where('status', 1)
             ->where('pay_status', 1)
             ->where('return_status', 0)
             ->sum('amount');
 
-        $data['user_total_money'] = Wallet::query()->sum('available_balance');
+        $data['user_total_money'] = StatsScope::excludeTestMembers(Wallet::query())->sum('available_balance');
 
         $info = Operation::where('date', $data['date'])->first();
         

+ 4 - 1
app/Http/Controllers/admin/Agent.php

@@ -35,7 +35,7 @@ class Agent extends AgentApiController
             $data = $request->validate([
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
                 'username' => ['nullable', 'string', 'max:64'], 'real_name' => ['nullable', 'string', 'max:128'],
-                'type' => ['nullable', 'integer', 'in:1,2'], 'status' => ['nullable', 'integer', 'in:0,1'],
+                'type' => ['nullable', 'integer', 'in:1,2'], 'status' => ['nullable', 'integer', 'in:0,1'], 'is_test' => ['nullable', 'integer', 'in:0,1'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
             ]);
             $page = max(1, (int) ($data['page'] ?? 1));
@@ -45,6 +45,7 @@ class Agent extends AgentApiController
             if (!empty($data['real_name'])) $query->where('real_name', 'like', '%' . $data['real_name'] . '%');
             if (!empty($data['type'])) $query->where('type', $data['type']);
             if (isset($data['status'])) $query->where('status', $data['status']);
+            if (isset($data['is_test'])) $query->where('is_test', $data['is_test']);
             if (!empty($data['start_date'])) $query->where('created_at', '>=', $data['start_date'] . ' 00:00:00');
             if (!empty($data['end_date'])) $query->where('created_at', '<=', $data['end_date'] . ' 23:59:59');
             $total = (clone $query)->count();
@@ -478,6 +479,8 @@ class Agent extends AgentApiController
             'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'status' => ['nullable', 'integer', 'in:0,1'],
+            // 测试代理:名下会员和自身资金不参与总后台统计
+            'is_test' => ['nullable', 'integer', 'in:0,1'],
             'initial_balance' => $update ? ['prohibited'] : ['nullable', 'numeric', 'min:0', new DecimalNumber()],
         ];
     }

+ 14 - 9
app/Http/Controllers/admin/Home.php

@@ -2,6 +2,8 @@
 
 namespace App\Http\Controllers\admin;
 
+use App\Support\StatsScope;
+
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Models\PaymentOrder;
@@ -34,6 +36,7 @@ class Home extends Controller
             //投注统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%投注')->where('users.from', '<>', 2);
+            StatsScope::excludeTestUsers($query, 'users'); // 测试代理名下会员不统计
             $query1 = clone $query;
             $bet['amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注金额
             $bet['amount'] = abs($bet['amount']);
@@ -54,6 +57,7 @@ class Home extends Controller
             //投注退款统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%退款')->where('users.from', '<>', 2);
+            StatsScope::excludeTestUsers($query, 'users'); // 测试代理名下会员不统计
             $query1 = clone $query;
             $bet['refund_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注退款金额
             $query1 = clone $query;
@@ -70,6 +74,7 @@ class Home extends Controller
             //投注中奖统计
             $query = BalanceLog::leftJoin('users', 'users.member_id', '=', 'balance_logs.member_id');
             $query->where('balance_logs.type', 1)->where('balance_logs.change_type', 'like', '%中奖')->where('users.from', '<>', 2);
+            StatsScope::excludeTestUsers($query, 'users'); // 测试代理名下会员不统计
             $query1 = clone $query;
             $bet['win_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$start, $end])->sum('amount');//投注中奖金额
             $query1 = clone $query;
@@ -84,7 +89,7 @@ class Home extends Controller
             $virtual_bet['today_win_amount'] = (float)$query1->whereBetween('balance_logs.created_at', [$today, $end])->sum('amount');//今日投注中奖金额
 
             //余额宝统计
-            $query = BalanceLog::query();
+            $query = StatsScope::excludeTestMembers(BalanceLog::query());
             $yuebao['in_amount'] = (float)$query->where('change_type', '余额宝转入')->whereBetween('created_at', [$start, $end])->sum('amount');//余额宝转入金额
             $yuebao['in_amount'] = abs($yuebao['in_amount']);
             $yuebao['today_in_amount'] = (float)$query->where('change_type', '余额宝转入')->whereBetween('created_at', [$today, $end])->sum('amount');//今日余额宝转入金额
@@ -117,7 +122,7 @@ class Home extends Controller
             $activity['today_unfreeze_amount'] = abs($activity['today_unfreeze_amount']);
 
             //新增会员统计
-            $query = User::where('from','<>',2);
+            $query = StatsScope::excludeTestUsers(User::where('from','<>',2), 'users');
             $totalUser = (float)$query->whereBetween('created_at', [$start, $end])->count('id');
             $todayUser = (float)$query->whereBetween('created_at', [$today, $end])->count('id');//今日新增会员
 
@@ -127,7 +132,7 @@ class Home extends Controller
             $todayVirtualUser = (float)$query->whereBetween('created_at', [$today, $end])->count('id');//今日新增会员
             
             //第三方提现统计(RMB)
-            $query = PaymentOrder::query();
+            $query = StatsScope::excludeTestMembers(PaymentOrder::query());
             $query->whereBetween('created_at', [$start, $end]);
             $query1 = clone $query;
             $totalAmount = (float)$query1->where('type', 2)->sum('amount');
@@ -142,7 +147,7 @@ class Home extends Controller
 
             
             //第三方提现统计(RMB)今日
-            $query = PaymentOrder::query();
+            $query = StatsScope::excludeTestMembers(PaymentOrder::query());
             $query->whereBetween('created_at', [$today, $end]);
             $query1 = clone $query;
             $todayAmount = (float)$query1->where('type', 2)->sum('amount');
@@ -182,7 +187,7 @@ class Home extends Controller
             $rechargeRmbTodayFail = (float)$query1->where('type', 1)->where('status', 3)->sum('amount');//失败
 
             //USDT提现统计
-            $query = Withdraw::query();
+            $query = StatsScope::excludeTestMembers(Withdraw::query());
             $query->whereBetween('created_at', [$start, $end]);
             $query1 = clone $query;
             $usdTotalAmount = (float)$query1->sum('amount');//总金额
@@ -192,7 +197,7 @@ class Home extends Controller
             $usdTotalFail = (float)$query1->where('status', 2)->sum('amount');//失败
             
             //USDT提现统计(今日)
-            $query = Withdraw::query();
+            $query = StatsScope::excludeTestMembers(Withdraw::query());
             $query->whereBetween('created_at', [$today, $end]);
             $query1 = clone $query;
             $usdTodayAmount = (float)$query1->sum('amount');//总金额
@@ -202,7 +207,7 @@ class Home extends Controller
             $usdTodayFail = (float)$query1->where('status', 2)->sum('amount');//失败
             
             //USDT提现统计(今日)
-            $query = Withdraw::query();
+            $query = StatsScope::excludeTestMembers(Withdraw::query());
             $query->whereBetween('created_at', [$today, $end]);
             $query1 = clone $query;
             $usdTodayAmount = (float)$query1->sum('amount');//总金额
@@ -213,7 +218,7 @@ class Home extends Controller
 
 
             //USDT充值统计
-            $query = Recharge::query();
+            $query = StatsScope::excludeTestMembers(Recharge::query());
             $query->whereBetween('created_at', [$start, $end]);
             $query1 = clone $query;
             $rechargeTotalAmount = (float)$query1->sum('amount');//总充值
@@ -223,7 +228,7 @@ class Home extends Controller
             $rechargeTotalFail = (float)$query1->where('status', 2)->sum('amount');//失败
 
             //USDT充值(今日)
-            $query = Recharge::query();
+            $query = StatsScope::excludeTestMembers(Recharge::query());
             $query->whereBetween('created_at', [$today, $end]);
             $query1 = clone $query;
             $rechargeTodayAmount = (float)$query1->sum('amount');//总充值

+ 2 - 1
app/Http/Controllers/admin/Operation.php

@@ -34,7 +34,8 @@ class Operation extends Controller
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
-            $query = User::where('users.from','<>', 2);
+            // 排除试玩账号和测试会员(测试代理名下)
+            $query = \App\Support\StatsScope::excludeTestUsers(User::where('users.from','<>', 2), 'users');
             if (isset($params['user_id'])) {
                 $user_id = $params['user_id'] ?? null;
                 $query->where(function ($query1) use ($user_id) {

+ 2 - 2
app/Models/Agent/Agent.php

@@ -21,14 +21,14 @@ class Agent extends BaseModel
         'real_name', 'language', 'invitation_code', 'balance', 'frozen_balance',
         'deposit_fee_rate', 'withdraw_fee_rate',
         'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
-        'profit_commission_profile_id', 'type',
+        'profit_commission_profile_id', 'type', 'is_test',
     ];
     // realtime_balance 是系统内部的实时保证金依据,不对代理端返回。
     protected $hidden = ['password', 'withdraw_password', 'realtime_balance'];
     protected $appends = ['type_text'];
     protected $casts = [
         'owner_user_id' => 'integer',
-        'parent_id' => 'integer', 'level' => 'integer', 'status' => 'integer', 'type' => 'integer',
+        'parent_id' => 'integer', 'level' => 'integer', 'status' => 'integer', 'type' => 'integer', 'is_test' => 'integer',
         'balance' => 'decimal:4', 'frozen_balance' => 'decimal:4',
         'margin_amount' => 'decimal:4', 'realtime_balance' => 'decimal:4',
         'profit_active' => 'integer', 'profit_active_changed_at' => 'datetime',

+ 4 - 3
app/Services/Agent/AgentEquityService.php

@@ -112,7 +112,8 @@ class AgentEquityService
         if (!empty($params['start_date'])) $query->where($dateColumn, '>=', $params['start_date'] . ' 00:00:00');
         if (!empty($params['end_date'])) $query->where($dateColumn, '<=', $params['end_date'] . ' 23:59:59');
 
-        $sums = (clone $query)->selectRaw(
+        // 汇总不含测试代理(列表照常显示)
+        $sums = \App\Support\StatsScope::excludeTestAgents(clone $query)->selectRaw(
             'COALESCE(SUM(CASE WHEN status = ? THEN percent ELSE 0 END), 0) AS completed_percent, '
             . 'COALESCE(SUM(CASE WHEN status = ? THEN amount ELSE 0 END), 0) AS completed_amount, '
             . 'COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS pending_count, '
@@ -120,14 +121,14 @@ class AgentEquityService
             [AgentEquityOrder::STATUS_COMPLETED, AgentEquityOrder::STATUS_COMPLETED, AgentEquityOrder::STATUS_PENDING, AgentEquityOrder::STATUS_PENDING]
         )->first();
         $total = (clone $query)->count();
-        $list = $query->with('agent:id,username,real_name,type,equity_percent,balance')->orderByDesc('id')->forPage($page, $limit)->get();
+        $list = $query->with('agent:id,username,real_name,type,equity_percent,balance,is_test')->orderByDesc('id')->forPage($page, $limit)->get();
         $summary = [
             'completed_percent' => bcadd((string) $sums->completed_percent, '0', 4),
             'completed_amount' => bcadd((string) $sums->completed_amount, '0', 4),
             'pending_count' => (int) $sums->pending_count,
             'pending_amount' => bcadd((string) $sums->pending_amount, '0', 4),
             // 全平台入股代理当前持股合计(不受筛选影响)
-            'total_equity_percent' => bcadd((string) Agent::query()->where('type', Agent::TYPE_EQUITY)->sum('equity_percent'), '0', 4),
+            'total_equity_percent' => bcadd((string) \App\Support\StatsScope::excludeTestAgents(Agent::query()->where('type', Agent::TYPE_EQUITY), 'id')->sum('equity_percent'), '0', 4),
         ];
         return compact('total', 'page', 'limit', 'list', 'summary');
     }

+ 2 - 1
app/Services/Agent/AgentPeriodReportService.php

@@ -117,8 +117,9 @@ class AgentPeriodReportService
     private function members(Agent $agent)
     {
         $query = DB::table('users');
+        // 入股股东看全平台:排除试玩账号和测试会员;代理看自己名下的会员(测试代理也照常显示自己的)
         return $agent->isEquity()
-            ? $query->where(fn ($q) => $q->whereNull('from')->orWhere('from', '<>', 2))
+            ? \App\Support\StatsScope::realUsers($query, 'users')
             : $query->where('agent_id', $agent->id);
     }
 

+ 5 - 1
app/Services/Agent/AgentReportService.php

@@ -27,7 +27,8 @@ class AgentReportService
     public function dashboard(Agent $agent, string $startDate, string $endDate): array
     {
         if ($agent->isEquity()) {
-            $members = User::query()->where(fn ($q) => $q->whereNull('from')->orWhere('from', '<>', 2));
+            // 全平台:排除试玩账号和测试会员(测试代理名下)
+            $members = \App\Support\StatsScope::realUsers(User::query(), 'users');
             return $this->aggregate($members, 0, [], $startDate, $endDate) + ['scope' => 'platform'];
         }
         return $this->summaryForTree($agent, $startDate, $endDate) + ['scope' => 'own'];
@@ -176,6 +177,9 @@ class AgentReportService
         if ($scope) {
             $ids = $this->visibleAgentIds($scope);
             $query->whereIn('id', $ids);
+        } else {
+            // 总后台代理报表:不列出测试代理
+            \App\Support\StatsScope::excludeTestAgents($query, 'id');
         }
         if (!empty($params['username'])) {
             $query->where('username', 'like', '%' . $params['username'] . '%');

+ 2 - 1
app/Services/Agent/AgentService.php

@@ -46,6 +46,7 @@ class AgentService
                 'deposit_fee_rate' => (string) ($data['deposit_fee_rate'] ?? 0),
                 'withdraw_fee_rate' => (string) ($data['withdraw_fee_rate'] ?? 0),
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
+                'is_test' => (int) ($data['is_test'] ?? 0),
                 'created_by' => $createdBy,
             ] + array_intersect_key($data, ['language' => true]));
             $agent->path = '/' . $agent->id . '/';
@@ -65,7 +66,7 @@ class AgentService
             if (array_key_exists('real_name', $data)) {
                 $agent->real_name = trim((string) ($data['real_name'] ?? ''));
             }
-            foreach (['username', 'language', 'status', 'deposit_fee_rate', 'withdraw_fee_rate'] as $field) {
+            foreach (['username', 'language', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'is_test'] as $field) {
                 if (array_key_exists($field, $data) && $data[$field] !== null) {
                     $agent->{$field} = $data[$field];
                 }

+ 8 - 7
app/Services/FundsSummaryService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use Carbon\Carbon;
+use App\Support\StatsScope;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 
@@ -134,7 +135,7 @@ class FundsSummaryService
         $result = ['deposit_fee' => 0, 'deposit_fee_people' => 0, 'withdraw_fee' => 0, 'withdraw_fee_people' => 0,
             'channel_fee' => 0, 'channel_fee_people' => 0, 'unrecorded' => 0];
         if (Schema::hasTable('agent_payment_orders')) {
-            $deposit = self::applyDateRange(DB::table('agent_payment_orders')
+            $deposit = self::applyDateRange(StatsScope::excludeTestAgents(DB::table('agent_payment_orders'))
                 ->where('type', PaymentOrderService::TYPE_PAY)
                 ->where('status', PaymentOrderService::STATUS_SUCCESS)
                 ->where('fee', '>', 0), 'created_at', $start, $end)
@@ -143,7 +144,7 @@ class FundsSummaryService
             $result = array_merge($result, (array)$deposit);
         }
         if (Schema::hasTable('agent_withdrawals') && Schema::hasColumn('agent_withdrawals', 'channel_fee')) {
-            $withdraw = self::applyDateRange(DB::table('agent_withdrawals')->where('status', 'paid'), 'paid_at', $start, $end)
+            $withdraw = self::applyDateRange(StatsScope::excludeTestAgents(DB::table('agent_withdrawals'))->where('status', 'paid'), 'paid_at', $start, $end)
                 ->selectRaw(<<<'SQL'
                     COALESCE(SUM(fee), 0) AS withdraw_fee,
                     COUNT(DISTINCT CASE WHEN fee > 0 THEN agent_id END) AS withdraw_fee_people,
@@ -160,7 +161,7 @@ class FundsSummaryService
 
     private static function balanceAggregates(?Carbon $start, ?Carbon $end): object
     {
-        $query = DB::table('balance_logs')
+        $query = StatsScope::excludeTestMembers(DB::table('balance_logs'))
             ->whereIn('change_type', [
                 '充值', '三方充值', '人工充值', '订单补偿', '人工扣款',
                 '比比返', '笔笔返', '返水', '回水',
@@ -237,7 +238,7 @@ class FundsSummaryService
         $payout = PaymentOrderService::TYPE_PAYOUT;
         $manualPayout = PaymentOrderService::TYPE_SELF_PAYOUT;
 
-        $query = DB::table('payment_orders')
+        $query = StatsScope::excludeTestMembers(DB::table('payment_orders'))
             ->where('status', PaymentOrderService::STATUS_SUCCESS)
             ->whereIn('type', [$pay, $payout, $manualPayout]);
 
@@ -272,12 +273,12 @@ class FundsSummaryService
             ->where('w.service_charge', '>', 0)
             ->selectRaw("'member_withdraw_deduction' AS category")
             ->addSelect('bl.member_id as member_id');
-        $manualDebits = self::applyDateRange(DB::table('balance_logs as bl')
+        $manualDebits = self::applyDateRange(StatsScope::excludeTestMembers(DB::table('balance_logs as bl'), 'bl.member_id')
             ->where('bl.change_type', '人工扣款')
             ->where('bl.amount', '<', 0), 'bl.created_at', $start, $end)
             ->selectRaw("'manual_withdrawal' AS category")
             ->addSelect('bl.member_id as member_id');
-        $manualPayouts = self::applyDateRange(DB::table('payment_orders as po')
+        $manualPayouts = self::applyDateRange(StatsScope::excludeTestMembers(DB::table('payment_orders as po'), 'po.member_id')
             ->where('po.type', PaymentOrderService::TYPE_SELF_PAYOUT)
             ->where('po.status', PaymentOrderService::STATUS_SUCCESS), 'po.created_at', $start, $end)
             ->selectRaw("'manual_withdrawal' AS category")
@@ -307,7 +308,7 @@ class FundsSummaryService
 
     private static function successfulWithdrawLogs(?Carbon $start, ?Carbon $end)
     {
-        $query = DB::table('balance_logs as bl')
+        $query = StatsScope::excludeTestMembers(DB::table('balance_logs as bl'), 'bl.member_id')
             ->join('withdraws as w', 'w.id', '=', 'bl.related_id')
             ->where('w.status', 1)
             ->where('bl.change_type', '提现')

+ 3 - 0
app/Services/MemberDailyReportService.php

@@ -120,6 +120,9 @@ class MemberDailyReportService
         if ($agent) {
             $query->where('r.agent_id', $agent->id);
         } else {
+            // 总后台:排除试玩账号和测试会员(测试代理名下)
+            \App\Support\StatsScope::excludeTestMembers($query, 'r.user_id', 'id')
+                ->where(fn ($q) => $q->whereNull('u.from')->orWhere('u.from', '<>', 2));
             if (!empty($params['agent_id'])) $query->where('r.agent_id', (int) $params['agent_id']);
             if (!empty($params['agent_type'])) $query->where('a.type', (int) $params['agent_type']);
         }

+ 5 - 3
app/Services/NewUserSummaryService.php

@@ -34,6 +34,8 @@ class NewUserSummaryService
 
         // 包名风格 = APP
         $isApp = AppDomain::sqlIsApp('u.register_domain');
+        // 测试代理名下会员不统计
+        $notTest = \App\Support\StatsScope::rawExcludeTestUsers('u');
 
         // 1) 注册按天
         $registerRows = DB::select(
@@ -42,7 +44,7 @@ class NewUserSummaryService
                 SUM(CASE WHEN {$isApp} THEN 1 ELSE 0 END) AS app_register,
                 SUM(CASE WHEN {$isApp} THEN 0 ELSE 1 END) AS h5_register
              FROM {$users} u
-             WHERE u.created_at BETWEEN ? AND ?
+             WHERE u.created_at BETWEEN ? AND ?{$notTest}
              GROUP BY DATE(u.created_at)",
             [$rangeStart, $rangeEnd]
         );
@@ -63,7 +65,7 @@ class NewUserSummaryService
                     COALESCE(NULLIF(u.member_id, ''), u.user_id) AS member_key,
                     CASE WHEN {$isApp} THEN 1 ELSE 0 END AS is_app
                 FROM {$users} u
-                WHERE u.created_at BETWEEN ? AND ?
+                WHERE u.created_at BETWEEN ? AND ?{$notTest}
              ) nu
              INNER JOIN (
                 SELECT bl.member_id, MIN(bl.created_at) AS first_at
@@ -114,7 +116,7 @@ class NewUserSummaryService
                     DATE(u.created_at) AS reg_date,
                     COALESCE(NULLIF(u.member_id, ''), u.user_id) AS member_key
                 FROM {$users} u
-                WHERE u.created_at BETWEEN ? AND ?
+                WHERE u.created_at BETWEEN ? AND ?{$notTest}
              ) nu
              INNER JOIN {$logs} bl
                 ON bl.member_id COLLATE utf8mb4_general_ci = nu.member_key COLLATE utf8mb4_general_ci

+ 1 - 1
app/Services/OnlineUserService.php

@@ -222,7 +222,7 @@ class OnlineUserService
                 GROUP BY user_id
              ) latest ON latest.user_id COLLATE utf8mb4_general_ci = u.user_id COLLATE utf8mb4_general_ci
              LEFT JOIN {$login} ul ON ul.id = latest.max_id
-             WHERE {$onlineSql} = 1
+             WHERE {$onlineSql} = 1" . \App\Support\StatsScope::rawExcludeTestUsers('u') . "
              GROUP BY COALESCE(NULLIF(ul.platform, ''), '')",
             $bindings
         );

+ 4 - 1
app/Services/RegisterStatService.php

@@ -49,7 +49,8 @@ class RegisterStatService
             ];
         }
 
-        $rows = User::query()
+        // 测试代理名下会员不统计
+        $rows = \App\Support\StatsScope::excludeTestUsers(User::query(), 'users')
             ->selectRaw("{$bucketExpr} as time_key, `from` as user_from, COUNT(*) as cnt")
             ->whereBetween('created_at', [$rangeStart, $rangeEnd])
             ->groupByRaw("{$bucketExpr}, `from`")
@@ -91,6 +92,8 @@ class RegisterStatService
             ->where('status', UserLogin::STATUS_SUCCESS)
             ->whereNotNull('user_id')
             ->where('user_id', '<>', '');
+        // user_login.user_id 是会员登录 ID(users.user_id);测试代理名下会员不统计
+        \App\Support\StatsScope::excludeTestMembers($base, 'user_id', 'user_id');
         if ($startDate) {
             $base->where('created_at', '>=', $startDate . ' 00:00:00');
         }

+ 8 - 3
app/Services/Reporting/ReportReader.php

@@ -53,7 +53,7 @@ class ReportReader
             DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')
                 ->min(DB::raw($effectiveAt)),
             DB::table('reporting_activity')->min('observed_at'),
-            DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->min('created_at')];
+            \App\Support\StatsScope::realUsers(DB::table('users'))->min('created_at')];
         foreach ($earliest as $at) if ($at !== null) $dates[] = substr((string) $at, 0, 10);
         return min($dates);
     }
@@ -265,6 +265,7 @@ class ReportReader
         if ($p['report_type'] !== 'sharing' && Schema::hasTable('agent_rebate_records') && empty($p['member_account']) && empty($p['inviter_account'])) {
             $result['agent_unpaid'] = Money::zero();
             foreach (DB::table('agent_rebate_records')->cursor() as $row) {
+                if (!empty($agents[$row->agent_id]['is_test'])) continue;
                 if (!empty($p['agent_account']) && !str_contains($agents[$row->agent_id]['username'] ?? '', $p['agent_account'])) continue;
                 foreach (['flow', 'profit'] as $part) if ($row->{$part . '_status'} === 'pending') $result['agent_unpaid'] = bcadd($result['agent_unpaid'], (string) $row->{$part . '_commission'}, 10);
             }
@@ -309,9 +310,11 @@ class ReportReader
 
     private function people(array $p): array
     {
-        $agents = []; if (Schema::hasTable('agents')) foreach (DB::table('agents')->get(['id', 'username']) as $r) $agents[$r->id] = (array) $r;
+        $testAgents = array_flip(\App\Support\StatsScope::testAgentIdList());
+        $agents = []; if (Schema::hasTable('agents')) foreach (DB::table('agents')->get(['id', 'username']) as $r) $agents[$r->id] = (array) $r + ['is_test' => isset($testAgents[$r->id])];
         $invites = []; if (Schema::hasTable('share_members')) foreach (DB::table('share_members')->where('bound_at', '<', $p['end'])->get(['user_id', 'parent_user_id']) as $r) $invites[$r->user_id] = $r->parent_user_id;
-        $users = []; foreach (DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->where('created_at', '<', $p['end'])->get() as $r) {
+        // 排除试玩账号和测试会员(测试代理名下)
+        $users = []; foreach (\App\Support\StatsScope::realUsers(DB::table('users'))->where('created_at', '<', $p['end'])->get() as $r) {
             $u = (array) $r; $u['inviter_id'] = $invites[$r->id] ?? null; $users[$r->id] = $u;
         }
         return [$users, $agents];
@@ -321,6 +324,8 @@ class ReportReader
     {
         $uid = $row['user_id'] ?? null; $agent = $row['agent_id'] ?? null; $inviter = $row['inviter_id'] ?? null;
         if ($uid !== null && !isset($users[$uid])) return false;
+        // 只有代理、没有会员的事件(代理提现、代理分成):测试代理的不统计
+        if ($uid === null && $agent && !empty($agents[$agent]['is_test'])) return false;
         if ($p['platform'] !== 'all' && ($row['client_platform'] ?? 'unknown') !== $p['platform']) return false;
         if ($p['report_type'] === 'promotion' && !$agent) return false;
         if ($p['report_type'] === 'sharing' && !$inviter && !in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true)) return false;

+ 15 - 6
app/Services/ShareEarningReportService.php

@@ -16,7 +16,7 @@ class ShareEarningReportService
         $range = !empty($params['start_date']) || !empty($params['end_date']) ? $this->dateRange($params) : null;
         $page = (int)($params['page'] ?? 1);
         $limit = (int)($params['limit'] ?? 20);
-        $dateQuery = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '');
+        $dateQuery = $this->invitees()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '');
         if ($range !== null) {
             $dateQuery->whereBetween('created_at', [$range[0] . ' 00:00:00', $range[1] . ' 23:59:59']);
         }
@@ -29,14 +29,14 @@ class ShareEarningReportService
         $pageStart = (string)$pageDates->min();
         $pageEnd = (string)$pageDates->max();
 
-        $basicRows = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+        $basicRows = $this->invitees()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
             ->selectRaw('DATE(created_at) AS stat_date')
             ->selectRaw('COUNT(*) AS registered_members')
             ->selectRaw('COUNT(DISTINCT agent_user_code) AS promoter_count')
             ->groupByRaw('DATE(created_at)')->get()->keyBy('stat_date');
 
-        $inviteeBase = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+        $inviteeBase = $this->invitees()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
             ->select(['id', 'member_id'])->selectRaw('DATE(created_at) AS stat_date');
         $memberMetrics = DB::query()->fromSub($inviteeBase, 'invitee')
@@ -57,7 +57,7 @@ class ShareEarningReportService
             ->selectRaw('SUM(CASE WHEN deposit >= ? THEN 1 ELSE 0 END) AS valid_members', [$validMin])
             ->groupBy('stat_date')->get()->keyBy('stat_date');
 
-        $pageMemberIds = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+        $pageMemberIds = $this->invitees()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
             ->select('member_id');
         $firstLoginIds = UserLogin::query()->where('status', UserLogin::STATUS_SUCCESS)
@@ -70,6 +70,7 @@ class ShareEarningReportService
             ->leftJoinSub($firstLoginIds, 'first_login', 'first_login.user_id', '=', 'invitee_user.member_id')
             ->leftJoin('user_login as first_login_row', 'first_login_row.id', '=', 'first_login.first_login_id')
             ->whereNotNull('invitee_user.agent_user_code')->where('invitee_user.agent_user_code', '<>', '')
+            ->where(fn ($q) => \App\Support\StatsScope::excludeTestUsers($q, 'invitee_user'))
             ->whereBetween('invitee_user.created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
             ->selectRaw("DATE({$userAlias}.created_at) AS stat_date")
             ->selectRaw("SUM(CASE WHEN {$loginAlias}.platform = ? THEN 1 ELSE 0 END) AS h5_registered", ['H5'])
@@ -103,7 +104,7 @@ class ShareEarningReportService
     public function promoterDetails(array $params): array
     {
         $day = $params['date'];
-        $promoterCodes = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+        $promoterCodes = $this->invitees()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
             ->select('agent_user_code')->distinct();
         $promoterAlias = DB::getTablePrefix() . 'promoter';
@@ -153,7 +154,7 @@ class ShareEarningReportService
 
     public function registrationDetails(array $params): array
     {
-        $query = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+        $query = $this->invitees()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$params['date'] . ' 00:00:00', $params['date'] . ' 23:59:59']);
         if (!empty($params['member_id'])) $query->where('member_id', $params['member_id']);
         if (!empty($params['account'])) $query->where('account', 'like', '%' . $params['account'] . '%');
@@ -185,6 +186,8 @@ class ShareEarningReportService
         $query = ShareCommissionRecord::query()->from('share_commission_records as share_record')
             ->leftJoin('users as promoter', 'promoter.id', '=', 'share_record.promoter_user_id')
             ->where('share_record.status', 1);
+        // 测试代理名下会员不统计
+        \App\Support\StatsScope::excludeTestMembers($query, 'share_record.promoter_user_id', 'id');
         if ($range !== null) $query->whereBetween('share_record.stat_date', $range);
         if (!empty($params['account'])) $query->where('promoter.account', 'like', '%' . $params['account'] . '%');
         $totalQuery = clone $query;
@@ -227,6 +230,12 @@ class ShareEarningReportService
         ];
     }
 
+    /** 分享注册的会员(排除测试代理名下的测试会员)。 */
+    private function invitees()
+    {
+        return \App\Support\StatsScope::excludeTestUsers(User::query(), 'users');
+    }
+
     private function moneyMetricsByMember(array $memberIds): array
     {
         $memberIds = array_values(array_unique(array_filter(array_map('strval', $memberIds))));

+ 2 - 2
app/Services/ThirdGameCommissionService.php

@@ -152,8 +152,8 @@ class ThirdGameCommissionService
         $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); })
+            // 排除试玩账号和测试会员(测试代理名下)
+            $query = \App\Support\StatsScope::realUsers(DB::table('third_game_orders as o')->join('users as u', 'u.id', '=', 'o.user_id'), 'u')
                 ->where('o.status', 1);
             // Same settlement date as the report: an admin-verified time (结算日核对) wins over the provider time.
             $grammar = $query->getGrammar();

+ 77 - 0
app/Support/StatsScope.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 统计口径:排除试玩账号(users.from = 2)和测试会员 / 测试代理(agents.is_test = 1)。
+ * 测试会员 = users.agent_id 指向测试代理的会员,按代理当前设置判断(切换后历史数据跟着变)。
+ * 只用于统计;明细列表、待办角标、代理看自己名下会员的数据不用它。
+ */
+final class StatsScope
+{
+    private static ?\WeakMap $enabled = null;
+
+    /** agents.is_test 存在时才过滤(迁移执行前、或没有代理表时不过滤)。按数据库连接缓存,避免每次查表结构。 */
+    public static function enabled(): bool
+    {
+        self::$enabled ??= new \WeakMap();
+        $connection = DB::connection();
+        return self::$enabled[$connection] ??= DB::getSchemaBuilder()->hasTable('agents') && DB::getSchemaBuilder()->hasColumn('agents', 'is_test');
+    }
+
+    /** 当前所有测试代理的 ID(用于在 PHP 里过滤已加载的数据)。 */
+    public static function testAgentIdList(): array
+    {
+        return self::enabled() ? array_map('intval', self::testAgentIds()->pluck('id')->all()) : [];
+    }
+
+    /** 测试代理 ID 子查询。 */
+    public static function testAgentIds()
+    {
+        return DB::table('agents')->select('id')->where('is_test', 1);
+    }
+
+    /** 测试会员的某一列(member_id / id)子查询。 */
+    public static function testMembers(string $column = 'member_id')
+    {
+        return DB::table('users')->select($column)->whereIn('agent_id', self::testAgentIds());
+    }
+
+    /** users 查询:排除测试会员。$alias 为 users 表在查询里的别名或表名。 */
+    public static function excludeTestUsers($query, string $alias = 'users')
+    {
+        if (!self::enabled()) return $query;
+        return $query->where(fn ($q) => $q->whereNull($alias . '.agent_id')->orWhereNotIn($alias . '.agent_id', self::testAgentIds()));
+    }
+
+    /** users 查询:只留真实会员(排除试玩账号和测试会员)。 */
+    public static function realUsers($query, string $alias = 'users')
+    {
+        $query->where(fn ($q) => $q->whereNull($alias . '.from')->orWhere($alias . '.from', '<>', 2));
+        return self::excludeTestUsers($query, $alias);
+    }
+
+    /** 原生 SQL 用:users 别名为 $alias 时追加的条件(以 AND 开头),未启用时为空串。 */
+    public static function rawExcludeTestUsers(string $alias = 'u'): string
+    {
+        if (!self::enabled()) return '';
+        $agents = DB::getTablePrefix() . 'agents';
+        return " AND ({$alias}.agent_id IS NULL OR {$alias}.agent_id NOT IN (SELECT id FROM {$agents} WHERE is_test = 1))";
+    }
+
+    /** 按会员统计的表(member_id 或 user_id 列):排除测试会员。 */
+    public static function excludeTestMembers($query, string $column = 'member_id', string $userColumn = 'member_id')
+    {
+        if (!self::enabled()) return $query;
+        return $query->whereNotIn($column, self::testMembers($userColumn));
+    }
+
+    /** 代理资金表(agent_id 列):排除测试代理。agent_id 为空的行保留。 */
+    public static function excludeTestAgents($query, string $column = 'agent_id')
+    {
+        if (!self::enabled()) return $query;
+        return $query->where(fn ($q) => $q->whereNull($column)->orWhereNotIn($column, self::testAgentIds()));
+    }
+}

+ 28 - 0
database/migrations/2026_11_10_120000_add_is_test_to_agents.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 测试代理:agents.is_test = 1 时,名下会员(users.agent_id 指向它)和它自己的资金都不参与总后台统计
+ * (按代理当前设置在读数据时排除,见 App\Support\StatsScope)。测试代理自己的代理后台照常显示。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasColumn('agents', 'is_test')) {
+            Schema::table('agents', fn (Blueprint $t) => $t->unsignedTinyInteger('is_test')->default(0)->index()->comment('1 测试代理:名下会员和自身资金不进统计'));
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('agents', 'is_test')) {
+            Schema::table('agents', function (Blueprint $t) {
+                $t->dropIndex(['is_test']);
+                $t->dropColumn('is_test');
+            });
+        }
+    }
+};

+ 4 - 0
docs/前端交付/README.md

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 测试代理(2026-11-10)
+
+- [测试代理-前端修改单](测试代理-前端修改单.md):总后台创建 / 编辑代理新增「测试代理」开关 `is_test`,代理列表新增标签和筛选。测试代理名下会员和代理自身资金不参与任何统计,测试代理自己的后台照常显示。
+
 ## 代理域名(2026-11-09,取代「代理绑定多个域名」)
 
 - [代理域名-前端修改单](代理域名-前端修改单.md):代理域名**不分 PC 和 H5**,每个代理可以绑定多个域名。绑定接口改为 `domains` 数组,代理信息改为返回 `domains`。旧的 `pc`/`h5` 参数和 `pc_domain`/`h5_domain`/`pc_register_url`/`h5_register_url` 字段**已删除,不兼容**。「代理域名管理」去掉了类型。

+ 67 - 0
docs/前端交付/测试代理-前端修改单.md

@@ -0,0 +1,67 @@
+# 测试代理-前端修改单(2026-11-10)
+
+## 功能
+
+总后台创建或编辑代理时,可以把代理设为**测试代理**。测试代理用来联调和演示,它的数据不混进平台的经营数据:
+
+- 通过测试代理绑定的域名(或邀请码)注册的会员是**测试会员**,他们的充值、提现、投注、输赢、优惠、返水,都**不参与**总后台的任何统计,也不参与入股股东看到的全平台统计;
+- 测试代理**自己的资金**(代理充值、代理提现、合营分成、入股购买)也不参与总后台统计;
+- 测试代理**登录自己的代理后台,照常看到**名下会员的数据,方便测试代理后台本身;
+- 会员算不算测试,**跟随代理当前的设置**:正式代理改成测试后,它名下会员的历史数据也立即从统计里消失;改回正式又会算回来。
+
+明细列表照常显示测试会员和测试代理的记录,比如会员列表、订单、充提记录、代理充提审核、入股购买记录列表,需要正常处理他们的订单。
+
+## 一、总后台
+
+### 1. 新增 / 编辑代理
+
+`POST /admin/agent/create`、`POST /admin/agent/update` 新增参数:
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `is_test` | 否 | `1` 测试代理,`0` 正式代理;新增时默认 `0`,编辑时不传则不改 |
+
+表单加一个开关「测试代理」,下方提示:「开启后,该代理名下会员和代理自身资金不参与任何统计;该代理自己的后台照常显示。」
+
+### 2. 代理列表 / 详情
+
+- `GET /admin/agent`、`GET /admin/agent/show` 返回新增 `is_test`(`0` / `1`)。列表里测试代理加一个「测试」标签。
+- `GET /admin/agent` 新增筛选参数 `is_test`:`1` 只看测试代理,`0` 只看正式代理,不传看全部。
+
+### 3. 入股购买记录
+
+`GET /admin/agent/equity-orders` 列表里的 `agent` 新增 `is_test`,测试代理的行加「测试」标签。顶部 `summary` 的合计**不含**测试代理。
+
+### 4. 不含测试数据的统计(不用改,了解即可)
+
+以下页面的数字自动排除测试会员和测试代理,接口不变:
+
+- 首页(投注、退款、中奖、余额宝和活动、新增会员、三方和 USDT 充提)
+- 报表汇总、明细、龙虎榜、资金流动、订单、事件
+- 资金汇总、用户报表、运营数据(运营数据只影响上线后生成的天数)
+- 会员盈亏日报、代理报表(不列出测试代理)
+- 新用户汇总、注册趋势、注册域名、在线人数统计
+- 分享赚钱统计、三方游戏佣金汇总
+- 入股购买记录的汇总
+
+不排除的:待处理角标(测试代理的订单也需要处理)、各种明细列表。
+
+## 二、代理独立后台
+
+不需要改。测试代理登录后看到的数据和正式代理一样(名下会员照常统计)。入股股东看到的全平台数据已自动排除测试会员。
+
+## 三、会员前台
+
+不需要改。
+
+## 上线
+
+后端执行迁移 `2026_11_10_120000_add_is_test_to_agents`(新增 `agents.is_test`)。这个迁移要和代码一起上线,因为创建代理会写这一列。
+
+## 验收
+
+1. 新建代理时打开「测试代理」,列表显示「测试」标签,用 `is_test=1` 能筛出来。
+2. 从测试代理的域名注册会员并充值、投注:总后台首页、报表、资金汇总、会员盈亏日报的数字不变;会员列表和充值记录里能看到这笔记录。
+3. 测试代理登录自己的代理后台,首页和周期报表能看到这个会员的数据。
+4. 入股股东的首页和周期报表(全平台)不包含这笔数据。
+5. 把测试代理改为正式代理,上面的统计里出现这笔数据;再改回测试又消失。

+ 1 - 0
docs/后端/代理合营与入股-部署说明.md

@@ -14,6 +14,7 @@ php artisan migrate --force
 | `2026_11_07_130000_add_agent_joint_venture_admin_menus.php` | 总后台菜单:入股配置、分成方案申请、代理充值记录、会员盈亏日报,以及保存/审核按钮。代理菜单挂在标题含「代理」的一级菜单下;找不到时挂到根节点,需要手动调整 |
 | `2026_11_08_150000_add_type_to_agent_applications.php` | `agent_applications.type`(1 合营,默认 / 2 入股):会员申请时选择的代理类型,由 melbet_sport-api 的 `POST /api/agent/apply` 写入。**需要先于 melbet 新代码执行**,否则会员提交申请会因缺少该列报错 |
 | `2026_11_08_170000_add_agent_withdrawal_channels.php` | 代理提现支持全部提现通道:`agent_withdrawals` 新增 `pay_no`、`pay_data`、`callback_data`(三方代付)。通道存在原有的 `account_type`,提现账户沿用 `agent_withdraw_accounts`(`type` = 通道,`details` 存开户行/收款码/收款方式) |
+| `2026_11_10_120000_add_is_test_to_agents.php` | 测试代理:`agents.is_test`。测试代理名下会员(`users.agent_id` 指向它)和它自己的资金,在读数据时从统计里排除(`App\Support\StatsScope`),按代理当前设置判断;迁移执行前不过滤。**注意**:创建代理会写这一列,需要和代码一起上线 |
 | `2026_11_09_140000_merge_agent_domain_types.php` | 代理域名不分 PC / H5:已有的 PC、H5 域名合并为同一个列表(同一代理同一主机名只留一条),`agent_domains.host` 改为全局唯一;「代理域名管理」去掉类型 |
 | `2026_11_09_130000_allow_multiple_agent_domains.php` | 代理可以绑定多个域名:`agent_domains` 去掉 `(agent_id, type)` 唯一索引,改为 `(agent_id, type, host)` 唯一。**回滚前**每个代理每种类型只能保留一个域名 |
 | `2026_11_09_120000_create_agent_domain_pool.php` | 代理域名池:新表 `agent_domain_pool`(按 `host` 唯一),导入已有的 `agent_domains`;菜单「代理域名管理」`agentDomainPool` 和新增 / 修改 / 删除按钮。代理列表绑定域名时会自动登记到域名池,停用的域名不能绑定 |

+ 3 - 0
docs/总后台/代理列表.md

@@ -23,6 +23,7 @@ Content-Type: application/json
 | `real_name` | string | 否 | - | 真实姓名,模糊查询,最长 128 |
 | `parent_id` | integer | 否 | - | 直属上级代理 ID |
 | `status` | integer | 否 | - | `1` 启用,`0` 禁用 |
+| `is_test` | integer | 否 | - | `1` 只看测试代理,`0` 只看正式代理 |
 | `start_date` | string | 否 | - | 注册开始日期,`Y-m-d` |
 | `end_date` | string | 否 | - | 注册结束日期,`Y-m-d`,不能早于开始日期 |
 
@@ -132,6 +133,7 @@ GET /admin/agent/show?id=12
 | `flow_commission_profile_id` | integer | 是 | 流水佣金方案 ID;用方案列表 Select 选择 |
 | `profit_commission_profile_id` | integer | 否 | 盈亏比例方案 ID;不传使用默认方案 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
+| `is_test` | integer | 否 | `1` 测试代理(名下会员和代理自身资金不参与总后台统计),`0` 正式;默认 `0` |
 
 ```json
 {
@@ -185,6 +187,7 @@ GET /admin/agent/show?id=12
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用 |
+| `is_test` | integer | 否 | `1` 改为测试代理,`0` 改为正式代理;名下已有会员跟着变 |
 | `flow_commission_profile_id` | integer | 否 | 切换代理使用的流水佣金方案 |
 | `profit_commission_profile_id` | integer | 否 | 切换代理使用的盈亏比例方案 |
 

+ 2 - 1
tests/Integration/AgentApplicationTest.php

@@ -78,7 +78,8 @@ class AgentApplicationTest extends TestCase
             $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
         });
         foreach (['2026_10_26_110000_create_site_messages.php', '2026_10_27_150000_create_site_realtime_events.php', '2026_10_29_140000_create_agent_applications.php',
-            '2026_11_07_120000_agent_joint_venture_and_equity.php', '2026_11_08_150000_add_type_to_agent_applications.php'] as $file) {
+            '2026_11_07_120000_agent_joint_venture_and_equity.php', '2026_11_08_150000_add_type_to_agent_applications.php',
+            '2026_11_10_120000_add_is_test_to_agents.php'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
         }
         foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([

+ 18 - 0
tests/Integration/ReportingTest.php

@@ -267,6 +267,24 @@ class ReportingTest extends TestCase
         $this->assertSame('1', $this->reader->details(['report_type' => 'sharing'])['data'][0]['group_id']);
     }
 
+    public function test_test_agent_members_are_left_out_of_reports_and_follow_the_current_flag(): void
+    {
+        // 测试代理标记必须在第一次读统计前就存在(StatsScope 按连接缓存是否启用)
+        Schema::table('agents', fn (Blueprint $table) => $table->unsignedTinyInteger('is_test')->default(0));
+        $this->bet(); $this->verify($this->deposit());
+        $row = $this->reader->summary([])['summary'];
+        $this->assertSame(['100.0000000000', '1000.0000000000'], [$row['bet_amount'], $row['deposit_amount']]);
+
+        DB::table('agents')->where('id', 10)->update(['is_test' => 1]); // 会员 200 属于代理 10
+        $row = $this->reader->summary([])['summary'];
+        $this->assertSame(0, bccomp($row['bet_amount'], '0', 10), 'test member bets are not counted');
+        $this->assertSame(0, bccomp($row['deposit_amount'], '0', 10), 'test member deposits are not counted');
+        $this->assertNotContains('2', array_column($this->reader->details($this->today())['data'], 'group_id'));
+
+        DB::table('agents')->where('id', 10)->update(['is_test' => 0]);
+        $this->assertSame('100.0000000000', $this->reader->summary([])['summary']['bet_amount'], 'switching back restores history');
+    }
+
     public function test_category_column_options_only_hide_fields_not_applicable_to_game_categories(): void
     {
         $this->bet();

+ 122 - 0
tests/Integration/TestAgentStatsTest.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Agent as AdminAgent;
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentSetting;
+use App\Services\Agent\AgentDomainService;
+use App\Services\Agent\AgentEquityService;
+use App\Services\Agent\AgentPeriodReportService;
+use App\Services\Agent\AgentReportService;
+use App\Services\Agent\AgentWalletService;
+use App\Services\MemberDailyReportService;
+use App\Support\StatsScope;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 测试代理:名下会员和自身资金不进统计;测试代理自己的后台照常显示;切换后跟随代理当前设置。今天 2026-09-07。 */
+class TestAgentStatsTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private Agent $real;
+    private Agent $test;
+    private Agent $shareholder;
+
+    private function seed(): void
+    {
+        if (!Schema::hasColumn('users', 'from')) Schema::table('users', fn (Blueprint $t) => $t->integer('from')->nullable());
+        $this->real = $this->agent();
+        $this->test = $this->agents->create(['username' => 'testagent', 'password' => 'secret1', 'type' => Agent::TYPE_JOINT, 'is_test' => 1]);
+        $this->shareholder = $this->agent(Agent::TYPE_EQUITY);
+        foreach (['r1' => $this->real->id, 't1' => $this->test->id, 'free' => null] as $memberId => $agentId) {
+            $id = DB::table('users')->insertGetId(['member_id' => $memberId, 'username' => $memberId, 'agent_id' => $agentId, 'from' => 1, 'created_at' => '2026-09-07 08:00:00']);
+            DB::table('recharges')->insert(['member_id' => $memberId, 'status' => 1, 'amount' => 100, 'created_at' => '2026-09-07 09:00:00']);
+            DB::table('member_daily_reports')->insert(['report_date' => '2026-09-07', 'user_id' => $id, 'member_id' => $memberId, 'agent_id' => $agentId,
+                'deposit_amount' => 100, 'bet_count' => 1, 'bet_amount' => 10, 'categories' => '{}', 'created_at' => now(), 'updated_at' => now()]);
+        }
+    }
+
+    public function test_admin_creates_edits_and_filters_test_agents(): void
+    {
+        $this->seed();
+        $this->assertSame(1, $this->test->fresh()->is_test);
+        $this->assertSame(0, $this->real->fresh()->is_test);
+        $this->agents->update($this->real, ['is_test' => 1]);
+        $this->assertSame(1, $this->real->fresh()->is_test);
+        $this->agents->update($this->real, ['is_test' => 0, 'status' => 1]);
+
+        $request = Request::create('/admin/agent', 'GET', ['is_test' => 1]);
+        $request->user = (object) ['id' => 1];
+        $this->app->instance('request', $request);
+        $list = (new AdminAgent())->index($request, new AgentDomainService())->getData(true)['data']['list'];
+        $this->assertSame(['testagent'], array_column($list, 'username'));
+        $this->assertSame(1, $list[0]['is_test']);
+    }
+
+    public function test_admin_and_shareholder_stats_skip_test_members_but_test_agent_sees_its_own(): void
+    {
+        $this->seed();
+        $reports = new AgentReportService();
+        // 入股股东的全平台统计:不含测试会员
+        $platform = $reports->dashboard($this->shareholder, '2026-09-07', '2026-09-07');
+        $this->assertSame([2, '200.0000'], [$platform['member_count'], $platform['deposit_amount']]);
+        $period = (new AgentPeriodReportService())->report($this->shareholder, ['period' => 'day', 'start_date' => '2026-09-07', 'end_date' => '2026-09-07']);
+        $this->assertSame(['200.0000', 2], [$period['totals']['deposit_amount'], $period['totals']['new_members']]);
+
+        // 测试代理自己的后台照常显示
+        $own = $reports->dashboard($this->test, '2026-09-07', '2026-09-07');
+        $this->assertSame([1, '100.0000'], [$own['member_count'], $own['deposit_amount']]);
+        $this->assertSame(1, (new MemberDailyReportService())->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $this->test)['total']);
+
+        // 总后台:会员盈亏日报、代理报表不含测试数据
+        $daily = (new MemberDailyReportService())->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07']);
+        $this->assertSame(['free', 'r1'], collect($daily['list'])->pluck('member_id')->sort()->values()->all());
+        $this->assertSame('200.0000', $daily['totals']['deposit_amount']);
+        $agentRows = array_column($reports->reportRows(null, ['start_date' => '2026-09-07'])['list'], 'username');
+        $this->assertNotContains('testagent', $agentRows);
+        $this->assertContains($this->real->username, $agentRows);
+        $this->assertSame(['testagent'], array_column($reports->reportRows($this->test, ['start_date' => '2026-09-07'])['list'], 'username'), 'test agent still sees its own report');
+
+        // 按会员 / 按代理的资金表
+        $this->assertSame(200.0, (float) StatsScope::excludeTestMembers(DB::table('recharges'))->sum('amount'));
+        DB::table('agent_withdrawals')->insert([
+            ['order_no' => 'AW1', 'request_key' => 'k1', 'agent_id' => $this->test->id, 'account_snapshot' => '{}', 'amount' => 50, 'actual_amount' => 50, 'status' => 'paid'],
+            ['order_no' => 'AW2', 'request_key' => 'k2', 'agent_id' => $this->real->id, 'account_snapshot' => '{}', 'amount' => 30, 'actual_amount' => 30, 'status' => 'paid'],
+        ]);
+        $this->assertSame(30.0, (float) StatsScope::excludeTestAgents(DB::table('agent_withdrawals'))->sum('amount'), 'test agent own money is left out');
+    }
+
+    public function test_switching_the_flag_moves_history_in_and_out(): void
+    {
+        $this->seed();
+        $reports = new AgentReportService();
+        $this->assertSame('200.0000', $reports->dashboard($this->shareholder, '2026-09-07', '2026-09-07')['deposit_amount']);
+        $this->agents->update($this->real, ['is_test' => 1]);
+        $this->assertSame('100.0000', $reports->dashboard($this->shareholder, '2026-09-07', '2026-09-07')['deposit_amount'], 'existing members follow the agent');
+        $this->agents->update($this->real, ['is_test' => 0]);
+        $this->agents->update($this->test, ['is_test' => 0]);
+        $this->assertSame('300.0000', $reports->dashboard($this->shareholder, '2026-09-07', '2026-09-07')['deposit_amount']);
+    }
+
+    public function test_equity_summary_skips_test_shareholders(): void
+    {
+        $this->seed();
+        AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 1000, 'equity_min_percent' => 0.1, 'equity_max_percent' => 1]);
+        $equity = new AgentEquityService(new AgentWalletService());
+        $testShareholder = $this->agents->create(['username' => 'testholder', 'password' => 'secret1', 'type' => Agent::TYPE_EQUITY, 'is_test' => 1]);
+        foreach ([$this->shareholder, $testShareholder] as $holder) {
+            Agent::query()->whereKey($holder->id)->update(['balance' => 1000, 'realtime_balance' => 1000]);
+            $equity->order($holder->fresh(), '0.5');
+        }
+        $page = $equity->paginate([]);
+        $this->assertSame(2, $page['total'], 'list still shows both');
+        $this->assertSame(['0.5000', '500.0000', '0.5000'], [$page['summary']['completed_percent'], $page['summary']['completed_amount'], $page['summary']['total_equity_percent']]);
+        $this->assertSame(1, collect($page['list'])->firstWhere('agent_id', $testShareholder->id)->agent->is_test);
+    }
+}

+ 1 - 0
tests/Support/AgentTestEnvironment.php

@@ -94,6 +94,7 @@ trait AgentTestEnvironment
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_190000_add_agent_recharge_locks.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_130000_allow_multiple_agent_domains.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_140000_merge_agent_domain_types.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_120000_add_is_test_to_agents.php')->up();
 
         foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
             Schema::create($table, function (Blueprint $t) {