doge пре 2 дана
родитељ
комит
88d1d58cc9
79 измењених фајлова са 3398 додато и 1999 уклоњено
  1. 1 1
      app/Console/Commands/AgentDailySettle.php
  2. 20 0
      app/Console/Commands/AgentProfitSync.php
  3. 20 0
      app/Console/Commands/AgentRechargeSync.php
  4. 30 0
      app/Console/Commands/MemberDailyReport.php
  5. 4 0
      app/Console/Kernel.php
  6. 115 69
      app/Http/Controllers/admin/Agent.php
  7. 24 0
      app/Http/Controllers/admin/MemberDailyReport.php
  8. 6 0
      app/Http/Controllers/admin/Sync.php
  9. 1 10
      app/Http/Controllers/agent/AuthController.php
  10. 48 113
      app/Http/Controllers/agent/CommissionController.php
  11. 44 0
      app/Http/Controllers/agent/EquityController.php
  12. 30 2
      app/Http/Controllers/agent/MemberController.php
  13. 5 24
      app/Http/Controllers/agent/ProfileController.php
  14. 0 109
      app/Http/Controllers/agent/SubAgentController.php
  15. 63 10
      app/Http/Controllers/agent/WalletController.php
  16. 17 1
      app/Models/ActivityReward.php
  17. 21 30
      app/Models/Agent/Agent.php
  18. 0 16
      app/Models/Agent/AgentDailyGameStat.php
  19. 31 0
      app/Models/Agent/AgentEquityOrder.php
  20. 0 41
      app/Models/Agent/AgentFlowCommissionAssignment.php
  21. 0 75
      app/Models/Agent/AgentFlowCommissionProfile.php
  22. 33 0
      app/Models/Agent/AgentPlanApplication.php
  23. 6 5
      app/Models/Agent/AgentProfitCommissionAssignment.php
  24. 24 36
      app/Models/Agent/AgentProfitCommissionProfile.php
  25. 25 0
      app/Models/Agent/AgentProfitEntry.php
  26. 25 21
      app/Models/Agent/AgentRebateRecord.php
  27. 35 0
      app/Models/Agent/AgentRecharge.php
  28. 23 0
      app/Models/Agent/AgentSetting.php
  29. 14 0
      app/Models/Agent/AgentWallet.php
  30. 5 2
      app/Models/Agent/AgentWithdrawal.php
  31. 18 0
      app/Models/MemberDailyReport.php
  32. 1 1
      app/Services/ActivityRewardService.php
  33. 4 5
      app/Services/Agent/AgentApplicationService.php
  34. 57 218
      app/Services/Agent/AgentCommissionService.php
  35. 105 0
      app/Services/Agent/AgentEquityService.php
  36. 26 0
      app/Services/Agent/AgentExchangeRate.php
  37. 0 85
      app/Services/Agent/AgentFlowCommissionAssignmentService.php
  38. 0 216
      app/Services/Agent/AgentFlowCommissionProfileService.php
  39. 93 0
      app/Services/Agent/AgentPlanApplicationService.php
  40. 38 0
      app/Services/Agent/AgentProfileService.php
  41. 18 58
      app/Services/Agent/AgentProfitCommissionAssignmentService.php
  42. 36 177
      app/Services/Agent/AgentProfitCommissionProfileService.php
  43. 174 0
      app/Services/Agent/AgentProfitLedgerService.php
  44. 210 0
      app/Services/Agent/AgentRechargeService.php
  45. 8 81
      app/Services/Agent/AgentReportService.php
  46. 29 236
      app/Services/Agent/AgentService.php
  47. 145 67
      app/Services/Agent/AgentWalletService.php
  48. 28 10
      app/Services/Agent/AgentWithdrawalService.php
  49. 9 2
      app/Services/CollectService.php
  50. 144 0
      app/Services/MemberDailyReportService.php
  51. 3 3
      app/Services/Sharing/ShareAdminService.php
  52. 9 1
      app/Services/Sharing/ShareDefaults.php
  53. 1 1
      app/Services/Sharing/ShareSettlementService.php
  54. 3 0
      app/Services/UserService.php
  55. 2 0
      app/Support/ActivityCatalog.php
  56. 266 0
      database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php
  57. 68 0
      database/migrations/2026_11_07_130000_add_agent_joint_venture_admin_menus.php
  58. 22 0
      database/migrations/2026_11_07_150000_normalize_ordinary_activity_platforms.php
  59. 2 0
      docs/代理/代理佣金比例.md
  60. 287 0
      docs/代理/代理后台调整-前端接入.md
  61. 2 0
      docs/代理/代理流水佣金比例.md
  62. 2 0
      docs/代理/代理盈亏佣金比例.md
  63. 2 0
      docs/代理/所有代理.md
  64. 2 0
      docs/前端接口/活动/会员活动列表类型与游戏类型返回-前端接入说明.md
  65. 12 8
      docs/前端接口/活动/列表筛选.md
  66. 3 2
      docs/前端接口/活动/资格条件与展示开关.md
  67. 69 0
      docs/后端/代理合营与入股-部署说明.md
  68. 274 0
      docs/总后台/代理合营与入股-前端接入.md
  69. 2 0
      docs/总后台/代理流水佣金比例-前端变更说明.md
  70. 2 0
      docs/总后台/代理流水佣金比例.md
  71. 8 3
      routes/admin.php
  72. 14 12
      routes/agent.php
  73. 63 0
      tests/Integration/ActivityCatalogTest.php
  74. 3 2
      tests/Integration/ActivityRewardEligibilityTest.php
  75. 13 221
      tests/Integration/AgentApiRegressionTest.php
  76. 14 24
      tests/Integration/AgentApplicationTest.php
  77. 293 0
      tests/Integration/AgentJointVentureTest.php
  78. 1 1
      tests/Integration/SharingSettlementTest.php
  79. 143 0
      tests/Support/AgentTestEnvironment.php

+ 1 - 1
app/Console/Commands/AgentDailySettle.php

@@ -10,7 +10,7 @@ class AgentDailySettle extends Command
 {
     protected $signature = 'agent:settle {date? : Y-m-d,默认昨天} {--no-credit : 只生成佣金记录,不入账}';
 
-    protected $description = '生成代理日报并结算流水/盈亏佣金';
+    protected $description = '生成代理日报并入账合营分成';
 
     public function handle(AgentReportService $reports, AgentCommissionService $commissions): int
     {

+ 20 - 0
app/Console/Commands/AgentProfitSync.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Agent\AgentProfitLedgerService;
+use Illuminate\Console\Command;
+
+class AgentProfitSync extends Command
+{
+    protected $signature = 'agent:profit-sync';
+
+    protected $description = '按已结算注单实时计算合营代理分成(更新实时保证金)';
+
+    public function handle(AgentProfitLedgerService $ledger): int
+    {
+        $result = $ledger->sync();
+        $this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
+        return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
+    }
+}

+ 20 - 0
app/Console/Commands/AgentRechargeSync.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Agent\AgentRechargeService;
+use Illuminate\Console\Command;
+
+class AgentRechargeSync extends Command
+{
+    protected $signature = 'agent:recharge-sync {agent_id? : 指定代理ID,补查该代理的链上充值}';
+
+    protected $description = '同步并确认代理 USDT 自动充值';
+
+    public function handle(AgentRechargeService $recharges): int
+    {
+        $agentId = $this->argument('agent_id');
+        $this->info(json_encode($recharges->sync($agentId ? (int) $agentId : null), JSON_UNESCAPED_UNICODE));
+        return self::SUCCESS;
+    }
+}

+ 30 - 0
app/Console/Commands/MemberDailyReport.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\MemberDailyReportService;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+
+class MemberDailyReport extends Command
+{
+    protected $signature = 'report:member-daily {date? : Y-m-d,默认今天} {--days=1 : 从 date 往前共重算几天}';
+
+    protected $description = '生成会员每日盈亏报表';
+
+    public function handle(MemberDailyReportService $reports): int
+    {
+        $date = (string) ($this->argument('date') ?: now()->toDateString());
+        $parsed = Carbon::createFromFormat('!Y-m-d', $date);
+        if (!$parsed || $parsed->format('Y-m-d') !== $date) {
+            $this->error('日期必须是有效的 Y-m-d');
+            return self::FAILURE;
+        }
+        $days = max(1, min(31, (int) $this->option('days')));
+        for ($i = 0; $i < $days; $i++) {
+            $day = $parsed->copy()->subDays($i)->toDateString();
+            $this->info($day . ':' . $reports->refresh($day) . ' 行');
+        }
+        return self::SUCCESS;
+    }
+}

+ 4 - 0
app/Console/Kernel.php

@@ -40,6 +40,10 @@ class Kernel extends ConsoleKernel
             ->dailyAt('04:10')
             ->withoutOverlapping(180);
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('agent:profit-sync')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('report:member-daily')->everyTenMinutes()->withoutOverlapping(30);
+        $schedule->command('report:member-daily ' . now()->subDay()->toDateString() . ' --days=3')
+            ->dailyAt('04:30')->withoutOverlapping(60);
         $schedule->command('rewards:sync-bases')->everyMinute()->withoutOverlapping(30);
         $schedule->command('wagering:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);

+ 115 - 69
app/Http/Controllers/admin/Agent.php

@@ -14,7 +14,11 @@ use App\Services\Agent\AgentReportService;
 use App\Services\Agent\AgentService;
 use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWithdrawalService;
-use App\Services\Agent\AgentFlowCommissionProfileService;
+use App\Services\Agent\AgentPlanApplicationService;
+use App\Services\Agent\AgentRechargeService;
+use App\Models\Agent\AgentSetting;
+use App\Models\Agent\AgentRecharge;
+use App\Models\Agent\AgentProfitEntry;
 use App\Services\Agent\AgentProfitCommissionProfileService;
 use Illuminate\Http\Request;
 use Illuminate\Validation\Rule;
@@ -31,15 +35,15 @@ 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'],
-                'parent_id' => ['nullable', 'integer'], 'status' => ['nullable', 'integer', 'in:0,1'],
+                'type' => ['nullable', 'integer', 'in:1,2'], 'status' => ['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));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
-            $query = AgentModel::query()->with(['parent:id,username', 'token:id,agent_id,expires_at,last_used_at', 'flowCommissionProfile', 'profitCommissionProfile'])->withCount(['children', 'members']);
+            $query = AgentModel::query()->with(['token:id,agent_id,expires_at,last_used_at', 'profitCommissionProfile'])->withCount(['members']);
             if (!empty($data['username'])) $query->where('username', 'like', '%' . $data['username'] . '%');
             if (!empty($data['real_name'])) $query->where('real_name', 'like', '%' . $data['real_name'] . '%');
-            if (!empty($data['parent_id'])) $query->where('parent_id', $data['parent_id']);
+            if (!empty($data['type'])) $query->where('type', $data['type']);
             if (isset($data['status'])) $query->where('status', $data['status']);
             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');
@@ -53,7 +57,7 @@ class Agent extends AgentApiController
     {
         return $this->execute(function () use ($request, $domains) {
             $data = $request->validate(['id' => ['required', 'integer', 'exists:agents,id']]);
-            $agent = AgentModel::query()->with(['parent:id,username', 'token:id,agent_id,expires_at,last_used_at', 'flowCommissionProfile', 'profitCommissionProfile'])->withCount(['children', 'members'])->findOrFail($data['id']);
+            $agent = AgentModel::query()->with(['token:id,agent_id,expires_at,last_used_at', 'profitCommissionProfile'])->withCount(['members'])->findOrFail($data['id']);
             return $this->formatAgent($agent, $domains);
         });
     }
@@ -64,9 +68,6 @@ class Agent extends AgentApiController
             $data = $request->validate($this->rules());
             return DB::transaction(function () use ($data, $service) {
                 $agent = $service->create($data, (int) request()->user->id);
-                if (!empty($data['owner_user_id'])) {
-                    $agent = app(\App\Services\Agent\AgentApplicationService::class)->bindMember($agent, (int) $data['owner_user_id']);
-                }
                 if (bccomp((string) ($data['initial_balance'] ?? 0), '0', 4) > 0) {
                     app(AgentWalletService::class)->adjust(
                         (int) $agent->id,
@@ -77,7 +78,7 @@ class Agent extends AgentApiController
                         'admin',
                         (int) request()->user->id
                     );
-                    $agent = $agent->fresh(['flowCommissionProfile', 'profitCommissionProfile']);
+                    $agent = $agent->fresh(['profitCommissionProfile']);
                 }
                 return $agent;
             });
@@ -89,16 +90,7 @@ class Agent extends AgentApiController
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate(array_merge(['id' => ['required', 'integer', 'exists:agents,id']], $this->rules(true)));
             return DB::transaction(function () use ($service, $data) {
-                $agent = $service->update(
-                    AgentModel::query()->findOrFail($data['id']),
-                    $data,
-                    null,
-                    (int) request()->user->id
-                );
-                if (!empty($data['owner_user_id'])) {
-                    $agent = app(\App\Services\Agent\AgentApplicationService::class)->bindMember($agent, (int) $data['owner_user_id']);
-                }
-                return $agent;
+                return $service->update(AgentModel::query()->findOrFail($data['id']), $data);
             });
         });
     }
@@ -118,7 +110,7 @@ class Agent extends AgentApiController
     {
         return $this->execute(function () use ($request, $wallets) {
             $data = $request->validate([
-                'agent_id' => ['required', 'integer', 'exists:agents,id'], 'type' => ['required', 'in:credit,debit'],
+                'agent_id' => ['required', 'integer', 'exists:agents,id'], 'type' => ['required', 'in:credit,debit,margin_credit,margin_refund'],
                 'amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()], 'remark' => ['required', 'string', 'max:500'],
                 'request_id' => ['required', 'string', 'max:64'],
             ]);
@@ -239,13 +231,14 @@ class Agent extends AgentApiController
                 'agent_id' => ['nullable', 'integer'], 'order_no' => ['nullable', 'string', 'max:48'],
                 'username' => ['nullable', 'string', 'max:64'],
                 'platform' => ['nullable', 'string', 'max:32'],
-                'flow_status' => ['nullable', 'in:pending,credited'],
+                'category' => ['nullable', 'string', 'max:32'],
                 'profit_status' => ['nullable', 'in:pending,credited'],
                 'settlement_date' => ['nullable', 'date_format:Y-m-d'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
             ]);
             $query = AgentRebateRecord::query()->with('agent:id,username');
-            foreach (['agent_id', 'order_no', 'platform', 'flow_status', 'profit_status'] as $field) {
+            if (!empty($data['category'])) $query->where('platform', $data['category']);
+            foreach (['agent_id', 'order_no', 'platform', 'profit_status'] as $field) {
                 if (!empty($data[$field])) $query->where($field, $data[$field]);
             }
             if (!empty($data['username'])) {
@@ -269,72 +262,128 @@ class Agent extends AgentApiController
         });
     }
 
-    public function flowCommissionProfiles(Request $request, AgentFlowCommissionProfileService $service)
-    {
-        return $this->execute(function () use ($request, $service) {
-            $data = $request->validate([
-                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-                'name' => ['nullable', 'string', 'max:128'],
-            ]);
-            return $service->paginate($data);
-        });
-    }
-
-    public function saveFlowCommissionProfile(Request $request, AgentFlowCommissionProfileService $service)
+    public function saveProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
     {
         return $this->execute(function () use ($request, $service) {
-            $id = (int)$request->input('id', 0);
             $data = $request->validate([
-                'id' => ['nullable', 'integer', 'exists:agent_flow_commission_profiles,id'],
-                'name' => ['required', 'string', 'max:128', Rule::unique('agent_flow_commission_profiles', 'name')->whereNull('owner_agent_id')->ignore($id)],
+                'id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
+                'name' => ['required', 'string', 'max:128'],
+                'margin_amount' => ['required', 'numeric', 'min:0', new DecimalNumber()],
+                'f1_lottery_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'f1_sport_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'live_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'slot_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'lottery_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'sport_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'esports_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'fishing_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'chess_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'is_default' => ['required', 'boolean'],
             ]);
-            return $service->save($data, (int)request()->user->id);
+            return $service->save($data, (int) request()->user->id);
         });
     }
 
-    public function deleteFlowCommissionProfile(Request $request, AgentFlowCommissionProfileService $service)
+    public function deleteProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
     {
         return $this->execute(function () use ($request, $service) {
-            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_flow_commission_profiles,id']]);
-            $service->delete((int)$data['id'], (int)request()->user->id);
+            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_profit_commission_profiles,id']]);
+            $service->delete((int) $data['id']);
             return [];
         });
     }
 
-    public function saveProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    /** 代理全局配置:停止分成阈值(方案保证金的百分比)、入股价格与可购买区间。 */
+    public function settings()
+    {
+        return $this->execute(fn () => AgentSetting::current());
+    }
+
+    public function saveSettings(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'margin_stop_percent' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'equity_enabled' => ['required', 'boolean'],
+                'equity_price_per_percent' => ['required', 'numeric', 'min:0', new DecimalNumber()],
+                'equity_min_percent' => ['required', 'numeric', 'gt:0', 'max:100', new DecimalNumber(3, 4)],
+                'equity_max_percent' => ['required', 'numeric', 'gte:equity_min_percent', 'max:100', new DecimalNumber(3, 4)],
+            ]);
+            $setting = AgentSetting::current();
+            $setting->fill($data + ['updated_by' => (int) request()->user->id]);
+            $setting->equity_enabled = $request->boolean('equity_enabled') ? 1 : 0;
+            $setting->save();
+            return $setting->fresh();
+        });
+    }
+
+    public function planApplications(Request $request, AgentPlanApplicationService $service)
     {
         return $this->execute(function () use ($request, $service) {
-            $id = (int) $request->input('id', 0);
             $data = $request->validate([
-                'id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
-                'name' => ['required', 'string', 'max:128', Rule::unique('agent_profit_commission_profiles', 'name')->whereNull('owner_agent_id')->ignore($id)],
-                'live_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'slot_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'lottery_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'sport_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'esports_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'fishing_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'chess_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'is_default' => ['required', 'boolean'],
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'agent_id' => ['nullable', 'integer'], 'username' => ['nullable', 'string', 'max:64'],
+                'status' => ['nullable', 'in:pending,approved,rejected'],
             ]);
-            return $service->save($data, (int) request()->user->id);
+            return $service->paginate($data);
         });
     }
 
-    public function deleteProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    public function auditPlanApplication(Request $request, AgentPlanApplicationService $service)
     {
         return $this->execute(function () use ($request, $service) {
-            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_profit_commission_profiles,id']]);
-            $service->delete((int) $data['id'], (int) request()->user->id);
-            return [];
+            $data = $request->validate([
+                'id' => ['required', 'integer'], 'status' => ['required', 'in:approved,rejected'],
+                'reject_reason' => ['required_if:status,rejected', 'nullable', 'string', 'max:500'],
+            ]);
+            return $service->audit((int) $data['id'], $data['status'], (int) request()->user->id, (string) ($data['reject_reason'] ?? ''));
+        });
+    }
+
+    public function recharges(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'agent_id' => ['nullable', 'integer'], 'order_no' => ['nullable', 'string', 'max:40'],
+                'txid' => ['nullable', 'string', 'max:100'], 'type' => ['nullable', 'integer', 'in:1,2'],
+                'status' => ['nullable', 'integer', 'in:0,1,2,3'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentRecharge::query()->with('agent:id,username,real_name,type');
+            foreach (['agent_id', 'order_no', 'txid', 'type'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
+            if (isset($data['status'])) $query->where('status', (int) $data['status']);
+            return $this->paginate($query, $data);
+        });
+    }
+
+    public function auditRecharge(Request $request, AgentRechargeService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'id' => ['required', 'integer'], 'status' => ['required', 'integer', 'in:1,2'],
+                'remark' => ['required_if:status,2', 'nullable', 'string', 'max:500'],
+            ]);
+            return $service->audit((int) $data['id'], (int) $data['status'], (int) request()->user->id, (string) ($data['remark'] ?? ''));
+        });
+    }
+
+    public function profitEntries(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'agent_id' => ['nullable', 'integer'], 'user_id' => ['nullable', 'integer'], 'category' => ['nullable', 'string', 'max:32'],
+                'status' => ['nullable', 'in:counted,skipped_inactive,skipped_no_plan,skipped_category'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentProfitEntry::query();
+            foreach (['agent_id', 'user_id', 'category', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
+            if (!empty($data['start_date'])) $query->where('settle_date', '>=', $data['start_date']);
+            if (!empty($data['end_date'])) $query->where('settle_date', '<=', $data['end_date']);
+            $page = max(1, (int) ($data['page'] ?? 1));
+            $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
+            $total = (clone $query)->count();
+            $list = $query->orderByDesc('id')->forPage($page, $limit)->get()->each->makeVisible('realtime_balance_after');
+            return compact('total', 'page', 'limit', 'list');
         });
     }
 
@@ -342,21 +391,14 @@ class Agent extends AgentApiController
     {
         return [
             'language' => ['sometimes', 'required', 'in:zh,en,vi'],
-            'owner_user_id' => ['nullable', 'integer', 'min:1', 'exists:users,id'],
+            'type' => [$update ? 'nullable' : 'required', 'integer', 'in:1,2'],
             'username' => $update
                 ? ['sometimes', 'string', 'min:4', 'max:64', 'alpha_dash', Rule::unique('agents', 'username')->ignore((int) request()->input('id'))]
                 : ['required', 'string', 'min:4', 'max:64', 'alpha_dash', 'unique:agents,username'],
             'password' => [$update ? 'nullable' : 'required', 'string', 'min:6', 'max:100'],
             'withdraw_password' => ['nullable', 'string', 'min:6', 'max:100'], 'real_name' => ['nullable', 'string', 'max:128'],
-            'parent_id' => $update ? ['nullable', 'integer', 'exists:agents,id'] : ['prohibited'],
             'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'flow_commission_rate' => ['prohibited'],
-            'profit_commission_rate' => ['prohibited'],
-            'flow_commission_profile_id' => $update
-                ? ['nullable', 'integer', Rule::exists('agent_flow_commission_profiles', 'id')->whereNull('owner_agent_id')]
-                : ['required', 'integer', Rule::exists('agent_flow_commission_profiles', 'id')->whereNull('owner_agent_id')],
-            'profit_commission_profile_id' => ['nullable', 'integer', Rule::exists('agent_profit_commission_profiles', 'id')->whereNull('owner_agent_id')],
             'status' => ['nullable', 'integer', 'in:0,1'],
             'initial_balance' => $update ? ['prohibited'] : ['nullable', 'numeric', 'min:0', new DecimalNumber()],
         ];
@@ -382,7 +424,11 @@ class Agent extends AgentApiController
             && $token->last_used_at->gte(now()->subMinutes(config('agent.online_minutes', 5)));
         $data = $agent->toArray();
         unset($data['token']);
-        return array_merge($data, $domains->format($agent), [
+        $wallets = app(AgentWalletService::class);
+        return array_merge($data, $domains->format($agent), $wallets->funds($agent), [
+            // 总后台可以看到实时保证金;代理端不返回。
+            'realtime_balance' => (string) $agent->realtime_balance,
+            'realtime_margin' => $agent->isJoint() ? $wallets->effectiveMargin($agent) : null,
             'login_status' => $online ? 1 : 0,
             'login_status_text' => $online ? '在线' : '离线',
             'last_active_at' => $token?->last_used_at?->toDateTimeString(),

+ 24 - 0
app/Http/Controllers/admin/MemberDailyReport.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\MemberDailyReportService;
+use Illuminate\Http\Request;
+
+/** 会员每日盈亏报表(总后台看全部会员,可按代理、代理类型筛选)。 */
+class MemberDailyReport extends AgentApiController
+{
+    public function index(Request $request, MemberDailyReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+                'member_id' => ['nullable', 'string', 'max:64'], 'username' => ['nullable', 'string', 'max:128'],
+                'agent_id' => ['nullable', 'integer'], 'agent_type' => ['nullable', 'integer', 'in:1,2'],
+            ]);
+            return $reports->paginate($data);
+        });
+    }
+}

+ 6 - 0
app/Http/Controllers/admin/Sync.php

@@ -23,6 +23,12 @@ class Sync extends Controller
     {
         $synced = RechargeService::syncPendingUsdtRechargeRecords();
         RechargeService::syncRechargeStay();
+        // 代理 USDT 充值复用同一个外部定时任务,失败不影响会员充值同步结果。
+        try {
+            $synced['agent'] = app(\App\Services\Agent\AgentRechargeService::class)->sync();
+        } catch (\Throwable $e) {
+            report($e);
+        }
         return $this->success($synced);
     }
 

+ 1 - 10
app/Http/Controllers/agent/AuthController.php

@@ -17,16 +17,7 @@ class AuthController extends AgentApiController
             ]);
             $result = $service->login(trim($data['username']), $data['password'], $request);
             $agent = \App\Models\Agent\Agent::query()->where('username', trim($data['username']))->firstOrFail();
-            $result['agent'] = [
-                'id' => (int) $agent->id,
-                'username' => $agent->username,
-                'real_name' => $agent->real_name,
-                'language' => $agent->language ?? 'zh',
-                'invitation_code' => $agent->invitation_code,
-                'level' => (int) $agent->level,
-                'level_text' => $agent->level_text,
-                'can_create_sub_agent' => $agent->can_create_sub_agent,
-            ];
+            $result['agent'] = app(\App\Services\Agent\AgentProfileService::class)->summary($agent);
             return $result;
         });
     }

+ 48 - 113
app/Http/Controllers/agent/CommissionController.php

@@ -3,150 +3,85 @@
 namespace App\Http\Controllers\agent;
 
 use App\Http\Controllers\AgentApiController;
-use App\Models\Agent\Agent;
+use App\Models\Agent\AgentProfitEntry;
 use App\Models\Agent\AgentRebateRecord;
-use App\Rules\DecimalNumber;
-use App\Services\Agent\AgentFlowCommissionProfileService;
-use App\Services\Agent\AgentProfitCommissionProfileService;
-use App\Services\Agent\AgentReportService;
+use App\Services\Agent\AgentPlanApplicationService;
 use Illuminate\Http\Request;
-use Illuminate\Validation\Rule;
 
 class CommissionController extends AgentApiController
 {
-    public function records(Request $request, AgentReportService $reports)
+    /** 合营分成每日入账记录。 */
+    public function records(Request $request)
     {
-        return $this->execute(function () use ($request, $reports) {
+        return $this->execute(function () use ($request) {
             $data = $request->validate([
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-                'order_no' => ['nullable', 'string', 'max:48'], 'username' => ['nullable', 'string', 'max:64'],
-                'platform' => ['nullable', 'string', 'max:32'],
-                'flow_status' => ['nullable', 'in:pending,credited'],
+                'order_no' => ['nullable', 'string', 'max:48'], 'category' => ['nullable', 'string', 'max:32'],
                 'profit_status' => ['nullable', 'in:pending,credited'],
-                'settlement_date' => ['nullable', 'date_format:Y-m-d'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
-                'include_children' => ['nullable', 'boolean'],
             ]);
-            $page = max(1, (int) ($data['page'] ?? 1));
-            $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
-            $agent = $this->currentAgent();
-            $ids = $request->boolean('include_children') ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
-            $query = AgentRebateRecord::query()->with('agent:id,username')->whereIn('agent_id', $ids);
-            foreach (['order_no', 'platform', 'flow_status', 'profit_status'] as $field) {
-                if (!empty($data[$field])) $query->where($field, $data[$field]);
-            }
-            if (!empty($data['username'])) {
-                $query->whereHas('agent', fn($query) => $query->where('username', 'like', '%' . $data['username'] . '%'));
-            }
-            if (!empty($data['settlement_date'])) $query->where('settlement_date', $data['settlement_date']);
-            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();
-            $list = $query->orderByDesc('settlement_date')->orderByDesc('id')->forPage($page, $limit)->get();
-            return compact('total', 'page', 'limit', 'list');
+            $query = AgentRebateRecord::query()->where('agent_id', $this->currentAgent()->id);
+            if (!empty($data['order_no'])) $query->where('order_no', $data['order_no']);
+            if (!empty($data['category'])) $query->where('platform', $data['category']);
+            if (!empty($data['profit_status'])) $query->where('profit_status', $data['profit_status']);
+            if (!empty($data['start_date'])) $query->where('settlement_date', '>=', $data['start_date']);
+            if (!empty($data['end_date'])) $query->where('settlement_date', '<=', $data['end_date']);
+            return $this->page($query, $data, 'settlement_date');
         });
     }
 
-    public function flowProfiles(Request $request, AgentFlowCommissionProfileService $service)
+    /** 合营分成注单明细(实时,含未入账)。 */
+    public function entries(Request $request)
     {
-        return $this->execute(function () use ($request, $service) {
-            return $this->listProfiles($request, $service);
-        });
-    }
-
-    public function saveFlowCommissionProfile(Request $request, AgentFlowCommissionProfileService $service)
-    {
-        return $this->execute(function () use ($request, $service) {
-            $owner = $this->requireLevelOne();
-            $data = $request->validate($this->profileRules('agent_flow_commission_profiles', (int) $owner->id));
-            return $service->save($data, null, $owner);
-        });
-    }
-
-    public function deleteFlowCommissionProfile(Request $request, AgentFlowCommissionProfileService $service)
-    {
-        return $this->execute(function () use ($request, $service) {
-            $owner = $this->requireLevelOne();
-            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_flow_commission_profiles,id']]);
-            $service->delete((int) $data['id'], null, $owner);
-            return [];
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'category' => ['nullable', 'string', 'max:32'], 'user_id' => ['nullable', 'integer'],
+                'status' => ['nullable', 'in:counted,skipped_inactive,skipped_no_plan,skipped_category'],
+                'credited' => ['nullable', 'boolean'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentProfitEntry::query()->where('agent_id', $this->currentAgent()->id);
+            foreach (['category', 'user_id', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
+            if (isset($data['credited'])) $request->boolean('credited') ? $query->whereNotNull('rebate_record_id') : $query->whereNull('rebate_record_id');
+            if (!empty($data['start_date'])) $query->where('settle_date', '>=', $data['start_date']);
+            if (!empty($data['end_date'])) $query->where('settle_date', '<=', $data['end_date']);
+            return $this->page($query, $data, 'settled_at');
         });
     }
 
-    public function profitProfiles(Request $request, AgentProfitCommissionProfileService $service)
+    public function plans(AgentPlanApplicationService $service)
     {
-        return $this->execute(function () use ($request, $service) {
-            return $this->listProfiles($request, $service);
-        });
+        return $this->execute(fn () => $service->plans($this->currentAgent()->fresh()));
     }
 
-    public function saveProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    public function applyPlan(Request $request, AgentPlanApplicationService $service)
     {
         return $this->execute(function () use ($request, $service) {
-            $owner = $this->requireLevelOne();
-            $data = $request->validate($this->profileRules('agent_profit_commission_profiles', (int) $owner->id));
-            return $service->save($data, null, $owner);
+            $data = $request->validate([
+                'profile_id' => ['required', 'integer'], 'remark' => ['nullable', 'string', 'max:500'],
+            ]);
+            return $service->apply($this->currentAgent(), (int) $data['profile_id'], (string) ($data['remark'] ?? ''));
         });
     }
 
-    public function deleteProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    public function planApplications(Request $request, AgentPlanApplicationService $service)
     {
         return $this->execute(function () use ($request, $service) {
-            $owner = $this->requireLevelOne();
-            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_profit_commission_profiles,id']]);
-            $service->delete((int) $data['id'], null, $owner);
-            return [];
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'status' => ['nullable', 'in:pending,approved,rejected'],
+            ]);
+            return $service->paginate($data, $this->currentAgent());
         });
     }
 
-    private function listProfiles(
-        Request $request,
-        AgentFlowCommissionProfileService|AgentProfitCommissionProfileService $service
-    ): array {
-        $data = $request->validate([
-            'page' => ['nullable', 'integer', 'min:1'],
-            'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-            'name' => ['nullable', 'string', 'max:128'],
-            'selectable' => ['nullable', 'boolean'],
-        ]);
-        $agent = $this->currentAgent();
-        if ((int) $agent->level !== Agent::LEVEL_ONE) {
-            $page = max(1, (int) ($data['page'] ?? 1));
-            $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
-            return ['total' => 0, 'page' => $page, 'limit' => $limit, 'list' => []];
-        }
-        if ($request->boolean('selectable')) {
-            return $service->paginateSelectable($agent, $data);
-        }
-        return $service->paginate($data, $agent);
-    }
-
-    private function requireLevelOne(): Agent
-    {
-        $agent = $this->currentAgent();
-        if ((int) $agent->level !== Agent::LEVEL_ONE) {
-            throw new \RuntimeException('只有一级代理可以配置下级佣金方案');
-        }
-        return $agent;
-    }
-
-    private function profileRules(string $table, int $ownerId): array
+    private function page($query, array $data, string $orderBy): array
     {
-        $id = (int) request()->input('id', 0);
-        return [
-            'id' => ['nullable', 'integer', 'exists:' . $table . ',id'],
-            'name' => [
-                'required', 'string', 'max:128',
-                Rule::unique($table, 'name')->where('owner_agent_id', $ownerId)->ignore($id),
-            ],
-            'live_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'slot_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'lottery_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'sport_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'esports_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'fishing_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'chess_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'is_default' => ['required', 'boolean'],
-        ];
+        $page = max(1, (int) ($data['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc($orderBy)->orderByDesc('id')->forPage($page, $limit)->get();
+        return compact('total', 'page', 'limit', 'list');
     }
 }

+ 44 - 0
app/Http/Controllers/agent/EquityController.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Models\Agent\AgentEquityOrder;
+use App\Rules\DecimalNumber;
+use App\Services\Agent\AgentEquityService;
+use Illuminate\Http\Request;
+
+class EquityController extends AgentApiController
+{
+    public function index(Request $request, AgentEquityService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+            ]);
+            $agent = $this->currentAgent()->fresh();
+            $page = max(1, (int) ($data['page'] ?? 1));
+            $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
+            $query = AgentEquityOrder::query()->where('agent_id', $agent->id);
+            $total = (clone $query)->count();
+            $orders = $query->orderByDesc('id')->forPage($page, $limit)->get();
+            return $service->overview($agent) + ['orders' => compact('total', 'page', 'limit') + ['list' => $orders]];
+        });
+    }
+
+    public function order(Request $request, AgentEquityService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['percent' => ['required', 'numeric', 'gt:0', new DecimalNumber(3, 4)]]);
+            return $service->order($this->currentAgent(), (string) $data['percent']);
+        });
+    }
+
+    public function cancel(Request $request, AgentEquityService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer']]);
+            return $service->cancel($this->currentAgent(), (int) $data['id']);
+        });
+    }
+}

+ 30 - 2
app/Http/Controllers/agent/MemberController.php

@@ -4,13 +4,41 @@ namespace App\Http\Controllers\agent;
 
 use App\Http\Controllers\AgentApiController;
 use App\Services\Agent\AgentMemberService;
+use App\Services\MemberDailyReportService;
+use App\Services\UserService;
 use Illuminate\Http\Request;
 
 class MemberController extends AgentApiController
 {
-    public function index(Request $request, AgentMemberService $service)
+    /** 会员列表:字段与总后台 GET /admin/user 一致,只查本代理的会员;不提供充值、提现、冻结、解冻。 */
+    public function index(Request $request)
     {
-        return $this->execute(fn() => $service->paginate($this->currentAgent(), $this->validateList($request)));
+        return $this->execute(function () use ($request) {
+            $fields = 'available_balance,created_at,last_active_time,frozen_balance,yuebao,total_consume,wager_required,wager_completed,wager_remaining,third_game_total_balance';
+            $search = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'member_id' => ['nullable', 'string', 'min:1'], 'account' => ['nullable', 'string', 'min:1'],
+                'like_first_name' => ['nullable', 'string', 'min:1'], 'username' => ['nullable', 'string', 'min:1'],
+                'register_ip' => ['nullable', 'string', 'min:1'], 'level' => ['nullable'], 'is_online' => ['nullable', 'boolean'],
+                'sort_field' => ['nullable', 'string', 'in:' . $fields], 'sort_order' => ['nullable', 'string', 'in:asc,desc'],
+                'start_time' => ['nullable', 'date_format:Y-m-d', 'required_with:end_time'],
+                'end_time' => ['nullable', 'date_format:Y-m-d', 'required_with:start_time'],
+            ]);
+            $search['agent_id'] = (int) $this->currentAgent()->id;
+            return UserService::paginate($search, $search['sort_order'] ?? 'desc', $search['sort_field'] ?? 'created_at');
+        });
+    }
+
+    public function dailyReport(Request $request, MemberDailyReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+                'member_id' => ['nullable', 'string', 'max:64'], 'username' => ['nullable', 'string', 'max:128'],
+            ]);
+            return $reports->paginate($data, $this->currentAgent());
+        });
     }
 
     public function finance(Request $request, AgentMemberService $service)

+ 5 - 24
app/Http/Controllers/agent/ProfileController.php

@@ -13,33 +13,14 @@ use Illuminate\Support\Facades\DB;
 
 class ProfileController extends AgentApiController
 {
-    public function me(AgentDomainService $domains)
+    public function me(\App\Services\Agent\AgentProfileService $profiles)
     {
-        return $this->execute(function () use ($domains) {
-            $agent = $this->currentAgent()->load(['parent:id,username', 'flowCommissionProfile', 'profitCommissionProfile']);
-            return array_merge([
-                'id' => (int) $agent->id,
-                'username' => $agent->username,
-                'real_name' => $agent->real_name,
-                'language' => $agent->language ?? 'zh',
-                'parent_id' => $agent->parent_id,
-                'parent_username' => $agent->parent?->username,
-                'level' => (int) $agent->level,
-                'level_text' => $agent->level_text,
-                'can_create_sub_agent' => $agent->can_create_sub_agent,
-                'flow_commission_profile_id' => $agent->flow_commission_profile_id,
-                'flow_commission_profile' => $agent->flowCommissionProfile,
-                'profit_commission_profile_id' => $agent->profit_commission_profile_id,
-                'profit_commission_profile' => $agent->profitCommissionProfile,
-                'invitation_code' => $agent->invitation_code,
-                'balance' => (string) $agent->balance,
-                'frozen_balance' => (string) $agent->frozen_balance,
-                'deposit_fee_rate' => (string) $agent->deposit_fee_rate,
-                'withdraw_fee_rate' => (string) $agent->withdraw_fee_rate,
-                'profit_commission_rate' => (string) $agent->profit_commission_rate,
+        return $this->execute(function () use ($profiles) {
+            $agent = $this->currentAgent();
+            return $profiles->summary($agent) + [
                 'last_login_at' => $agent->last_login_at?->toDateTimeString(),
                 'last_login_ip' => $agent->last_login_ip,
-            ], $domains->format($agent));
+            ];
         });
     }
 

+ 0 - 109
app/Http/Controllers/agent/SubAgentController.php

@@ -1,109 +0,0 @@
-<?php
-
-namespace App\Http\Controllers\agent;
-
-use App\Http\Controllers\AgentApiController;
-use App\Models\Agent\Agent;
-use App\Services\Agent\AgentDomainService;
-use App\Services\Agent\AgentReportService;
-use App\Services\Agent\AgentService;
-use App\Services\Agent\AgentWalletService;
-use Illuminate\Http\Request;
-use App\Rules\DecimalNumber;
-
-class SubAgentController extends AgentApiController
-{
-    public function index(Request $request, AgentReportService $reports, AgentDomainService $domains)
-    {
-        return $this->execute(function () use ($request, $reports, $domains) {
-            $data = $request->validate([
-                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-                'username' => ['nullable', 'string', 'max:64'], 'status' => ['nullable', 'integer', 'in:0,1'],
-                'direct_only' => ['nullable', 'boolean'],
-            ]);
-            $root = $this->currentAgent();
-            $page = max(1, (int) ($data['page'] ?? 1));
-            $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
-            $query = Agent::query()->with(['parent:id,username', 'flowCommissionProfile', 'profitCommissionProfile']);
-            if ($request->boolean('direct_only')) {
-                $query->where('parent_id', $root->id);
-            } else {
-                $query->whereIn('id', $reports->visibleAgentIds($root, false));
-            }
-            if (!empty($data['username'])) $query->where('username', 'like', '%' . $data['username'] . '%');
-            if (isset($data['status'])) $query->where('status', $data['status']);
-            $total = (clone $query)->count();
-            $list = $query->orderByDesc('id')->forPage($page, $limit)->get()->map(function (Agent $agent) use ($domains) {
-                return array_merge($agent->toArray(), $domains->format($agent));
-            });
-            return compact('total', 'page', 'limit', 'list');
-        });
-    }
-
-    public function store(Request $request, AgentService $service)
-    {
-        return $this->execute(function () use ($request, $service) {
-            $root = $this->currentAgent();
-            $data = $request->validate([
-                'username' => ['required', 'string', 'min:4', 'max:64', 'alpha_dash', 'unique:agents,username'],
-                'password' => ['required', 'string', 'min:6', 'max:100'],
-                'withdraw_password' => ['nullable', 'string', 'min:6', 'max:100'],
-                'real_name' => ['nullable', 'string', 'max:128'],
-                'parent_id' => ['prohibited'],
-                'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'flow_commission_rate' => ['prohibited'],
-                'profit_commission_rate' => ['prohibited'],
-                'flow_commission_profile_id' => ['required', 'integer', 'exists:agent_flow_commission_profiles,id'],
-                'profit_commission_profile_id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
-                'status' => ['nullable', 'integer', 'in:0,1'],
-            ]);
-            return $service->create($data, null, $root);
-        });
-    }
-
-    public function update(Request $request, AgentService $service)
-    {
-        return $this->execute(function () use ($request, $service) {
-            $root = $this->currentAgent();
-            $data = $request->validate([
-                'id' => ['required', 'integer', 'exists:agents,id'],
-                'password' => ['nullable', 'string', 'min:6', 'max:100'],
-                'withdraw_password' => ['nullable', 'string', 'min:6', 'max:100'],
-                'real_name' => ['nullable', 'string', 'max:128'],
-                'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'flow_commission_rate' => ['prohibited'],
-                'profit_commission_rate' => ['prohibited'],
-                'flow_commission_profile_id' => ['nullable', 'integer', 'exists:agent_flow_commission_profiles,id'],
-                'profit_commission_profile_id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
-                'status' => ['nullable', 'integer', 'in:0,1'],
-            ]);
-            if ((int) $data['id'] === (int) $root->id) throw new \RuntimeException('不能在下级代理接口修改自己');
-            return $service->update(Agent::query()->findOrFail($data['id']), $data, $root);
-        });
-    }
-
-    public function transfer(Request $request, AgentService $agents, AgentWalletService $wallets)
-    {
-        return $this->execute(function () use ($request, $agents, $wallets) {
-            $data = $request->validate([
-                'agent_id' => ['required', 'integer', 'exists:agents,id'],
-                'direction' => ['required', 'in:credit,debit'],
-                'amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()],
-                'remark' => ['required', 'string', 'max:500'],
-                'request_id' => ['required', 'string', 'max:64'],
-            ]);
-            $root = $this->currentAgent();
-            if ((int) $data['agent_id'] === (int) $root->id) throw new \RuntimeException('不能给自己转账');
-            $agents->assertVisible($root, (int) $data['agent_id']);
-            $target = Agent::query()->findOrFail($data['agent_id']);
-            if ($data['direction'] === 'credit' && (int) $target->status !== Agent::STATUS_ENABLED) {
-                throw new \RuntimeException('不能给已禁用代理下发额度');
-            }
-            $from = $data['direction'] === 'credit' ? (int) $root->id : (int) $data['agent_id'];
-            $to = $data['direction'] === 'credit' ? (int) $data['agent_id'] : (int) $root->id;
-            return $wallets->transfer($from, $to, (string) $data['amount'], $data['remark'], 'agent:' . $root->id . ':' . $data['request_id'], (int) $root->id);
-        });
-    }
-}

+ 63 - 10
app/Http/Controllers/agent/WalletController.php

@@ -6,6 +6,10 @@ use App\Http\Controllers\AgentApiController;
 use App\Models\Agent\AgentBalanceLog;
 use App\Models\Agent\AgentWithdrawAccount;
 use App\Models\Agent\AgentWithdrawal;
+use App\Models\Agent\AgentRecharge;
+use App\Services\Agent\AgentExchangeRate;
+use App\Services\Agent\AgentRechargeService;
+use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWithdrawalService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
@@ -28,24 +32,28 @@ class WalletController extends AgentApiController
     public function accounts(Request $request)
     {
         return $this->execute(function () use ($request) {
-            $data = $request->validate(['type' => ['nullable', 'in:bank,alipay,wechat,usdt,other']]);
             return AgentWithdrawAccount::query()->where('agent_id', $this->currentAgent()->id)
-                ->when(!empty($data['type']), fn($query) => $query->where('type', $data['type']))
-                ->orderByDesc('id')->get();
+                ->where('type', 'usdt')->orderByDesc('id')->get();
         });
     }
 
     public function saveAccount(Request $request)
     {
         return $this->execute(function () use ($request) {
+            // 代理提现仅支持 USDT,地址按公链校验格式。
             $data = $request->validate([
-                'id' => ['nullable', 'integer'], 'type' => ['required', 'in:bank,alipay,wechat,usdt,other'],
-                'name' => ['nullable', 'string', 'max:128'], 'account' => ['required', 'string', 'max:255'],
-                'bank_name' => ['nullable', 'string', 'max:128'], 'network' => ['nullable', 'string', 'max:32'],
-                'details' => ['nullable', 'array'], 'status' => ['nullable', 'integer', 'in:0,1'],
+                'id' => ['nullable', 'integer'], 'name' => ['nullable', 'string', 'max:128'],
+                'network' => ['required', 'in:TRC20,ERC20'],
+                'account' => ['required', 'string', 'max:255', function ($attribute, $value, $fail) use ($request) {
+                    $pattern = $request->input('network') === 'ERC20' ? '/^0x[0-9a-fA-F]{40}$/D' : '/^T[1-9A-HJ-NP-Za-km-z]{33}$/D';
+                    if (!preg_match($pattern, (string) $value)) $fail('USDT 地址格式错误');
+                }],
+                'status' => ['nullable', 'integer', 'in:0,1'],
             ]);
+            $data['type'] = 'usdt';
+            $data['bank_name'] = '';
             $isNew = empty($data['id']);
-            foreach (['name', 'bank_name', 'network'] as $field) {
+            foreach (['name', 'network'] as $field) {
                 if ($isNew || array_key_exists($field, $data)) {
                     $data[$field] = trim((string) ($data[$field] ?? ''));
                 }
@@ -94,11 +102,56 @@ class WalletController extends AgentApiController
     {
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
-                'account_id' => ['required', 'integer'], 'amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()],
+                'account_id' => ['required', 'integer'], 'usdt_amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()],
                 'withdraw_password' => ['required', 'string'], 'remark' => ['nullable', 'string', 'max:500'],
                 'request_id' => ['required', 'string', 'max:64'],
             ]);
-            return $service->request($this->currentAgent(), (int) $data['account_id'], (string) $data['amount'], $data['withdraw_password'], $data['request_id'], (string) ($data['remark'] ?? ''));
+            return $service->request($this->currentAgent(), (int) $data['account_id'], (string) $data['usdt_amount'], $data['withdraw_password'], $data['request_id'], (string) ($data['remark'] ?? ''));
+        });
+    }
+
+    /** 余额构成:总余额、保证金、盈亏、可提现。 */
+    public function summary(AgentWalletService $wallets)
+    {
+        return $this->execute(fn () => $wallets->funds($this->currentAgent()->fresh()) + [
+            'withdraw_exchange_rate' => AgentExchangeRate::withdraw(),
+            'recharge_exchange_rate' => AgentExchangeRate::recharge(),
+        ]);
+    }
+
+    public function rechargeAddress(Request $request, AgentRechargeService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['net' => ['nullable', 'in:trc20,erc20,TRC20,ERC20']]);
+            return $service->address($this->currentAgent(), (string) ($data['net'] ?? 'trc20'));
+        });
+    }
+
+    public function submitRecharge(Request $request, AgentRechargeService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'net' => ['required', 'in:trc20,erc20,TRC20,ERC20'],
+                'usdt_amount' => ['required', 'numeric', 'min:0.01', new DecimalNumber()],
+                'to_address' => ['required', 'string', 'max:64'],
+                'image' => ['required', 'url', 'max:1000'],
+            ]);
+            return $service->submitProof($this->currentAgent(), $data);
+        });
+    }
+
+    public function recharges(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'status' => ['nullable', 'integer', 'in:0,1,2,3'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentRecharge::query()->where('agent_id', $this->currentAgent()->id);
+            if (isset($data['status'])) $query->where('status', (int) $data['status']);
+            unset($data['status']);
+            return $this->pageQuery($query, $data);
         });
     }
 

+ 17 - 1
app/Models/ActivityReward.php

@@ -27,7 +27,23 @@ class ActivityReward extends BaseModel
         'publish_platforms', 'budget', 'reward_quota', 'sort', 'register_time_limited', 'register_start_time', 'register_end_time',
         'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'game_category', 'click_type', 'link', 'link_pc', 'link_h5', 'click_position'];
 
-    protected $appends = ['type_name', 'game_category_name', 'click_type', 'link', 'link_pc', 'link_h5', 'click_position', 'can_jump'];
+    protected $appends = ['type_name', 'game_category_name', 'platform_label', 'click_type', 'link', 'link_pc', 'link_h5', 'click_position', 'can_jump'];
+
+    protected function getPublishPlatformsAttribute($value): ?array
+    {
+        // 普通活动没有平台配置,历史单平台值也按全部平台返回。
+        if ((int)($this->attributes['type'] ?? 0) === 2) {
+            return ActivityCatalog::PUBLISH_PLATFORMS;
+        }
+        return $value === null ? null : $this->fromJson($value);
+    }
+
+    protected function getPlatformLabelAttribute(): string
+    {
+        $platforms = $this->publish_platforms ?? [];
+        return !$platforms || !array_diff(ActivityCatalog::PUBLISH_PLATFORMS, $platforms)
+            ? '全部平台' : implode('、', $platforms);
+    }
 
     protected function getClickTypeAttribute($value): string
     {

+ 21 - 30
app/Models/Agent/Agent.php

@@ -10,8 +10,10 @@ class Agent extends BaseModel
     public const STATUS_DISABLED = 0;
     public const STATUS_ENABLED = 1;
     public const LEVEL_ONE = 1;
-    public const LEVEL_TWO = 2;
-    public const MAX_LEVEL = self::LEVEL_TWO;
+
+    public const TYPE_JOINT = 1;
+    public const TYPE_EQUITY = 2;
+    public const TYPE_NAMES = [self::TYPE_JOINT => '合营', self::TYPE_EQUITY => '入股'];
 
     protected $table = 'agents';
     protected $fillable = [
@@ -19,25 +21,24 @@ 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',
-        'flow_commission_profile_id', 'profit_commission_profile_id',
+        'profit_commission_profile_id', 'type',
     ];
-    protected $hidden = ['password', 'withdraw_password'];
-    protected $appends = ['level_text', 'can_create_sub_agent'];
+    // 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',
+        'parent_id' => 'integer', 'level' => 'integer', 'status' => 'integer', 'type' => 'integer',
         'balance' => 'decimal:4', 'frozen_balance' => 'decimal:4',
+        'margin_amount' => 'decimal:4', 'realtime_balance' => 'decimal:4',
+        'profit_active' => 'integer', 'profit_active_changed_at' => 'datetime',
+        'equity_percent' => 'decimal:4',
         'deposit_fee_rate' => 'decimal:4', 'withdraw_fee_rate' => 'decimal:4',
         'profit_commission_rate' => 'decimal:4',
         'last_login_at' => 'datetime',
-        'flow_commission_profile_id' => 'integer', 'profit_commission_profile_id' => 'integer',
+        'profit_commission_profile_id' => 'integer',
     ];
 
-    public function parent()
-    {
-        return $this->belongsTo(self::class, 'parent_id');
-    }
-
     public function domains()
     {
         return $this->hasMany(AgentDomain::class);
@@ -48,11 +49,6 @@ class Agent extends BaseModel
         return $this->hasOne(AgentToken::class);
     }
 
-    public function children()
-    {
-        return $this->hasMany(self::class, 'parent_id');
-    }
-
     public function members()
     {
         return $this->hasMany(User::class, 'agent_id');
@@ -63,28 +59,23 @@ class Agent extends BaseModel
         return $this->belongsTo(AgentProfitCommissionProfile::class, 'profit_commission_profile_id');
     }
 
-    public function flowCommissionProfile()
-    {
-        return $this->belongsTo(AgentFlowCommissionProfile::class, 'flow_commission_profile_id');
-    }
-
-    public function flowCommissionAssignments()
+    public function profitCommissionAssignments()
     {
-        return $this->hasMany(AgentFlowCommissionAssignment::class);
+        return $this->hasMany(AgentProfitCommissionAssignment::class);
     }
 
-    public function profitCommissionAssignments()
+    public function isJoint(): bool
     {
-        return $this->hasMany(AgentProfitCommissionAssignment::class);
+        return (int) $this->type === self::TYPE_JOINT;
     }
 
-    public function getLevelTextAttribute(): string
+    public function isEquity(): bool
     {
-        return (int) $this->level === self::LEVEL_ONE ? '一级代理' : '二级代理';
+        return (int) $this->type === self::TYPE_EQUITY;
     }
 
-    public function getCanCreateSubAgentAttribute(): bool
+    public function getTypeTextAttribute(): string
     {
-        return (int) $this->level === self::LEVEL_ONE;
+        return self::TYPE_NAMES[(int) ($this->type ?: self::TYPE_JOINT)] ?? '';
     }
 }

+ 0 - 16
app/Models/Agent/AgentDailyGameStat.php

@@ -1,16 +0,0 @@
-<?php
-
-namespace App\Models\Agent;
-
-use App\Models\BaseModel;
-
-class AgentDailyGameStat extends BaseModel
-{
-    protected $table = 'agent_daily_game_stats';
-    protected $guarded = [];
-    protected $hidden = [];
-    protected $casts = [
-        'stat_date' => 'date:Y-m-d', 'agent_id' => 'integer', 'game_type' => 'integer', 'bet_count' => 'integer',
-        'bet_amount' => 'decimal:4', 'valid_bet_amount' => 'decimal:4', 'win_loss' => 'decimal:4',
-    ];
-}

+ 31 - 0
app/Models/Agent/AgentEquityOrder.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentEquityOrder extends BaseModel
+{
+    public const STATUS_PENDING = 'pending_payment';
+    public const STATUS_COMPLETED = 'completed';
+    public const STATUS_CANCELLED = 'cancelled';
+
+    protected $table = 'agent_equity_orders';
+    protected $guarded = ['id'];
+    protected $hidden = [];
+    protected $appends = ['status_text'];
+    protected $casts = [
+        'agent_id' => 'integer', 'percent' => 'decimal:4', 'price_per_percent' => 'decimal:4',
+        'amount' => 'decimal:4', 'completed_at' => 'datetime',
+    ];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+
+    public function getStatusTextAttribute(): string
+    {
+        return [self::STATUS_PENDING => '待付款', self::STATUS_COMPLETED => '已开通', self::STATUS_CANCELLED => '已取消'][(string) $this->status] ?? '';
+    }
+}

+ 0 - 41
app/Models/Agent/AgentFlowCommissionAssignment.php

@@ -1,41 +0,0 @@
-<?php
-
-namespace App\Models\Agent;
-
-use App\Models\BaseModel;
-
-class AgentFlowCommissionAssignment extends BaseModel
-{
-    protected $table = 'agent_flow_commission_assignments';
-    protected $hidden = [];
-    protected $guarded = [];
-    protected $casts = [
-        'agent_id' => 'integer',
-        'flow_commission_profile_id' => 'integer',
-        'live_rate' => 'decimal:4',
-        'slot_rate' => 'decimal:4',
-        'lottery_rate' => 'decimal:4',
-        'sport_rate' => 'decimal:4',
-        'esports_rate' => 'decimal:4',
-        'fishing_rate' => 'decimal:4',
-        'chess_rate' => 'decimal:4',
-        'effective_from' => 'date:Y-m-d',
-        'created_by' => 'integer',
-    ];
-
-    public function agent()
-    {
-        return $this->belongsTo(Agent::class);
-    }
-
-    public function profile()
-    {
-        return $this->belongsTo(AgentFlowCommissionProfile::class, 'flow_commission_profile_id');
-    }
-
-    public function rateForGameType(int $gameType): string
-    {
-        $field = AgentFlowCommissionProfile::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;
-        return $field ? (string)$this->{$field} : '0.0000';
-    }
-}

+ 0 - 75
app/Models/Agent/AgentFlowCommissionProfile.php

@@ -1,75 +0,0 @@
-<?php
-
-namespace App\Models\Agent;
-
-use App\Models\BaseModel;
-
-class AgentFlowCommissionProfile extends BaseModel
-{
-    public const RATE_FIELDS = [
-        'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
-        'esports_rate', 'fishing_rate', 'chess_rate',
-    ];
-
-    public const GAME_TYPE_RATE_FIELDS = [
-        1 => 'live_rate',
-        2 => 'slot_rate',
-        3 => 'lottery_rate',
-        4 => 'sport_rate',
-        5 => 'esports_rate',
-        6 => 'fishing_rate',
-        7 => 'chess_rate',
-    ];
-
-    protected $table = 'agent_flow_commission_profiles';
-    protected $hidden = [];
-    protected $fillable = [
-        'name', 'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
-        'esports_rate', 'fishing_rate', 'chess_rate', 'is_default',
-    ];
-    protected $casts = [
-        'owner_agent_id' => 'integer',
-        'live_rate' => 'decimal:4', 'slot_rate' => 'decimal:4', 'lottery_rate' => 'decimal:4',
-        'sport_rate' => 'decimal:4', 'esports_rate' => 'decimal:4', 'fishing_rate' => 'decimal:4',
-        'chess_rate' => 'decimal:4', 'is_default' => 'integer',
-    ];
-
-    public function owner()
-    {
-        return $this->belongsTo(Agent::class, 'owner_agent_id');
-    }
-
-    public function agents()
-    {
-        return $this->hasMany(Agent::class, 'flow_commission_profile_id');
-    }
-
-    public function isOfficial(): bool
-    {
-        return empty($this->owner_agent_id);
-    }
-
-    public function isOwnedBy(?int $agentId): bool
-    {
-        return $agentId !== null && (int) $this->owner_agent_id === $agentId;
-    }
-
-    public static function officialDefaultId(): ?int
-    {
-        $id = static::query()->whereNull('owner_agent_id')->where('is_default', 1)->value('id');
-        return $id ? (int) $id : null;
-    }
-
-    public function rateForGameType(int $gameType): string
-    {
-        $field = self::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;
-        return $field ? (string)$this->{$field} : '0.0000';
-    }
-
-    public function rateSnapshot(): array
-    {
-        $snapshot = [];
-        foreach (self::RATE_FIELDS as $field) $snapshot[$field] = (string)$this->{$field};
-        return $snapshot;
-    }
-}

+ 33 - 0
app/Models/Agent/AgentPlanApplication.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentPlanApplication extends BaseModel
+{
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_APPROVED = 'approved';
+    public const STATUS_REJECTED = 'rejected';
+
+    protected $table = 'agent_plan_applications';
+    protected $guarded = ['id'];
+    protected $hidden = [];
+    protected $appends = ['status_text'];
+    protected $casts = ['agent_id' => 'integer', 'profile_id' => 'integer', 'reviewed_by' => 'integer', 'reviewed_at' => 'datetime'];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+
+    public function profile()
+    {
+        return $this->belongsTo(AgentProfitCommissionProfile::class, 'profile_id');
+    }
+
+    public function getStatusTextAttribute(): string
+    {
+        return [self::STATUS_PENDING => '待审核', self::STATUS_APPROVED => '已通过', self::STATUS_REJECTED => '已驳回'][(string) $this->status] ?? '';
+    }
+}

+ 6 - 5
app/Models/Agent/AgentProfitCommissionAssignment.php

@@ -12,14 +12,16 @@ class AgentProfitCommissionAssignment extends BaseModel
     protected $casts = [
         'agent_id' => 'integer',
         'profit_commission_profile_id' => 'integer',
+        'margin_amount' => 'decimal:4',
+        'f1_lottery_rate' => 'decimal:4',
+        'f1_sport_rate' => 'decimal:4',
         'live_rate' => 'decimal:4',
         'slot_rate' => 'decimal:4',
-        'lottery_rate' => 'decimal:4',
         'sport_rate' => 'decimal:4',
         'esports_rate' => 'decimal:4',
         'fishing_rate' => 'decimal:4',
         'chess_rate' => 'decimal:4',
-        'effective_from' => 'date:Y-m-d',
+        'effective_from' => 'datetime:Y-m-d H:i:s',
         'created_by' => 'integer',
     ];
 
@@ -33,9 +35,8 @@ class AgentProfitCommissionAssignment extends BaseModel
         return $this->belongsTo(AgentProfitCommissionProfile::class, 'profit_commission_profile_id');
     }
 
-    public function rateForGameType(int $gameType): string
+    public function rateForCategory(string $category): string
     {
-        $field = AgentProfitCommissionProfile::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;
-        return $field ? (string) $this->{$field} : '0.0000';
+        return AgentProfitCommissionProfile::rateForCategory($this, $category);
     }
 }

+ 24 - 36
app/Models/Agent/AgentProfitCommissionProfile.php

@@ -6,64 +6,52 @@ use App\Models\BaseModel;
 
 class AgentProfitCommissionProfile extends BaseModel
 {
-    public const RATE_FIELDS = [
-        'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
-        'esports_rate', 'fishing_rate', 'chess_rate',
+    /** 分成分类(与分享返佣一致,共 8 类,暂无三方彩票)=> 比例字段。 */
+    public const CATEGORY_RATE_FIELDS = [
+        'self:lottery' => 'f1_lottery_rate',
+        'self:sport' => 'f1_sport_rate',
+        'third:1' => 'live_rate',
+        'third:2' => 'slot_rate',
+        'third:4' => 'sport_rate',
+        'third:5' => 'esports_rate',
+        'third:6' => 'fishing_rate',
+        'third:7' => 'chess_rate',
     ];
 
-    public const GAME_TYPE_RATE_FIELDS = [
-        1 => 'live_rate',
-        2 => 'slot_rate',
-        3 => 'lottery_rate',
-        4 => 'sport_rate',
-        5 => 'esports_rate',
-        6 => 'fishing_rate',
-        7 => 'chess_rate',
+    public const RATE_FIELDS = [
+        'f1_lottery_rate', 'f1_sport_rate', 'live_rate', 'slot_rate',
+        'sport_rate', 'esports_rate', 'fishing_rate', 'chess_rate',
     ];
 
     protected $table = 'agent_profit_commission_profiles';
-    protected $hidden = [];
+    protected $hidden = ['owner_agent_id'];
     protected $fillable = [
-        'name', 'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
-        'esports_rate', 'fishing_rate', 'chess_rate', 'is_default',
+        'name', 'margin_amount', 'f1_lottery_rate', 'f1_sport_rate', 'live_rate', 'slot_rate',
+        'sport_rate', 'esports_rate', 'fishing_rate', 'chess_rate', 'is_default',
     ];
     protected $casts = [
-        'owner_agent_id' => 'integer',
-        'live_rate' => 'decimal:4', 'slot_rate' => 'decimal:4', 'lottery_rate' => 'decimal:4',
-        'sport_rate' => 'decimal:4', 'esports_rate' => 'decimal:4', 'fishing_rate' => 'decimal:4',
-        'chess_rate' => 'decimal:4', 'is_default' => 'integer',
+        'margin_amount' => 'decimal:4',
+        'f1_lottery_rate' => 'decimal:4', 'f1_sport_rate' => 'decimal:4',
+        'live_rate' => 'decimal:4', 'slot_rate' => 'decimal:4', 'sport_rate' => 'decimal:4',
+        'esports_rate' => 'decimal:4', 'fishing_rate' => 'decimal:4', 'chess_rate' => 'decimal:4',
+        'is_default' => 'integer',
     ];
 
-    public function owner()
-    {
-        return $this->belongsTo(Agent::class, 'owner_agent_id');
-    }
-
     public function agents()
     {
         return $this->hasMany(Agent::class, 'profit_commission_profile_id');
     }
 
-    public function isOfficial(): bool
-    {
-        return empty($this->owner_agent_id);
-    }
-
-    public function isOwnedBy(?int $agentId): bool
-    {
-        return $agentId !== null && (int) $this->owner_agent_id === $agentId;
-    }
-
     public static function officialDefaultId(): ?int
     {
         $id = static::query()->whereNull('owner_agent_id')->where('is_default', 1)->value('id');
         return $id ? (int) $id : null;
     }
 
-    public function rateForGameType(int $gameType): string
+    public static function rateForCategory(object $source, string $category): string
     {
-        $field = self::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;
-        return $field ? (string) $this->{$field} : '0.0000';
+        $field = self::CATEGORY_RATE_FIELDS[$category] ?? null;
+        return $field ? bcadd((string) ($source->{$field} ?? 0), '0', 4) : '0.0000';
     }
 
     public function rateSnapshot(): array

+ 25 - 0
app/Models/Agent/AgentProfitEntry.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+/** 合营实时分成账本:每条已结算注单(share_bet_facts)一行,改派彩/作废时追加 revision。 */
+class AgentProfitEntry extends BaseModel
+{
+    public const STATUS_COUNTED = 'counted';
+    public const STATUS_SKIPPED_INACTIVE = 'skipped_inactive';
+    public const STATUS_SKIPPED_NO_PLAN = 'skipped_no_plan';
+    public const STATUS_SKIPPED_CATEGORY = 'skipped_category';
+
+    protected $table = 'agent_profit_entries';
+    protected $guarded = ['id'];
+    protected $hidden = ['realtime_balance_after'];
+    protected $casts = [
+        'agent_id' => 'integer', 'fact_id' => 'integer', 'revision' => 'integer', 'user_id' => 'integer',
+        'settled_at' => 'datetime:Y-m-d H:i:s', 'settle_date' => 'date:Y-m-d',
+        'bet_amount' => 'decimal:4', 'effective_amount' => 'decimal:4', 'net_profit' => 'decimal:4',
+        'rate' => 'decimal:4', 'share_amount' => 'decimal:4', 'realtime_balance_after' => 'decimal:4',
+        'rebate_record_id' => 'integer',
+    ];
+}

+ 25 - 21
app/Models/Agent/AgentRebateRecord.php

@@ -4,30 +4,34 @@ namespace App\Models\Agent;
 
 use App\Models\BaseModel;
 
+/**
+ * 合营分成每日入账记录。settlement_date 是入账批次日期,platform 存分类 key(如 self:lottery)。
+ */
 class AgentRebateRecord extends BaseModel
 {
     public const STATUS_PENDING = 'pending';
     public const STATUS_CREDITED = 'credited';
 
-    public const GAME_TYPE_NAMES = [
-        0 => '历史汇总',
-        1 => '真人',
-        2 => '电子',
-        3 => '彩票',
-        4 => '体育',
-        5 => '电竞',
-        6 => '捕鱼',
-        7 => '棋牌',
+    /** 分类 key => [game_type 编码, 名称]。 */
+    public const CATEGORIES = [
+        'self:lottery' => [8, 'F1彩票'],
+        'self:sport' => [9, 'F1体育'],
+        'third:1' => [1, '真人'],
+        'third:2' => [2, '电子'],
+        'third:4' => [4, '体育'],
+        'third:5' => [5, '电竞'],
+        'third:6' => [6, '捕鱼'],
+        'third:7' => [7, '棋牌'],
     ];
 
     protected $table = 'agent_rebate_records';
     protected $hidden = ['snapshot'];
     protected $guarded = [];
-    protected $appends = ['agent_username', 'game_type_text', 'flow_status_text', 'profit_status_text'];
+    protected $appends = ['agent_username', 'category', 'category_text', 'profit_status_text'];
     protected $casts = [
         'settlement_date' => 'date:Y-m-d', 'agent_id' => 'integer', 'game_type' => 'integer', 'bet_count' => 'integer',
+        'entry_count' => 'integer',
         'bet_amount' => 'decimal:4', 'valid_bet_amount' => 'decimal:4', 'win_loss' => 'decimal:4',
-        'flow_rate' => 'decimal:4', 'flow_commission' => 'decimal:4',
         'profit_rate' => 'decimal:4', 'profit_commission' => 'decimal:4',
         'snapshot' => 'array', 'credited_at' => 'datetime',
     ];
@@ -37,28 +41,28 @@ class AgentRebateRecord extends BaseModel
         return $this->belongsTo(Agent::class);
     }
 
-    public function getAgentUsernameAttribute(): string
+    public static function categoryName(string $category): string
     {
-        return (string) ($this->agent?->username ?? '');
+        return self::CATEGORIES[$category][1] ?? $category;
     }
 
-    public function getGameTypeTextAttribute(): string
+    public function getAgentUsernameAttribute(): string
     {
-        return self::GAME_TYPE_NAMES[(int) $this->game_type] ?? '未知';
+        return (string) ($this->agent?->username ?? '');
     }
 
-    public function getFlowStatusTextAttribute(): string
+    public function getCategoryAttribute(): string
     {
-        return $this->statusText((string) $this->flow_status);
+        return (string) $this->platform;
     }
 
-    public function getProfitStatusTextAttribute(): string
+    public function getCategoryTextAttribute(): string
     {
-        return $this->statusText((string) $this->profit_status);
+        return self::categoryName((string) $this->platform);
     }
 
-    private function statusText(string $status): string
+    public function getProfitStatusTextAttribute(): string
     {
-        return $status === self::STATUS_CREDITED ? '已返佣' : '待返佣';
+        return (string) $this->profit_status === self::STATUS_CREDITED ? '已入账' : '待入账';
     }
 }

+ 35 - 0
app/Models/Agent/AgentRecharge.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentRecharge extends BaseModel
+{
+    public const TYPE_AUTO = 1;
+    public const TYPE_HAND = 2;
+    public const STATUS_STAY = 0;
+    public const STATUS_SUCCESS = 1;
+    public const STATUS_FAIL = 2;
+    public const STATUS_IGNORE = 3;
+
+    protected $table = 'agent_recharges';
+    protected $guarded = ['id'];
+    protected $hidden = [];
+    protected $appends = ['status_text'];
+    protected $casts = [
+        'agent_id' => 'integer', 'type' => 'integer', 'status' => 'integer', 'confirmations' => 'integer',
+        'usdt_amount' => 'decimal:4', 'exchange_rate' => 'decimal:4', 'amount' => 'decimal:4',
+        'audit_admin_id' => 'integer', 'confirmed_at' => 'datetime',
+    ];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+
+    public function getStatusTextAttribute(): string
+    {
+        return [self::STATUS_STAY => '待确认', self::STATUS_SUCCESS => '已到账', self::STATUS_FAIL => '已驳回', self::STATUS_IGNORE => '已忽略'][(int) $this->status] ?? '';
+    }
+}

+ 23 - 0
app/Models/Agent/AgentSetting.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+/** 代理全局配置(单行 id=1):停止分成阈值、入股价格与区间。 */
+class AgentSetting extends BaseModel
+{
+    protected $table = 'agent_settings';
+    protected $guarded = ['id'];
+    protected $hidden = ['created_at'];
+    protected $casts = [
+        'margin_stop_percent' => 'decimal:4', 'equity_enabled' => 'integer',
+        'equity_price_per_percent' => 'decimal:4', 'equity_min_percent' => 'decimal:4',
+        'equity_max_percent' => 'decimal:4', 'updated_by' => 'integer',
+    ];
+
+    public static function current(): self
+    {
+        return static::query()->firstOrCreate(['id' => 1]);
+    }
+}

+ 14 - 0
app/Models/Agent/AgentWallet.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+/** 代理 USDT 充值地址(自动到账模式),与会员 wallets 分开存放。 */
+class AgentWallet extends BaseModel
+{
+    protected $table = 'agent_wallets';
+    protected $guarded = ['id'];
+    protected $hidden = ['private_key', 'created_at', 'updated_at'];
+    protected $casts = ['agent_id' => 'integer'];
+}

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

@@ -4,9 +4,12 @@ use App\Models\BaseModel;
 class AgentWithdrawal extends BaseModel
 {
     protected $table = 'agent_withdrawals';
-    protected $fillable = ['order_no', 'request_key', 'agent_id', 'account_id', 'account_type', 'account_snapshot', 'amount', 'fee', 'actual_amount', 'status', 'remark', 'audit_remark', 'audit_admin_id', 'audited_at', 'paid_at'];
+    protected $fillable = ['order_no', 'request_key', 'agent_id', 'account_id', 'account_type', 'account_snapshot', 'amount', 'fee', 'actual_amount',
+        'usdt_amount', 'actual_usdt_amount', 'exchange_rate', 'status', 'remark', 'audit_remark', 'audit_admin_id', 'audited_at', 'paid_at'];
     protected $hidden = ['request_key'];
-    protected $casts = ['agent_id' => 'integer', 'amount' => 'decimal:4', 'fee' => 'decimal:4', 'actual_amount' => 'decimal:4', 'account_snapshot' => 'array', 'audited_at' => 'datetime', 'paid_at' => 'datetime'];
+    protected $casts = ['agent_id' => 'integer', 'amount' => 'decimal:4', 'fee' => 'decimal:4', 'actual_amount' => 'decimal:4',
+        'usdt_amount' => 'decimal:4', 'actual_usdt_amount' => 'decimal:4', 'exchange_rate' => 'decimal:4',
+        'account_snapshot' => 'array', 'audited_at' => 'datetime', 'paid_at' => 'datetime'];
 
     public function agent()
     {

+ 18 - 0
app/Models/MemberDailyReport.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Models;
+
+/** 会员每日盈亏报表(每个会员每天一行),由 report:member-daily 生成。 */
+class MemberDailyReport extends BaseModel
+{
+    protected $table = 'member_daily_reports';
+    protected $guarded = ['id'];
+    protected $hidden = ['created_at'];
+    protected $casts = [
+        'report_date' => 'date:Y-m-d', 'user_id' => 'integer', 'agent_id' => 'integer', 'bet_count' => 'integer',
+        'deposit_amount' => 'decimal:4', 'withdraw_amount' => 'decimal:4', 'manual_credit' => 'decimal:4',
+        'manual_debit' => 'decimal:4', 'bonus_amount' => 'decimal:4', 'rebate_amount' => 'decimal:4',
+        'bet_amount' => 'decimal:4', 'valid_bet_amount' => 'decimal:4', 'win_loss' => 'decimal:4',
+        'agent_share' => 'decimal:4', 'categories' => 'array',
+    ];
+}

+ 1 - 1
app/Services/ActivityRewardService.php

@@ -42,7 +42,7 @@ class ActivityRewardService extends BaseService
     {
         $defaults = static::configurationDefaults();
         if ((int)($params['type'] ?? $existing?->type) === 2) {
-            return $defaults;
+            return array_replace($defaults, ['publish_platforms' => ActivityCatalog::PUBLISH_PLATFORMS]);
         }
         $quotaMode = !$existing || $existing->quota_enabled;
         Validator::make($params, [$quotaMode ? 'budget' : 'reward_quota' => ['prohibited']])->validate();

+ 4 - 5
app/Services/Agent/AgentApplicationService.php

@@ -36,8 +36,7 @@ class AgentApplicationService
         $data = Validator::make($input, [
             'id' => ['required', 'integer', 'min:1'], 'status' => ['required', 'in:approved,rejected'],
             'remark' => ['required_if:status,rejected', 'nullable', 'string', 'max:500'],
-            'flow_commission_profile_id' => ['nullable', 'integer', Rule::exists('agent_flow_commission_profiles', 'id')->whereNull('owner_agent_id')],
-            'profit_commission_profile_id' => ['nullable', 'integer', Rule::exists('agent_profit_commission_profiles', 'id')->whereNull('owner_agent_id')],
+            'type' => ['nullable', 'integer', Rule::in(array_keys(Agent::TYPE_NAMES))],
         ])->validate();
         return DB::transaction(function () use ($data, $agents) {
             $found = AgentApplication::findOrFail($data['id']);
@@ -54,8 +53,8 @@ class AgentApplicationService
             if ($data['status'] === 'approved') {
                 if (Agent::where('owner_user_id', $member->id)->exists()) throw new \RuntimeException('该会员已是代理');
                 if (Agent::whereRaw('LOWER(username) = ?', [strtolower($application->username)])->exists()) throw new \RuntimeException('代理账号已被使用,请驳回后重新申请');
-                $options = array_filter(array_intersect_key($data, array_flip(['flow_commission_profile_id', 'profit_commission_profile_id'])), fn ($value) => $value !== null);
-                $agent = $agents->createFromApplication($application, $options + [
+                $agent = $agents->createFromApplication($application, [
+                    'type' => (int) ($data['type'] ?? Agent::TYPE_JOINT),
                     'real_name' => $member->first_name ?: '', 'language' => in_array($member->language, ['zh', 'en', 'vi'], true) ? $member->language : 'zh',
                 ], (int) $actor['operator_id']);
                 $this->bindMember($agent, (int) $member->id);
@@ -84,7 +83,7 @@ class AgentApplicationService
             if ($agent->owner_user_id !== null && (int) $agent->owner_user_id !== $userId) throw new \RuntimeException('代理已绑定其他会员');
             if (Agent::where('owner_user_id', $userId)->where('id', '<>', $agent->id)->exists()) throw new \RuntimeException('该会员已绑定其他代理账号');
             $agent->forceFill(['owner_user_id' => $userId])->save();
-            return $agent->load(['flowCommissionProfile', 'profitCommissionProfile']);
+            return $agent->load(['profitCommissionProfile']);
         }, 3);
     }
 

+ 57 - 218
app/Services/Agent/AgentCommissionService.php

@@ -3,21 +3,20 @@
 namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
-use App\Models\Agent\AgentCommission;
 use App\Models\Agent\AgentDailyStat;
-use App\Models\Agent\AgentDailyGameStat;
+use App\Models\Agent\AgentProfitEntry;
 use App\Models\Agent\AgentRebateRecord;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 
+/**
+ * 合营分成每日入账:把结算日不晚于 $date、尚未入账的实时分成按代理和分类汇总成 AgentRebateRecord,
+ * 再正负入账到代理余额。settlement_date 是入账批次日期;补结算的旧注单会进入下一次批次。
+ */
 class AgentCommissionService
 {
-    public function __construct(
-        private AgentReportService $reports,
-        private AgentWalletService $wallets,
-        private AgentFlowCommissionAssignmentService $flowAssignments,
-        private AgentProfitCommissionAssignmentService $profitAssignments
-    ) {
+    public function __construct(private AgentWalletService $wallets)
+    {
     }
 
     public function settle(string $date, bool $credit = true): array
@@ -25,224 +24,64 @@ class AgentCommissionService
         $day = Carbon::parse($date)->toDateString();
         $created = 0;
         $credited = 0;
-        $legacyRowsByAgent = AgentCommission::query()->where('settlement_date', $day)
-            ->get(['agent_id', 'type', 'status'])->groupBy('agent_id');
-        Agent::query()->where('status', Agent::STATUS_ENABLED)
-            ->where('created_at', '<', Carbon::parse($day)->addDay()->startOfDay())
-            ->orderBy('id')->chunkById(100, function ($agents) use ($day, $credit, $legacyRowsByAgent, &$created, &$credited) {
-            foreach ($agents as $agent) {
-                $legacyRows = $legacyRowsByAgent->get($agent->id, collect());
-                if ($legacyRows->isNotEmpty()) {
-                    $creditedTypes = $legacyRows->where('status', AgentRebateRecord::STATUS_CREDITED)
-                        ->pluck('type')->unique()->values();
-                    if ($creditedTypes->contains('flow') && $creditedTypes->contains('profit')) {
-                        continue;
+        $agentIds = AgentProfitEntry::query()->where('status', AgentProfitEntry::STATUS_COUNTED)
+            ->whereNull('rebate_record_id')->where('settle_date', '<=', $day)
+            ->distinct()->orderBy('agent_id')->pluck('agent_id');
+        foreach ($agentIds as $agentId) {
+            $result = DB::transaction(function () use ($agentId, $day, $credit) {
+                $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
+                $entries = AgentProfitEntry::query()->where('agent_id', $agent->id)
+                    ->where('status', AgentProfitEntry::STATUS_COUNTED)->whereNull('rebate_record_id')
+                    ->where('settle_date', '<=', $day)->orderBy('id')->lockForUpdate()->get();
+                $created = $credited = 0;
+                foreach ($entries->groupBy('category') as $category => $rows) {
+                    [$gameType] = AgentRebateRecord::CATEGORIES[$category] ?? [0];
+                    $record = AgentRebateRecord::query()->lockForUpdate()->firstOrNew([
+                        'settlement_date' => $day, 'agent_id' => $agent->id,
+                        'platform' => (string) $category, 'game_type' => $gameType,
+                    ]);
+                    if ($record->exists && $record->profit_status === AgentRebateRecord::STATUS_CREDITED) {
+                        continue; // 同一批次已入账,新增的补结算注单留到下一批次。
                     }
-                    throw new \RuntimeException("代理 {$agent->id} 的 {$day} 存在未完成旧佣金记录,请先人工核对");
-                }
-                $rows = $this->spreadRows($agent, $day);
-                foreach ($rows as $row) {
-                    $result = DB::transaction(function () use ($agent, $row, $day, $credit) {
-                        $record = AgentRebateRecord::query()->lockForUpdate()->firstOrNew([
-                            'settlement_date' => $day, 'agent_id' => $agent->id,
-                            'platform' => (string) $row['platform'], 'game_type' => (int) $row['game_type'],
-                        ]);
-                        $isNew = !$record->exists;
-                        if ($isNew) {
-                            $record->fill([
-                                'order_no' => $record->order_no ?: $this->rebateOrderNo($day, (int) $agent->id, (string) $row['platform'], (int) $row['game_type']),
-                                'bet_count' => (int) $row['bet_count'], 'bet_amount' => $row['bet_amount'],
-                                'valid_bet_amount' => $row['valid_bet_amount'], 'win_loss' => $row['win_loss'],
-                                'flow_rate' => $row['flow_rate'], 'flow_commission' => $row['flow_commission'],
-                                'flow_status' => AgentRebateRecord::STATUS_PENDING,
-                                'profit_rate' => $row['profit_rate'], 'profit_commission' => $row['profit_commission'],
-                                'profit_status' => AgentRebateRecord::STATUS_PENDING,
-                                'snapshot' => $row['snapshot'],
-                            ]);
-                        } elseif ($record->flow_status === AgentRebateRecord::STATUS_PENDING
-                            && $record->profit_status === AgentRebateRecord::STATUS_PENDING) {
-                            $record->bet_count = (int) $row['bet_count'];
-                            $record->bet_amount = $row['bet_amount'];
-                            $record->valid_bet_amount = $row['valid_bet_amount'];
-                            $record->win_loss = $row['win_loss'];
-                            $record->flow_rate = $row['flow_rate'];
-                            $record->flow_commission = $row['flow_commission'];
-                            $record->profit_rate = $row['profit_rate'];
-                            $record->profit_commission = $row['profit_commission'];
-                            $record->snapshot = $row['snapshot'];
-                        }
-                        $record->save();
-                        $partsCredited = 0;
-                        if ($credit) {
-                            $partsCredited += $this->creditRebatePart($record, 'flow', $day, (int) $agent->id);
-                            $partsCredited += $this->creditRebatePart($record, 'profit', $day, (int) $agent->id);
-                            $record->refresh();
-                            if ($record->flow_status === AgentRebateRecord::STATUS_CREDITED
-                                && $record->profit_status === AgentRebateRecord::STATUS_CREDITED
-                                && $record->credited_at === null) {
-                                $record->credited_at = now();
-                            }
-                            $record->save();
-                        }
-                        return ['created' => $isNew ? 1 : 0, 'credited' => $partsCredited];
-                    }, 3);
-                    $created += $result['created'];
-                    $credited += $result['credited'];
+                    $created += $record->exists ? 0 : 1;
+                    $sum = fn (string $field) => $rows->reduce(fn ($carry, $row) => bcadd($carry, (string) $row->{$field}, 4), '0.0000');
+                    $record->fill([
+                        'order_no' => $record->order_no ?: $this->orderNo($day, (int) $agent->id, (string) $category),
+                        'bet_count' => $rows->where('revision', 0)->count(), 'entry_count' => $rows->count(),
+                        'bet_amount' => $sum('bet_amount'), 'valid_bet_amount' => $sum('effective_amount'),
+                        'win_loss' => $sum('net_profit'), 'profit_rate' => (string) $rows->last()->rate,
+                        'profit_commission' => $sum('share_amount'),
+                        'profit_status' => AgentRebateRecord::STATUS_PENDING,
+                        'snapshot' => ['entry_ids' => [$rows->first()->id, $rows->last()->id],
+                            'rates' => $rows->pluck('rate')->map(fn ($rate) => (string) $rate)->unique()->values()->all()],
+                    ]);
+                    $record->save();
+                    if (!$credit) continue;
+                    $this->wallets->creditProfitShare($agent, (string) $record->profit_commission,
+                        'rebate:' . $record->id . ':profit', (int) $record->id,
+                        $day . ' ' . AgentRebateRecord::categoryName((string) $category) . '合营分成');
+                    $record->profit_status = AgentRebateRecord::STATUS_CREDITED;
+                    $record->credited_at = now();
+                    $record->save();
+                    AgentProfitEntry::query()->whereIn('id', $rows->pluck('id')->all())->update(['rebate_record_id' => $record->id]);
+                    ++$credited;
                 }
                 AgentDailyStat::query()->where('stat_date', $day)->where('agent_id', $agent->id)->update([
                     'commission_amount' => AgentRebateRecord::query()->where('settlement_date', $day)
-                        ->where('agent_id', $agent->id)
-                        ->selectRaw("COALESCE(SUM(CASE WHEN flow_status='credited' THEN flow_commission ELSE 0 END + CASE WHEN profit_status='credited' THEN profit_commission ELSE 0 END),0) total")
-                        ->value('total'),
+                        ->where('agent_id', $agent->id)->where('profit_status', AgentRebateRecord::STATUS_CREDITED)
+                        ->sum('profit_commission'),
                 ]);
-            }
-        });
-        return compact('created', 'credited');
-    }
-
-    public static function rateSpread(string $parentRate, string $childRate): string
-    {
-        $diff = bcsub($parentRate, $childRate, 4);
-        return bccomp($diff, '0', 4) > 0 ? $diff : '0.0000';
-    }
-
-    public static function amountByRate(string $base, string $rate): string
-    {
-        return bcdiv(bcmul($base, $rate, 8), '100', 4);
-    }
-
-    public static function profitBase(string $winLoss): string
-    {
-        return bccomp($winLoss, '0', 4) < 0 ? bcsub('0', $winLoss, 4) : '0.0000';
-    }
-
-    private function spreadRows(Agent $agent, string $day): array
-    {
-        $flowAssignment = $this->flowAssignments->at($agent, $day);
-        $profitAssignment = $this->profitAssignments->at($agent, $day);
-        $descendantIds = [(int) $agent->id];
-        $rows = [];
-        $this->addSegment($rows, $this->gameStats($day, [(int) $agent->id]), $flowAssignment, $profitAssignment, false);
-
-        $children = Agent::query()->where('parent_id', $agent->id)
-            ->where('created_at', '<', Carbon::parse($day)->addDay()->startOfDay())
-            ->orderBy('id')->get();
-        $childSnapshots = [];
-        foreach ($children as $child) {
-            $childFlow = $this->flowAssignments->at($child, $day);
-            $childProfit = $this->profitAssignments->at($child, $day);
-            $childIds = $this->reports->visibleAgentIds($child);
-            $descendantIds = array_merge($descendantIds, $childIds);
-            $this->addSegment($rows, $this->gameStats($day, $childIds), $flowAssignment, $profitAssignment, true, $childFlow, $childProfit);
-            $childSnapshots[] = [
-                'agent_id' => (int) $child->id,
-                'agent_ids' => $childIds,
-                'flow_profile_id' => $childFlow->flow_commission_profile_id,
-                'flow_assignment_id' => $childFlow->id,
-                'profit_profile_id' => $childProfit->profit_commission_profile_id,
-                'profit_assignment_id' => $childProfit->id,
-            ];
-        }
-
-        $snapshot = [
-            'mode' => 'spread',
-            'agent_ids' => $descendantIds,
-            'flow_profile_id' => $flowAssignment->flow_commission_profile_id,
-            'flow_assignment_id' => $flowAssignment->id,
-            'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
-            'profit_assignment_id' => $profitAssignment->id,
-            'children' => $childSnapshots,
-        ];
-        foreach ($rows as &$row) {
-            $row['flow_rate'] = $flowAssignment->rateForGameType((int) $row['game_type']);
-            $row['profit_rate'] = $profitAssignment->rateForGameType((int) $row['game_type']);
-            $row['snapshot'] = $snapshot;
-        }
-        unset($row);
-        return array_values($rows);
-    }
-
-    private function addSegment(
-        array &$rows,
-        $stats,
-        $flowAssignment,
-        $profitAssignment,
-        bool $spread,
-        $childFlow = null,
-        $childProfit = null
-    ): void {
-        foreach ($stats as $stat) {
-            $gameType = (int) $stat->game_type;
-            $flowRate = $spread
-                ? self::rateSpread($flowAssignment->rateForGameType($gameType), $childFlow->rateForGameType($gameType))
-                : $flowAssignment->rateForGameType($gameType);
-            $profitRate = $spread
-                ? self::rateSpread($profitAssignment->rateForGameType($gameType), $childProfit->rateForGameType($gameType))
-                : $profitAssignment->rateForGameType($gameType);
-            $key = (string) $stat->platform . "\0" . $gameType;
-            if (!isset($rows[$key])) {
-                $rows[$key] = [
-                    'platform' => (string) $stat->platform,
-                    'game_type' => $gameType,
-                    'bet_count' => 0,
-                    'bet_amount' => '0.0000',
-                    'valid_bet_amount' => '0.0000',
-                    'win_loss' => '0.0000',
-                    'flow_commission' => '0.0000',
-                    'profit_commission' => '0.0000',
-                ];
-            }
-            $rows[$key]['bet_count'] += (int) $stat->bet_count;
-            $rows[$key]['bet_amount'] = bcadd($rows[$key]['bet_amount'], (string) $stat->bet_amount, 4);
-            $rows[$key]['valid_bet_amount'] = bcadd($rows[$key]['valid_bet_amount'], (string) $stat->valid_bet_amount, 4);
-            $rows[$key]['win_loss'] = bcadd($rows[$key]['win_loss'], (string) $stat->win_loss, 4);
-            $rows[$key]['flow_commission'] = bcadd(
-                $rows[$key]['flow_commission'],
-                self::amountByRate((string) $stat->valid_bet_amount, $flowRate),
-                4
-            );
-            $rows[$key]['profit_commission'] = bcadd(
-                $rows[$key]['profit_commission'],
-                self::amountByRate(self::profitBase((string) $stat->win_loss), $profitRate),
-                4
-            );
+                return compact('created', 'credited');
+            }, 3);
+            $created += $result['created'];
+            $credited += $result['credited'];
         }
+        return compact('created', 'credited');
     }
 
-    private function gameStats(string $day, array $agentIds)
-    {
-        $agentIds = array_values(array_unique(array_filter(array_map('intval', $agentIds))));
-        if ($agentIds === []) return collect();
-        return AgentDailyGameStat::query()->where('stat_date', $day)->whereIn('agent_id', $agentIds)
-            ->selectRaw('platform, game_type, SUM(bet_count) bet_count, SUM(bet_amount) bet_amount, SUM(valid_bet_amount) valid_bet_amount, SUM(win_loss) win_loss')
-            ->groupBy('platform', 'game_type')->get();
-    }
-
-    private function creditRebatePart(AgentRebateRecord $record, string $type, string $day, int $agentId): int
-    {
-        return DB::transaction(function () use ($record, $type, $day, $agentId) {
-            $locked = AgentRebateRecord::query()->lockForUpdate()->findOrFail($record->id);
-            $statusField = $type . '_status';
-            $amountField = $type . '_commission';
-            if ($locked->{$statusField} !== AgentRebateRecord::STATUS_PENDING) return 0;
-            $amount = (string) $locked->{$amountField};
-            if (bccomp($amount, '0', 4) > 0) {
-                $this->wallets->adjust(
-                    $agentId, $amount, 'commission',
-                    $day . ($type === 'flow' ? '流水佣金' : '盈亏佣金') . '-' . $locked->platform,
-                    'rebate:' . $locked->id . ':' . $type, 'system', null,
-                    'agent_rebate_record', (int) $locked->id
-                );
-            }
-            $locked->{$statusField} = AgentRebateRecord::STATUS_CREDITED;
-            $locked->save();
-            return 1;
-        });
-    }
-
-    private function rebateOrderNo(string $day, int $agentId, string $platform, int $gameType): string
+    private function orderNo(string $day, int $agentId, string $category): string
     {
-        return 'AR' . str_replace('-', '', $day) . $agentId
-            . strtoupper(substr(sha1($platform . '|' . $gameType), 0, 10));
+        return 'AR' . str_replace('-', '', $day) . str_pad((string) $agentId, 8, '0', STR_PAD_LEFT)
+            . strtoupper(substr(hash('sha256', $category), 0, 6));
     }
 }

+ 105 - 0
app/Services/Agent/AgentEquityService.php

@@ -0,0 +1,105 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentEquityOrder;
+use App\Models\Agent\AgentSetting;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+/**
+ * 入股:代理在独立后台选择购买比例,金额 = 比例 × 每 1% 价格。
+ * 余额足够立即扣款开通;不够则生成待付款订单,USDT 充值到账后自动扣款开通,无需审核。
+ * 分红每月由总后台根据报表人工发放。
+ */
+class AgentEquityService
+{
+    public function __construct(private AgentWalletService $wallets)
+    {
+    }
+
+    public function overview(Agent $agent): array
+    {
+        $setting = AgentSetting::current();
+        $pending = AgentEquityOrder::query()->where('agent_id', $agent->id)->where('status', AgentEquityOrder::STATUS_PENDING)->first();
+        $held = bcadd((string) $agent->equity_percent, '0', 4);
+        $remaining = bcsub((string) $setting->equity_max_percent, $held, 4);
+        return [
+            'enabled' => (int) $setting->equity_enabled,
+            'price_per_percent' => (string) $setting->equity_price_per_percent,
+            'min_percent' => (string) $setting->equity_min_percent,
+            'max_percent' => (string) $setting->equity_max_percent,
+            'equity_percent' => $held,
+            'remaining_percent' => bccomp($remaining, '0', 4) > 0 ? $remaining : '0.0000',
+            'balance' => (string) $agent->balance,
+            'pending_order' => $pending,
+        ];
+    }
+
+    public function order(Agent $agent, string $percent): AgentEquityOrder
+    {
+        $order = DB::transaction(function () use ($agent, $percent) {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            if (!$agent->isEquity()) throw new \RuntimeException('只有入股代理可以购买股份');
+            $setting = AgentSetting::current();
+            if (!(int) $setting->equity_enabled || bccomp((string) $setting->equity_price_per_percent, '0', 4) <= 0) {
+                throw new \RuntimeException('入股暂未开放');
+            }
+            if (bccomp($percent, (string) $setting->equity_min_percent, 4) < 0 || bccomp($percent, (string) $setting->equity_max_percent, 4) > 0) {
+                throw new \InvalidArgumentException("购买比例须在 {$setting->equity_min_percent}% 至 {$setting->equity_max_percent}% 之间");
+            }
+            if (AgentEquityOrder::query()->where('agent_id', $agent->id)->where('status', AgentEquityOrder::STATUS_PENDING)->exists()) {
+                throw new \RuntimeException('已有待付款的入股订单,请先充值或取消');
+            }
+            if (bccomp(bcadd((string) $agent->equity_percent, $percent, 4), (string) $setting->equity_max_percent, 4) > 0) {
+                throw new \RuntimeException("累计持股不能超过 {$setting->equity_max_percent}%");
+            }
+            return AgentEquityOrder::query()->create([
+                'order_no' => 'AE' . now()->format('YmdHis') . strtoupper(Str::random(6)),
+                'agent_id' => $agent->id, 'percent' => bcadd($percent, '0', 4),
+                'price_per_percent' => (string) $setting->equity_price_per_percent,
+                'amount' => bcmul($percent, (string) $setting->equity_price_per_percent, 4),
+                'status' => AgentEquityOrder::STATUS_PENDING,
+            ]);
+        });
+        $this->fulfillPending((int) $agent->id);
+        return $order->fresh();
+    }
+
+    public function cancel(Agent $agent, int $orderId): AgentEquityOrder
+    {
+        return DB::transaction(function () use ($agent, $orderId) {
+            $order = AgentEquityOrder::query()->where('agent_id', $agent->id)->lockForUpdate()->findOrFail($orderId);
+            if ($order->status !== AgentEquityOrder::STATUS_PENDING) throw new \RuntimeException('只有待付款订单可以取消');
+            $order->status = AgentEquityOrder::STATUS_CANCELLED;
+            $order->save();
+            return $order;
+        });
+    }
+
+    /** 余额足够时完成待付款订单(下单后、充值到账后调用)。 */
+    public function fulfillPending(int $agentId): int
+    {
+        return DB::transaction(function () use ($agentId) {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
+            if (!$agent->isEquity()) return 0;
+            $done = 0;
+            $orders = AgentEquityOrder::query()->where('agent_id', $agentId)->where('status', AgentEquityOrder::STATUS_PENDING)
+                ->orderBy('id')->lockForUpdate()->get();
+            foreach ($orders as $order) {
+                if (bccomp((string) $agent->balance, (string) $order->amount, 4) < 0) break;
+                $this->wallets->adjust($agentId, (string) $order->amount, 'equity_purchase', '购买股份 ' . $order->percent . '%',
+                    'equity-order:' . $order->id, 'agent', $agentId, 'agent_equity_order', (int) $order->id);
+                $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
+                $agent->equity_percent = bcadd((string) $agent->equity_percent, (string) $order->percent, 4);
+                $agent->save();
+                $order->status = AgentEquityOrder::STATUS_COMPLETED;
+                $order->completed_at = now();
+                $order->save();
+                ++$done;
+            }
+            return $done;
+        });
+    }
+}

+ 26 - 0
app/Services/Agent/AgentExchangeRate.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Services\Agent;
+
+use Illuminate\Support\Facades\DB;
+
+/** 代理充值、提现沿用会员 USDT 的汇率口径:充值用 exchange_rate_rmb,提现再加 exchange_rate_difference。 */
+final class AgentExchangeRate
+{
+    public static function recharge(): string
+    {
+        return self::value('exchange_rate_rmb', '1');
+    }
+
+    public static function withdraw(): string
+    {
+        return bcadd(self::recharge(), self::value('exchange_rate_difference', '0'), 4);
+    }
+
+    private static function value(string $field, string $default): string
+    {
+        $value = DB::table('config')->where('field', $field)->value('val');
+        $value = is_numeric($value) ? (string) $value : $default;
+        return bcadd($value, '0', 4);
+    }
+}

+ 0 - 85
app/Services/Agent/AgentFlowCommissionAssignmentService.php

@@ -1,85 +0,0 @@
-<?php
-
-namespace App\Services\Agent;
-
-use App\Models\Agent\Agent;
-use App\Models\Agent\AgentCommission;
-use App\Models\Agent\AgentFlowCommissionAssignment;
-use App\Models\Agent\AgentFlowCommissionProfile;
-use App\Models\Agent\AgentRebateRecord;
-use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
-
-class AgentFlowCommissionAssignmentService
-{
-    public function at(Agent $agent, string $date): AgentFlowCommissionAssignment
-    {
-        $day = Carbon::parse($date)->toDateString();
-        $assignment = AgentFlowCommissionAssignment::query()
-            ->where('agent_id', $agent->id)
-            ->where('effective_from', '<=', $day)
-            ->orderByDesc('effective_from')->orderByDesc('id')->first();
-        if (!$assignment) {
-            throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的流水佣金方案历史");
-        }
-        return $assignment;
-    }
-
-    public function assign(
-        Agent $agent,
-        AgentFlowCommissionProfile $profile,
-        string $effectiveFrom,
-        ?int $createdBy = null
-    ): AgentFlowCommissionAssignment {
-        $this->assignMany([(int)$agent->id], $profile, $effectiveFrom, $createdBy);
-        return AgentFlowCommissionAssignment::query()
-            ->where('agent_id', $agent->id)
-            ->where('effective_from', Carbon::parse($effectiveFrom)->toDateString())
-            ->firstOrFail();
-    }
-
-    public function assignMany(
-        array $agentIds,
-        AgentFlowCommissionProfile $profile,
-        string $effectiveFrom,
-        ?int $createdBy = null
-    ): void {
-        $agentIds = array_values(array_unique(array_filter(array_map('intval', $agentIds))));
-        if ($agentIds === []) return;
-        $day = Carbon::parse($effectiveFrom)->toDateString();
-        $values = array_merge($profile->rateSnapshot(), [
-            'flow_commission_profile_id' => (int)$profile->id,
-            'profile_name' => (string)$profile->name,
-            'created_by' => $createdBy,
-        ]);
-        DB::transaction(function () use ($agentIds, $day, $values) {
-            $this->assertMutable($agentIds, $day);
-            foreach (array_chunk($agentIds, 500) as $chunk) {
-                foreach ($chunk as $agentId) {
-                    AgentFlowCommissionAssignment::query()->updateOrCreate(
-                        ['agent_id' => $agentId, 'effective_from' => $day],
-                        $values
-                    );
-                }
-            }
-        });
-    }
-
-    private function assertMutable(array $agentIds, string $day): void
-    {
-        $newCredited = AgentRebateRecord::query()->whereIn('agent_id', $agentIds)
-            ->where('settlement_date', '>=', $day)
-            ->lockForUpdate()
-            ->pluck('flow_status')
-            ->contains(AgentRebateRecord::STATUS_CREDITED);
-        $legacyCredited = AgentCommission::query()->whereIn('agent_id', $agentIds)
-            ->where('type', 'flow')
-            ->where('settlement_date', '>=', $day)
-            ->lockForUpdate()
-            ->pluck('status')
-            ->contains(AgentRebateRecord::STATUS_CREDITED);
-        if ($newCredited || $legacyCredited) {
-            throw new \RuntimeException('生效日期已有流水佣金入账,不能修改比例方案');
-        }
-    }
-}

+ 0 - 216
app/Services/Agent/AgentFlowCommissionProfileService.php

@@ -1,216 +0,0 @@
-<?php
-
-namespace App\Services\Agent;
-
-use App\Models\Agent\Agent;
-use App\Models\Agent\AgentFlowCommissionProfile;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Support\Facades\DB;
-
-class AgentFlowCommissionProfileService
-{
-    public function __construct(private AgentFlowCommissionAssignmentService $assignments)
-    {
-    }
-
-    public function paginate(array $params, ?Agent $owner = null): array
-    {
-        $page = max(1, (int) ($params['page'] ?? 1));
-        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
-        $query = $this->ownedQuery($owner);
-        if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
-        $total = (clone $query)->count();
-        $list = $query->orderByDesc('is_default')->orderByDesc('id')->forPage($page, $limit)->get();
-        if ($owner) {
-            $list->each(fn (AgentFlowCommissionProfile $profile) => $profile->setAttribute('editable', true));
-        }
-        return compact('total', 'page', 'limit', 'list');
-    }
-
-    public function paginateSelectable(Agent $agent, array $params): array
-    {
-        $agent->loadMissing('flowCommissionProfile');
-        if (!$agent->flowCommissionProfile) throw new \RuntimeException('当前代理未关联流水佣金方案');
-        $page = max(1, (int) ($params['page'] ?? 1));
-        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
-        $query = AgentFlowCommissionProfile::query()->where(function (Builder $query) use ($agent) {
-            $query->whereNull('owner_agent_id')->orWhere('owner_agent_id', $agent->id);
-        });
-        foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-            $query->where($field, '<=', (string) $agent->flowCommissionProfile->{$field});
-        }
-        if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
-        $total = (clone $query)->count();
-        $list = $query->orderByRaw('owner_agent_id is null')->orderByDesc('is_default')->orderByDesc('id')
-            ->forPage($page, $limit)->get();
-        $list->each(function (AgentFlowCommissionProfile $profile) use ($agent) {
-            $profile->setAttribute('editable', $profile->isOwnedBy((int) $agent->id));
-        });
-        return compact('total', 'page', 'limit', 'list');
-    }
-
-    public function save(array $data, ?int $adminId = null, ?Agent $owner = null): AgentFlowCommissionProfile
-    {
-        return DB::transaction(function () use ($data, $adminId, $owner) {
-            $profiles = $this->ownedQuery($owner)->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
-            $profile = empty($data['id'])
-                ? new AgentFlowCommissionProfile()
-                : AgentFlowCommissionProfile::query()->lockForUpdate()->findOrFail($data['id']);
-            if ($profile->exists) $this->assertOwned($profile, $owner);
-            else $profile->owner_agent_id = $owner?->id;
-
-            $oldDefaultId = $profiles->firstWhere('is_default', 1)?->id;
-            $isDefault = !empty($data['is_default']);
-            if ($profile->exists && (int) $oldDefaultId === (int) $profile->id && !$isDefault) {
-                throw new \RuntimeException('默认流水方案必须始终保留一条,请先将其他方案设为默认');
-            }
-            $name = trim((string) $data['name']);
-            if ($name === '') throw new \InvalidArgumentException('流水方案名称不能为空');
-            $duplicate = $this->ownedQuery($owner)->where('name', $name)
-                ->when($profile->exists, fn ($query) => $query->where('id', '<>', $profile->id))->exists();
-            if ($duplicate) throw new \InvalidArgumentException('流水方案名称已存在');
-
-            $profile->name = $name;
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $profile->{$field} = (string) $data[$field];
-            if ($owner) $this->assertRatesWithinOwner($owner, $profile);
-            $willBeDefault = $isDefault || (!$profile->exists && !$oldDefaultId);
-            $profile->is_default = $willBeDefault ? 1 : 0;
-            $snapshotChanged = !$profile->exists || $profile->isDirty(AgentFlowCommissionProfile::RATE_FIELDS);
-            $affectedAgentIds = [];
-            if ($snapshotChanged && $profile->exists) {
-                $affectedAgentIds = Agent::query()->where('flow_commission_profile_id', $profile->id)
-                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
-            }
-            if ($willBeDefault) {
-                $defaultAgentIds = $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
-                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
-                $affectedAgentIds = array_values(array_unique(array_merge($affectedAgentIds, $defaultAgentIds)));
-            }
-            $this->assertHierarchy($affectedAgentIds, $profile);
-            $profile->save();
-
-            if ($willBeDefault) {
-                $this->ownedQuery($owner)->where('id', '<>', $profile->id)->update(['is_default' => 0]);
-                $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
-                    ->update(['flow_commission_profile_id' => $profile->id]);
-            }
-            $this->assignments->assignMany($affectedAgentIds, $profile, now()->toDateString(), $adminId);
-            return $profile->fresh();
-        });
-    }
-
-    public function delete(int $id, ?int $adminId = null, ?Agent $owner = null): void
-    {
-        DB::transaction(function () use ($id, $adminId, $owner) {
-            $profile = AgentFlowCommissionProfile::query()->lockForUpdate()->findOrFail($id);
-            $this->assertOwned($profile, $owner);
-            if ((int) $profile->is_default === 1) throw new \RuntimeException('默认流水方案不能删除');
-            $default = $this->ownedQuery($owner)->where('is_default', 1)->lockForUpdate()->first();
-            if (!$default) throw new \RuntimeException('缺少默认流水方案,不能删除');
-            $agentIds = $this->agentsUsingProfile($profile->id, $owner);
-            $this->assertHierarchy($agentIds, $default);
-            $this->assignments->assignMany($agentIds, $default, now()->toDateString(), $adminId);
-            Agent::query()->where('flow_commission_profile_id', $profile->id)
-                ->when($owner, fn ($query) => $query->where('parent_id', $owner->id))
-                ->update(['flow_commission_profile_id' => $default->id]);
-            $profile->delete();
-        });
-    }
-
-    private function ownedQuery(?Agent $owner): Builder
-    {
-        $query = AgentFlowCommissionProfile::query();
-        return $owner
-            ? $query->where('owner_agent_id', $owner->id)
-            : $query->whereNull('owner_agent_id');
-    }
-
-    private function defaultMigrationQuery(?int $oldDefaultId, AgentFlowCommissionProfile $profile, ?Agent $owner): Builder
-    {
-        $query = Agent::query()->where(function (Builder $query) use ($oldDefaultId, $profile) {
-            $query->whereNull('flow_commission_profile_id');
-            if ($oldDefaultId && (int) $oldDefaultId !== (int) $profile->id) {
-                $query->orWhere('flow_commission_profile_id', $oldDefaultId);
-            }
-        });
-        return $owner ? $query->where('parent_id', $owner->id) : $query;
-    }
-
-    private function agentsUsingProfile(int $profileId, ?Agent $owner): array
-    {
-        $ids = Agent::query()->where('flow_commission_profile_id', $profileId)
-            ->pluck('id')->map(fn ($agentId) => (int) $agentId);
-        if ($owner) {
-            $childIds = Agent::query()->where('parent_id', $owner->id)
-                ->pluck('id')->map(fn ($agentId) => (int) $agentId);
-            if ($ids->diff($childIds)->isNotEmpty()) {
-                throw new \RuntimeException('该流水方案仍被非直属下级使用,不能删除');
-            }
-            return $ids->intersect($childIds)->values()->all();
-        }
-        return $ids->all();
-    }
-
-    private function assertOwned(AgentFlowCommissionProfile $profile, ?Agent $owner): void
-    {
-        if ($owner) {
-            if (!$profile->isOwnedBy((int) $owner->id)) {
-                throw new \RuntimeException('无权操作该流水方案');
-            }
-            return;
-        }
-        if (!$profile->isOfficial()) {
-            throw new \RuntimeException('总后台只能维护官方流水方案');
-        }
-    }
-
-    private function assertRatesWithinOwner(Agent $owner, AgentFlowCommissionProfile $profile): void
-    {
-        $owner->loadMissing('flowCommissionProfile');
-        if (!$owner->flowCommissionProfile) throw new \RuntimeException('当前代理未关联流水佣金方案');
-        foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-            if (bccomp((string) $profile->{$field}, (string) $owner->flowCommissionProfile->{$field}, 4) > 0) {
-                throw new \RuntimeException('下级流水方案不能高于当前代理方案');
-            }
-        }
-    }
-
-    private function assertHierarchy(array $agentIds, AgentFlowCommissionProfile $candidate): void
-    {
-        $agentIds = array_values(array_unique(array_map('intval', $agentIds)));
-        if ($agentIds === []) return;
-        $affected = array_fill_keys($agentIds, true);
-        $agents = Agent::query()->with([
-            'parent.flowCommissionProfile',
-            'children.flowCommissionProfile',
-        ])->whereIn('id', $agentIds)->get();
-        foreach ($agents as $agent) {
-            if ($agent->parent) {
-                foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-                    if (!isset($affected[(int) $agent->parent->id]) && !$agent->parent->flowCommissionProfile) {
-                        throw new \RuntimeException('上级代理未关联流水佣金方案');
-                    }
-                    $parentRate = isset($affected[(int) $agent->parent->id])
-                        ? (string) $candidate->{$field}
-                        : (string) $agent->parent->flowCommissionProfile->{$field};
-                    if (bccomp((string) $candidate->{$field}, $parentRate, 4) > 0) {
-                        throw new \RuntimeException('修改后的流水方案会高于上级代理方案');
-                    }
-                }
-            }
-            foreach ($agent->children as $child) {
-                foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-                    if (!isset($affected[(int) $child->id]) && !$child->flowCommissionProfile) {
-                        throw new \RuntimeException('下级代理未关联流水佣金方案');
-                    }
-                    $childRate = isset($affected[(int) $child->id])
-                        ? (string) $candidate->{$field}
-                        : (string) $child->flowCommissionProfile->{$field};
-                    if (bccomp((string) $candidate->{$field}, $childRate, 4) < 0) {
-                        throw new \RuntimeException('修改后的流水方案会低于下级代理方案');
-                    }
-                }
-            }
-        }
-    }
-}

+ 93 - 0
app/Services/Agent/AgentPlanApplicationService.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentPlanApplication;
+use App\Models\Agent\AgentProfitCommissionProfile;
+use App\Models\Agent\AgentSetting;
+use Illuminate\Support\Facades\DB;
+
+/** 合营代理申请分成方案(方案由总后台配置),总后台审核通过后立即生效。 */
+class AgentPlanApplicationService
+{
+    public function __construct(
+        private AgentProfitCommissionAssignmentService $assignments,
+        private AgentWalletService $wallets
+    ) {
+    }
+
+    public function plans(Agent $agent): array
+    {
+        $agent->loadMissing('profitCommissionProfile');
+        return [
+            'current_profile' => $agent->profitCommissionProfile,
+            'profit_active' => (int) $agent->profit_active,
+            'margin_stop_percent' => (string) AgentSetting::current()->margin_stop_percent,
+            'pending_application' => AgentPlanApplication::query()->where('agent_id', $agent->id)
+                ->where('status', AgentPlanApplication::STATUS_PENDING)->latest('id')->first(),
+            'list' => AgentProfitCommissionProfile::query()->whereNull('owner_agent_id')
+                ->orderBy('margin_amount')->orderBy('id')->get(),
+        ];
+    }
+
+    public function apply(Agent $agent, int $profileId, string $remark = ''): AgentPlanApplication
+    {
+        return DB::transaction(function () use ($agent, $profileId, $remark) {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            if (!$agent->isJoint()) throw new \RuntimeException('只有合营代理可以申请分成方案');
+            $profile = AgentProfitCommissionProfile::query()->whereNull('owner_agent_id')->findOrFail($profileId);
+            if ((int) $agent->profit_commission_profile_id === (int) $profile->id) throw new \RuntimeException('已在使用该方案');
+            if (AgentPlanApplication::query()->where('agent_id', $agent->id)->where('status', AgentPlanApplication::STATUS_PENDING)->exists()) {
+                throw new \RuntimeException('已有待审核的方案申请');
+            }
+            return AgentPlanApplication::query()->create([
+                'agent_id' => $agent->id, 'profile_id' => $profile->id, 'profile_name' => $profile->name,
+                'status' => AgentPlanApplication::STATUS_PENDING, 'remark' => trim($remark),
+            ]);
+        });
+    }
+
+    public function paginate(array $params, ?Agent $agent = null): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = AgentPlanApplication::query()->with(['agent:id,username,real_name,balance,margin_amount', 'profile']);
+        if ($agent) $query->where('agent_id', $agent->id);
+        if (!empty($params['agent_id'])) $query->where('agent_id', (int) $params['agent_id']);
+        if (!empty($params['status'])) $query->where('status', $params['status']);
+        if (!empty($params['username'])) {
+            $query->whereHas('agent', fn ($q) => $q->where('username', 'like', '%' . $params['username'] . '%'));
+        }
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc('id')->forPage($page, $limit)->get();
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    public function audit(int $id, string $status, int $adminId, string $reason = ''): AgentPlanApplication
+    {
+        return DB::transaction(function () use ($id, $status, $adminId, $reason) {
+            $application = AgentPlanApplication::query()->lockForUpdate()->findOrFail($id);
+            if ($application->status !== AgentPlanApplication::STATUS_PENDING) throw new \RuntimeException('申请已审核');
+            $agent = Agent::query()->lockForUpdate()->findOrFail($application->agent_id);
+            if ($status === AgentPlanApplication::STATUS_APPROVED) {
+                $profile = AgentProfitCommissionProfile::query()->whereNull('owner_agent_id')->find($application->profile_id);
+                if (!$profile) throw new \RuntimeException('方案已删除,请驳回');
+                if (!$agent->isJoint()) throw new \RuntimeException('只有合营代理可以使用分成方案');
+                $agent->profit_commission_profile_id = $profile->id;
+                $agent->save();
+                $this->assignments->assign($agent, $profile, $adminId);
+                $this->wallets->evaluateActivation($agent, true);
+            } else {
+                $reason = trim($reason);
+                if ($reason === '') throw new \InvalidArgumentException('请填写驳回原因');
+                $application->reject_reason = $reason;
+            }
+            $application->status = $status;
+            $application->reviewed_by = $adminId;
+            $application->reviewed_at = now();
+            $application->save();
+            return $application->fresh(['agent:id,username,profit_active', 'profile']);
+        });
+    }
+}

+ 38 - 0
app/Services/Agent/AgentProfileService.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+
+/** 登录与个人信息共用的代理概要:类型、平台邀请地址、资金构成、合营方案或入股比例。不含实时保证金。 */
+class AgentProfileService
+{
+    public function __construct(private AgentDomainService $domains, private AgentWalletService $wallets)
+    {
+    }
+
+    public function summary(Agent $agent): array
+    {
+        $agent->loadMissing('profitCommissionProfile');
+        $data = [
+            'id' => (int) $agent->id,
+            'username' => $agent->username,
+            'real_name' => $agent->real_name,
+            'language' => $agent->language ?? 'zh',
+            'invitation_code' => $agent->invitation_code,
+            'type' => (int) $agent->type,
+            'type_text' => $agent->type_text,
+            'withdraw_fee_rate' => (string) $agent->withdraw_fee_rate,
+            'has_withdraw_password' => (string) $agent->withdraw_password !== '',
+        ] + $this->domains->format($agent) + $this->wallets->funds($agent);
+        if ($agent->isJoint()) {
+            $profile = $agent->profitCommissionProfile;
+            $data['profit_commission_profile_id'] = $agent->profit_commission_profile_id;
+            $data['profit_commission_profile'] = $profile;
+            $data['required_margin_amount'] = $profile ? (string) $profile->margin_amount : null;
+            $data['profit_active'] = (int) $agent->profit_active;
+            $data['profit_active_text'] = (int) $agent->profit_active ? '分成中' : ($profile ? '保证金不足,暂停分成' : '未开通方案');
+        }
+        return $data;
+    }
+}

+ 18 - 58
app/Services/Agent/AgentProfitCommissionAssignmentService.php

@@ -3,85 +3,45 @@
 namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
-use App\Models\Agent\AgentCommission;
 use App\Models\Agent\AgentProfitCommissionAssignment;
 use App\Models\Agent\AgentProfitCommissionProfile;
-use App\Models\Agent\AgentRebateRecord;
 use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
 
+/** 记录代理在每个时刻使用的分成比例快照,实时分成按注单结算时间取比例。 */
 class AgentProfitCommissionAssignmentService
 {
-    public function at(Agent $agent, string $date): AgentProfitCommissionAssignment
+    public function at(Agent $agent, string $time): ?AgentProfitCommissionAssignment
     {
-        $day = Carbon::parse($date)->toDateString();
-        $assignment = AgentProfitCommissionAssignment::query()
+        return AgentProfitCommissionAssignment::query()
             ->where('agent_id', $agent->id)
-            ->where('effective_from', '<=', $day)
+            ->where('effective_from', '<=', Carbon::parse($time)->format('Y-m-d H:i:s'))
             ->orderByDesc('effective_from')
             ->orderByDesc('id')
             ->first();
-        if (!$assignment) {
-            throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的盈亏佣金比例历史");
-        }
-        return $assignment;
     }
 
-    public function assign(
-        Agent $agent,
-        AgentProfitCommissionProfile $profile,
-        string $effectiveFrom,
-        ?int $createdBy = null
-    ): AgentProfitCommissionAssignment {
-        $this->assignMany([(int) $agent->id], $profile, $effectiveFrom, $createdBy);
-        return AgentProfitCommissionAssignment::query()
-            ->where('agent_id', $agent->id)
-            ->where('effective_from', Carbon::parse($effectiveFrom)->toDateString())
-            ->firstOrFail();
+    public function assign(Agent $agent, AgentProfitCommissionProfile $profile, ?int $createdBy = null): AgentProfitCommissionAssignment
+    {
+        $this->assignMany([(int) $agent->id], $profile, $createdBy);
+        return AgentProfitCommissionAssignment::query()->where('agent_id', $agent->id)
+            ->orderByDesc('effective_from')->orderByDesc('id')->firstOrFail();
     }
 
-    public function assignMany(
-        array $agentIds,
-        AgentProfitCommissionProfile $profile,
-        string $effectiveFrom,
-        ?int $createdBy = null
-    ): void {
+    public function assignMany(array $agentIds, AgentProfitCommissionProfile $profile, ?int $createdBy = null): void
+    {
         $agentIds = array_values(array_unique(array_filter(array_map('intval', $agentIds))));
-        if ($agentIds === []) return;
-        $day = Carbon::parse($effectiveFrom)->toDateString();
+        $now = now()->format('Y-m-d H:i:s');
         $values = array_merge($profile->rateSnapshot(), [
             'profit_commission_profile_id' => (int) $profile->id,
             'profile_name' => (string) $profile->name,
+            'margin_amount' => (string) $profile->margin_amount,
             'created_by' => $createdBy,
         ]);
-        DB::transaction(function () use ($agentIds, $day, $values) {
-            $this->assertMutable($agentIds, $day);
-            foreach (array_chunk($agentIds, 500) as $chunk) {
-                foreach ($chunk as $agentId) {
-                    AgentProfitCommissionAssignment::query()->updateOrCreate(
-                        ['agent_id' => $agentId, 'effective_from' => $day],
-                        $values
-                    );
-                }
-            }
-        });
-    }
-
-    private function assertMutable(array $agentIds, string $day): void
-    {
-        $newCredited = AgentRebateRecord::query()->whereIn('agent_id', $agentIds)
-            ->where('settlement_date', '>=', $day)
-            ->lockForUpdate()
-            ->pluck('profit_status')
-            ->contains(AgentRebateRecord::STATUS_CREDITED);
-        $legacyCredited = AgentCommission::query()->whereIn('agent_id', $agentIds)
-            ->where('type', 'profit')
-            ->where('settlement_date', '>=', $day)
-            ->lockForUpdate()
-            ->pluck('status')
-            ->contains(AgentRebateRecord::STATUS_CREDITED);
-        if ($newCredited || $legacyCredited) {
-            throw new \RuntimeException('生效日期已有盈亏佣金入账,不能修改比例方案');
+        foreach ($agentIds as $agentId) {
+            AgentProfitCommissionAssignment::query()->updateOrCreate(
+                ['agent_id' => $agentId, 'effective_from' => $now],
+                $values
+            );
         }
     }
 }

+ 36 - 177
app/Services/Agent/AgentProfitCommissionProfileService.php

@@ -3,214 +3,73 @@
 namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
+use App\Models\Agent\AgentPlanApplication;
 use App\Models\Agent\AgentProfitCommissionProfile;
-use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Facades\DB;
 
+/** 合营盈亏分成方案(只有总后台官方方案)。 */
 class AgentProfitCommissionProfileService
 {
-    public function __construct(private AgentProfitCommissionAssignmentService $assignments)
-    {
-    }
-
-    public function paginate(array $params, ?Agent $owner = null): array
-    {
-        $page = max(1, (int) ($params['page'] ?? 1));
-        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
-        $query = $this->ownedQuery($owner);
-        if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
-        $total = (clone $query)->count();
-        $list = $query->orderByDesc('is_default')->orderByDesc('id')->forPage($page, $limit)->get();
-        if ($owner) {
-            $list->each(fn (AgentProfitCommissionProfile $profile) => $profile->setAttribute('editable', true));
-        }
-        return compact('total', 'page', 'limit', 'list');
+    public function __construct(
+        private AgentProfitCommissionAssignmentService $assignments,
+        private AgentWalletService $wallets
+    ) {
     }
 
-    public function paginateSelectable(Agent $agent, array $params): array
+    public function paginate(array $params): array
     {
-        $agent->loadMissing('profitCommissionProfile');
-        if (!$agent->profitCommissionProfile) throw new \RuntimeException('当前代理未关联盈亏佣金方案');
         $page = max(1, (int) ($params['page'] ?? 1));
         $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
-        $query = AgentProfitCommissionProfile::query()->where(function (Builder $query) use ($agent) {
-            $query->whereNull('owner_agent_id')->orWhere('owner_agent_id', $agent->id);
-        });
-        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
-            $query->where($field, '<=', (string) $agent->profitCommissionProfile->{$field});
-        }
+        $query = AgentProfitCommissionProfile::query()->whereNull('owner_agent_id');
         if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
         $total = (clone $query)->count();
-        $list = $query->orderByRaw('owner_agent_id is null')->orderByDesc('is_default')->orderByDesc('id')
-            ->forPage($page, $limit)->get();
-        $list->each(function (AgentProfitCommissionProfile $profile) use ($agent) {
-            $profile->setAttribute('editable', $profile->isOwnedBy((int) $agent->id));
-        });
+        $list = $query->withCount('agents')->orderBy('margin_amount')->orderByDesc('id')->forPage($page, $limit)->get();
         return compact('total', 'page', 'limit', 'list');
     }
 
-    public function save(array $data, ?int $adminId = null, ?Agent $owner = null): AgentProfitCommissionProfile
+    public function save(array $data, ?int $adminId = null): AgentProfitCommissionProfile
     {
-        return DB::transaction(function () use ($data, $adminId, $owner) {
-            $profiles = $this->ownedQuery($owner)->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
+        return DB::transaction(function () use ($data, $adminId) {
             $profile = empty($data['id'])
                 ? new AgentProfitCommissionProfile()
-                : AgentProfitCommissionProfile::query()->lockForUpdate()->findOrFail($data['id']);
-            if ($profile->exists) $this->assertOwned($profile, $owner);
-            else $profile->owner_agent_id = $owner?->id;
-
-            $oldDefaultId = $profiles->firstWhere('is_default', 1)?->id;
-            $isDefault = !empty($data['is_default']);
-            if ($profile->exists && (int) $oldDefaultId === (int) $profile->id && !$isDefault) {
-                throw new \RuntimeException('默认比例必须始终保留一条,请先将其他比例设为默认');
-            }
+                : AgentProfitCommissionProfile::query()->whereNull('owner_agent_id')->lockForUpdate()->findOrFail($data['id']);
             $name = trim((string) $data['name']);
-            if ($name === '') throw new \InvalidArgumentException('盈亏比例名称不能为空');
-            $duplicate = $this->ownedQuery($owner)->where('name', $name)
+            if ($name === '') throw new \InvalidArgumentException('方案名称不能为空');
+            $duplicate = AgentProfitCommissionProfile::query()->whereNull('owner_agent_id')->where('name', $name)
                 ->when($profile->exists, fn ($query) => $query->where('id', '<>', $profile->id))->exists();
-            if ($duplicate) throw new \InvalidArgumentException('盈亏比例名称已存在');
+            if ($duplicate) throw new \InvalidArgumentException('方案名称已存在');
 
             $profile->name = $name;
-            foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) $profile->{$field} = (string) $data[$field];
-            if ($owner) $this->assertRatesWithinOwner($owner, $profile);
-            $willBeDefault = $isDefault || (!$profile->exists && !$oldDefaultId);
-            $profile->is_default = $willBeDefault ? 1 : 0;
-            $snapshotChanged = !$profile->exists || $profile->isDirty(AgentProfitCommissionProfile::RATE_FIELDS);
-            $affectedAgentIds = [];
-            if ($snapshotChanged && $profile->exists) {
-                $affectedAgentIds = Agent::query()->where('profit_commission_profile_id', $profile->id)
-                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
-            }
-            if ($willBeDefault) {
-                $defaultAgentIds = $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
-                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
-                $affectedAgentIds = array_values(array_unique(array_merge($affectedAgentIds, $defaultAgentIds)));
+            $profile->margin_amount = bcadd((string) $data['margin_amount'], '0', 4);
+            foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+                $profile->{$field} = bcadd((string) $data[$field], '0', 4);
             }
-            $this->assertHierarchy($affectedAgentIds, $profile);
+            if (!$profile->exists) $profile->is_default = 0;
+            $changed = $profile->exists && $profile->isDirty(array_merge(AgentProfitCommissionProfile::RATE_FIELDS, ['margin_amount', 'name']));
             $profile->save();
-
-            if ($willBeDefault) {
-                $this->ownedQuery($owner)->where('id', '<>', $profile->id)->update(['is_default' => 0]);
-                $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
-                    ->update(['profit_commission_profile_id' => $profile->id]);
+            if ($changed) {
+                // 已在使用该方案的代理:比例从现在起按新值计算,并按新保证金重新判断是否继续分成。
+                $agentIds = Agent::query()->where('profit_commission_profile_id', $profile->id)->pluck('id')->all();
+                $this->assignments->assignMany($agentIds, $profile, $adminId);
+                foreach ($agentIds as $agentId) {
+                    $this->wallets->evaluateActivation(Agent::query()->lockForUpdate()->findOrFail($agentId));
+                }
             }
-            $this->assignments->assignMany($affectedAgentIds, $profile, now()->toDateString(), $adminId);
             return $profile->fresh();
         });
     }
 
-    public function delete(int $id, ?int $adminId = null, ?Agent $owner = null): void
+    public function delete(int $id): void
     {
-        DB::transaction(function () use ($id, $adminId, $owner) {
-            $profile = AgentProfitCommissionProfile::query()->lockForUpdate()->findOrFail($id);
-            $this->assertOwned($profile, $owner);
-            if ((int) $profile->is_default === 1) throw new \RuntimeException('默认比例不能删除');
-            $default = $this->ownedQuery($owner)->where('is_default', 1)->lockForUpdate()->first();
-            if (!$default) throw new \RuntimeException('缺少默认比例,不能删除');
-            $agentIds = $this->agentsUsingProfile($profile->id, $owner);
-            $this->assertHierarchy($agentIds, $default);
-            $this->assignments->assignMany($agentIds, $default, now()->toDateString(), $adminId);
-            Agent::query()->where('profit_commission_profile_id', $profile->id)
-                ->when($owner, fn ($query) => $query->where('parent_id', $owner->id))
-                ->update(['profit_commission_profile_id' => $default->id]);
-            $profile->delete();
-        });
-    }
-
-    private function ownedQuery(?Agent $owner): Builder
-    {
-        $query = AgentProfitCommissionProfile::query();
-        return $owner
-            ? $query->where('owner_agent_id', $owner->id)
-            : $query->whereNull('owner_agent_id');
-    }
-
-    private function defaultMigrationQuery(?int $oldDefaultId, AgentProfitCommissionProfile $profile, ?Agent $owner): Builder
-    {
-        $query = Agent::query()->where(function (Builder $query) use ($oldDefaultId, $profile) {
-            $query->whereNull('profit_commission_profile_id');
-            if ($oldDefaultId && (int) $oldDefaultId !== (int) $profile->id) {
-                $query->orWhere('profit_commission_profile_id', $oldDefaultId);
-            }
-        });
-        return $owner ? $query->where('parent_id', $owner->id) : $query;
-    }
-
-    private function agentsUsingProfile(int $profileId, ?Agent $owner): array
-    {
-        $ids = Agent::query()->where('profit_commission_profile_id', $profileId)
-            ->pluck('id')->map(fn ($agentId) => (int) $agentId);
-        if ($owner) {
-            $childIds = Agent::query()->where('parent_id', $owner->id)
-                ->pluck('id')->map(fn ($agentId) => (int) $agentId);
-            if ($ids->diff($childIds)->isNotEmpty()) {
-                throw new \RuntimeException('该盈亏方案仍被非直属下级使用,不能删除');
-            }
-            return $ids->intersect($childIds)->values()->all();
-        }
-        return $ids->all();
-    }
-
-    private function assertOwned(AgentProfitCommissionProfile $profile, ?Agent $owner): void
-    {
-        if ($owner) {
-            if (!$profile->isOwnedBy((int) $owner->id)) {
-                throw new \RuntimeException('无权操作该盈亏方案');
-            }
-            return;
-        }
-        if (!$profile->isOfficial()) {
-            throw new \RuntimeException('总后台只能维护官方盈亏方案');
-        }
-    }
-
-    private function assertRatesWithinOwner(Agent $owner, AgentProfitCommissionProfile $profile): void
-    {
-        $owner->loadMissing('profitCommissionProfile');
-        if (!$owner->profitCommissionProfile) throw new \RuntimeException('当前代理未关联盈亏佣金方案');
-        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
-            if (bccomp((string) $profile->{$field}, (string) $owner->profitCommissionProfile->{$field}, 4) > 0) {
-                throw new \RuntimeException('下级盈亏方案不能高于当前代理方案');
+        DB::transaction(function () use ($id) {
+            $profile = AgentProfitCommissionProfile::query()->whereNull('owner_agent_id')->lockForUpdate()->findOrFail($id);
+            if (Agent::query()->where('profit_commission_profile_id', $profile->id)->exists()) {
+                throw new \RuntimeException('仍有代理使用该方案,不能删除');
             }
-        }
-    }
-
-    private function assertHierarchy(array $agentIds, AgentProfitCommissionProfile $candidate): void
-    {
-        $agentIds = array_values(array_unique(array_map('intval', $agentIds)));
-        if ($agentIds === []) return;
-        $affected = array_fill_keys($agentIds, true);
-        $agents = Agent::query()->with([
-            'parent.profitCommissionProfile',
-            'children.profitCommissionProfile',
-        ])->whereIn('id', $agentIds)->get();
-        foreach ($agents as $agent) {
-            if ($agent->parent) {
-                foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
-                    if (!isset($affected[(int) $agent->parent->id]) && !$agent->parent->profitCommissionProfile) {
-                        throw new \RuntimeException('上级代理未关联盈亏佣金方案');
-                    }
-                    $parentRate = isset($affected[(int) $agent->parent->id])
-                        ? (string) $candidate->{$field}
-                        : (string) $agent->parent->profitCommissionProfile->{$field};
-                    if (bccomp((string) $candidate->{$field}, $parentRate, 4) > 0) {
-                        throw new \RuntimeException('修改后的盈亏方案会高于上级代理方案');
-                    }
-                }
-            }
-            foreach ($agent->children as $child) {
-                foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
-                    if (!isset($affected[(int) $child->id]) && !$child->profitCommissionProfile) {
-                        throw new \RuntimeException('下级代理未关联盈亏佣金方案');
-                    }
-                    $childRate = isset($affected[(int) $child->id])
-                        ? (string) $candidate->{$field}
-                        : (string) $child->profitCommissionProfile->{$field};
-                    if (bccomp((string) $candidate->{$field}, $childRate, 4) < 0) {
-                        throw new \RuntimeException('修改后的盈亏方案会低于下级代理方案');
-                    }
-                }
+            if (AgentPlanApplication::query()->where('profile_id', $profile->id)->where('status', AgentPlanApplication::STATUS_PENDING)->exists()) {
+                throw new \RuntimeException('该方案有待审核的申请,不能删除');
             }
-        }
+            $profile->delete();
+        });
     }
 }

+ 174 - 0
app/Services/Agent/AgentProfitLedgerService.php

@@ -0,0 +1,174 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentProfitCommissionProfile;
+use App\Models\Agent\AgentProfitEntry;
+use App\Services\Sharing\EffectiveBet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+/**
+ * 合营实时分成:逐笔读取已结算注单(share_bet_facts),按代理当前方案比例计算分成并实时计入 realtime_balance。
+ *
+ * - 会员输 100、比例 10%:代理 +10;会员赢 100:代理 -10;
+ * - 代理分成关闭(实时保证金不足)时注单记为 skipped_inactive,不再分成;
+ * - 亏损最多扣到实时余额为 0;
+ * - 注单改派彩或作废:未入账的原地修正,已入账的追加 revision 冲正。
+ * share_bet_facts 由 share:sync-facts 维护(含 melbet 结算补偿),这里按 updated_at 游标增量读取。
+ */
+class AgentProfitLedgerService
+{
+    private const CURSOR = 'agent_profit_cursor';
+
+    public function __construct(
+        private AgentWalletService $wallets,
+        private AgentProfitCommissionAssignmentService $assignments
+    ) {
+    }
+
+    public function sync(): array
+    {
+        $startedAt = now()->format('Y-m-d H:i:s');
+        $cursor = DB::table('share_system_state')->where('key', self::CURSOR)->value('value');
+        $since = $cursor
+            ? Carbon::parse(json_decode((string) $cursor, true, 512, JSON_THROW_ON_ERROR)['updated_at'])->subMinutes(5)
+            : now()->subDays(2);
+        $result = ['facts' => 0, 'counted' => 0, 'skipped' => 0, 'corrected' => 0, 'unchanged' => 0, 'failed' => 0];
+        DB::table('share_bet_facts')->where('updated_at', '>=', $since->format('Y-m-d H:i:s'))
+            ->orderBy('id')->chunkById(500, function ($facts) use (&$result) {
+                $agentByUser = DB::table('users')->join('agents', 'agents.id', '=', 'users.agent_id')
+                    ->whereIn('users.id', $facts->pluck('user_id')->unique()->all())
+                    ->where('agents.type', Agent::TYPE_JOINT)
+                    ->pluck('agents.id', 'users.id');
+                foreach ($facts as $fact) {
+                    $agentId = $agentByUser[$fact->user_id] ?? null;
+                    if (!$agentId) continue;
+                    ++$result['facts'];
+                    try {
+                        ++$result[$this->process($fact, (int) $agentId)];
+                    } catch (Throwable $e) {
+                        ++$result['failed'];
+                        Log::error('agent_profit_entry_failed', ['fact_id' => $fact->id, 'agent_id' => $agentId, 'exception' => $e]);
+                    }
+                }
+            });
+        if ($result['failed'] === 0) {
+            DB::table('share_system_state')->updateOrInsert(['key' => self::CURSOR],
+                ['value' => json_encode(['updated_at' => $startedAt]), 'updated_at' => $startedAt, 'created_at' => $startedAt]);
+        }
+        return $result;
+    }
+
+    /** @return string counted|skipped|corrected|unchanged */
+    public function process(object $fact, int $agentId): string
+    {
+        return DB::transaction(function () use ($fact, $agentId) {
+            $agent = Agent::query()->lockForUpdate()->find($agentId);
+            if (!$agent || !$agent->isJoint()) return 'unchanged';
+            $entries = AgentProfitEntry::query()->where('fact_id', $fact->id)->orderBy('revision')->lockForUpdate()->get();
+            $live = (int) $fact->settled === 1 && (int) $fact->voided === 0 && !empty($fact->settled_at);
+            $net = $live ? $this->money($fact->net_profit) : '0.0000';
+            if ($entries->isEmpty()) {
+                return $live ? $this->create($agent, $fact, $net) : 'unchanged';
+            }
+            return $this->correct($agent, $fact, $entries, $net);
+        }, 3);
+    }
+
+    private function create(Agent $agent, object $fact, string $net): string
+    {
+        $category = (string) $fact->category;
+        $assignment = $this->assignments->at($agent, (string) $fact->settled_at);
+        $status = AgentProfitEntry::STATUS_COUNTED;
+        $rate = '0.0000';
+        if (!isset(AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS[$category])) {
+            $status = AgentProfitEntry::STATUS_SKIPPED_CATEGORY;
+        } elseif (!$assignment || !$agent->profit_commission_profile_id) {
+            $status = AgentProfitEntry::STATUS_SKIPPED_NO_PLAN;
+        } elseif (!(int) $agent->profit_active) {
+            $status = AgentProfitEntry::STATUS_SKIPPED_INACTIVE;
+            $rate = $assignment->rateForCategory($category);
+        } else {
+            $rate = $assignment->rateForCategory($category);
+        }
+        $share = $status === AgentProfitEntry::STATUS_COUNTED ? $this->capped($agent, $this->share($net, $rate)) : '0.0000';
+        if (bccomp($share, '0', 4) !== 0) {
+            $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $share, 4);
+            $agent->save();
+        }
+        AgentProfitEntry::query()->create([
+            'agent_id' => $agent->id, 'fact_id' => $fact->id, 'revision' => 0, 'user_id' => $fact->user_id,
+            'category' => $category, 'settled_at' => $fact->settled_at,
+            'settle_date' => Carbon::parse($fact->settled_at)->toDateString(),
+            'bet_amount' => $this->money($fact->bet_amount), 'effective_amount' => $this->money($fact->effective_amount),
+            'net_profit' => $net, 'rate' => $rate, 'share_amount' => $share, 'status' => $status,
+            'realtime_balance_after' => $agent->realtime_balance,
+        ]);
+        if ($status === AgentProfitEntry::STATUS_COUNTED) {
+            $this->wallets->evaluateActivation($agent);
+            return 'counted';
+        }
+        return 'skipped';
+    }
+
+    private function correct(Agent $agent, object $fact, $entries, string $net): string
+    {
+        $latest = $entries->last();
+        $first = $entries->first();
+        if (bccomp((string) $latest->net_profit, $net, 4) === 0) return 'unchanged';
+        if ($first->status !== AgentProfitEntry::STATUS_COUNTED) {
+            // 当时未参与分成的注单,改派彩后仍不分成,只更新记录。
+            $latest->net_profit = $net;
+            $latest->save();
+            return 'unchanged';
+        }
+        $target = $this->share($net, (string) $first->rate);
+        $applied = '0.0000';
+        foreach ($entries as $entry) $applied = bcadd($applied, (string) $entry->share_amount, 4);
+        $delta = bccomp($target, $applied, 4) < 0 ? $this->capped($agent, bcsub($target, $applied, 4)) : bcsub($target, $applied, 4);
+        $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $delta, 4);
+        $agent->save();
+        if ($latest->rebate_record_id === null) {
+            $latest->net_profit = $net;
+            $latest->share_amount = bcadd((string) $latest->share_amount, $delta, 4);
+            $latest->realtime_balance_after = $agent->realtime_balance;
+            $latest->save();
+        } else {
+            $now = now();
+            AgentProfitEntry::query()->create([
+                'agent_id' => $agent->id, 'fact_id' => $fact->id, 'revision' => (int) $latest->revision + 1,
+                'user_id' => $fact->user_id, 'category' => $first->category,
+                'settled_at' => $now->format('Y-m-d H:i:s'), 'settle_date' => $now->toDateString(),
+                'bet_amount' => 0, 'effective_amount' => 0, 'net_profit' => $net, 'rate' => $first->rate,
+                'share_amount' => $delta, 'status' => AgentProfitEntry::STATUS_COUNTED,
+                'realtime_balance_after' => $agent->realtime_balance,
+            ]);
+        }
+        $this->wallets->evaluateActivation($agent);
+        return 'corrected';
+    }
+
+    /** 代理视角的分成:会员盈亏取反 × 比例。 */
+    public function share(string $net, string $rate): string
+    {
+        return bcdiv(bcmul(bcsub('0', $net, 4), $rate, 8), '100', 4);
+    }
+
+    /** 亏损最多扣到实时余额为 0。 */
+    private function capped(Agent $agent, string $share): string
+    {
+        if (bccomp($share, '0', 4) >= 0) return $share;
+        $available = bcadd((string) $agent->realtime_balance, '0', 4);
+        if (bccomp($available, '0', 4) <= 0) return '0.0000';
+        return bccomp(bcadd($available, $share, 4), '0', 4) < 0 ? bcsub('0', $available, 4) : $share;
+    }
+
+    private function money($value): string
+    {
+        return bcadd(EffectiveBet::decimal($value ?? '0'), '0', 4);
+    }
+}

+ 210 - 0
app/Services/Agent/AgentRechargeService.php

@@ -0,0 +1,210 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Helpers\TronHelper;
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentRecharge;
+use App\Models\Agent\AgentWallet;
+use App\Services\CollectService;
+use App\Services\UsdtRechargeConfigService;
+use App\Services\WalletService;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use Throwable;
+
+/**
+ * 代理 USDT 充值,沿用会员前台的 USDT 充值逻辑(config.receiving_type):
+ * - 1 自动到账:每个代理一个固定 TRC20 地址,轮询链上转账,确认数达标后入账并生成归集记录;
+ * - 2 人工确认:展示平台收款地址,代理提交转账凭证,总后台审核后入账。
+ * 入账金额 = USDT 数量 × exchange_rate_rmb。合营代理的充值全部计入保证金。
+ * 记录写在 agent_recharges,不进会员 recharges,避免被统计成会员充值。
+ */
+class AgentRechargeService
+{
+    private const PENDING_KEY = 'agent_usdt_recharge_pending';
+    private const PENDING_TTL = 7200;
+
+    public function __construct(private AgentWalletService $wallets, private AgentEquityService $equity)
+    {
+    }
+
+    public function address(Agent $agent, string $net): array
+    {
+        $net = strtolower($net);
+        if (!in_array($net, ['trc20', 'erc20'], true)) throw new \InvalidArgumentException('充值类型错误');
+        $config = [
+            'exchange_rate' => AgentExchangeRate::recharge(),
+        ] + (new UsdtRechargeConfigService())->get();
+        if ($this->mode() === AgentRecharge::TYPE_AUTO) {
+            if ($net !== 'trc20') throw new \InvalidArgumentException('自动到账仅支持 TRC20');
+            $wallet = $this->wallet($agent);
+            $this->markPending((int) $agent->id);
+            $qr = WalletService::getPlatformImageAddress($wallet->address);
+            return ['mode' => AgentRecharge::TYPE_AUTO, 'net' => 'TRC20', 'address' => $wallet->address, 'qrcode' => $qr['full_path']] + $config;
+        }
+        $field = $net === 'trc20' ? 'receiving_address' : 'receiving_address_erc20';
+        $address = (string) DB::table('config')->where('field', $field)->value('val');
+        if ($address === '') throw new \RuntimeException('平台暂未配置收款地址');
+        $qr = WalletService::getPlatformImageAddress($address);
+        return ['mode' => AgentRecharge::TYPE_HAND, 'net' => strtoupper($net), 'address' => $address, 'qrcode' => $qr['full_path']] + $config;
+    }
+
+    /** 人工确认模式:提交转账凭证。 */
+    public function submitProof(Agent $agent, array $data): AgentRecharge
+    {
+        if ($this->mode() !== AgentRecharge::TYPE_HAND) throw new \RuntimeException('当前为自动到账模式,转账后无需提交凭证');
+        return AgentRecharge::query()->create([
+            'order_no' => $this->orderNo(), 'agent_id' => $agent->id, 'type' => AgentRecharge::TYPE_HAND,
+            'net' => strtoupper((string) $data['net']), 'to_address' => trim((string) $data['to_address']),
+            'usdt_amount' => bcadd((string) $data['usdt_amount'], '0', 4), 'image' => (string) $data['image'],
+            'status' => AgentRecharge::STATUS_STAY,
+        ]);
+    }
+
+    /** 总后台审核凭证充值:1 通过入账,2 驳回。 */
+    public function audit(int $id, int $status, int $adminId, string $remark = ''): AgentRecharge
+    {
+        if ($status === AgentRecharge::STATUS_FAIL) {
+            return DB::transaction(function () use ($id, $adminId, $remark) {
+                $recharge = AgentRecharge::query()->where('type', AgentRecharge::TYPE_HAND)->lockForUpdate()->findOrFail($id);
+                if ((int) $recharge->status !== AgentRecharge::STATUS_STAY) throw new \RuntimeException('充值记录已处理');
+                if (trim($remark) === '') throw new \InvalidArgumentException('请填写驳回原因');
+                $recharge->forceFill(['status' => AgentRecharge::STATUS_FAIL, 'remark' => trim($remark), 'audit_admin_id' => $adminId])->save();
+                return $recharge;
+            });
+        }
+        $recharge = AgentRecharge::query()->where('type', AgentRecharge::TYPE_HAND)->findOrFail($id);
+        if (!$this->credit($recharge, (string) $recharge->usdt_amount, ['audit_admin_id' => $adminId, 'remark' => trim($remark)])) {
+            throw new \RuntimeException('充值记录已处理');
+        }
+        return $recharge->fresh();
+    }
+
+    /** 自动模式:同步待查询代理的链上充值并确认入账。由 /admin/sync/recharge 和 agent:recharge-sync 调用。 */
+    public function sync(?int $agentId = null): array
+    {
+        $result = ['agents' => 0, 'found' => 0, 'credited' => 0, 'ignored' => 0];
+        $agentIds = $agentId ? [$agentId] : array_keys($this->pendingQueue());
+        foreach (AgentWallet::query()->whereIn('agent_id', $agentIds)->get() as $wallet) {
+            ++$result['agents'];
+            try {
+                foreach (TronHelper::getTrc20UsdtRecharges($wallet->address) as $transfer) {
+                    if (strcasecmp((string) $transfer['to_address'], (string) $wallet->address) !== 0) continue;
+                    $created = AgentRecharge::query()->firstOrCreate(['txid' => $transfer['txid']], [
+                        'order_no' => $this->orderNo(), 'agent_id' => $wallet->agent_id, 'type' => AgentRecharge::TYPE_AUTO,
+                        'net' => 'TRC20', 'from_address' => (string) $transfer['from_address'],
+                        'to_address' => (string) $transfer['to_address'], 'usdt_amount' => bcadd((string) $transfer['amount'], '0', 4),
+                        'status' => AgentRecharge::STATUS_STAY,
+                    ]);
+                    if ($created->wasRecentlyCreated) ++$result['found'];
+                }
+            } catch (Throwable $e) {
+                Log::warning('agent_usdt_recharge_sync_failed', ['agent_id' => $wallet->agent_id, 'error' => $e->getMessage()]);
+            }
+        }
+        $pending = AgentRecharge::query()->where('type', AgentRecharge::TYPE_AUTO)->where('status', AgentRecharge::STATUS_STAY)
+            ->when($agentId, fn ($q) => $q->where('agent_id', $agentId))->orderBy('id')->limit(200)->get();
+        foreach ($pending as $recharge) {
+            $outcome = $this->confirm($recharge);
+            if ($outcome !== null) ++$result[$outcome];
+        }
+        return $result;
+    }
+
+    private function confirm(AgentRecharge $recharge): ?string
+    {
+        $wallet = AgentWallet::query()->where('agent_id', $recharge->agent_id)->first();
+        if (!$wallet || strcasecmp($wallet->address, (string) $recharge->to_address) !== 0) {
+            $recharge->forceFill(['status' => AgentRecharge::STATUS_IGNORE, 'remark' => '收款地址不是该代理的充值地址'])->save();
+            return 'ignored';
+        }
+        $result = TronHelper::getUsdtTransferConfirmation((string) $recharge->txid, $wallet->address);
+        if (empty($result['success'])) {
+            if (!empty($result['invalid'])) {
+                $recharge->forceFill(['status' => AgentRecharge::STATUS_IGNORE, 'remark' => (string) ($result['message'] ?? '无效USDT充值交易')])->save();
+                return 'ignored';
+            }
+            return null;
+        }
+        if ((int) $result['confirmations'] < TronHelper::CONFIRMED_NUMBER) {
+            $recharge->forceFill(['confirmations' => (int) $result['confirmations']])->save();
+            return null;
+        }
+        $credited = $this->credit($recharge, (string) $result['amount'], [
+            'confirmations' => (int) $result['confirmations'], 'from_address' => (string) $result['from_address'],
+        ]);
+        if ($credited) {
+            try {
+                CollectService::createCollect($wallet->address, 'USDT', 'TRC20');
+            } catch (Throwable $e) {
+                Log::warning('agent_usdt_collect_create_failed', ['agent_id' => $recharge->agent_id, 'error' => $e->getMessage()]);
+            }
+        }
+        return $credited ? 'credited' : null;
+    }
+
+    /** 入账:记录改为成功、代理加款(合营计入保证金),再尝试完成入股待付款订单。 */
+    public function credit(AgentRecharge $recharge, string $usdtAmount, array $extra = []): bool
+    {
+        $credited = DB::transaction(function () use ($recharge, $usdtAmount, $extra) {
+            $recharge = AgentRecharge::query()->lockForUpdate()->find($recharge->id);
+            if (!$recharge || (int) $recharge->status !== AgentRecharge::STATUS_STAY) return false;
+            $rate = AgentExchangeRate::recharge();
+            $amount = bcmul($usdtAmount, $rate, 4);
+            if (bccomp($amount, '0', 4) <= 0) throw new \RuntimeException('充值金额必须大于0');
+            $recharge->forceFill($extra + [
+                'usdt_amount' => bcadd($usdtAmount, '0', 4), 'exchange_rate' => $rate, 'amount' => $amount,
+                'status' => AgentRecharge::STATUS_SUCCESS, 'confirmed_at' => now(),
+            ])->save();
+            $this->wallets->adjust((int) $recharge->agent_id, $amount, 'recharge',
+                'USDT充值 ' . $recharge->usdt_amount . ' USDT,汇率 ' . $rate,
+                'agent-recharge:' . $recharge->id, $recharge->type === AgentRecharge::TYPE_HAND ? 'admin' : 'system',
+                $extra['audit_admin_id'] ?? null, 'agent_recharge', (int) $recharge->id);
+            return true;
+        }, 3);
+        if ($credited) $this->equity->fulfillPending((int) $recharge->agent_id);
+        return $credited;
+    }
+
+    public function mode(): int
+    {
+        return (int) DB::table('config')->where('field', 'receiving_type')->value('val') === 1
+            ? AgentRecharge::TYPE_AUTO : AgentRecharge::TYPE_HAND;
+    }
+
+    private function wallet(Agent $agent): AgentWallet
+    {
+        return DB::transaction(function () use ($agent) {
+            Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            $wallet = AgentWallet::query()->where('agent_id', $agent->id)->first();
+            if ($wallet) return $wallet;
+            $tron = TronHelper::createAddress('AG' . $agent->id);
+            if (empty($tron['address']) || empty($tron['private_key'])) throw new \RuntimeException('充值地址生成失败,请稍后重试');
+            return AgentWallet::query()->create([
+                'agent_id' => $agent->id, 'address' => $tron['address'], 'private_key' => $tron['private_key'], 'net' => 'TRC20',
+            ]);
+        });
+    }
+
+    private function markPending(int $agentId): void
+    {
+        $queue = $this->pendingQueue();
+        $queue[$agentId] = time() + self::PENDING_TTL;
+        Cache::put(self::PENDING_KEY, $queue, self::PENDING_TTL);
+    }
+
+    private function pendingQueue(): array
+    {
+        $queue = Cache::get(self::PENDING_KEY, []);
+        $now = time();
+        return array_filter(is_array($queue) ? $queue : [], fn ($expires) => (int) $expires > $now);
+    }
+
+    private function orderNo(): string
+    {
+        return 'AD' . now()->format('YmdHis') . strtoupper(Str::random(8));
+    }
+}

+ 8 - 81
app/Services/Agent/AgentReportService.php

@@ -4,7 +4,6 @@ namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentDailyStat;
-use App\Models\Agent\AgentDailyGameStat;
 use App\Models\Agent\AgentRebateRecord;
 use App\Models\User;
 use Carbon\Carbon;
@@ -14,18 +13,10 @@ use App\Services\PaymentOrderService;
 
 class AgentReportService
 {
+    /** 二级代理已取消,代理只能看到自己。 */
     public function visibleAgentIds(Agent $root, bool $includeSelf = true): array
     {
-        return Agent::query()
-            ->when($includeSelf, function ($query) use ($root) {
-                $query->where(function ($query) use ($root) {
-                    $query->whereKey($root->id)
-                        ->orWhere('path', 'like', rtrim((string) $root->path, '/') . '/%');
-                });
-            }, fn($query) => $query
-                ->where('id', '<>', $root->id)
-                ->where('path', 'like', rtrim((string) $root->path, '/') . '/%'))
-            ->pluck('id')->map(fn($id) => (int) $id)->all();
+        return $includeSelf ? [(int) $root->id] : [];
     }
 
     public function summaryForTree(Agent $root, string $startDate, string $endDate): array
@@ -107,8 +98,8 @@ class AgentReportService
         $commissionAmount = $commissionAgentIds === [] ? 0 : AgentRebateRecord::query()
             ->whereIn('agent_id', $commissionAgentIds)
             ->whereBetween('settlement_date', [$start->toDateString(), $end->toDateString()])
-            ->selectRaw("COALESCE(SUM(CASE WHEN flow_status='credited' THEN flow_commission ELSE 0 END + CASE WHEN profit_status='credited' THEN profit_commission ELSE 0 END),0) total")
-            ->value('total');
+            ->where('profit_status', AgentRebateRecord::STATUS_CREDITED)
+            ->sum('profit_commission');
 
         return [
             'agent_count' => $agentCount,
@@ -132,7 +123,6 @@ class AgentReportService
     public function refreshDaily(string $date): int
     {
         $day = Carbon::parse($date)->toDateString();
-        $this->refreshDailyGameStats($day);
         $count = 0;
         Agent::query()->orderBy('id')->chunkById(100, function ($agents) use ($day, &$count) {
             foreach ($agents as $agent) {
@@ -140,7 +130,7 @@ class AgentReportService
                 AgentDailyStat::query()->updateOrCreate(
                     ['stat_date' => $day, 'agent_id' => $agent->id],
                     [
-                        'direct_agents' => Agent::query()->where('parent_id', $agent->id)->count(),
+                        'direct_agents' => 0,
                         'direct_members' => $summary['member_count'],
                         'member_balance' => $summary['member_balance'],
                         'deposit_amount' => $summary['deposit_amount'],
@@ -163,76 +153,12 @@ class AgentReportService
         return $count;
     }
 
-    public function refreshDailyGameStats(string $date): int
-    {
-        $day = Carbon::parse($date)->toDateString();
-        $start = Carbon::parse($day)->startOfDay();
-        $end = Carbon::parse($day)->endOfDay();
-        $queries = [];
-        if (Schema::hasTable('third_game_orders')) {
-            $queries[] = DB::table('third_game_orders as o')->join('users as u', 'u.id', '=', 'o.user_id')
-                ->whereNotNull('u.agent_id')->where('o.status', 1)->whereBetween('o.last_update_time', [$start, $end])
-                ->selectRaw($this->qualifiedStatColumns('u.agent_id, LOWER(o.platform) platform, o.game_type, COUNT(*) bet_count, COALESCE(SUM(o.bet_amount),0) bet_amount, COALESCE(SUM(o.valid_amount),0) valid_bet_amount, COALESCE(SUM(o.settled_amount),0) win_loss'))
-                ->groupBy('u.agent_id', 'o.platform', 'o.game_type');
-        }
-        if (Schema::hasTable('bets')) {
-            $queries[] = DB::table('bets as o')->join('users as u', 'u.member_id', '=', 'o.member_id')
-                ->whereNotNull('u.agent_id')->where('o.status', 2)->whereBetween('o.created_at', [$start, $end])
-                ->selectRaw($this->qualifiedStatColumns("u.agent_id, 'pc28' platform, 3 game_type, COUNT(*) bet_count, COALESCE(SUM(o.amount),0) bet_amount, COALESCE(SUM(o.amount),0) valid_bet_amount, COALESCE(SUM(o.profit-o.amount),0) win_loss"))
-                ->groupBy('u.agent_id');
-        }
-        foreach ([['sport_game_order', 'sport', 4], ['jisu_game_order', 'jisu', 3]] as [$table, $platform, $gameType]) {
-            if (!Schema::hasTable($table)) continue;
-            $queries[] = DB::table("{$table} as o")->join('users as u', 'u.member_id', '=', 'o.member_id')
-                ->whereNotNull('u.agent_id')->whereIn('o.status', [1, 2])->whereBetween('o.created_at', [$start, $end])
-                ->selectRaw($this->qualifiedStatColumns("u.agent_id, '{$platform}' platform, {$gameType} game_type, COUNT(*) bet_count, COALESCE(SUM(o.amount),0) bet_amount, COALESCE(SUM(o.amount),0) valid_bet_amount, COALESCE(SUM(CASE WHEN o.status=1 THEN -o.amount ELSE o.profit_and_loss END),0) win_loss"))
-                ->groupBy('u.agent_id');
-        }
-        if (Schema::hasTable('lhc_order')) {
-            $amount = Schema::hasColumn('lhc_order', 'total_amount') ? 'COALESCE(o.total_amount,o.amount)' : 'o.amount';
-            $queries[] = DB::table('lhc_order as o')->join('users as u', 'u.member_id', '=', 'o.member_id')
-                ->whereNotNull('u.agent_id')->whereIn('o.lottery_status', [1, 2])
-                ->whereBetween('o.created_at', [$start->timestamp, $end->timestamp])
-                ->selectRaw($this->qualifiedStatColumns("u.agent_id, 'lhc' platform, 3 game_type, COUNT(*) bet_count, COALESCE(SUM({$amount}),0) bet_amount, COALESCE(SUM({$amount}),0) valid_bet_amount, COALESCE(SUM(COALESCE(o.win_amount,0)-{$amount}),0) win_loss"))
-                ->groupBy('u.agent_id');
-        }
-        $rows = collect();
-        if ($queries !== []) {
-            $union = array_shift($queries);
-            foreach ($queries as $query) $union->unionAll($query);
-            $rows = DB::query()->fromSub($union, 'game_stats')
-                ->selectRaw('agent_id, platform, game_type, SUM(bet_count) bet_count, SUM(bet_amount) bet_amount, SUM(valid_bet_amount) valid_bet_amount, SUM(win_loss) win_loss')
-                ->groupBy('agent_id', 'platform', 'game_type')->get();
-        }
-        DB::transaction(function () use ($day, $rows) {
-            AgentDailyGameStat::query()->where('stat_date', $day)->delete();
-            $now = now();
-            foreach ($rows->chunk(500) as $chunk) {
-                AgentDailyGameStat::query()->insert($chunk->map(fn($row) => [
-                    'stat_date' => $day, 'agent_id' => (int) $row->agent_id,
-                    'platform' => (string) $row->platform, 'game_type' => (int) $row->game_type,
-                    'bet_count' => (int) $row->bet_count, 'bet_amount' => (string) $row->bet_amount,
-                    'valid_bet_amount' => (string) $row->valid_bet_amount, 'win_loss' => (string) $row->win_loss,
-                    'created_at' => $now, 'updated_at' => $now,
-                ])->all());
-            }
-        });
-        return $rows->count();
-    }
-
-    private function qualifiedStatColumns(string $expression): string
-    {
-        // Laravel prefixes table aliases too (u -> bot_u). Wrap identifiers in these internal SQL templates.
-        $grammar = DB::connection()->getQueryGrammar();
-        return preg_replace_callback('/\b[uo]\.[a-z_]+\b/', fn ($match) => $grammar->wrap($match[0]), $expression);
-    }
-
     public function reportRows(?Agent $scope, array $params): array
     {
         $page = max(1, (int) ($params['page'] ?? 1));
         // 报表每行需要聚合整棵代理树;上线批量聚合前先限制单页,避免一次请求放大到上千条 SQL。
         $limit = min(20, max(1, (int) ($params['limit'] ?? 10)));
-        $query = Agent::query()->with('parent:id,username');
+        $query = Agent::query();
         if ($scope) {
             $ids = $this->visibleAgentIds($scope);
             $query->whereIn('id', $ids);
@@ -249,7 +175,8 @@ class AgentReportService
                 'id' => (int) $agent->id,
                 'username' => $agent->username,
                 'real_name' => $agent->real_name,
-                'parent_username' => $agent->parent?->username,
+                'type' => (int) $agent->type,
+                'type_text' => $agent->type_text,
                 'balance' => (string) $agent->balance,
                 'frozen_balance' => (string) $agent->frozen_balance,
             ], $row);

+ 29 - 236
app/Services/Agent/AgentService.php

@@ -3,25 +3,19 @@
 namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
-use App\Models\Agent\AgentFlowCommissionProfile;
-use App\Models\Agent\AgentProfitCommissionProfile;
-use App\Models\Agent\AgentToken;
 use App\Models\Agent\AgentApplication;
+use App\Models\Agent\AgentEquityOrder;
+use App\Models\Agent\AgentToken;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
+/** 代理账号(只有一级代理)。分成方案由代理在独立后台申请,总后台审核后生效。 */
 class AgentService
 {
-    public function __construct(
-        private AgentFlowCommissionAssignmentService $flowAssignments,
-        private AgentProfitCommissionAssignmentService $profitAssignments
-    ) {
-    }
-
-    public function create(array $data, ?int $createdBy = null, ?Agent $scopeAgent = null): Agent
+    public function create(array $data, ?int $createdBy = null): Agent
     {
-        return $this->createAccount($data, $createdBy, $scopeAgent);
+        return $this->createAccount($data, $createdBy);
     }
 
     public function createFromApplication(AgentApplication $application, array $options, int $createdBy): Agent
@@ -29,52 +23,21 @@ class AgentService
         if ($application->status !== 'pending' || !$application->password_hash || !$application->withdraw_password_hash) {
             throw new \RuntimeException('申请已处理或凭证不完整');
         }
-        return $this->createAccount(['username' => $application->username] + $options, $createdBy, null, [
+        return $this->createAccount(['username' => $application->username] + $options, $createdBy, [
             'password' => $application->password_hash, 'withdraw_password' => $application->withdraw_password_hash,
         ]);
     }
 
-    private function createAccount(array $data, ?int $createdBy, ?Agent $scopeAgent, ?array $hashes = null): Agent
+    private function createAccount(array $data, ?int $createdBy, ?array $hashes = null): Agent
     {
-        return DB::transaction(function () use ($data, $createdBy, $scopeAgent, $hashes): Agent {
-            $parentId = isset($data['parent_id']) ? (int) $data['parent_id'] : null;
-            if ($scopeAgent) {
-                if ((int) $scopeAgent->level !== Agent::LEVEL_ONE) {
-                    throw new \RuntimeException('二级代理不能继续新增下级代理');
-                }
-                if ($parentId && $parentId !== (int) $scopeAgent->id) {
-                    throw new \RuntimeException('一级代理只能新增自己的直属二级代理');
-                }
-                $parentId = (int) $scopeAgent->id;
-            } elseif ($parentId) {
-                throw new \RuntimeException('总后台只能新增一级代理');
-            }
-            $parent = $parentId ? Agent::query()->lockForUpdate()->findOrFail($parentId) : null;
-            if ($scopeAgent && $parent && (int) $parent->status !== Agent::STATUS_ENABLED) {
-                throw new \RuntimeException('不能在已禁用代理下新增账号');
-            }
-            $level = $parent ? ((int) $parent->level + 1) : Agent::LEVEL_ONE;
-            if ($level > Agent::MAX_LEVEL) {
-                throw new \RuntimeException('代理层级最多为二级');
-            }
-            $flowProfileId = (int)($data['flow_commission_profile_id']
-                ?? $parent?->flow_commission_profile_id
-                ?? AgentFlowCommissionProfile::officialDefaultId());
-            if ($flowProfileId <= 0) throw new \RuntimeException('请先配置默认代理流水佣金方案');
-            $flowProfile = AgentFlowCommissionProfile::query()->findOrFail($flowProfileId);
-            $this->assertProfileAssignable($parent, $flowProfile, $scopeAgent);
-
-            $profitProfileId = (int) ($data['profit_commission_profile_id']
-                ?? $parent?->profit_commission_profile_id
-                ?? AgentProfitCommissionProfile::officialDefaultId());
-            if ($profitProfileId <= 0) throw new \RuntimeException('请先配置默认代理盈亏佣金比例');
-            $profitProfile = AgentProfitCommissionProfile::query()->findOrFail($profitProfileId);
-            $this->assertProfileAssignable($parent, $profitProfile, $scopeAgent);
-
+        return DB::transaction(function () use ($data, $createdBy, $hashes): Agent {
+            $type = (int) ($data['type'] ?? Agent::TYPE_JOINT);
+            if (!isset(Agent::TYPE_NAMES[$type])) throw new \InvalidArgumentException('代理类型错误');
             $agent = Agent::query()->create([
-                'parent_id' => $parent?->id,
+                'parent_id' => null,
                 'path' => '/',
-                'level' => $level,
+                'level' => Agent::LEVEL_ONE,
+                'type' => $type,
                 'username' => trim((string) $data['username']),
                 'password' => $hashes['password'] ?? Hash::make((string) $data['password']),
                 'withdraw_password' => $hashes['withdraw_password'] ?? (empty($data['withdraw_password']) ? '' : Hash::make((string) $data['withdraw_password'])),
@@ -82,57 +45,27 @@ class AgentService
                 'invitation_code' => $this->invitationCode(),
                 'deposit_fee_rate' => (string) ($data['deposit_fee_rate'] ?? 0),
                 'withdraw_fee_rate' => (string) ($data['withdraw_fee_rate'] ?? 0),
-                'profit_commission_rate' => (string) ($data['profit_commission_rate'] ?? 0),
-                'flow_commission_profile_id' => $flowProfileId,
-                'profit_commission_profile_id' => $profitProfileId,
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
                 'created_by' => $createdBy,
             ] + array_intersect_key($data, ['language' => true]));
-            $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
+            $agent->path = '/' . $agent->id . '/';
             $agent->save();
-            $this->flowAssignments->assign($agent, $flowProfile, now()->toDateString(), $createdBy);
-            $this->profitAssignments->assign($agent, $profitProfile, now()->toDateString(), $createdBy);
-            return $agent->fresh(['flowCommissionProfile', 'profitCommissionProfile']);
+            return $agent->fresh(['profitCommissionProfile']);
         });
     }
 
-    public function update(Agent $agent, array $data, ?Agent $scopeAgent = null, ?int $createdBy = null): Agent
+    public function update(Agent $agent, array $data): Agent
     {
-        return DB::transaction(function () use ($agent, $data, $scopeAgent, $createdBy): Agent {
+        return DB::transaction(function () use ($agent, $data): Agent {
             $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
-            $newFlowProfile = null;
-            if (array_key_exists('flow_commission_profile_id', $data)
-                && $data['flow_commission_profile_id'] !== null
-                && (int)$data['flow_commission_profile_id'] !== (int)$agent->flow_commission_profile_id) {
-                $newFlowProfile = AgentFlowCommissionProfile::query()
-                    ->findOrFail((int)$data['flow_commission_profile_id']);
-                $this->assertFlowProfileCoversChildren($agent, $newFlowProfile);
+            if (array_key_exists('type', $data) && $data['type'] !== null && (int) $data['type'] !== (int) $agent->type) {
+                $this->assertTypeChangeable($agent);
+                $agent->type = (int) $data['type'];
             }
-            $newProfitProfile = null;
-            if (array_key_exists('profit_commission_profile_id', $data)
-                && $data['profit_commission_profile_id'] !== null
-                && (int) $data['profit_commission_profile_id'] !== (int) $agent->profit_commission_profile_id) {
-                $newProfitProfile = AgentProfitCommissionProfile::query()
-                    ->findOrFail((int) $data['profit_commission_profile_id']);
-                $this->assertProfitProfileCoversChildren($agent, $newProfitProfile);
-            }
-            if ($scopeAgent) {
-                $this->assertVisible($scopeAgent, (int) $agent->id);
-            } elseif (array_key_exists('parent_id', $data)) {
-                $this->move(
-                    $agent,
-                    $data['parent_id'] === null ? null : (int)$data['parent_id'],
-                    $newFlowProfile,
-                    $newProfitProfile
-                );
-            }
-            $parent = $agent->parent_id ? Agent::query()->findOrFail($agent->parent_id) : null;
-            if ($newFlowProfile) $this->assertProfileAssignable($parent, $newFlowProfile, $scopeAgent);
-            if ($newProfitProfile) $this->assertProfileAssignable($parent, $newProfitProfile, $scopeAgent);
             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', 'flow_commission_profile_id', 'profit_commission_profile_id'] as $field) {
+            foreach (['username', 'language', 'status', 'deposit_fee_rate', 'withdraw_fee_rate'] as $field) {
                 if (array_key_exists($field, $data) && $data[$field] !== null) {
                     $agent->{$field} = $data[$field];
                 }
@@ -147,24 +80,18 @@ class AgentService
             if (!empty($data['password']) || (int) $agent->status === Agent::STATUS_DISABLED) {
                 AgentToken::query()->where('agent_id', $agent->id)->delete();
             }
-            if ($newFlowProfile) {
-                $this->flowAssignments->assign($agent, $newFlowProfile, now()->toDateString(), $createdBy);
-            }
-            if ($newProfitProfile) {
-                $this->profitAssignments->assign($agent, $newProfitProfile, now()->toDateString(), $createdBy);
-            }
-            return $agent->fresh(['flowCommissionProfile', 'profitCommissionProfile']);
+            return $agent->fresh(['profitCommissionProfile']);
         });
     }
 
-    public function assertVisible(Agent $root, int $targetId): void
+    /** 已有资金、方案或股份的代理不能切换类型,避免保证金与股份口径混在一起。 */
+    private function assertTypeChangeable(Agent $agent): void
     {
-        if ((int) $root->id === $targetId) {
-            return;
-        }
-        $visible = Agent::query()->whereKey($targetId)->where('path', 'like', rtrim((string) $root->path, '/') . '/%')->exists();
-        if (!$visible) {
-            throw new \RuntimeException('无权访问该代理');
+        if (bccomp((string) $agent->balance, '0', 4) !== 0 || bccomp((string) $agent->frozen_balance, '0', 4) !== 0
+            || bccomp((string) $agent->margin_amount, '0', 4) !== 0 || bccomp((string) $agent->equity_percent, '0', 4) !== 0
+            || $agent->profit_commission_profile_id
+            || AgentEquityOrder::query()->where('agent_id', $agent->id)->where('status', AgentEquityOrder::STATUS_PENDING)->exists()) {
+            throw new \RuntimeException('代理已有余额、保证金、方案或股份,不能修改类型');
         }
     }
 
@@ -175,138 +102,4 @@ class AgentService
         } while (Agent::query()->where('invitation_code', $code)->exists());
         return $code;
     }
-
-    private function assertProfileAssignable(
-        ?Agent $parent,
-        AgentFlowCommissionProfile|AgentProfitCommissionProfile $profile,
-        ?Agent $scopeAgent = null
-    ): void {
-        $isFlow = $profile instanceof AgentFlowCommissionProfile;
-        if ($scopeAgent) {
-            if (!$parent) throw new \RuntimeException('一级代理只能给直属下级指定方案');
-            if (!$profile->isOfficial() && !$profile->isOwnedBy((int) $parent->id)) {
-                throw new \RuntimeException($isFlow ? '只能使用官方方案或自己创建的流水方案' : '只能使用官方方案或自己创建的盈亏方案');
-            }
-        } elseif (!$profile->isOfficial()) {
-            throw new \RuntimeException($isFlow ? '总后台只能给代理指定官方流水方案' : '总后台只能给代理指定官方盈亏方案');
-        }
-        if ($parent) {
-            $isFlow
-                ? $this->assertFlowProfileWithinParent($parent, $profile)
-                : $this->assertProfitProfileWithinParent($parent, $profile);
-        }
-    }
-
-    private function assertFlowProfileWithinParent(Agent $parent, AgentFlowCommissionProfile $profile): void
-    {
-        $parent->loadMissing('flowCommissionProfile');
-        foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-            if (!$parent->flowCommissionProfile) throw new \RuntimeException('上级代理未关联流水佣金方案');
-            $parentRate = (string)$parent->flowCommissionProfile->{$field};
-            if (bccomp((string)$profile->{$field}, $parentRate, 4) > 0) {
-                throw new \RuntimeException('下级代理流水佣金方案不能高于上级方案');
-            }
-        }
-    }
-
-    private function assertProfitProfileWithinParent(Agent $parent, AgentProfitCommissionProfile $profile): void
-    {
-        $parent->loadMissing('profitCommissionProfile');
-        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
-            if (!$parent->profitCommissionProfile) throw new \RuntimeException('上级代理未关联盈亏佣金方案');
-            $parentRate = (string) $parent->profitCommissionProfile->{$field};
-            if (bccomp((string) $profile->{$field}, $parentRate, 4) > 0) {
-                throw new \RuntimeException('下级代理盈亏佣金方案不能高于上级方案');
-            }
-        }
-    }
-
-    private function assertFlowProfileCoversChildren(Agent $agent, AgentFlowCommissionProfile $profile): void
-    {
-        $children = Agent::query()->with('flowCommissionProfile')->where('parent_id', $agent->id)->get();
-        foreach ($children as $child) {
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-                if (!$child->flowCommissionProfile) throw new \RuntimeException('下级代理未关联流水佣金方案');
-                $childRate = (string)$child->flowCommissionProfile->{$field};
-                if (bccomp((string)$profile->{$field}, $childRate, 4) < 0) {
-                    throw new \RuntimeException('代理流水佣金方案不能低于现有下级方案');
-                }
-            }
-        }
-    }
-
-    private function assertProfitProfileCoversChildren(Agent $agent, AgentProfitCommissionProfile $profile): void
-    {
-        $children = Agent::query()->with('profitCommissionProfile')->where('parent_id', $agent->id)->get();
-        foreach ($children as $child) {
-            foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
-                if (!$child->profitCommissionProfile) throw new \RuntimeException('下级代理未关联盈亏佣金方案');
-                $childRate = (string) $child->profitCommissionProfile->{$field};
-                if (bccomp((string) $profile->{$field}, $childRate, 4) < 0) {
-                    throw new \RuntimeException('代理盈亏佣金方案不能低于现有下级方案');
-                }
-            }
-        }
-    }
-
-    private function move(
-        Agent $agent,
-        ?int $parentId,
-        ?AgentFlowCommissionProfile $flowProfile = null,
-        ?AgentProfitCommissionProfile $profitProfile = null
-    ): void {
-        if ($parentId === (int) $agent->id) {
-            throw new \RuntimeException('上级代理不能是自己');
-        }
-        $currentParentId = $agent->parent_id === null ? null : (int) $agent->parent_id;
-        if ($parentId === $currentParentId) {
-            return;
-        }
-        $parent = $parentId ? Agent::query()->lockForUpdate()->findOrFail($parentId) : null;
-        if ($parent && str_starts_with((string) $parent->path, (string) $agent->path)) {
-            throw new \RuntimeException('不能把代理移动到自己的下级');
-        }
-        if ($parent && (int) $parent->level !== Agent::LEVEL_ONE) {
-            throw new \RuntimeException('二级代理不能作为上级代理');
-        }
-        if ($parent) {
-            $agent->loadMissing(['flowCommissionProfile', 'profitCommissionProfile']);
-            $effectiveFlowProfile = $flowProfile ?: $agent->flowCommissionProfile;
-            if (!$effectiveFlowProfile) throw new \RuntimeException('代理未关联流水佣金方案');
-            $this->assertProfileAssignable($parent, $effectiveFlowProfile);
-            $effectiveProfitProfile = $profitProfile ?: $agent->profitCommissionProfile;
-            if (!$effectiveProfitProfile) throw new \RuntimeException('代理未关联盈亏佣金方案');
-            $this->assertProfileAssignable($parent, $effectiveProfitProfile);
-        } else {
-            $agent->loadMissing(['flowCommissionProfile', 'profitCommissionProfile']);
-            $effectiveFlowProfile = $flowProfile ?: $agent->flowCommissionProfile;
-            if ($effectiveFlowProfile && !$effectiveFlowProfile->isOfficial()) {
-                throw new \RuntimeException('一级代理只能使用官方流水方案');
-            }
-            $effectiveProfitProfile = $profitProfile ?: $agent->profitCommissionProfile;
-            if ($effectiveProfitProfile && !$effectiveProfitProfile->isOfficial()) {
-                throw new \RuntimeException('一级代理只能使用官方盈亏方案');
-            }
-        }
-        $oldPath = (string) $agent->path;
-        $oldLevel = (int) $agent->level;
-        $newLevel = $parent ? ((int) $parent->level + 1) : 1;
-        $newPath = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
-        $descendants = Agent::query()->where('path', 'like', rtrim($oldPath, '/') . '/%')->lockForUpdate()->get();
-        $newMaxLevel = $descendants->max(fn(Agent $descendant) =>
-            (int) $descendant->level + $newLevel - $oldLevel
-        ) ?? $newLevel;
-        if ($newLevel > Agent::MAX_LEVEL || $newMaxLevel > Agent::MAX_LEVEL) {
-            throw new \RuntimeException('移动后会产生三级代理,操作已拒绝');
-        }
-
-        $agent->parent_id = $parent?->id;
-        $agent->path = $newPath;
-        $agent->level = $newLevel;
-        foreach ($descendants as $descendant) {
-            $descendant->path = $newPath . substr((string) $descendant->path, strlen($oldPath));
-            $descendant->level = max(1, (int) $descendant->level + $newLevel - $oldLevel);
-            $descendant->save();
-        }
-    }
 }

+ 145 - 67
app/Services/Agent/AgentWalletService.php

@@ -4,67 +4,33 @@ namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentBalanceLog;
+use App\Models\Agent\AgentProfitCommissionProfile;
+use App\Models\Agent\AgentProfitEntry;
+use App\Models\Agent\AgentSetting;
 use Illuminate\Support\Facades\DB;
 
+/**
+ * 代理资金唯一入口。
+ *
+ * 合营代理:总余额 balance = 保证金本金 margin_amount + 盈亏(可为负)。
+ * - 充值全部计入保证金本金,不可提现;
+ * - 盈亏为负时等于从保证金扣除,之后的盈利先补足保证金;
+ * - 只有超出保证金本金的部分可以提现。
+ * realtime_balance = balance + 尚未入账的实时分成,是判断「实时保证金」的内部字段,不对代理展示。
+ */
 class AgentWalletService
 {
-    public function transfer(int $fromAgentId, int $toAgentId, string $amount, string $remark, string $idempotencyKey, int $operatorId): array
-    {
-        if ($fromAgentId === $toAgentId) {
-            throw new \InvalidArgumentException('转出与转入代理不能相同');
-        }
-        if (bccomp($amount, '0', 4) <= 0) {
-            throw new \InvalidArgumentException('金额必须大于0');
-        }
+    /** 类型 => 方向(1 加,-1 减)。profit_share 单独走 creditProfitShare。 */
+    private const DIRECTIONS = [
+        'credit' => 1, 'debit' => -1, 'recharge' => 1, 'withdraw_refund' => 1,
+        'equity_purchase' => -1, 'margin_credit' => 1, 'margin_refund' => -1,
+    ];
 
-        return DB::transaction(function () use ($fromAgentId, $toAgentId, $amount, $remark, $idempotencyKey, $operatorId) {
-            $locked = Agent::query()->whereIn('id', [$fromAgentId, $toAgentId])->orderBy('id')->lockForUpdate()->get()->keyBy('id');
-            $from = $locked->get($fromAgentId);
-            $to = $locked->get($toAgentId);
-            if (!$from || !$to) {
-                throw new \RuntimeException('代理不存在');
-            }
-            $operator = $locked->get($operatorId);
-            $child = $operatorId === $fromAgentId ? $to : $from;
-            if (!$operator || (int) $operator->status !== Agent::STATUS_ENABLED
-                || (int) $operator->level !== Agent::LEVEL_ONE || (int) $child->parent_id !== $operatorId) {
-                throw new \RuntimeException('只能操作当前直属下级的额度');
-            }
-            if ($operatorId === $fromAgentId && (int) $to->status !== Agent::STATUS_ENABLED) {
-                throw new \RuntimeException('不能给已禁用代理下发额度');
-            }
-            // Current read also works when an outer transaction already established a snapshot.
-            $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey . ':out')->lockForUpdate()->first();
-            if ($existing) {
-                $this->assertSameTransfer($existing, $fromAgentId, $toAgentId, $amount);
-                return ['from_balance' => (string) $from->balance, 'to_balance' => (string) $to->balance];
-            }
-            if (bccomp((string) $from->balance, $amount, 4) < 0) {
-                throw new \RuntimeException('代理余额不足');
-            }
-            $fromBefore = bcadd((string) $from->balance, '0', 4);
-            $toBefore = bcadd((string) $to->balance, '0', 4);
-            $from->balance = bcsub($fromBefore, $amount, 4);
-            $to->balance = bcadd($toBefore, $amount, 4);
-            $from->save();
-            $to->save();
-            AgentBalanceLog::query()->create([
-                'agent_id' => $fromAgentId, 'type' => 'transfer_out', 'amount' => bcsub('0', $amount, 4),
-                'before_balance' => $fromBefore, 'after_balance' => $from->balance,
-                'reference_type' => 'agent_transfer', 'reference_id' => $toAgentId,
-                'operator_type' => 'agent', 'operator_id' => $operatorId,
-                'idempotency_key' => $idempotencyKey . ':out', 'remark' => $remark,
-            ]);
-            AgentBalanceLog::query()->create([
-                'agent_id' => $toAgentId, 'type' => 'transfer_in', 'amount' => $amount,
-                'before_balance' => $toBefore, 'after_balance' => $to->balance,
-                'reference_type' => 'agent_transfer', 'reference_id' => $fromAgentId,
-                'operator_type' => 'agent', 'operator_id' => $operatorId,
-                'idempotency_key' => $idempotencyKey . ':in', 'remark' => $remark,
-            ]);
-            return ['from_balance' => (string) $from->balance, 'to_balance' => (string) $to->balance];
-        });
-    }
+    public const TYPE_NAMES = [
+        'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => 'USDT充值', 'withdraw_refund' => '提现退回',
+        'withdraw_freeze' => '提现冻结', 'equity_purchase' => '购买股份', 'margin_credit' => '人工增加保证金',
+        'margin_refund' => '退还保证金', 'profit_share' => '合营分成', 'commission' => '佣金(旧)',
+    ];
 
     public function adjust(
         int $agentId,
@@ -77,7 +43,7 @@ class AgentWalletService
         string $referenceType = '',
         ?int $referenceId = null
     ): AgentBalanceLog {
-        if (!in_array($type, ['credit', 'debit', 'commission', 'withdraw_refund'], true)) {
+        if (!isset(self::DIRECTIONS[$type])) {
             throw new \InvalidArgumentException('资金变动类型错误');
         }
         if (bccomp($amount, '0', 4) <= 0) {
@@ -91,37 +57,149 @@ class AgentWalletService
                 $this->assertSameAdjustment($existing, $agentId, $type, $amount);
                 return $existing;
             }
+            $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
             $before = bcadd((string) $agent->balance, '0', 4);
-            $signed = $type === 'debit' ? bcsub('0', $amount, 4) : bcadd($amount, '0', 4);
             $after = bcadd($before, $signed, 4);
-            if (bccomp($after, '0', 4) < 0) {
+            $realtime = bcadd((string) $agent->realtime_balance, $signed, 4);
+            if (self::DIRECTIONS[$type] < 0 && (bccomp($after, '0', 4) < 0 || bccomp($realtime, '0', 4) < 0)) {
                 throw new \RuntimeException('代理余额不足');
             }
             $agent->balance = $after;
+            $agent->realtime_balance = $realtime;
+            if (($type === 'recharge' && $agent->isJoint()) || $type === 'margin_credit') {
+                $agent->margin_amount = bcadd((string) $agent->margin_amount, $amount, 4);
+            } elseif ($type === 'margin_refund') {
+                if (bccomp((string) $agent->margin_amount, $amount, 4) < 0) {
+                    throw new \RuntimeException('退还金额不能超过保证金');
+                }
+                $agent->margin_amount = bcsub((string) $agent->margin_amount, $amount, 4);
+            }
             $agent->save();
-            return AgentBalanceLog::query()->create([
+            $log = AgentBalanceLog::query()->create([
                 'agent_id' => $agentId, 'type' => $type, 'amount' => $signed,
                 'before_balance' => $before, 'after_balance' => $after,
                 'operator_type' => $operatorType, 'operator_id' => $operatorId,
                 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
                 'reference_type' => $referenceType, 'reference_id' => $referenceId,
             ]);
+            $this->evaluateActivation($agent);
+            return $log;
         });
     }
 
-    private function assertSameTransfer(AgentBalanceLog $log, int $fromAgentId, int $toAgentId, string $amount): void
+    /** 每日入账合营分成:实时余额在注单结算时已计入,这里只改 balance。调用方需已在事务内锁住代理。 */
+    public function creditProfitShare(Agent $agent, string $signed, string $idempotencyKey, int $recordId, string $remark): ?AgentBalanceLog
+    {
+        if (bccomp($signed, '0', 4) === 0) return null;
+        $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
+        if ($existing) return $existing;
+        $before = bcadd((string) $agent->balance, '0', 4);
+        $agent->balance = bcadd($before, $signed, 4);
+        $agent->save();
+        return AgentBalanceLog::query()->create([
+            'agent_id' => $agent->id, 'type' => 'profit_share', 'amount' => bcadd($signed, '0', 4),
+            'before_balance' => $before, 'after_balance' => $agent->balance,
+            'operator_type' => 'system', 'operator_id' => null,
+            'idempotency_key' => $idempotencyKey, 'remark' => $remark,
+            'reference_type' => 'agent_rebate_record', 'reference_id' => $recordId,
+        ]);
+    }
+
+    /**
+     * 合营分成开关。实时有效保证金 = min(保证金本金, 实时余额):
+     * - 达到方案保证金 100% 才开启;
+     * - 开启后低于方案保证金 × 停止比例(默认 10%)即关闭,之后的注单不再分成。
+     */
+    public function evaluateActivation(Agent $agent, bool $planChanged = false): bool
     {
-        $expected = bcsub('0', $amount, 4);
-        if ((int) $log->agent_id !== $fromAgentId || (int) $log->reference_id !== $toAgentId || bccomp((string) $log->amount, $expected, 4) !== 0) {
-            throw new \RuntimeException('request_id 已用于其他资金操作');
+        if (!$agent->isJoint()) {
+            return $this->setActive($agent, false);
+        }
+        $profile = $agent->profit_commission_profile_id
+            ? AgentProfitCommissionProfile::query()->find($agent->profit_commission_profile_id)
+            : null;
+        if (!$profile) {
+            return $this->setActive($agent, false);
+        }
+        $effective = $this->effectiveMargin($agent);
+        $required = bcadd((string) $profile->margin_amount, '0', 4);
+        if (!(int) $agent->profit_active || $planChanged) {
+            return $this->setActive($agent, bccomp($effective, $required, 4) >= 0);
+        }
+        $stop = bcdiv(bcmul($required, (string) AgentSetting::current()->margin_stop_percent, 8), '100', 4);
+        return $this->setActive($agent, bccomp($effective, $stop, 4) >= 0);
+    }
+
+    public function effectiveMargin(Agent $agent): string
+    {
+        $margin = bcadd((string) $agent->margin_amount, '0', 4);
+        $realtime = bcadd((string) $agent->realtime_balance, '0', 4);
+        $value = bccomp($realtime, $margin, 4) < 0 ? $realtime : $margin;
+        return bccomp($value, '0', 4) < 0 ? '0.0000' : $value;
+    }
+
+    /** 代理端可见的资金构成(不含实时余额)。 */
+    public function funds(Agent $agent): array
+    {
+        $balance = bcadd((string) $agent->balance, '0', 4);
+        $margin = bcadd((string) $agent->margin_amount, '0', 4);
+        $data = [
+            'balance' => $balance,
+            'frozen_balance' => bcadd((string) $agent->frozen_balance, '0', 4),
+            'withdrawable_amount' => $this->withdrawable($agent),
+        ];
+        if ($agent->isJoint()) {
+            $marginBalance = bccomp($balance, $margin, 4) < 0 ? $balance : $margin;
+            $data += [
+                'margin_amount' => $margin,
+                'margin_balance' => bccomp($marginBalance, '0', 4) < 0 ? '0.0000' : $marginBalance,
+                'profit_amount' => bcsub($balance, $margin, 4),
+            ];
+        } else {
+            $data['equity_percent'] = bcadd((string) $agent->equity_percent, '0', 4);
+        }
+        return $data;
+    }
+
+    /**
+     * 可提现金额。合营:总余额加上「待入账亏损的最坏情况」后,超出保证金本金的部分;入股:全部余额。
+     * 待入账分成按结算日依次入账,取前缀和的最小值,保证每次入账后余额都不会低于保证金以下的已提部分。
+     */
+    public function withdrawable(Agent $agent): string
+    {
+        $balance = bcadd((string) $agent->balance, '0', 4);
+        if (!$agent->isJoint()) {
+            return bccomp($balance, '0', 4) > 0 ? $balance : '0.0000';
+        }
+        $sums = AgentProfitEntry::query()->where('agent_id', $agent->id)
+            ->where('status', AgentProfitEntry::STATUS_COUNTED)->whereNull('rebate_record_id')
+            ->groupBy('settle_date')->orderBy('settle_date')
+            ->selectRaw('settle_date, COALESCE(SUM(share_amount),0) total')->pluck('total');
+        $running = '0.0000';
+        $floor = '0.0000';
+        foreach ($sums as $sum) {
+            $running = bcadd($running, (string) $sum, 4);
+            if (bccomp($running, $floor, 4) < 0) $floor = $running;
+        }
+        $available = bcsub(bcadd($balance, $floor, 4), (string) $agent->margin_amount, 4);
+        return bccomp($available, '0', 4) > 0 ? $available : '0.0000';
+    }
+
+    private function setActive(Agent $agent, bool $active): bool
+    {
+        if ((int) $agent->profit_active !== ($active ? 1 : 0)) {
+            $agent->profit_active = $active ? 1 : 0;
+            $agent->profit_active_changed_at = now();
+            $agent->save();
         }
+        return $active;
     }
 
     private function assertSameAdjustment(AgentBalanceLog $log, int $agentId, string $type, string $amount): void
     {
-        $expected = $type === 'debit' ? bcsub('0', $amount, 4) : bcadd($amount, '0', 4);
-        if ((int) $log->agent_id !== $agentId || $log->type !== $type || bccomp((string) $log->amount, $expected, 4) !== 0) {
-            throw new \RuntimeException('request_id 已用于其他资金操作');
+        $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
+        if ((int) $log->agent_id !== $agentId || $log->type !== $type || bccomp((string) $log->amount, $signed, 4) !== 0) {
+            throw new \RuntimeException('request_id 已用于其他资金调整');
         }
     }
 }

+ 28 - 10
app/Services/Agent/AgentWithdrawalService.php

@@ -10,15 +10,24 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
+/**
+ * 代理提现,仅 USDT。代理填写 USDT 数量,按提现汇率(汇率 + 汇率差)折算后从余额冻结;
+ * 合营代理只能提取超出保证金的盈利部分。打款由总后台人工完成。
+ */
 class AgentWithdrawalService
 {
-    public function request(Agent $agent, int $accountId, string $amount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
+    public function __construct(private ?AgentWalletService $wallets = null)
     {
-        if (bccomp($amount, '0', 4) <= 0) {
+        $this->wallets ??= new AgentWalletService();
+    }
+
+    public function request(Agent $agent, int $accountId, string $usdtAmount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
+    {
+        if (bccomp($usdtAmount, '0', 4) <= 0) {
             throw new \InvalidArgumentException('提现金额必须大于0');
         }
         $requestKey = 'agent:' . $agent->id . ':' . $requestId;
-        return DB::transaction(function () use ($agent, $accountId, $amount, $withdrawPassword, $remark, $requestKey): AgentWithdrawal {
+        return DB::transaction(function () use ($agent, $accountId, $usdtAmount, $withdrawPassword, $remark, $requestKey): AgentWithdrawal {
             $lockedAgent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
             if ((int) $lockedAgent->status !== Agent::STATUS_ENABLED) {
                 throw new \RuntimeException('代理账号已禁用');
@@ -29,7 +38,7 @@ class AgentWithdrawalService
             // First consistent read is after the agent lock, so concurrent retries see the committed request.
             $existing = AgentWithdrawal::query()->where('request_key', $requestKey)->first();
             if ($existing) {
-                $this->assertSameRequest($existing, $accountId, $amount);
+                $this->assertSameRequest($existing, $accountId, $usdtAmount);
                 return $existing;
             }
             $account = AgentWithdrawAccount::query()
@@ -37,8 +46,13 @@ class AgentWithdrawalService
                 ->where('status', 1)
                 ->lockForUpdate()
                 ->findOrFail($accountId);
-            if (bccomp((string) $lockedAgent->balance, $amount, 4) < 0) {
-                throw new \RuntimeException('代理余额不足');
+            if ($account->type !== 'usdt') {
+                throw new \RuntimeException('仅支持提现到 USDT 地址');
+            }
+            $rate = AgentExchangeRate::withdraw();
+            $amount = bcmul($usdtAmount, $rate, 4);
+            if (bccomp($this->wallets->withdrawable($lockedAgent), $amount, 4) < 0) {
+                throw new \RuntimeException($lockedAgent->isJoint() ? '可提现盈利不足(保证金不可提现)' : '代理余额不足');
             }
 
             $fee = bcdiv(bcmul($amount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', 4);
@@ -49,6 +63,7 @@ class AgentWithdrawalService
 
             $before = bcadd((string) $lockedAgent->balance, '0', 4);
             $lockedAgent->balance = bcsub($before, $amount, 4);
+            $lockedAgent->realtime_balance = bcsub((string) $lockedAgent->realtime_balance, $amount, 4);
             $lockedAgent->frozen_balance = bcadd((string) $lockedAgent->frozen_balance, $amount, 4);
             $lockedAgent->save();
 
@@ -62,13 +77,14 @@ class AgentWithdrawalService
                     'type' => $account->type,
                     'name' => $account->name,
                     'account' => $account->account,
-                    'bank_name' => $account->bank_name,
                     'network' => $account->network,
-                    'details' => $account->details,
                 ],
                 'amount' => $amount,
                 'fee' => $fee,
                 'actual_amount' => $actual,
+                'usdt_amount' => $usdtAmount,
+                'actual_usdt_amount' => bcdiv($actual, $rate, 4),
+                'exchange_rate' => $rate,
                 'status' => 'pending',
                 'remark' => $remark,
             ]);
@@ -117,6 +133,7 @@ class AgentWithdrawalService
                     throw new \RuntimeException('代理冻结余额异常');
                 }
                 $agent->balance = bcadd($before, (string) $withdrawal->amount, 4);
+                $agent->realtime_balance = bcadd((string) $agent->realtime_balance, (string) $withdrawal->amount, 4);
                 $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
                 $agent->save();
                 AgentBalanceLog::query()->firstOrCreate(
@@ -137,6 +154,7 @@ class AgentWithdrawalService
                 $withdrawal->status = 'rejected';
                 $withdrawal->audited_at = now();
                 $withdrawal->audit_admin_id = $adminId;
+                $this->wallets->evaluateActivation($agent);
             } else {
                 if ($withdrawal->status !== 'approved') {
                     throw new \RuntimeException('只有已通过订单可以标记已付款');
@@ -156,9 +174,9 @@ class AgentWithdrawalService
         });
     }
 
-    private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $amount): void
+    private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $usdtAmount): void
     {
-        if ((int) $withdrawal->account_id !== $accountId || bccomp((string) $withdrawal->amount, $amount, 4) !== 0) {
+        if ((int) $withdrawal->account_id !== $accountId || bccomp((string) $withdrawal->usdt_amount, $usdtAmount, 4) !== 0) {
             throw new \RuntimeException('request_id 已用于其他提现申请');
         }
     }

+ 9 - 2
app/Services/CollectService.php

@@ -343,7 +343,7 @@ class CollectService extends BaseService
                 'status' => 'pending',
             ];
             $data = [];
-            $wallets = WalletService::findOne(['address' => $v['from_address']]);
+            $wallets = self::walletForAddress($v['from_address']);
 
             if (empty($wallets) || empty($wallets['private_key'])) {
                 $item['status'] = 'wallet_not_found';
@@ -504,7 +504,7 @@ class CollectService extends BaseService
                 'status' => 'pending',
             ];
             $data = [];
-            $wallets = WalletService::findOne(['address' => $v['from_address']]);
+            $wallets = self::walletForAddress($v['from_address']);
 
             if (empty($wallets) || empty($wallets['private_key'])) {
                 $item['status'] = 'wallet_not_found';
@@ -615,6 +615,13 @@ class CollectService extends BaseService
      * @description: 获取归集平台的接收地址
      * @return {*}
      */    
+    /** 归集私钥:会员充值地址在 wallets,代理充值地址在 agent_wallets。 */
+    private static function walletForAddress($address)
+    {
+        return WalletService::findOne(['address' => $address])
+            ?: \App\Models\Agent\AgentWallet::query()->where('address', $address)->first();
+    }
+
     public static function getUsdtAddress()
     {
         $usdt_address = config('app.usdt_address');

+ 144 - 0
app/Services/MemberDailyReportService.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Agent\Agent;
+use App\Models\MemberDailyReport;
+use App\Services\Sharing\ShareDefaults;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 会员每日盈亏报表(每个会员每天一行)。
+ * - 资金:充值/提现/人工加扣款/优惠/返水,口径同代理报表(AgentReportService::aggregate);
+ * - 注单:share_bet_facts 中当天结算、未作废的注单,按结算时间归日,按游戏分类拆分;
+ * - agent_share:当天合营代理从该会员获得的分成(正为代理收入)。
+ * 输赢 win_loss 为会员视角(正为会员赢)。
+ */
+class MemberDailyReportService
+{
+    private const MONEY_FIELDS = ['deposit_amount', 'withdraw_amount', 'manual_credit', 'manual_debit', 'bonus_amount',
+        'rebate_amount', 'bet_amount', 'valid_bet_amount', 'win_loss', 'agent_share'];
+
+    public function refresh(string $date): int
+    {
+        $day = Carbon::parse($date)->toDateString();
+        $start = $day . ' 00:00:00';
+        $end = $day . ' 23:59:59';
+        $byUser = [];
+        $byMember = [];
+
+        foreach (DB::table('share_bet_facts')->where('settled', 1)->where('voided', 0)->whereBetween('settled_at', [$start, $end])
+            ->groupBy('user_id', 'category')
+            ->selectRaw('user_id, category, COUNT(*) bet_count, COALESCE(SUM(bet_amount),0) bet_amount, COALESCE(SUM(effective_amount),0) valid_bet_amount, COALESCE(SUM(net_profit),0) win_loss')
+            ->get() as $row) {
+            $item = &$byUser[(int) $row->user_id];
+            $item['bet_count'] = ($item['bet_count'] ?? 0) + (int) $row->bet_count;
+            foreach (['bet_amount', 'valid_bet_amount', 'win_loss'] as $field) {
+                $item[$field] = bcadd($item[$field] ?? '0', (string) $row->{$field}, 4);
+            }
+            $item['categories'][(string) $row->category] = [
+                'name' => ShareDefaults::categories()[(string) $row->category] ?? (string) $row->category,
+                'bet_count' => (int) $row->bet_count,
+                'bet_amount' => bcadd((string) $row->bet_amount, '0', 4),
+                'valid_bet_amount' => bcadd((string) $row->valid_bet_amount, '0', 4),
+                'win_loss' => bcadd((string) $row->win_loss, '0', 4),
+            ];
+            unset($item);
+        }
+        if (Schema::hasTable('agent_profit_entries')) {
+            foreach (DB::table('agent_profit_entries')->where('status', 'counted')->where('settle_date', $day)
+                ->groupBy('user_id')->selectRaw('user_id, COALESCE(SUM(share_amount),0) total')->get() as $row) {
+                $byUser[(int) $row->user_id]['agent_share'] = bcadd((string) $row->total, '0', 4);
+            }
+        }
+
+        $this->addMoney($byMember, DB::table('recharges')->where('status', 1)->whereBetween('created_at', [$start, $end]), 'deposit_amount');
+        $this->addMoney($byMember, DB::table('withdraws')->where('status', 1)->whereBetween('created_at', [$start, $end]), 'withdraw_amount');
+        if (Schema::hasTable('payment_orders')) {
+            $orders = fn () => DB::table('payment_orders')->where('status', PaymentOrderService::STATUS_SUCCESS)->whereBetween('created_at', [$start, $end]);
+            $this->addMoney($byMember, $orders()->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY]), 'deposit_amount');
+            $this->addMoney($byMember, $orders()->whereIn('type', [PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT]), 'withdraw_amount');
+        }
+        foreach (DB::table('balance_logs')->whereBetween('created_at', [$start, $end])->groupBy('member_id')
+            ->selectRaw(<<<'SQL'
+                member_id,
+                COALESCE(SUM(CASE WHEN ((change_type = '人工充值' AND related_id IS NULL) OR change_type = '订单补偿') AND amount > 0 THEN amount ELSE 0 END), 0) AS manual_credit,
+                COALESCE(SUM(CASE WHEN change_type = '人工扣款' AND amount < 0 THEN ABS(amount) ELSE 0 END), 0) AS manual_debit,
+                COALESCE(SUM(CASE WHEN (change_type IN ('注册赠送','优惠活动','即充即送','充值返现','老用户回归') OR (change_type = '人工充值' AND related_id = 0)) AND amount > 0 THEN amount ELSE 0 END), 0) AS bonus_amount,
+                COALESCE(SUM(CASE WHEN change_type IN ('比比返','笔笔返','返水','回水') AND amount > 0 THEN amount ELSE 0 END), 0) AS rebate_amount
+            SQL)->get() as $row) {
+            foreach (['manual_credit', 'manual_debit', 'bonus_amount', 'rebate_amount'] as $field) {
+                if (bccomp((string) $row->{$field}, '0', 4) === 0) continue;
+                $byMember[(string) $row->member_id][$field] = bcadd($byMember[(string) $row->member_id][$field] ?? '0', (string) $row->{$field}, 4);
+            }
+        }
+
+        $users = collect();
+        foreach (array_chunk(array_keys($byUser), 500) as $ids) {
+            $users = $users->merge(DB::table('users')->whereIn('id', $ids)->get(['id', 'member_id', 'agent_id']));
+        }
+        foreach (array_chunk(array_map('strval', array_keys($byMember)), 500) as $memberIds) {
+            $users = $users->merge(DB::table('users')->whereIn('member_id', $memberIds)->get(['id', 'member_id', 'agent_id']));
+        }
+        $rows = [];
+        $now = now();
+        foreach ($users->unique('id') as $user) {
+            $values = array_merge($byUser[(int) $user->id] ?? [], $byMember[(string) $user->member_id] ?? []);
+            $row = ['report_date' => $day, 'user_id' => (int) $user->id, 'member_id' => (string) $user->member_id,
+                'agent_id' => $user->agent_id ? (int) $user->agent_id : null, 'bet_count' => (int) ($values['bet_count'] ?? 0),
+                'categories' => json_encode($values['categories'] ?? new \stdClass(), JSON_UNESCAPED_UNICODE),
+                'created_at' => $now, 'updated_at' => $now];
+            foreach (self::MONEY_FIELDS as $field) $row[$field] = bcadd($values[$field] ?? '0', '0', 4);
+            $rows[] = $row;
+        }
+        DB::transaction(function () use ($day, $rows) {
+            DB::table('member_daily_reports')->where('report_date', $day)->delete();
+            foreach (array_chunk($rows, 500) as $chunk) DB::table('member_daily_reports')->insert($chunk);
+        });
+        return count($rows);
+    }
+
+    /** 总后台查看全部会员;代理端传入 $agent 只看自己的会员。 */
+    public function paginate(array $params, ?Agent $agent = null): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $startDate = (string) ($params['start_date'] ?? now()->subDays(6)->toDateString());
+        $endDate = (string) ($params['end_date'] ?? now()->toDateString());
+        if (Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)) > 92) {
+            throw new \InvalidArgumentException('单次查询不能超过92天');
+        }
+        $query = MemberDailyReport::query()->from('member_daily_reports as r')
+            ->leftJoin('users as u', 'u.id', '=', 'r.user_id')
+            ->leftJoin('agents as a', 'a.id', '=', 'r.agent_id')
+            ->whereBetween('r.report_date', [$startDate, $endDate]);
+        if ($agent) {
+            $query->where('r.agent_id', $agent->id);
+        } else {
+            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']);
+        }
+        if (!empty($params['member_id'])) $query->where('r.member_id', (string) $params['member_id']);
+        if (!empty($params['username'])) $query->where('u.username', 'like', '%' . $params['username'] . '%');
+        // 原生 SQL 中的别名也要带表前缀(r -> bot_r),交给 grammar 包裹。
+        $grammar = DB::connection()->getQueryGrammar();
+        $summary = (clone $query)->selectRaw('COUNT(*) row_count, COALESCE(SUM(' . $grammar->wrap('r.bet_count') . '),0) bet_count, '
+            . implode(', ', array_map(fn ($field) => 'COALESCE(SUM(' . $grammar->wrap("r.{$field}") . "),0) {$field}", self::MONEY_FIELDS)))
+            ->toBase()->first();
+        $total = (int) ($summary->row_count ?? 0);
+        $list = $query->select('r.*', 'u.username', 'u.first_name', 'a.username as agent_username', 'a.type as agent_type')
+            ->orderByDesc('r.report_date')->orderByDesc('r.id')->forPage($page, $limit)->get();
+        $totals = ['bet_count' => (int) ($summary->bet_count ?? 0)];
+        foreach (self::MONEY_FIELDS as $field) $totals[$field] = bcadd((string) ($summary->{$field} ?? 0), '0', 4);
+        return compact('total', 'page', 'limit', 'totals', 'list');
+    }
+
+    private function addMoney(array &$byMember, $query, string $field): void
+    {
+        foreach ($query->groupBy('member_id')->selectRaw('member_id, COALESCE(SUM(amount),0) total')->get() as $row) {
+            $byMember[(string) $row->member_id][$field] = bcadd($byMember[(string) $row->member_id][$field] ?? '0', (string) $row->total, 4);
+        }
+    }
+}

+ 3 - 3
app/Services/Sharing/ShareAdminService.php

@@ -73,11 +73,11 @@ class ShareAdminService
 
     public function rateRules(?string $category = null): array
     {
-        if ($category !== null && !array_key_exists($category, ShareDefaults::categories())) $this->invalid('category', '无效的游戏分类');
+        if ($category !== null && !array_key_exists($category, ShareDefaults::commissionCategories())) $this->invalid('category', '无效的游戏分类');
         $current = $this->policy(false);
         $pending = $this->policy(true);
         $categories = [];
-        foreach (ShareDefaults::categories() as $key => $label) {
+        foreach (ShareDefaults::commissionCategories() as $key => $label) {
             if ($category !== null && $category !== $key) continue;
             $categories[] = ['category' => $key, 'name' => $label,
                 'current_rules' => $current['rules'][$key] ?? [],
@@ -91,7 +91,7 @@ class ShareAdminService
     public function saveRateRules(string $category, array $rules): array
     {
         $data = Validator::make(['category' => $category, 'rules' => $rules], [
-            'category' => ['required', Rule::in(array_keys(ShareDefaults::categories()))],
+            'category' => ['required', Rule::in(array_keys(ShareDefaults::commissionCategories()))],
             'rules' => ['required', 'array', 'min:1', 'max:100'],
             'rules.*.min_valid_members' => ['required', 'integer', 'min:0', 'max:2147483647'],
             'rules.*.min_performance' => array_merge(['required'], self::MONEY),

+ 9 - 1
app/Services/Sharing/ShareDefaults.php

@@ -13,6 +13,14 @@ final class ShareDefaults
             'third:7' => '棋牌', 'self:sport' => 'F1体育', 'self:lottery' => 'F1彩票'];
     }
 
+    /** 返佣与代理合营分成使用的 8 类(暂无三方彩票;以后接入时按 third:3 标识加回)。 */
+    public static function commissionCategories(): array
+    {
+        $categories = self::categories();
+        unset($categories['third:3']);
+        return $categories;
+    }
+
     public static function rules(): array
     {
         $rows = [];
@@ -21,6 +29,6 @@ final class ShareDefaults
             [5,900000,'0.55'],[5,1100000,'0.60'],[5,1500000,'0.65'],[5,1700000,'0.70']] as [$people,$amount,$rate]) {
             $rows[] = ['min_valid_members' => $people, 'min_performance' => (string)$amount, 'rate' => $rate];
         }
-        return array_fill_keys(array_keys(self::categories()), $rows);
+        return array_fill_keys(array_keys(self::commissionCategories()), $rows);
     }
 }

+ 1 - 1
app/Services/Sharing/ShareSettlementService.php

@@ -45,7 +45,7 @@ class ShareSettlementService
                 }
                 continue;
             }
-            if (!array_key_exists($fact->category,ShareDefaults::categories())) {
+            if (!array_key_exists($fact->category,ShareDefaults::commissionCategories())) {
                 if ($withDiagnostics) ++$diagnostics['excluded']['unsupported_category'];
                 continue;
             }

+ 3 - 0
app/Services/UserService.php

@@ -90,6 +90,9 @@ class UserService extends BaseService
             $endTime = $search['end_time'] . " 23:59:59";
             $where[] = ['users.created_at', '<=', $endTime];
         }
+        if (!empty($search['agent_id'])) {
+            $where[] = ['users.agent_id', '=', (int) $search['agent_id']];
+        }
         if (!empty($search['recharge_channel_group_id'])) {
             $where[] = ['users.recharge_channel_group_id', '=', $search['recharge_channel_group_id']];
         }

+ 2 - 0
app/Support/ActivityCatalog.php

@@ -7,6 +7,8 @@ use App\Services\Sharing\ShareDefaults;
 /** Activity display categories include hot/other and a single F1 lottery category. */
 final class ActivityCatalog
 {
+    public const PUBLISH_PLATFORMS = ['PC', 'H5', 'APP'];
+
     public static function activityTypes(): array
     {
         return [2 => '普通活动', 3 => '即充即送', 4 => '老用户召集令', 5 => 'USDT使用教学', 6 => '余额宝'];

+ 266 - 0
database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php

@@ -0,0 +1,266 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 代理合营/入股改造:
+ * - 代理类型、保证金、实时余额、入股比例;
+ * - 盈亏方案改为 8 类分类比例 + 保证金,去掉代理自建方案;
+ * - 去掉流水佣金、二级代理、按日游戏统计;
+ * - 新增代理 USDT 充值、入股订单、方案申请、实时分成账本、会员每日盈亏报表。
+ * 代理模块尚未上线,删除的结构在 down() 中只重建空表/空列。
+ */
+return new class extends Migration
+{
+    public function up(): void
+    {
+        $this->agents();
+        $this->dropFlowCommission();
+        $this->profitProfiles();
+        $this->rebateRecords();
+        Schema::dropIfExists('agent_daily_game_stats');
+        $this->withdrawals();
+        $this->createTables();
+        $this->shareRules();
+    }
+
+    private function agents(): void
+    {
+        Schema::table('agents', function (Blueprint $table) {
+            if (!Schema::hasColumn('agents', 'type')) $table->unsignedTinyInteger('type')->default(1)->index();
+            if (!Schema::hasColumn('agents', 'margin_amount')) $table->decimal('margin_amount', 18, 4)->default(0);
+            if (!Schema::hasColumn('agents', 'realtime_balance')) $table->decimal('realtime_balance', 18, 4)->default(0);
+            if (!Schema::hasColumn('agents', 'profit_active')) $table->unsignedTinyInteger('profit_active')->default(0);
+            if (!Schema::hasColumn('agents', 'profit_active_changed_at')) $table->timestamp('profit_active_changed_at')->nullable();
+            if (!Schema::hasColumn('agents', 'equity_percent')) $table->decimal('equity_percent', 8, 4)->default(0);
+        });
+        DB::table('agents')->update(['realtime_balance' => DB::raw(DB::getQueryGrammar()->wrap('balance'))]);
+        // 二级代理取消:原有下级全部变为独立的一级代理。
+        foreach (DB::table('agents')->whereNotNull('parent_id')->orWhere('level', '<>', 1)->pluck('id') as $id) {
+            DB::table('agents')->where('id', $id)->update(['parent_id' => null, 'level' => 1, 'path' => '/' . $id . '/']);
+        }
+    }
+
+    private function dropFlowCommission(): void
+    {
+        if (Schema::hasColumn('agents', 'flow_commission_profile_id')) {
+            Schema::table('agents', function (Blueprint $table) {
+                if (Schema::hasIndex('agents', ['flow_commission_profile_id'])) $table->dropIndex(['flow_commission_profile_id']);
+            });
+            Schema::table('agents', fn (Blueprint $table) => $table->dropColumn('flow_commission_profile_id'));
+        }
+        Schema::dropIfExists('agent_flow_commission_assignments');
+        Schema::dropIfExists('agent_flow_commission_profiles');
+    }
+
+    private function profitProfiles(): void
+    {
+        // 代理自建方案随二级代理一起取消,使用这些方案的代理改为未分配。
+        if (Schema::hasColumn('agent_profit_commission_profiles', 'owner_agent_id')) {
+            $owned = DB::table('agent_profit_commission_profiles')->whereNotNull('owner_agent_id')->pluck('id');
+            if ($owned->isNotEmpty()) {
+                DB::table('agents')->whereIn('profit_commission_profile_id', $owned)->update(['profit_commission_profile_id' => null]);
+                DB::table('agent_profit_commission_profiles')->whereIn('id', $owned)->delete();
+            }
+        }
+        foreach (['agent_profit_commission_profiles', 'agent_profit_commission_assignments'] as $table) {
+            if (Schema::hasColumn($table, 'lottery_rate') && !Schema::hasColumn($table, 'f1_lottery_rate')) {
+                Schema::table($table, fn (Blueprint $t) => $t->renameColumn('lottery_rate', 'f1_lottery_rate'));
+            }
+            if (!Schema::hasColumn($table, 'f1_sport_rate')) {
+                Schema::table($table, fn (Blueprint $t) => $t->decimal('f1_sport_rate', 8, 4)->default(0));
+                // 原来 F1 体育与三方体育共用 sport_rate,拆分时沿用原比例。
+                DB::table($table)->update(['f1_sport_rate' => DB::raw(DB::getQueryGrammar()->wrap('sport_rate'))]);
+            }
+            if (!Schema::hasColumn($table, 'margin_amount')) {
+                Schema::table($table, fn (Blueprint $t) => $t->decimal('margin_amount', 18, 4)->default(0));
+            }
+        }
+        // 方案审批通过即时生效,生效时间精确到秒。
+        Schema::table('agent_profit_commission_assignments', fn (Blueprint $t) => $t->dateTime('effective_from')->change());
+    }
+
+    private function rebateRecords(): void
+    {
+        if (!Schema::hasColumn('agent_rebate_records', 'flow_status')) return;
+        Schema::table('agent_rebate_records', function (Blueprint $table) {
+            if (Schema::hasIndex('agent_rebate_records', ['flow_status'])) $table->dropIndex(['flow_status']);
+        });
+        Schema::table('agent_rebate_records', fn (Blueprint $table) => $table->dropColumn(['flow_rate', 'flow_commission', 'flow_status']));
+        Schema::table('agent_rebate_records', function (Blueprint $table) {
+            $table->unsignedInteger('entry_count')->default(0);
+        });
+    }
+
+    private function withdrawals(): void
+    {
+        Schema::table('agent_withdrawals', function (Blueprint $table) {
+            if (!Schema::hasColumn('agent_withdrawals', 'usdt_amount')) $table->decimal('usdt_amount', 18, 4)->default(0);
+            if (!Schema::hasColumn('agent_withdrawals', 'actual_usdt_amount')) $table->decimal('actual_usdt_amount', 18, 4)->default(0);
+            if (!Schema::hasColumn('agent_withdrawals', 'exchange_rate')) $table->decimal('exchange_rate', 18, 4)->default(0);
+        });
+    }
+
+    private function createTables(): void
+    {
+        if (!Schema::hasTable('agent_settings')) {
+            Schema::create('agent_settings', function (Blueprint $t) {
+                $t->id();
+                $t->decimal('margin_stop_percent', 8, 4)->default(10);
+                $t->unsignedTinyInteger('equity_enabled')->default(0);
+                $t->decimal('equity_price_per_percent', 18, 4)->default(0);
+                $t->decimal('equity_min_percent', 8, 4)->default(0.1);
+                $t->decimal('equity_max_percent', 8, 4)->default(1);
+                $t->unsignedBigInteger('updated_by')->nullable();
+                $t->timestamps();
+            });
+            DB::table('agent_settings')->insert(['id' => 1, 'created_at' => now(), 'updated_at' => now()]);
+        }
+        if (!Schema::hasTable('agent_wallets')) {
+            Schema::create('agent_wallets', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('agent_id')->unique();
+                $t->string('address', 64)->unique();
+                $t->text('private_key');
+                $t->string('net', 16)->default('TRC20');
+                $t->timestamps();
+            });
+        }
+        if (!Schema::hasTable('agent_recharges')) {
+            Schema::create('agent_recharges', function (Blueprint $t) {
+                $t->id();
+                $t->string('order_no', 40)->unique();
+                $t->unsignedBigInteger('agent_id')->index();
+                $t->unsignedTinyInteger('type')->comment('1链上自动 2凭证人工');
+                $t->string('txid', 100)->nullable()->unique();
+                $t->string('net', 16)->default('TRC20');
+                $t->string('from_address', 64)->default('');
+                $t->string('to_address', 64)->default('');
+                $t->decimal('usdt_amount', 18, 4)->default(0);
+                $t->decimal('exchange_rate', 18, 4)->default(0);
+                $t->decimal('amount', 18, 4)->default(0)->comment('入账金额');
+                $t->string('image', 1000)->default('');
+                $t->unsignedTinyInteger('status')->default(0)->index()->comment('0待确认 1成功 2失败 3忽略');
+                $t->unsignedInteger('confirmations')->default(0);
+                $t->string('remark', 500)->default('');
+                $t->unsignedBigInteger('audit_admin_id')->nullable();
+                $t->timestamp('confirmed_at')->nullable();
+                $t->timestamps();
+                $t->index(['agent_id', 'created_at']);
+            });
+        }
+        if (!Schema::hasTable('agent_equity_orders')) {
+            Schema::create('agent_equity_orders', function (Blueprint $t) {
+                $t->id();
+                $t->string('order_no', 40)->unique();
+                $t->unsignedBigInteger('agent_id')->index();
+                $t->decimal('percent', 8, 4);
+                $t->decimal('price_per_percent', 18, 4);
+                $t->decimal('amount', 18, 4);
+                $t->string('status', 16)->default('pending_payment')->index();
+                $t->timestamp('completed_at')->nullable();
+                $t->timestamps();
+            });
+        }
+        if (!Schema::hasTable('agent_plan_applications')) {
+            Schema::create('agent_plan_applications', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('agent_id')->index();
+                $t->unsignedBigInteger('profile_id');
+                $t->string('profile_name', 128)->default('');
+                $t->string('status', 16)->default('pending')->index();
+                $t->string('remark', 500)->default('');
+                $t->string('reject_reason', 500)->default('');
+                $t->unsignedBigInteger('reviewed_by')->nullable();
+                $t->timestamp('reviewed_at')->nullable();
+                $t->timestamps();
+            });
+        }
+        if (!Schema::hasTable('agent_profit_entries')) {
+            Schema::create('agent_profit_entries', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('agent_id');
+                $t->unsignedBigInteger('fact_id');
+                $t->unsignedInteger('revision')->default(0);
+                $t->unsignedBigInteger('user_id')->index();
+                $t->string('category', 32);
+                $t->dateTime('settled_at');
+                $t->date('settle_date');
+                $t->decimal('bet_amount', 24, 4)->default(0);
+                $t->decimal('effective_amount', 24, 4)->default(0);
+                $t->decimal('net_profit', 24, 4)->default(0)->comment('会员盈亏');
+                $t->decimal('rate', 8, 4)->default(0);
+                $t->decimal('share_amount', 18, 4)->default(0)->comment('代理分成,正为代理收入');
+                $t->string('status', 24)->index();
+                $t->decimal('realtime_balance_after', 18, 4)->nullable();
+                $t->unsignedBigInteger('rebate_record_id')->nullable()->index();
+                $t->timestamps();
+                $t->unique(['fact_id', 'revision']);
+                $t->index(['agent_id', 'settle_date']);
+            });
+        }
+        if (!Schema::hasTable('member_daily_reports')) {
+            Schema::create('member_daily_reports', function (Blueprint $t) {
+                $t->id();
+                $t->date('report_date');
+                $t->unsignedBigInteger('user_id');
+                $t->string('member_id', 64)->index();
+                $t->unsignedBigInteger('agent_id')->nullable()->index();
+                foreach (['deposit_amount', 'withdraw_amount', 'manual_credit', 'manual_debit', 'bonus_amount', 'rebate_amount',
+                    'bet_amount', 'valid_bet_amount', 'win_loss', 'agent_share'] as $column) {
+                    $t->decimal($column, 18, 4)->default(0);
+                }
+                $t->unsignedInteger('bet_count')->default(0);
+                $t->json('categories')->nullable();
+                $t->timestamps();
+                $t->unique(['report_date', 'user_id']);
+                $t->index(['agent_id', 'report_date']);
+            });
+        }
+    }
+
+    private function shareRules(): void
+    {
+        if (!Schema::hasTable('share_policies')) return;
+        foreach (DB::table('share_policies')->get(['id', 'rules']) as $policy) {
+            $rules = json_decode((string) $policy->rules, true);
+            if (!is_array($rules) || !array_key_exists('third:3', $rules)) continue;
+            unset($rules['third:3']);
+            DB::table('share_policies')->where('id', $policy->id)->update(['rules' => json_encode($rules, JSON_THROW_ON_ERROR)]);
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (['member_daily_reports', 'agent_profit_entries', 'agent_plan_applications', 'agent_equity_orders',
+            'agent_recharges', 'agent_wallets', 'agent_settings'] as $table) {
+            Schema::dropIfExists($table);
+        }
+        Schema::table('agent_withdrawals', fn (Blueprint $t) => $t->dropColumn(['usdt_amount', 'actual_usdt_amount', 'exchange_rate']));
+        Schema::table('agent_rebate_records', function (Blueprint $t) {
+            $t->dropColumn('entry_count');
+        });
+        Schema::table('agent_rebate_records', function (Blueprint $t) {
+            $t->decimal('flow_rate', 8, 4)->default(0);
+            $t->decimal('flow_commission', 18, 4)->default(0);
+            $t->string('flow_status', 16)->default('pending')->index();
+        });
+        Schema::table('agent_profit_commission_assignments', fn (Blueprint $t) => $t->date('effective_from')->change());
+        foreach (['agent_profit_commission_profiles', 'agent_profit_commission_assignments'] as $table) {
+            Schema::table($table, fn (Blueprint $t) => $t->dropColumn(['f1_sport_rate', 'margin_amount']));
+            Schema::table($table, fn (Blueprint $t) => $t->renameColumn('f1_lottery_rate', 'lottery_rate'));
+        }
+        Schema::table('agents', function (Blueprint $t) {
+            $t->dropIndex(['type']);
+        });
+        Schema::table('agents', function (Blueprint $t) {
+            $t->dropColumn(['type', 'margin_amount', 'realtime_balance', 'profit_active', 'profit_active_changed_at', 'equity_percent']);
+            $t->unsignedBigInteger('flow_commission_profile_id')->nullable()->index();
+        });
+        // 流水佣金只重建空结构,数据不恢复;按日游戏统计表不再恢复。
+        (require __DIR__ . '/2026_09_02_120000_create_agent_flow_commission_profiles.php')->up();
+    }
+};

+ 68 - 0
database/migrations/2026_11_07_130000_add_agent_joint_venture_admin_menus.php

@@ -0,0 +1,68 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 总后台菜单:代理管理 → 入股配置 / 分成方案申请 / 代理充值记录;会员管理 → 会员盈亏日报。
+ * 以及对应的保存、审核按钮权限。
+ */
+return new class extends Migration {
+    /** 菜单 uri => [标题, 所属分组(agent|user), 排序, 按钮 uri => 标题] */
+    private const MENUS = [
+        'agentSettings' => ['入股配置', 'agent', 20, ['admin/agent/settings/save' => '入股配置-保存']],
+        'agentPlanApplications' => ['分成方案申请', 'agent', 21, ['admin/agent/plan-applications/audit' => '分成方案申请-审核']],
+        'agentRecharges' => ['代理充值记录', 'agent', 22, ['admin/agent/recharges/audit' => '代理充值-审核']],
+        'memberDailyReport' => ['会员盈亏日报', 'user', 6, []],
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $now = date('Y-m-d H:i:s');
+        $parents = [
+            'agent' => DB::table('menus')->where('type', 1)->where('parent_id', 0)
+                ->where(fn ($q) => $q->whereIn('uri', ['agent', 'agents'])->orWhere('title', 'like', '%代理%'))->value('id'),
+            'user' => DB::table('menus')->where('uri', 'user')->where('type', 1)->where('parent_id', 0)->value('id')
+                ?? DB::table('menus')->where('title', '会员管理')->where('type', 1)->value('id'),
+        ];
+        foreach (self::MENUS as $uri => [$title, $group, $sort, $buttons]) {
+            $parentId = (int) ($parents[$group] ?? 0);
+            $menuId = DB::table('menus')->where('uri', $uri)->where('type', 1)->value('id');
+            if ($menuId) {
+                DB::table('menus')->where('id', $menuId)->update(['parent_id' => $parentId, 'title' => $title, 'status' => 1, 'updated_at' => $now]);
+            } else {
+                $menuId = DB::table('menus')->insertGetId([
+                    'parent_id' => $parentId, 'title' => $title, 'icon' => null, 'uri' => $uri,
+                    'permission_name' => 'agent.' . $uri, 'sort' => $sort, 'status' => 1, 'type' => 1,
+                    'created_at' => $now, 'updated_at' => $now,
+                ]);
+            }
+            $buttonSort = 0;
+            foreach ($buttons as $buttonUri => $buttonTitle) {
+                $button = DB::table('menus')->where('uri', $buttonUri)->value('id');
+                $values = ['parent_id' => $menuId, 'title' => $buttonTitle, 'status' => 1, 'type' => 2, 'updated_at' => $now];
+                if ($button) {
+                    DB::table('menus')->where('id', $button)->update($values);
+                } else {
+                    DB::table('menus')->insert($values + [
+                        'icon' => null, 'uri' => $buttonUri, 'permission_name' => str_replace('/', '.', $buttonUri),
+                        'sort' => $buttonSort, 'created_at' => $now,
+                    ]);
+                }
+                $buttonSort++;
+            }
+        }
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $uris = array_keys(self::MENUS);
+        foreach (self::MENUS as [, , , $buttons]) $uris = array_merge($uris, array_keys($buttons));
+        $ids = DB::table('menus')->whereIn('uri', $uris)->pluck('id');
+        if (Schema::hasTable('role_menu')) DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+        DB::table('menus')->whereIn('id', $ids)->delete();
+    }
+};

+ 22 - 0
database/migrations/2026_11_07_150000_normalize_ordinary_activity_platforms.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (Schema::hasTable('activity_rewards') && Schema::hasColumn('activity_rewards', 'publish_platforms')) {
+            // 会员 API 直接读取此表,明确全部平台,避免空值或旧配置被客户端误判。
+            DB::table('activity_rewards')->where('type', 2)->update([
+                'publish_platforms' => json_encode(['PC', 'H5', 'APP'], JSON_THROW_ON_ERROR),
+            ]);
+        }
+    }
+
+    public function down(): void
+    {
+        // 普通活动始终为全部平台;回滚代码时保留修正后的有效配置。
+    }
+};

+ 2 - 0
docs/代理/代理佣金比例.md

@@ -1,5 +1,7 @@
 # 代理后台-佣金比例
 
+> **已废弃(2026-11-07)**:二级代理、流水佣金、代理自建方案已取消,本文接口已删除。新接口见 [代理后台调整-前端接入](../代理/代理后台调整-前端接入.md) 和 [代理合营与入股-前端接入](../总后台/代理合营与入股-前端接入.md)。
+
 代理后台的佣金比例分成两套方案,和总后台对应:
 
 - [流水佣金比例](./代理流水佣金比例.md)

+ 287 - 0
docs/代理/代理后台调整-前端接入.md

@@ -0,0 +1,287 @@
+# 代理后台-合营/入股调整(前端接入)
+
+给代理独立后台前端看。
+
+请求头带 `Authorization: Bearer <agent_token>`(登录接口除外)。返回结构不变:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。金额都是 4 位小数的字符串,比例单位是 %。
+
+## 改动一览
+
+| 模块 | 改动 |
+|---|---|
+| 登录 / 个人信息 | 新增代理类型(合营/入股)、平台邀请地址、余额构成、方案或持股比例;去掉层级字段 |
+| 资金账户 | 新增余额构成接口;**新增 USDT 充值**;提现**只支持 USDT**,按 USDT 数量提交 |
+| 所有会员 | 列表字段改为和总后台会员列表一致;不提供充值、提现、冻结、解冻 |
+| 会员盈亏日报(新) | 每个会员每天一行 |
+| 分成方案(新) | 合营代理选择总后台配置的方案并提交申请 |
+| 入股(新) | 入股代理选择购买比例,充值到账后自动开通 |
+| 返佣记录 | 改为「合营分成记录」:只有盈亏分成,金额可为负;新增注单明细 |
+| 删除 | 所有代理(下级管理)、下级转账、流水佣金比例、代理自建盈亏方案 |
+
+根据 `type` 切换菜单:`1` 合营显示「分成方案」,`2` 入股显示「入股」。
+
+## 业务规则(页面文案需要用到)
+
+- **合营**:按游戏分类的比例和名下会员双向分成。会员输 100、比例 10%,代理得 10;会员赢 100,代理亏 10。
+  - 充值的金额**全部计入保证金,不能提现**。总余额 = 保证金 + 盈亏;盈亏为负时从保证金扣除,之后的盈利先补足保证金,**只有超出保证金的盈利可以提现**。
+  - 方案要求的保证金交足才开始分成。保证金被亏到方案要求的一定比例以下(默认 10%)时暂停分成,需要再充值到方案保证金的 100% 才恢复。只展示「分成中/已暂停」,不展示实时保证金的具体数值。
+  - 分成逐笔实时计算,每天 04:10 入账前一天的分成。当天未入账的亏损会预留,不能提现。
+- **入股**:按比例购买股份。余额够就立即开通;不够就生成待付款订单,充值到账后自动扣款开通。分红每月由平台人工发放,到账后计入余额,可以提现。
+- 余额单位和会员钱包相同。充值按 `exchange_rate_rmb` 把 USDT 折算进余额;提现按「汇率 + 汇率差」把余额折算成 USDT。
+
+## 1. 登录与个人信息
+
+### `POST /agent/auth/login`
+
+请求不变(`username`、`password`)。返回的 `data.agent` 改为下面的代理概要,和 `GET /agent/profile` 相同:
+
+| 字段 | 说明 |
+|---|---|
+| `id`、`username`、`real_name`、`language`、`invitation_code` | 不变 |
+| `type` / `type_text` | `1` 合营 / `2` 入股 |
+| `pc_domain`、`h5_domain` | 总后台分配给代理的推广域名 |
+| `pc_register_url`、`h5_register_url` | **平台邀请地址**(域名 + 注册页 + `agent_code`),没分配域名时为空字符串 |
+| `balance` | 总余额 |
+| `frozen_balance` | 提现冻结中 |
+| `withdrawable_amount` | 当前可提现 |
+| `withdraw_fee_rate` | 提现手续费 % |
+| `has_withdraw_password` | 是否已设置提现密码 |
+| 合营:`margin_amount` | 累计保证金(充值合计) |
+| 合营:`margin_balance` | 当前保证金余额 |
+| 合营:`profit_amount` | 盈亏 = 总余额 − 保证金,可以为负 |
+| 合营:`profit_commission_profile` | 当前方案(名称、保证金、8 个比例),没有时为 `null` |
+| 合营:`required_margin_amount` | 当前方案要求的保证金 |
+| 合营:`profit_active` / `profit_active_text` | `1` 分成中;`0` 未开通方案,或保证金不足暂停分成 |
+| 入股:`equity_percent` | 已持股比例 % |
+
+```json
+{"token":"…","token_type":"Bearer","expires_at":"2026-11-14 12:00:00","agent":{
+  "id":12,"username":"agent12","real_name":"","language":"zh","invitation_code":"ABCD123456",
+  "type":1,"type_text":"合营","withdraw_fee_rate":"0.0000","has_withdraw_password":true,
+  "pc_domain":"https://a.example.com","h5_domain":"https://m.example.com",
+  "pc_register_url":"https://a.example.com/register?agent_code=ABCD123456","h5_register_url":"https://m.example.com/#/register?agent_code=ABCD123456",
+  "balance":"21500.0000","frozen_balance":"0.0000","withdrawable_amount":"1500.0000",
+  "margin_amount":"20000.0000","margin_balance":"20000.0000","profit_amount":"1500.0000",
+  "profit_commission_profile_id":2,"profit_commission_profile":{"id":2,"name":"合营A","margin_amount":"20000.0000","f1_lottery_rate":"10.0000","f1_sport_rate":"10.0000","live_rate":"10.0000","slot_rate":"10.0000","sport_rate":"10.0000","esports_rate":"10.0000","fishing_rate":"10.0000","chess_rate":"10.0000"},
+  "required_margin_amount":"20000.0000","profit_active":1,"profit_active_text":"分成中"}}
+```
+
+### `GET /agent/profile`
+
+返回上面的概要,另外加 `last_login_at`、`last_login_ip`。已删除的字段:`parent_id`、`parent_username`、`level`、`level_text`、`can_create_sub_agent`、`flow_commission_profile(_id)`、`deposit_fee_rate`、`profit_commission_rate`。
+
+`GET /agent/dashboard` 不变;因为没有下级代理了,`agent_count` 永远是 0。
+
+## 2. 资金账户
+
+### `GET /agent/wallet/summary`(新增)
+
+返回余额构成(字段同上:`balance`、`frozen_balance`、`withdrawable_amount`,以及合营的保证金字段或入股的 `equity_percent`),外加:
+
+- `recharge_exchange_rate`:充值汇率,1 USDT 折合多少余额
+- `withdraw_exchange_rate`:提现汇率
+
+### `GET /agent/wallet/recharge/address`(新增)
+
+参数 `net`:`trc20`(默认)或 `erc20`。
+
+| 字段 | 说明 |
+|---|---|
+| `mode` | `1` 自动到账:向 `address` 转账即可,链上确认后自动入账,**不需要提交凭证**;`2` 人工确认:转账后调用下面的凭证接口 |
+| `net`、`address`、`qrcode` | 公链、收款地址、二维码图片地址 |
+| `exchange_rate` | 充值汇率 |
+| `public_chain`、`energy_amount`、`energy_trx` | 和会员前台充值页相同的提示配置 |
+
+自动到账模式只支持 TRC20,`net=erc20` 会返回错误。
+
+### `POST /agent/wallet/recharge`(新增,仅人工确认模式)
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `net` | 是 | `trc20` / `erc20` |
+| `usdt_amount` | 是 | 转账的 USDT 数量 |
+| `to_address` | 是 | 转入的平台地址 |
+| `image` | 是 | 转账截图 URL |
+
+总后台审核通过后入账。自动到账模式下调用会返回「当前为自动到账模式,转账后无需提交凭证」。
+
+### `GET /agent/wallet/recharges`(新增)
+
+参数:`page`、`limit`、`status`(`0` 待确认 / `1` 已到账 / `2` 已驳回 / `3` 已忽略)、`start_date`、`end_date`。
+
+字段:`order_no`、`type`(`1` 自动 / `2` 凭证)、`txid`、`net`、`to_address`、`usdt_amount`、`exchange_rate`、`amount`(入账金额)、`status`、`status_text`、`remark`(驳回原因)、`confirmed_at`、`created_at`。
+
+### 提现账户:只支持 USDT
+
+- `GET /agent/wallet/accounts`:只返回 USDT 账户,`type` 参数已删除。
+- `POST /agent/wallet/accounts`:参数改为 `id`(修改时传)、`name`(备注)、`network`(**必填**,`TRC20` 或 `ERC20`)、`account`(**必填**,USDT 地址,后端校验格式)、`status`。`type`、`bank_name`、`details` 不再接收。
+- `POST /agent/wallet/accounts/delete`:不变。
+
+### `POST /agent/wallet/withdrawals`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `account_id` | 是 | USDT 提现账户 |
+| `usdt_amount` | 是 | **提现的 USDT 数量**(原来的 `amount` 已改名) |
+| `withdraw_password` | 是 | |
+| `request_id` | 是 | 防重复提交的唯一串,重试时传同一个 |
+| `remark` | 否 | |
+
+折算后的余额金额 = `usdt_amount` × 提现汇率,不能超过 `withdrawable_amount`。合营代理超出时返回「可提现盈利不足(保证金不可提现)」。
+
+提现记录 `GET /agent/wallet/withdrawals` 新增字段:`usdt_amount`、`actual_usdt_amount`(扣手续费后实际到账的 USDT)、`exchange_rate`。`amount`、`fee`、`actual_amount` 仍是余额单位。
+
+### 资金流水 `GET /agent/wallet/logs`
+
+`type` 的取值:
+
+| 值 | 名称 |
+|---|---|
+| `recharge` | USDT充值 |
+| `profit_share` | 合营分成(可为负) |
+| `equity_purchase` | 购买股份 |
+| `withdraw_freeze` | 提现冻结 |
+| `withdraw_refund` | 提现退回 |
+| `credit` | 人工加款(含入股分红) |
+| `debit` | 人工扣款 |
+| `margin_credit` | 人工增加保证金 |
+| `margin_refund` | 退还保证金 |
+
+`transfer_in`、`transfer_out` 已经不会再产生。
+
+## 3. 所有会员
+
+### `GET /agent/members`
+
+字段和总后台 `GET /admin/user` 一致,只返回本代理名下的会员。**返回结构改为 `{"total":…, "data":[…]}`**(原来是 `total/page/limit/list`)。
+
+| 参数 | 说明 |
+|---|---|
+| `page`、`limit` | `limit` 最大 100 |
+| `member_id`、`account`、`username` | 精确查询 |
+| `like_first_name` | 昵称模糊查询 |
+| `register_ip`、`level`、`is_online` | 同总后台 |
+| `start_time`、`end_time` | 注册日期 Y-m-d,必须成对传 |
+| `sort_field`、`sort_order` | 同总后台:`available_balance`、`created_at`、`last_active_time`、`frozen_balance`、`yuebao`、`total_consume`、`wager_required`、`wager_completed`、`wager_remaining`、`third_game_total_balance` |
+
+列表里不要放充值、提现、冻结、解冻按钮,代理端没有这些接口。`keyword`、`agent_id`、`status`、`start_date`、`end_date` 参数已删除。
+
+`GET /agent/members/finance`、`GET /agent/members/bets` 不变。
+
+### `GET /agent/members/daily-report`(新增)
+
+会员盈亏日报,每个会员每天一行,只包含本代理的会员。
+
+参数:`start_date`、`end_date`(默认最近 7 天,跨度不超过 92 天)、`member_id`、`username`、`page`、`limit`。
+
+返回 `total/page/limit/totals/list`:
+
+- `totals` 是所选条件下的合计。
+- `list` 每行的字段:`report_date`、`member_id`、`username`、`first_name`、`deposit_amount`、`withdraw_amount`、`manual_credit`、`manual_debit`、`bonus_amount`、`rebate_amount`、`bet_count`、`bet_amount`、`valid_bet_amount`、`win_loss`、`agent_share`、`categories`。
+
+口径:
+
+- `win_loss` 是会员视角,正为会员赢,按结算时间归日。
+- `agent_share` 是代理当天从该会员拿到的分成,正为代理收入。
+- `categories` 按分类拆分,key 例如 `self:lottery`,值为 `{name, bet_count, bet_amount, valid_bet_amount, win_loss}`。
+- 当天的数据每 10 分钟刷新一次。
+
+## 4. 分成方案(合营)
+
+### `GET /agent/profit-plans`
+
+```json
+{"current_profile":{"id":2,"name":"合营A","margin_amount":"20000.0000","f1_lottery_rate":"10.0000","…":"…"},
+ "profit_active":1,"margin_stop_percent":"10.0000",
+ "pending_application":null,
+ "list":[{"id":2,"name":"合营A","margin_amount":"20000.0000","f1_lottery_rate":"10.0000","f1_sport_rate":"10.0000",
+   "live_rate":"10.0000","slot_rate":"10.0000","sport_rate":"10.0000","esports_rate":"10.0000","fishing_rate":"10.0000","chess_rate":"10.0000"}]}
+```
+
+8 个分类:
+
+| 字段 | 分类 |
+|---|---|
+| `f1_lottery_rate` | F1彩票 |
+| `f1_sport_rate` | F1体育 |
+| `live_rate` | 真人 |
+| `slot_rate` | 电子 |
+| `sport_rate` | 体育(三方) |
+| `esports_rate` | 电竞 |
+| `fishing_rate` | 捕鱼 |
+| `chess_rate` | 棋牌 |
+
+`margin_amount` 是这个方案要求的保证金。`margin_stop_percent` 可以用于提示「保证金低于方案要求的 X% 时暂停分成」。
+
+### `POST /agent/profit-plans/apply`
+
+参数:`profile_id`(必填)、`remark`。同一时间只能有一条待审核的申请;入股代理调用会返回错误。总后台审核通过后立即生效,保证金达到方案要求就开始分成。
+
+### `GET /agent/profit-plans/applications`
+
+参数:`page`、`limit`、`status`(`pending`/`approved`/`rejected`)。字段:`profile_name`、`status`、`status_text`、`reject_reason`、`reviewed_at`、`created_at`、`profile`。
+
+## 5. 入股
+
+### `GET /agent/equity`
+
+```json
+{"enabled":1,"price_per_percent":"100000.0000","min_percent":"0.1000","max_percent":"1.0000",
+ "equity_percent":"0.5000","remaining_percent":"0.5000","balance":"0.0000",
+ "pending_order":null,
+ "orders":{"total":1,"page":1,"limit":20,"list":[{"id":1,"order_no":"AE…","percent":"0.5000","price_per_percent":"100000.0000",
+   "amount":"50000.0000","status":"completed","status_text":"已开通","completed_at":"…","created_at":"…"}]}}
+```
+
+- 应付金额 = 购买比例 × `price_per_percent`。
+- `remaining_percent` 是还能买的比例(累计不超过 `max_percent`)。
+- `pending_order` 是待付款的订单,页面上要提示「充值到账后自动开通」。
+
+### `POST /agent/equity/orders`
+
+参数:`percent`(%,在 `min_percent`~`max_percent` 之间,最多 4 位小数)。
+
+- 余额够:立即扣款,返回 `status=completed`。
+- 余额不够:返回 `status=pending_payment`。之后引导用户去充值,USDT 到账后自动扣款开通。
+- 已有待付款订单时不能再下单。
+
+### `POST /agent/equity/orders/cancel`
+
+参数 `id`。只能取消 `pending_payment` 状态的订单。
+
+## 6. 合营分成记录(原返佣记录)
+
+### `GET /agent/commissions`
+
+参数:`page`、`limit`、`order_no`、`category`(分类 key)、`profit_status`(`pending`/`credited`)、`start_date`、`end_date`(按入账日期)。`username`、`platform`、`flow_status`、`settlement_date`、`include_children` 已删除。
+
+每行是一个入账批次中某个分类的汇总:
+
+- `settlement_date`、`category`、`category_text`
+- `bet_count`、`bet_amount`、`valid_bet_amount`
+- `win_loss`:会员视角
+- `profit_rate`
+- `profit_commission`:代理分成,**可为负**
+- `profit_status_text`:待入账 / 已入账
+
+### `GET /agent/commissions/entries`(新增)
+
+逐笔分成明细,实时更新,包括还没入账的分成。
+
+参数:`page`、`limit`、`category`、`user_id`、`status`、`credited`(`1` 已入账 / `0` 未入账)、`start_date`、`end_date`(按结算日)。
+
+字段:
+
+- `fact_id`、`revision`(`>0` 表示改单冲正)
+- `user_id`、`category`、`settled_at`、`settle_date`
+- `bet_amount`、`net_profit`:会员盈亏
+- `rate`、`share_amount`
+- `status`:`counted` 已计入;`skipped_inactive` 保证金不足,未分成;`skipped_no_plan` 还没有方案
+- `rebate_record_id`
+
+## 7. 已删除的接口
+
+- `GET/POST /agent/sub-agents`、`POST /agent/sub-agents/update`、`POST /agent/sub-agents/transfer`(二级代理已取消)
+- `GET/POST /agent/flow-commission-profiles`、`POST /agent/flow-commission-profiles/delete`(流水佣金已取消)
+- `GET/POST /agent/profit-commission-profiles`、`POST /agent/profit-commission-profiles/delete`(代理自建方案已取消,改用第 4 节)
+
+`GET /agent/reports` 的每行去掉了 `parent_username`,新增 `type`、`type_text`。

+ 2 - 0
docs/代理/代理流水佣金比例.md

@@ -1,5 +1,7 @@
 # 代理后台-流水佣金比例
 
+> **已废弃(2026-11-07)**:二级代理、流水佣金、代理自建方案已取消,本文接口已删除。新接口见 [代理后台调整-前端接入](../代理/代理后台调整-前端接入.md) 和 [代理合营与入股-前端接入](../总后台/代理合营与入股-前端接入.md)。
+
 需要请求头 `Authorization: Bearer <agent_token>`。只有一级代理可以维护方案;二级代理调用保存/删除会返回业务错误,列表返回空。
 
 总后台给一级代理指定官方方案后,一级代理在自己的方案池里再为二级代理配置更低或持平的方案。一级代理自己使用的方案仍由总后台指定,不会因为这里设默认而改变。

+ 2 - 0
docs/代理/代理盈亏佣金比例.md

@@ -1,5 +1,7 @@
 # 代理后台-盈亏佣金比例
 
+> **已废弃(2026-11-07)**:二级代理、流水佣金、代理自建方案已取消,本文接口已删除。新接口见 [代理后台调整-前端接入](../代理/代理后台调整-前端接入.md) 和 [代理合营与入股-前端接入](../总后台/代理合营与入股-前端接入.md)。
+
 需要请求头 `Authorization: Bearer <agent_token>`。只有一级代理可以维护方案;二级代理调用保存/删除会返回业务错误,列表返回空。
 
 总后台给一级代理指定官方盈亏方案后,一级代理在自己的方案池里再为二级代理配置更低或持平的方案。一级代理自己使用的方案仍由总后台指定,不会因为这里设默认而改变。

+ 2 - 0
docs/代理/所有代理.md

@@ -1,5 +1,7 @@
 # 代理后台-所有代理
 
+> **已废弃(2026-11-07)**:二级代理、流水佣金、代理自建方案已取消,本文接口已删除。新接口见 [代理后台调整-前端接入](../代理/代理后台调整-前端接入.md) 和 [代理合营与入股-前端接入](../总后台/代理合营与入股-前端接入.md)。
+
 需要请求头 `Authorization: Bearer <agent_token>`。当前代理只能查看和操作自己的下级树。
 
 ## 1. 下级代理列表

+ 2 - 0
docs/前端接口/活动/会员活动列表类型与游戏类型返回-前端接入说明.md

@@ -32,6 +32,8 @@ URL 相对会员 API 域名,继续使用现有请求封装和部署环境的
 
 历史 `type=1` 及未知活动类型保留原 ID,`type_name=null`,不自动改成普通活动。原“足球首页”只改显示名称,编号和既有业务规则保留。
 
+普通活动(`type=2`)固定发布到全部平台,`publish_platforms` 为 `["PC","H5","APP"]`,PC、H5、APP 的列表和详情均可返回。平台为空数组或 `null` 时同样表示全部平台,不要按空列表隐藏活动。当前客户端使用 `Platform: PC/H5/APP` 请求头,无此请求头时默认 PC。
+
 ### 游戏类型
 
 | `game_category` | `game_category_name` |

+ 12 - 8
docs/前端接口/活动/列表筛选.md

@@ -12,7 +12,7 @@
 | `status` | integer,`0/1` | 否 | 活动已保存的配置状态,`1` 上架、`0` 下架;与返回的活动 `status` 含义相同 |
 | `lang` | string | 否 | 现有语言代码,例如 `zh`、`en`,最长 `10` 个字符;参数名为 `lang` |
 | `title` | string | 否 | 原标题模糊筛选 |
-| `type` | 原活动类型 | 否 | 原业务类型 `1`~`6`;`2` 为足球首页,不能用作平台筛选 |
+| `type` | 原活动类型 | 否 | 原业务类型 `1`~`6`;`2` 为普通活动,不能用作平台筛选 |
 
 可选筛选项省略、空字符串或 `null` 表示该项“全部”。语言不传时查全部语言。`page`、`limit` 仍必须提交。`status=0` 是有效值,不要因判断为假而从请求中移除。
 
@@ -28,9 +28,11 @@
 | 包含 `PC` | 匹配 | 仅在同时包含 `H5` 时匹配 |
 | 包含 `H5` | 仅在同时包含 `PC` 时匹配 | 匹配 |
 | 仅 `APP` | 不匹配 | 不匹配 |
-| `type=2` 足球首页 | 匹配,不受该活动发布平台配置限制 | 匹配,不受该活动发布平台配置限制 |
+| `type=2` 普通活动 | 匹配,默认全部平台 | 匹配,默认全部平台 |
 
-足球首页的豁免只作用于平台匹配;语言、状态、标题和业务类型等其他筛选仍按正常条件组合。不把仅发布 APP 的普通活动视为 H5,也不修改活动原平台配置。
+普通活动(`type=2`,原足球首页)不提供 PC/H5 平台选项,固定全部平台;列表返回 `publish_platforms=["PC","H5","APP"]`、`platform_label="全部平台"`,历史空值或单平台配置也按全部平台回显。平台列直接展示 `platform_label`,不要显示 `-`。新增、编辑普通活动可省略 `publish_platforms`,服务端始终保存全部平台。
+
+普通活动的豁免只作用于平台匹配;语言、状态、标题和业务类型等其他筛选仍按正常条件组合。其他活动仍按原发布平台过滤,`publish_platforms=null/[]` 的平台列显示“全部平台”,仅发布 APP 的活动不匹配 PC/H5。
 
 **这里的状态是活动保存的上架 / 下架配置,不是按日期计算的新状态。** 活动 `status=1` 即匹配上架筛选,即使其尚未开始或已经结束。日期字段保持原值,前端不要把“已结束”自动改成 `status=0`,也不要在收到列表后自行移除这些记录。
 
@@ -56,7 +58,7 @@ Banner 列表使用实际展示状态,已结束活动对应的 Banner 会匹
 
 可继续组合原筛选,例如追加 `&type=3&title=充值`,只查询符合全部条件的业务类型 `3`、标题包含“充值”的活动;`type` 不改变平台参数的含义。
 
-成功 `code=0`,响应保持 `data.total/data.data`。下例仅列出部分原有活动字段:第一条即使结束日期已过,仍可匹配配置状态 `1`;第二条为足球首页,发布平台仅 `APP` 也符合其平台豁免规则。排序仍为 `sort` 倒序、`id` 倒序。
+成功 `code=0`,响应保持 `data.total/data.data`。下例仅列出部分活动字段;第二条为普通活动,发布平台返回全部平台。排序仍为 `sort` 倒序、`id` 倒序。
 
 ```json
 {
@@ -73,17 +75,19 @@ Banner 列表使用实际展示状态,已结束活动对应的 Banner 会匹
         "status": 1,
         "lang": "zh",
         "publish_platforms": ["H5"],
+        "platform_label": "H5",
         "start_time": "2026-10-01",
         "end_time": "2026-10-10",
         "sort": 10
       },
       {
         "id": 122,
-        "title": "足球首页",
+        "title": "普通活动",
         "type": 2,
         "status": 1,
         "lang": "zh",
-        "publish_platforms": ["APP"],
+        "publish_platforms": ["PC", "H5", "APP"],
+        "platform_label": "全部平台",
         "start_time": "2026-10-01",
         "end_time": "2026-10-31",
         "sort": 5
@@ -109,7 +113,7 @@ Banner 列表使用实际展示状态,已结束活动对应的 Banner 会匹
 ```
 
 - 不传平台、状态、语言时查全部,保留原分页、标题与业务类型筛选。
-- 普通活动 `publish_platforms=null/[]` 在 PC、H5 筛选中均可见;仅 APP 的普通活动两边均不可见。
-- `type=2` 足球首页在 PC、H5 筛选中都可见,其他语言或状态条件仍需匹配。
+- 非 `type=2` 活动的 `publish_platforms=null/[]` 在 PC、H5 筛选中均可见;仅 APP 的活动两边均不可见。
+- `type=2` 普通活动固定全部平台,在 PC、H5 筛选中都可见,其他语言或状态条件仍需匹配;平台列显示“全部平台”。
 - `status=0` 可正确查询配置为下架的活动;`status=1` 不因日期结束而自动排除活动。
 - 同时选择平台、状态、语言时,记录及 `total` 满足全部条件;变更筛选后回到第一页。

+ 3 - 2
docs/前端接口/活动/资格条件与展示开关.md

@@ -90,6 +90,7 @@ bot-28 后台、bot-28 Telegram 活动页、会员站点使用各自配置的 AP
 | 字段 | 类型 / 默认值 | 表单与提交规则 |
 |---|---|---|
 | `publish_platforms` | 数组 / `[]` | 「发布平台」多选,值为 `PC`、`H5`、`APP`;空数组表示全部平台 |
+| `platform_label` | 字符串,只读 | 后台列表的平台显示文字;全部平台显示“全部平台”,可直接用于平台列 |
 | `quota_enabled` | 布尔 / 旧活动 `false` | 只读创建来源标记;后台新建活动均为 `true`,足球首页仍豁免;编辑不能改变 |
 | `reward_quota` | 整数或 `null` / `null` | 新份数活动「获奖份数」;`null` 不限,`0` 禁止新的会员首次获奖;范围 `0`~`2147483647` |
 | `rewarded_count` | 整数 / `0` | 新份数活动已获奖的不同会员数,只读,不提交;旧活动不使用此计数 |
@@ -113,7 +114,7 @@ bot-28 后台、bot-28 Telegram 活动页、会员站点使用各自配置的 AP
 
 新建非足球活动默认开启份数模式,编辑省略**本表新增配置**时保留原值;基础必填字段仍必须提交,`params` 按上文保留。清空平台限制传 `[]`,份数活动取消限制传 `null`。注册时间或存款选择「不限」时提交开关 `0`,后端清空旧上下限。调整份数不清零已获奖人数,不限份数也登记首次获奖会员;旧活动不参与新登记和计数。
 
-未编辑的历史活动,后台列表中的平台字段可能为 `null`,回显时按 `[]`(不限)处理;保存时提交数组。
+普通活动(`type=2`,原足球首页)不显示 PC/H5 平台选择,固定全部平台;列表的 `publish_platforms` 返回 `["PC","H5","APP"]`,`platform_label` 返回“全部平台”,保存时可省略平台字段。其他历史活动的平台字段可能为 `null`,回显时按 `[]`(不限)处理;保存时提交数组。平台列直接展示 `platform_label`,空数组不显示 `-`。
 
 ## 保存示例
 
@@ -209,7 +210,7 @@ Authorization: <站点登录返回的原始Token>
 
 站点活动列表和详情均按 `Lang` 请求头筛选语种,未提供时默认 `zh`。
 
-两个入口都应显式传当前客户端 `platform=PC`、`H5` 或 `APP`。站点也支持 `platform` 请求头,查询参数优先,并兼容 `WECHAT/微信 → H5`、`IOS/ANDROID → APP`;前端统一提交标准值即可。配置了发布平台后,未传或无法识别平台的请求看不到该活动;空数组不受此限制。不要用注册平台或浏览器标识推断当前客户端。
+两个入口都使用 `Platform: PC`、`H5` 或 `APP` 请求头指定当前客户端;未传或无法识别时默认 PC,查询参数 `platform` 不参与平台判断。兼容 `WECHAT/微信 → H5`、`IOS/ANDROID → APP`,前端统一提交标准值即可。普通活动始终全部平台;其他活动配置了发布平台后按当前客户端匹配,空数组不限制平台。
 
 站点按现有 Token 登录态识别会员,无须提交 `member_id`;不带 Token 时按游客处理。bot-28 活动页沿用 `member_id`。返回活动新增:
 

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

@@ -0,0 +1,69 @@
+# 代理合营与入股:后端部署说明
+
+前端接口见:[总后台](../总后台/代理合营与入股-前端接入.md)、[代理后台](../代理/代理后台调整-前端接入.md)。
+
+## 迁移
+
+```bash
+php artisan migrate --force
+```
+
+| 迁移 | 内容 |
+|---|---|
+| `2026_11_07_120000_agent_joint_venture_and_equity.php` | 见下方明细 |
+| `2026_11_07_130000_add_agent_joint_venture_admin_menus.php` | 总后台菜单:入股配置、分成方案申请、代理充值记录、会员盈亏日报,以及保存/审核按钮。代理菜单挂在标题含「代理」的一级菜单下;找不到时挂到根节点,需要手动调整 |
+
+`2026_11_07_120000_agent_joint_venture_and_equity.php` 的内容:
+
+- **`agents`**:新增 `type`(1 合营,默认 / 2 入股)、`margin_amount`、`realtime_balance`(初始值等于 `balance`)、`profit_active`、`equity_percent`。原来的二级代理全部改成独立的一级代理。
+- **盈亏方案及其历史快照**:`lottery_rate` 改名为 `f1_lottery_rate`;新增 `f1_sport_rate`,初始值复制自 `sport_rate`;新增 `margin_amount`。快照的 `effective_from` 改为 datetime。删除代理自建的方案,用了这些方案的代理改为未分配。
+- **删除流水佣金**:删除 `agent_flow_commission_*` 两张表、`agents.flow_commission_profile_id`、`agent_rebate_records.flow_*`。同时删除 `agent_daily_game_stats`。
+- **新表**:`agent_settings`、`agent_wallets`、`agent_recharges`、`agent_equity_orders`、`agent_plan_applications`、`agent_profit_entries`、`member_daily_reports`。
+- **`agent_withdrawals`**:新增 `usdt_amount`、`actual_usdt_amount`、`exchange_rate`。
+- **`share_policies.rules`**:去掉 `third:3`(返佣改为 8 类)。
+
+代理模块还没上线,删除的结构在 `down()` 里只重建空表,数据不恢复。
+
+## 定时任务
+
+已经写在 `app/Console/Kernel.php`,不需要新增 crontab:
+
+| 任务 | 频率 | 作用 |
+|---|---|---|
+| `agent:profit-sync` | 每分钟 | 读取 `share_bet_facts` 中新结算或有变化的注单,逐笔计算合营分成,更新实时余额 |
+| `agent:settle` | 每天 04:10 | 把前一天及更早尚未入账的分成,按分类汇总入账到代理余额 |
+| `report:member-daily` | 每 10 分钟 | 刷新当天的会员盈亏日报 |
+| `report:member-daily {昨天} --days=3` | 每天 04:30 | 重算前 3 天,补上迟到的结算 |
+
+代理的 USDT 自动到账复用现有的外部 cron `GET /admin/sync/recharge`:会员同步完成后会接着同步代理。另外可以用 `php artisan agent:recharge-sync {agent_id?}` 手动补单。代理充值地址的私钥存放在 `agent_wallets`,归集任务(`/admin/sync/collect`)在 `wallets` 里找不到地址时会回退查这张表。
+
+## 实时保证金原理
+
+- `agent_profit_entries` 每条已结算注单对应一行(唯一键 `fact_id + revision`)。
+  - 代理处于分成中:`share = −会员盈亏 × 比例 / 100`,立即加到 `realtime_balance` 上。亏损最多扣到实时余额为 0。
+  - 代理未在分成:记为 `skipped_inactive`,不产生分成。
+- 开关规则(`AgentWalletService::evaluateActivation`):
+  - 实时有效保证金 = min(`margin_amount`, `realtime_balance`)。
+  - 有效保证金 ≥ 方案保证金时开启分成。
+  - 开启后,有效保证金低于方案保证金 × `agent_settings.margin_stop_percent`%(默认 10%)时关闭。
+  - 以下操作之后会重新判断:充值、人工调整、方案审批、提现驳回、每笔分成。
+- 注单改派彩或作废:
+  - 还没入账:原地修正,并按差额调整实时余额。
+  - 已经入账:追加一条 `revision+1` 的冲正记录,进入下一次入账批次。
+- 每天入账只改 `balance`,因为实时余额在注单结算时已经计入。所以始终满足:`realtime_balance = balance + 未入账的分成`。
+- 合营的可提现金额 = `balance` + 待入账分成按日累加的最低点 − `margin_amount`,最小为 0。这样当天还没入账的亏损不会被提走。
+
+## 已知口径
+
+- 分成和日报都基于 `share_bet_facts`,和返佣一致。旧的 PC28 `bets` 和 `jisu_game_order` 不参与分成。
+- 三方彩票(`third:3`)目前没有游戏,返佣和分成都不计算。以后接入时,在 `ShareDefaults::commissionCategories()` 和 `AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS` 里加回这个分类。
+- 入股的分红不自动发放,由财务按会员盈亏日报计算,再通过 `POST /admin/agent/adjust`(`credit`)发放。
+
+## 本地验证
+
+```bash
+php -d xdebug.mode=off -d error_reporting=24575 vendor/bin/phpunit --no-configuration --bootstrap tests/bootstrap-agent.php \
+  tests/Integration/AgentJointVentureTest.php tests/Integration/AgentApiRegressionTest.php tests/Integration/AgentApplicationTest.php
+```
+
+这些测试使用隔离的 SQLite,会把旧结构(流水佣金、二级代理)真实迁移到新结构后再验证。

+ 274 - 0
docs/总后台/代理合营与入股-前端接入.md

@@ -0,0 +1,274 @@
+# 总后台-代理合营与入股调整(前端接入)
+
+给总后台前端看。
+
+所有接口沿用总后台登录态,返回结构不变:`{"code":0,"timestamp":…,"msg":"OK","data":…}`。`/admin/agent/*` 仍然只对 `AGENT_ADMIN_IDS` 里的管理员开放。金额都是 4 位小数的字符串,比例单位是 %。
+
+## 改动一览
+
+| 页面 | 改动 |
+|---|---|
+| 代理列表 | 新增「代理类型」列和筛选(合营/入股),新增保证金、可提现、持股比例等列;去掉上级代理、下级数量 |
+| 新增/编辑代理 | 去掉 4 个字段:上级代理ID、绑定会员ID、流水佣金方案、盈亏佣金方案;新增「代理类型」 |
+| 盈亏佣金比例 `/admin/agent/profit-commission-profile` | 新增「保证金」;分类从 7 个改为 8 个,和邀请返佣一致 |
+| 流水佣金比例 | **整页删除**,接口已移除 |
+| 入股配置(新菜单) | 设置每 1% 股份的价格、可购买区间,以及合营停止分成的阈值 |
+| 分成方案申请(新菜单) | 审核合营代理提交的方案申请 |
+| 代理充值记录(新菜单) | 代理 USDT 充值列表;人工确认模式下审核凭证 |
+| 代理返佣记录 | 改为「合营分成记录」:只有盈亏分成,金额可为负;新增分成注单明细 |
+| 代理提现列表 | 仅 USDT,新增 USDT 数量和汇率 |
+| 会员列表 → 会员盈亏日报(新菜单) | 每个会员每天一行,可按代理和代理类型筛选 |
+| 返佣配置(分享) | 游戏分类改为 8 类,去掉「彩票(三方)」 |
+
+新菜单已经由迁移写入 `menus` 表,uri 分别是 `agentSettings`、`agentPlanApplications`、`agentRecharges`、`memberDailyReport`。按钮权限有 `admin/agent/settings/save`、`admin/agent/plan-applications/audit`、`admin/agent/recharges/audit`。
+
+## 业务规则(页面文案需要用到)
+
+- **合营**:按游戏分类的比例和会员双向分成。会员输 100、比例 10%,代理得 10;会员赢 100,代理亏 10。
+  - 代理充值的金额**全部计入保证金,不能提现**。总余额 = 保证金 + 盈亏;盈亏为负时等于从保证金扣除,之后的盈利先补足保证金,只有超出保证金的部分可以提现。
+  - 方案要求的保证金交足(≥100%)才开始分成。分成期间,实时保证金低于「方案保证金 × 停止比例」(默认 10%)后,之后的注单不再分成,需要再充值到方案保证金的 100% 才恢复。
+  - 实时保证金由系统按注单实时计算,**只有总后台能看到**(字段 `realtime_balance` / `realtime_margin`),代理端不展示。
+  - 分成逐笔实时计算,每天 04:10 把前一天的分成入账到余额。
+- **入股**:代理在独立后台自己选购买比例。充值到账后自动扣款开通,不需要审核。分红每月由财务按报表人工计算,再通过「人工加款」发放,系统不自动发钱。
+- 二级代理已取消,所有代理都是一级。
+
+## 1. 代理列表
+
+### `GET /admin/agent`
+
+新增筛选参数 `type`:`1` 合营,`2` 入股。参数 `parent_id` 已删除,其余参数不变。
+
+列表新增字段:
+
+| 字段 | 说明 |
+|---|---|
+| `type` / `type_text` | `1` 合营 / `2` 入股 |
+| `balance` | 总余额 |
+| `frozen_balance` | 提现冻结中 |
+| `withdrawable_amount` | 当前可提现 |
+| `margin_amount` | 合营:累计保证金本金(充值合计) |
+| `margin_balance` | 合营:当前保证金余额 = min(本金, 总余额) |
+| `profit_amount` | 合营:盈亏 = 总余额 − 本金,可以为负 |
+| `equity_percent` | 入股:已持股比例(%) |
+| `profit_commission_profile` | 合营:当前方案(含保证金和 8 个比例),可能为 `null` |
+| `profit_active` | 合营:`1` 分成中,`0` 未分成(没有方案或保证金不足) |
+| `realtime_balance` | 实时余额 = 总余额 + 尚未入账的分成。**仅总后台展示** |
+| `realtime_margin` | 实时保证金 = min(本金, 实时余额)。**仅总后台展示**;入股代理为 `null` |
+
+已删除的字段:`parent`、`parent_id`、`children_count`、`flow_commission_profile(_id)`、`level_text`、`can_create_sub_agent`。
+
+## 2. 新增/编辑代理
+
+### `POST /admin/agent/create`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `type` | integer | 是 | `1` 合营,`2` 入股 |
+| `username` | string | 是 | 4~64,字母数字下划线 |
+| `password` | string | 是 | 6~100 |
+| `withdraw_password` | string | 否 | 提现密码 |
+| `real_name` | string | 否 | |
+| `language` | string | 否 | `zh`/`en`/`vi` |
+| `withdraw_fee_rate` | decimal | 否 | 提现手续费 % |
+| `status` | integer | 否 | `1` 启用 `0` 禁用 |
+| `initial_balance` | decimal | 否 | 初始额度,按「人工加款」入账,不计入保证金 |
+
+**请从表单中移除** `parent_id`、`owner_user_id`、`flow_commission_profile_id`、`profit_commission_profile_id`。这些字段即使传了,后端也会忽略。合营方案改为由代理自己申请(见第 5 节)。
+
+### `POST /admin/agent/update`
+
+参数同上,外加 `id`,且都可以不传。`type` 只有在代理余额、冻结、保证金、持股都为 0,并且没有方案、没有待付款的入股订单时才能修改,否则返回「代理已有余额、保证金、方案或股份,不能修改类型」。
+
+## 3. 资金调整
+
+### `POST /admin/agent/adjust`
+
+`type` 新增两个值:
+
+| type | 说明 |
+|---|---|
+| `credit` | 人工加款。入股分红也用这个 |
+| `debit` | 人工扣款 |
+| `margin_credit` | 人工增加保证金:总余额和保证金本金同时增加 |
+| `margin_refund` | 退还保证金:总余额和保证金本金同时减少,不能超过本金 |
+
+## 4. 盈亏佣金比例(合营方案)
+
+### `GET /admin/agent/profit-commission-profiles`
+
+参数:`page`、`limit`、`name`。每条记录返回 `margin_amount`、8 个比例和 `agents_count`(正在使用该方案的代理数)。`is_default` 已不再使用,页面可以去掉。
+
+### `POST /admin/agent/profit-commission-profiles`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 修改时是 | |
+| `name` | string | 是 | 唯一 |
+| `margin_amount` | decimal | 是 | **保证金**,≥0。合营代理的保证金达到该值才开始分成 |
+| `f1_lottery_rate` | decimal | 是 | F1彩票,0~100 |
+| `f1_sport_rate` | decimal | 是 | F1体育 |
+| `live_rate` | decimal | 是 | 真人 |
+| `slot_rate` | decimal | 是 | 电子 |
+| `sport_rate` | decimal | 是 | 体育(三方) |
+| `esports_rate` | decimal | 是 | 电竞 |
+| `fishing_rate` | decimal | 是 | 捕鱼 |
+| `chess_rate` | decimal | 是 | 棋牌 |
+
+`lottery_rate` 已改名为 `f1_lottery_rate`。没有三方彩票,以后接入时再加。
+
+修改已在使用的方案后,新比例从保存时刻起生效;同时会按新的保证金重新判断这些代理是否继续分成。
+
+### `POST /admin/agent/profit-commission-profiles/delete`
+
+参数 `id`。仍有代理在使用、或有待审核申请的方案不能删除。
+
+### 已删除
+
+`GET/POST /admin/agent/flow-commission-profiles`、`POST /admin/agent/flow-commission-profiles/delete`。
+
+## 5. 分成方案申请
+
+合营代理在独立后台选择方案并提交申请,这里审核。审核通过后立即生效;如果代理保证金已经达到方案要求,就开始分成。
+
+### `GET /admin/agent/plan-applications`
+
+参数:`page`、`limit`、`agent_id`、`username`(代理账号模糊查询)、`status`(`pending`/`approved`/`rejected`)。
+
+```json
+{"total":1,"page":1,"limit":20,"list":[{
+  "id":3,"agent_id":12,"profile_id":2,"profile_name":"合营A","status":"pending","status_text":"待审核",
+  "remark":"","reject_reason":"","reviewed_by":null,"reviewed_at":null,"created_at":"2026-11-07 12:00:00",
+  "agent":{"id":12,"username":"agent12","real_name":"","balance":"20000.0000","margin_amount":"20000.0000"},
+  "profile":{"id":2,"name":"合营A","margin_amount":"20000.0000","f1_lottery_rate":"10.0000","…":"…"}
+}]}
+```
+
+### `POST /admin/agent/plan-applications/audit`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 申请 ID |
+| `status` | 是 | `approved` / `rejected` |
+| `reject_reason` | 驳回时是 | 驳回原因 |
+
+## 6. 入股配置与停止分成阈值
+
+### `GET /admin/agent/settings`
+
+```json
+{"id":1,"margin_stop_percent":"10.0000","equity_enabled":1,"equity_price_per_percent":"100000.0000",
+ "equity_min_percent":"0.1000","equity_max_percent":"1.0000","updated_by":1,"updated_at":"…"}
+```
+
+### `POST /admin/agent/settings`
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `margin_stop_percent` | 是 | 合营停止分成阈值,占方案保证金的 %,默认 10 |
+| `equity_enabled` | 是 | 是否开放入股购买 |
+| `equity_price_per_percent` | 是 | 每 1% 股份的价格(余额单位) |
+| `equity_min_percent` | 是 | 单次最少购买比例,例如 0.1 |
+| `equity_max_percent` | 是 | 单次最多购买比例,同时也是每个代理累计持股上限,例如 1 |
+
+## 7. 代理充值记录
+
+代理充值沿用会员 USDT 的充值方式(`receiving_type`):
+
+- **自动到账**:每个代理一个固定 TRC20 地址,链上确认后自动入账,复用现有的 `/admin/sync/recharge` 定时任务。
+- **人工确认**:代理上传转账凭证,在这里审核。
+
+入账金额 = USDT 数量 × `exchange_rate_rmb`。代理充值不写入会员充值表,不计入任何会员统计。
+
+### `GET /admin/agent/recharges`
+
+参数:`page`、`limit`、`agent_id`、`order_no`、`txid`、`type`(`1` 自动 / `2` 凭证)、`status`(`0` 待确认 / `1` 已到账 / `2` 已驳回 / `3` 已忽略)、`start_date`、`end_date`。
+
+记录字段:`order_no`、`agent`(`id`/`username`/`real_name`/`type`)、`type`、`txid`、`net`、`from_address`、`to_address`、`usdt_amount`、`exchange_rate`、`amount`(入账金额)、`image`、`status`、`status_text`、`confirmations`、`remark`、`audit_admin_id`、`confirmed_at`、`created_at`。
+
+### `POST /admin/agent/recharges/audit`
+
+只能审核 `type=2` 且 `status=0` 的记录。
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `id` | 是 | 记录 ID |
+| `status` | 是 | `1` 通过入账,`2` 驳回 |
+| `remark` | 驳回时是 | 驳回原因 |
+
+## 8. 合营分成记录(原代理返佣记录)
+
+### `GET /admin/agent/commissions`
+
+筛选参数:`agent_id`、`order_no`、`username`、`category`(分类 key)、`profit_status`(`pending`/`credited`)、`settlement_date`、`start_date`、`end_date`。`flow_status` 已删除。
+
+每条记录是「某代理在某个入账批次的某一分类」的汇总:
+
+| 字段 | 说明 |
+|---|---|
+| `settlement_date` | 入账批次日期(每天 04:10 入账前一天及更早未入账的分成) |
+| `category` / `category_text` | 分类 key / 名称 |
+| `bet_count` / `entry_count` | 注单数 / 账本条数(含改单冲正) |
+| `bet_amount` / `valid_bet_amount` | 投注额 / 有效投注 |
+| `win_loss` | 会员输赢(会员视角,正为会员赢) |
+| `profit_rate` | 比例 % |
+| `profit_commission` | 代理分成,**可为负** |
+| `profit_status` / `profit_status_text` | `pending` 待入账 / `credited` 已入账 |
+
+已删除:`flow_rate`、`flow_commission`、`flow_status`、`flow_status_text`、`game_type_text`。
+
+### `GET /admin/agent/profit-entries`(新增,分成注单明细)
+
+参数:`page`、`limit`、`agent_id`、`user_id`、`category`、`status`、`start_date`、`end_date`(按结算日)。
+
+| 字段 | 说明 |
+|---|---|
+| `fact_id` / `revision` | 注单事实 ID / 改单序号(0 为原单) |
+| `user_id`、`category`、`settled_at`、`settle_date` | 会员、分类、结算时间 |
+| `net_profit` | 会员盈亏 |
+| `rate`、`share_amount` | 比例、代理分成 |
+| `status` | `counted` 已计入;`skipped_inactive` 保证金不足未分成;`skipped_no_plan` 没有方案;`skipped_category` 分类不参与 |
+| `realtime_balance_after` | 计入后的实时余额(仅总后台) |
+| `rebate_record_id` | 已入账时对应的分成记录 ID;为空表示还没入账 |
+
+## 9. 代理提现
+
+`GET /admin/agent/withdrawals` 新增字段:`usdt_amount`(申请的 USDT 数量)、`actual_usdt_amount`(扣手续费后实际打款的 USDT)、`exchange_rate`(提现汇率 = 汇率 + 汇率差)。`amount`、`fee`、`actual_amount` 仍是余额单位。审核接口不变,打款仍然人工完成。
+
+## 10. 独立代理申请审核
+
+`POST /admin/agent/applicationAudit` 删除了 `flow_commission_profile_id`、`profit_commission_profile_id` 两个参数,新增可选的 `type`(默认 `1` 合营)。通过后代理没有方案,需要代理自己去申请。
+
+## 11. 会员盈亏日报
+
+### `GET /admin/user/daily-report`
+
+每个会员每天一行。系统每 10 分钟刷新当天的数据,每天 04:30 重算前 3 天,用来补上迟到的结算。
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `start_date` / `end_date` | Y-m-d | 否 | 最近 7 天 | 跨度不超过 92 天 |
+| `member_id` | string | 否 | | 会员编号 |
+| `username` | string | 否 | | 用户名模糊查询 |
+| `agent_id` | integer | 否 | | 所属独立代理 |
+| `agent_type` | integer | 否 | | `1` 合营 / `2` 入股 |
+| `page` / `limit` | integer | 否 | 1 / 20 | |
+
+```json
+{"total":1,"page":1,"limit":20,
+ "totals":{"bet_count":2,"deposit_amount":"300.0000","withdraw_amount":"0.0000","manual_credit":"0.0000","manual_debit":"0.0000",
+   "bonus_amount":"0.0000","rebate_amount":"0.0000","bet_amount":"200.0000","valid_bet_amount":"200.0000","win_loss":"-60.0000","agent_share":"10.0000"},
+ "list":[{"id":1,"report_date":"2026-09-07","user_id":5,"member_id":"10005","username":"u5","first_name":"",
+   "agent_id":12,"agent_username":"agent12","agent_type":1,
+   "deposit_amount":"300.0000","withdraw_amount":"0.0000","manual_credit":"0.0000","manual_debit":"0.0000",
+   "bonus_amount":"0.0000","rebate_amount":"0.0000","bet_count":2,"bet_amount":"200.0000","valid_bet_amount":"200.0000",
+   "win_loss":"-60.0000","agent_share":"10.0000",
+   "categories":{"self:lottery":{"name":"F1彩票","bet_count":1,"bet_amount":"100.0000","valid_bet_amount":"100.0000","win_loss":"-100.0000"}},
+   "updated_at":"2026-09-07 23:59:00"}]}
+```
+
+字段口径:
+
+- `win_loss` 是会员视角,正为会员赢,按结算时间归日。
+- `agent_share` 是当天合营代理从该会员拿到的分成,正为代理收入。
+- 充值、提现、人工加扣款、优惠、返水的口径和代理报表一致。
+- 注单来自返佣用的注单事实表,不含旧 PC28 和极速游戏。

+ 2 - 0
docs/总后台/代理流水佣金比例-前端变更说明.md

@@ -1,5 +1,7 @@
 # 代理流水佣金比例-前端变更说明
 
+> **已废弃(2026-11-07)**:二级代理、流水佣金、代理自建方案已取消,本文接口已删除。新接口见 [代理后台调整-前端接入](../代理/代理后台调整-前端接入.md) 和 [代理合营与入股-前端接入](../总后台/代理合营与入股-前端接入.md)。
+
 ## 1. 页面逻辑变化
 
 原来是给单个代理填写一个 `flow_rate`,现在改为先维护“流水佣金方案”。每套方案包含:真人、老虎机、彩票、体育、电竞、捕鱼、棋牌七个比例。

+ 2 - 0
docs/总后台/代理流水佣金比例.md

@@ -1,5 +1,7 @@
 # 总后台-代理流水佣金比例
 
+> **已废弃(2026-11-07)**:二级代理、流水佣金、代理自建方案已取消,本文接口已删除。新接口见 [代理后台调整-前端接入](../代理/代理后台调整-前端接入.md) 和 [代理合营与入股-前端接入](../总后台/代理合营与入股-前端接入.md)。
+
 该菜单维护可复用的官方“流水佣金方案”,不是直接修改某个代理的一条 `flow_rate`。一级代理给二级代理配置的自建方案不出现在此列表,由代理后台维护。
 
 每个代理通过 `flow_commission_profile_id` 关联一套方案;新增代理时前端必须使用 Select 选择方案。需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。

+ 8 - 3
routes/admin.php

@@ -168,12 +168,16 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/withdrawals/audit', [AgentAdmin::class, 'auditWithdrawal']);
             Route::get('/reports', [AgentAdmin::class, 'reports']);
             Route::get('/commissions', [AgentAdmin::class, 'commissions']);
-            Route::get('/flow-commission-profiles', [AgentAdmin::class, 'flowCommissionProfiles']);
-            Route::post('/flow-commission-profiles', [AgentAdmin::class, 'saveFlowCommissionProfile']);
-            Route::post('/flow-commission-profiles/delete', [AgentAdmin::class, 'deleteFlowCommissionProfile']);
             Route::get('/profit-commission-profiles', [AgentAdmin::class, 'profitCommissionProfiles']);
             Route::post('/profit-commission-profiles', [AgentAdmin::class, 'saveProfitCommissionProfile']);
             Route::post('/profit-commission-profiles/delete', [AgentAdmin::class, 'deleteProfitCommissionProfile']);
+            Route::get('/settings', [AgentAdmin::class, 'settings'])->defaults('permission_uri', 'admin/agent/settings');
+            Route::post('/settings', [AgentAdmin::class, 'saveSettings'])->defaults('permission_uri', 'admin/agent/settings/save');
+            Route::get('/plan-applications', [AgentAdmin::class, 'planApplications'])->defaults('permission_uri', 'admin/agent/plan-applications');
+            Route::post('/plan-applications/audit', [AgentAdmin::class, 'auditPlanApplication'])->defaults('permission_uri', 'admin/agent/plan-applications/audit');
+            Route::get('/recharges', [AgentAdmin::class, 'recharges'])->defaults('permission_uri', 'admin/agent/recharges');
+            Route::post('/recharges/audit', [AgentAdmin::class, 'auditRecharge'])->defaults('permission_uri', 'admin/agent/recharges/audit');
+            Route::get('/profit-entries', [AgentAdmin::class, 'profitEntries'])->defaults('permission_uri', 'admin/agent/commissions');
         });
 
         Route::get('/menu/mineMenu', [Menu::class, 'mineMenu']); // 我的菜单
@@ -464,6 +468,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/setPassword', [User::class, 'setPassword']);
             Route::post('/freeze', [WalletFreeze::class, 'freeze']);
             Route::post('/unfreeze', [WalletFreeze::class, 'unfreeze']);
+            Route::get('/daily-report', [\App\Http\Controllers\admin\MemberDailyReport::class, 'index']);
             Route::get('/thirdGame/detail', [User::class, 'thirdGameDetail']);
             Route::post('/thirdGame/recycle', [User::class, 'recycleThirdGameBalance']);
             Route::get('/thirdGame/recycleRecords', [User::class, 'thirdGameRecycleRecords']);

+ 14 - 12
routes/agent.php

@@ -7,7 +7,7 @@ use App\Http\Controllers\agent\MessageController;
 use App\Http\Controllers\agent\ProfileController;
 use App\Http\Controllers\agent\ReferralController;
 use App\Http\Controllers\agent\ReportController;
-use App\Http\Controllers\agent\SubAgentController;
+use App\Http\Controllers\agent\EquityController;
 use App\Http\Controllers\agent\WalletController;
 use Illuminate\Support\Facades\Route;
 
@@ -28,16 +28,16 @@ Route::middleware('agent.auth')->group(function () {
     Route::get('/profile/login-logs', [ProfileController::class, 'loginLogs']);
     Route::get('/dashboard', [ProfileController::class, 'dashboard']);
 
-    Route::get('/sub-agents', [SubAgentController::class, 'index']);
-    Route::post('/sub-agents', [SubAgentController::class, 'store']);
-    Route::post('/sub-agents/update', [SubAgentController::class, 'update']);
-    Route::post('/sub-agents/transfer', [SubAgentController::class, 'transfer']);
-
     Route::get('/members', [MemberController::class, 'index']);
     Route::get('/members/finance', [MemberController::class, 'finance']);
     Route::get('/members/bets', [MemberController::class, 'bets']);
+    Route::get('/members/daily-report', [MemberController::class, 'dailyReport']);
 
+    Route::get('/wallet/summary', [WalletController::class, 'summary']);
     Route::get('/wallet/logs', [WalletController::class, 'logs']);
+    Route::get('/wallet/recharge/address', [WalletController::class, 'rechargeAddress']);
+    Route::post('/wallet/recharge', [WalletController::class, 'submitRecharge']);
+    Route::get('/wallet/recharges', [WalletController::class, 'recharges']);
     Route::get('/wallet/accounts', [WalletController::class, 'accounts']);
     Route::post('/wallet/accounts', [WalletController::class, 'saveAccount']);
     Route::post('/wallet/accounts/delete', [WalletController::class, 'deleteAccount']);
@@ -45,11 +45,13 @@ Route::middleware('agent.auth')->group(function () {
     Route::post('/wallet/withdrawals', [WalletController::class, 'requestWithdrawal']);
 
     Route::get('/commissions', [CommissionController::class, 'records']);
-    Route::get('/flow-commission-profiles', [CommissionController::class, 'flowProfiles']);
-    Route::post('/flow-commission-profiles', [CommissionController::class, 'saveFlowCommissionProfile']);
-    Route::post('/flow-commission-profiles/delete', [CommissionController::class, 'deleteFlowCommissionProfile']);
-    Route::get('/profit-commission-profiles', [CommissionController::class, 'profitProfiles']);
-    Route::post('/profit-commission-profiles', [CommissionController::class, 'saveProfitCommissionProfile']);
-    Route::post('/profit-commission-profiles/delete', [CommissionController::class, 'deleteProfitCommissionProfile']);
+    Route::get('/commissions/entries', [CommissionController::class, 'entries']);
+    Route::get('/profit-plans', [CommissionController::class, 'plans']);
+    Route::post('/profit-plans/apply', [CommissionController::class, 'applyPlan']);
+    Route::get('/profit-plans/applications', [CommissionController::class, 'planApplications']);
+
+    Route::get('/equity', [EquityController::class, 'index']);
+    Route::post('/equity/orders', [EquityController::class, 'order']);
+    Route::post('/equity/orders/cancel', [EquityController::class, 'cancel']);
     Route::get('/reports', [ReportController::class, 'index']);
 });

+ 63 - 0
tests/Integration/ActivityCatalogTest.php

@@ -121,6 +121,69 @@ class ActivityCatalogTest extends TestCase
         return DB::table('activity_rewards')->insertGetId($data);
     }
 
+    public function test_ordinary_activities_return_and_save_all_platforms(): void
+    {
+        $platforms = ['PC', 'H5', 'APP'];
+        foreach ([null, [], ['H5'], ['APP']] as $legacyPlatforms) {
+            $this->rawActivity(['type' => 2, 'publish_platforms' => $legacyPlatforms]);
+        }
+        $before = DB::table('activity_rewards')->orderBy('id')->get()->toJson();
+        foreach ([[], ['platform' => 'pc'], ['platform' => 'h5']] as $filters) {
+            $page = $this->listing($filters);
+            $this->assertSame(4, $page['total']);
+            foreach ($page['data'] as $row) {
+                $this->assertSame($platforms, $row['publish_platforms']);
+                $this->assertSame('全部平台', $row['platform_label']);
+            }
+        }
+        $this->assertSame($before, DB::table('activity_rewards')->orderBy('id')->get()->toJson());
+
+        foreach ([[], ['publish_platforms' => ['H5']]] as $config) {
+            $result = $this->save(['type' => 2] + $config);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $id = DB::table('activity_rewards')->latest('id')->value('id');
+            $this->assertSame($platforms, json_decode(DB::table('activity_rewards')->where('id', $id)->value('publish_platforms'), true));
+            $result = $this->save(['id' => $id, 'type' => 2, 'title' => '编辑普通活动']);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $this->assertSame($platforms, ActivityReward::findOrFail($id)->publish_platforms);
+        }
+
+        $restricted = $this->rawActivity(['type' => 3, 'publish_platforms' => ['H5']]);
+        $this->assertSame(['H5'], ActivityReward::findOrFail($restricted)->publish_platforms);
+        $this->assertSame('H5', ActivityReward::findOrFail($restricted)->platform_label);
+        $this->assertNotContains($restricted, array_column($this->listing(['platform' => 'pc'])['data'], 'id'));
+        $unrestricted = $this->rawActivity(['type' => 3, 'publish_platforms' => []]);
+        $this->assertSame('全部平台', ActivityReward::findOrFail($unrestricted)->platform_label);
+    }
+
+    public function test_ordinary_platform_migration_normalizes_only_type_two_and_is_repeatable(): void
+    {
+        $ids = [];
+        foreach ([null, [], ['H5'], ['APP'], ['PC', 'H5', 'APP']] as $platforms) {
+            $ids[] = $this->rawActivity(['type' => 2, 'publish_platforms' => $platforms]);
+        }
+        foreach ([1, 3, 4, 5, 6] as $type) {
+            $this->rawActivity(['type' => $type, 'publish_platforms' => ['H5']]);
+        }
+        $others = DB::table('activity_rewards')->where('type', '!=', 2)->orderBy('id')->get()->toJson();
+        $ordinary = DB::table('activity_rewards')->where('type', 2)->orderBy('id')->get()->map(function ($row) {
+            unset($row->publish_platforms);
+            return $row;
+        })->toJson();
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_150000_normalize_ordinary_activity_platforms.php';
+        foreach ([1, 2] as $run) {
+            $migration->up();
+            foreach ($ids as $id) {
+                $this->assertSame(['PC', 'H5', 'APP'], json_decode(DB::table('activity_rewards')->where('id', $id)->value('publish_platforms'), true));
+            }
+            $this->assertSame($others, DB::table('activity_rewards')->where('type', '!=', 2)->orderBy('id')->get()->toJson());
+            $this->assertSame($ordinary, DB::table('activity_rewards')->where('type', 2)->orderBy('id')->get()->map(function ($row) {
+                unset($row->publish_platforms);
+                return $row;
+            })->toJson());
+        }
+    }
+
     public function test_expiry_updates_only_ended_published_activities_and_is_repeatable(): void
     {
         Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));

+ 3 - 2
tests/Integration/ActivityRewardEligibilityTest.php

@@ -245,7 +245,8 @@ class ActivityRewardEligibilityTest extends TestCase
             $this->assertSame($expected, array_column($result['data'], 'id'));
             $this->assertNotContains($rows['app']->id, array_column($result['data'], 'id'));
             $football = array_column($result['data'], null, 'id')[$rows['football']->id];
-            $this->assertSame(['APP'], $football['publish_platforms']);
+            $this->assertSame(['PC', 'H5', 'APP'], $football['publish_platforms']);
+            $this->assertSame('全部平台', $football['platform_label']);
         }
         $this->assertSame(7, $this->adminList()['total']);
     }
@@ -472,7 +473,7 @@ class ActivityRewardEligibilityTest extends TestCase
             'show_ineligible' => 'invalid',
         ];
         $football = $this->activity(['type' => 2] + $invalidFields);
-        $this->assertSame([], $football->publish_platforms);
+        $this->assertSame(['PC', 'H5', 'APP'], $football->publish_platforms);
         $this->assertSame([], $football->register_platforms);
         $this->assertFalse((bool)$football->register_time_limited);
         $this->assertFalse((bool)$football->deposit_limited);

+ 13 - 221
tests/Integration/AgentApiRegressionTest.php

@@ -7,15 +7,12 @@ use App\Http\Controllers\AgentApiController;
 use App\Http\Middleware\AgentAuthMiddleware;
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentBalanceLog;
-use App\Models\Agent\AgentDailyGameStat;
-use App\Models\Agent\AgentFlowCommissionProfile;
 use App\Models\Agent\AgentLoginLog;
 use App\Models\Agent\AgentRebateRecord;
 use App\Models\Agent\AgentToken;
 use App\Models\Agent\AgentWithdrawAccount;
 use App\Models\Agent\AgentWithdrawal;
 use App\Services\Agent\AgentCommissionService;
-use App\Services\Agent\AgentFlowCommissionAssignmentService;
 use App\Services\Agent\AgentMemberService;
 use App\Services\Agent\AgentProfitCommissionAssignmentService;
 use App\Services\Agent\AgentReportService;
@@ -41,109 +38,11 @@ use Illuminate\Translation\ArrayLoader;
 use Illuminate\Translation\Translator;
 use Illuminate\Validation\Factory;
 use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
 
 class AgentApiRegressionTest extends TestCase
 {
-    private Application $app;
-    private AgentService $agents;
-
-    protected function setUp(): void
-    {
-        parent::setUp();
-        Carbon::setTestNow(Carbon::parse('2026-09-07 12:00:00'));
-        Facade::clearResolvedInstances();
-        $this->app = new Application(dirname(__DIR__, 2));
-        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh'], 'agent' => []]));
-        Facade::setFacadeApplication($this->app);
-        $db = new Manager($this->app);
-        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
-        $db->setEventDispatcher(new Dispatcher($this->app));
-        $db->setAsGlobal();
-        $db->bootEloquent();
-        $this->app->instance('db', $db->getDatabaseManager());
-        $this->app->bind('db.schema', fn () => $db->schema());
-        $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
-        $translator = new Translator(new ArrayLoader(), 'zh');
-        $this->app->instance('translator', $translator);
-        $this->app->instance('validator', new Factory($translator, $this->app));
-        $this->app->instance('request', Request::create('/'));
-        Request::macro('validate', function (array $rules) {
-            return app('validator')->make($this->all(), $rules)->validate();
-        });
-        $responses = new \Tests\Support\JsonResponseFactory();
-        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, $responses);
-        Schema::create('users', function (Blueprint $t) {
-            $t->id(); $t->string('member_id'); $t->string('username'); $t->string('first_name')->default('');
-            $t->integer('status')->default(1); $t->string('register_ip')->default('');
-            $t->string('register_domain')->default(''); $t->timestamps();
-        });
-        // The profit migration now inspects MySQL index metadata before creating
-        // indexes. Keep the business schema real while providing empty metadata.
-        DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
-        DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
-        DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)');
-        foreach (['2026_08_24_140000_create_agent_system_tables.php',
-            '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
-            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
-        }
-        // Flow migration repairs MySQL information_schema indexes; use equivalent SQLite test tables.
-        Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
-            $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
-        });
-        Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
-            $t->id(); $t->unsignedBigInteger('agent_id'); $t->unsignedBigInteger('flow_commission_profile_id');
-            $t->string('profile_name'); $t->date('effective_from'); $t->unsignedBigInteger('created_by')->nullable();
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
-            $t->timestamps(); $t->unique(['agent_id', 'effective_from']);
-        });
-        Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable());
-        DB::table('agent_flow_commission_profiles')->insert(['name' => 'Default', 'is_default' => 1]);
-        foreach (['agent_flow_commission_profiles', 'agent_profit_commission_profiles'] as $table) {
-            Schema::table($table, fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
-        }
-        foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
-            Schema::create($table, function (Blueprint $t) {
-                $t->id(); $t->string('member_id'); $t->integer('status'); $t->string('type')->nullable();
-                $t->decimal('amount', 18, 4); $t->timestamps();
-            });
-        }
-        Schema::create('wallets', function (Blueprint $t) {
-            $t->id(); $t->string('member_id'); $t->decimal('available_balance', 18, 4)->default(0);
-            $t->decimal('frozen_balance', 18, 4)->default(0);
-        });
-        Schema::create('balance_logs', function (Blueprint $t) {
-            $t->id(); $t->string('member_id'); $t->string('change_type');
-            $t->integer('related_id')->nullable(); $t->decimal('amount', 18, 4); $t->timestamps();
-        });
-        $this->agents = new AgentService(new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
-    }
-
-    protected function tearDown(): void
-    {
-        DB::disconnect();
-        Facade::clearResolvedInstances();
-        Carbon::setTestNow();
-        parent::tearDown();
-    }
-
-    private function agent(?Agent $parent = null): Agent
-    {
-        static $number = 0;
-        return $this->agents->create([
-            'username' => 'agent' . ++$number, 'password' => 'old-password',
-            'withdraw_password' => 'withdraw-old',
-            'flow_commission_profile_id' => AgentFlowCommissionProfile::officialDefaultId(),
-        ], null, $parent);
-    }
-
-    private function token(Agent $agent): string
-    {
-        $plain = 'test-token-' . $agent->id;
-        AgentToken::query()->create(['agent_id' => $agent->id, 'token_hash' => hash('sha256', $plain),
-            'expires_at' => now()->addDay(), 'last_used_at' => now(), 'ip' => '', 'device' => 'pc']);
-        return $plain;
-    }
+    use AgentTestEnvironment;
 
     public function test_admin_password_reset_revokes_old_login(): void
     {
@@ -207,8 +106,9 @@ class AgentApiRegressionTest extends TestCase
 
     private function fundedAccount(Agent $agent): AgentWithdrawAccount
     {
-        Agent::query()->whereKey($agent->id)->update(['balance' => 100]);
-        return AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'bank', 'account' => 'test-account', 'status' => 1]);
+        Agent::query()->whereKey($agent->id)->update(['balance' => 100, 'realtime_balance' => 100]);
+        return AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
+            'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
     }
 
     public function test_withdrawal_uses_current_password_not_stale_request_model(): void
@@ -245,102 +145,17 @@ class AgentApiRegressionTest extends TestCase
         $this->assertSame(2, AgentBalanceLog::query()->count());
     }
 
-    public function test_transfer_rechecks_current_parent_inside_service(): void
-    {
-        $root = $this->agent(); $child = $this->agent($root); $other = $this->agent();
-        Agent::query()->whereKey($root->id)->update(['balance' => 100]);
-        Agent::query()->whereKey($child->id)->update(['parent_id' => $other->id, 'path' => $other->path . $child->id . '/']);
-        $this->expectException(\RuntimeException::class);
-        $this->expectExceptionMessage('只能操作当前直属下级的额度');
-        (new AgentWalletService())->transfer($root->id, $child->id, '10', 'test', 'moved', $root->id);
-    }
-
-    public function test_transfer_retry_and_reclaim_from_disabled_child(): void
-    {
-        $root = $this->agent(); $child = $this->agent($root);
-        Agent::query()->whereKey($root->id)->update(['balance' => 100]);
-        $wallets = new AgentWalletService();
-        $wallets->transfer($root->id, $child->id, '10', 'test', 'once', $root->id);
-        $wallets->transfer($root->id, $child->id, '10', 'test', 'once', $root->id);
-        $this->assertSame('90.0000', $root->fresh()->balance);
-        $this->agents->update($child, ['status' => 0]);
-        $wallets->transfer($child->id, $root->id, '10', 'test', 'reclaim', $root->id);
-        $this->assertSame('100.0000', $root->fresh()->balance);
-        $this->assertSame('0.0000', $child->fresh()->balance);
-    }
-
-    public function test_historical_settlement_ignores_new_agents_and_new_children(): void
-    {
-        Carbon::setTestNow(Carbon::parse('2026-09-06 12:00:00'));
-        $root = $this->agent();
-        Carbon::setTestNow(Carbon::parse('2026-09-07 12:00:00'));
-        $this->agent($root); $this->agent();
-        $service = new AgentCommissionService(new AgentReportService(), new AgentWalletService(),
-            new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
-        $this->assertSame(['created' => 0, 'credited' => 0], $service->settle('2026-09-06', false));
-    }
-
-    public function test_daily_game_stats_work_with_bot_table_prefix(): void
-    {
-        $agent = $this->agent();
-        DB::table('users')->insert(['member_id' => 'm1', 'username' => 'member', 'agent_id' => $agent->id]);
-        Schema::create('bets', function (Blueprint $t) {
-            $t->id(); $t->string('member_id'); $t->integer('status');
-            $t->decimal('amount', 18, 4); $t->decimal('profit', 18, 4); $t->timestamps();
-        });
-        DB::table('bets')->insert(['member_id' => 'm1', 'status' => 2, 'amount' => 10, 'profit' => 0, 'created_at' => now()]);
-        foreach (['sport_game_order', 'jisu_game_order'] as $table) {
-            Schema::create($table, function (Blueprint $t) {
-                $t->id(); $t->string('member_id'); $t->integer('status');
-                $t->decimal('amount', 18, 4); $t->decimal('profit_and_loss', 18, 4); $t->timestamps();
-            });
-            DB::table($table)->insert(['member_id' => 'm1', 'status' => 1, 'amount' => 10, 'profit_and_loss' => 0, 'created_at' => now()]);
-        }
-        Schema::create('lhc_order', function (Blueprint $t) {
-            $t->id(); $t->string('member_id'); $t->integer('lottery_status');
-            $t->decimal('amount', 18, 4); $t->decimal('total_amount', 18, 4)->nullable();
-            $t->decimal('win_amount', 18, 4); $t->integer('created_at');
-        });
-        DB::table('lhc_order')->insert(['member_id' => 'm1', 'lottery_status' => 1, 'amount' => 10,
-            'total_amount' => null, 'win_amount' => 0, 'created_at' => now()->timestamp]);
-        (require dirname(__DIR__, 2) . '/database/migrations/2026_08_21_120000_create_third_game_orders_table.php')->up();
-        DB::table('third_game_orders')->insert(['user_id' => DB::table('users')->value('id'), 'member_id' => 'm1',
-            'order_key' => 'test', 'game_order_id' => 'test', 'player_id' => 'test', 'currency' => 'CNY',
-            'platform' => 'TEST', 'game_type' => 1, 'status' => 1,
-            'bet_amount' => 10, 'valid_amount' => 10, 'settled_amount' => -10, 'last_update_time' => now()]);
-        $reports = new AgentReportService();
-        $this->assertSame(5, $reports->refreshDailyGameStats('2026-09-07'));
-        foreach (AgentDailyGameStat::query()->get() as $stat) {
-            $this->assertSame('10.0000', $stat->valid_bet_amount);
-            $this->assertSame('-10.0000', $stat->win_loss);
-        }
-        $summary = $reports->summaryForTree($agent, '2026-09-07', '2026-09-07');
-        $this->assertSame(5, $summary['bet_count']);
-        $this->assertSame('50.0000', $summary['valid_bet_amount']);
-        $this->assertSame('-50.0000', $summary['win_loss']);
-    }
-
-    public function test_member_finance_does_not_include_other_agent_tree(): void
+    public function test_member_finance_does_not_include_other_agent_members(): void
     {
-        $root = $this->agent(); $child = $this->agent($root); $other = $this->agent();
-        foreach ([$root, $child, $other] as $agent) {
-            DB::table('users')->insert(['member_id' => 'm' . $agent->id, 'username' => 'member' . $agent->id,
-                'agent_id' => $agent->id, 'created_at' => now()]);
+        $agent = $this->agent(); $other = $this->agent();
+        foreach ([$agent, $agent, $other] as $i => $owner) {
+            DB::table('users')->insert(['member_id' => 'm' . $i, 'username' => 'member' . $i,
+                'agent_id' => $owner->id, 'created_at' => now()]);
         }
         $service = new AgentMemberService(new AgentReportService());
-        $this->assertSame(2, $service->finance($root, [])['total']);
-        $this->assertSame(0, $service->finance($root, ['agent_id' => $other->id])['total']);
-        $this->assertSame(1, $service->finance($child, [])['total']);
-    }
-
-    public function test_sub_agent_cannot_take_profile_higher_than_parent(): void
-    {
-        $root = $this->agent();
-        $profile = AgentFlowCommissionProfile::query()->create(['name' => 'High rate', 'live_rate' => 90]);
-        $this->expectException(\RuntimeException::class);
-        $this->expectExceptionMessage('下级代理流水佣金方案不能高于上级方案');
-        $this->agents->create(['username' => 'child-high', 'password' => 'password',
-            'flow_commission_profile_id' => $profile->id], null, $root);
+        $this->assertSame(2, $service->finance($agent, [])['total']);
+        $this->assertSame(0, $service->finance($agent, ['agent_id' => $other->id])['total']);
+        $this->assertSame(1, $service->finance($other, [])['total']);
     }
 
     public function test_business_errors_keep_existing_response_contract(): void
@@ -356,29 +171,6 @@ class AgentApiRegressionTest extends TestCase
         $this->assertSame('代理余额不足', $data['msg']);
     }
 
-    public function test_settlement_preview_credit_and_retry_preserve_paid_snapshot(): void
-    {
-        Carbon::setTestNow(Carbon::parse('2026-09-06 12:00:00'));
-        DB::table('agent_flow_commission_profiles')->update(['live_rate' => 1]);
-        DB::table('agent_profit_commission_profiles')->update(['live_rate' => 2]);
-        $agent = $this->agent();
-        DB::table('agent_daily_game_stats')->insert(['stat_date' => '2026-09-06', 'agent_id' => $agent->id,
-            'platform' => 'test', 'game_type' => 1, 'bet_count' => 1, 'bet_amount' => 100,
-            'valid_bet_amount' => 100, 'win_loss' => -50]);
-        Carbon::setTestNow(Carbon::parse('2026-09-07 12:00:00'));
-        $service = new AgentCommissionService(new AgentReportService(), new AgentWalletService(),
-            new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
-        $this->assertSame(['created' => 1, 'credited' => 0], $service->settle('2026-09-06', false));
-        $this->assertSame('0.0000', $agent->fresh()->balance);
-        $this->assertSame(['created' => 0, 'credited' => 2], $service->settle('2026-09-06'));
-        $this->assertSame('2.0000', $agent->fresh()->balance);
-        DB::table('agent_daily_game_stats')->update(['valid_bet_amount' => 200]);
-        $this->assertSame(['created' => 0, 'credited' => 0], $service->settle('2026-09-06'));
-        $this->assertSame('2.0000', $agent->fresh()->balance);
-        $this->assertSame('100.0000', AgentRebateRecord::query()->firstOrFail()->valid_bet_amount);
-        $this->assertSame(2, AgentBalanceLog::query()->count());
-    }
-
     public function test_settlement_command_rejects_invalid_and_unfinished_dates(): void
     {
         $handler = $this->createMock(ExceptionHandler::class);

+ 14 - 24
tests/Integration/AgentApplicationTest.php

@@ -5,10 +5,7 @@ namespace Tests\Integration;
 use App\Http\Controllers\admin\AgentApplication as AdminApplication;
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentApplication;
-use App\Models\Agent\AgentFlowCommissionProfile;
 use App\Services\Agent\AgentApplicationService;
-use App\Services\Agent\AgentFlowCommissionAssignmentService;
-use App\Services\Agent\AgentProfitCommissionAssignmentService;
 use App\Services\Agent\AgentService;
 use App\Services\SiteInboxService;
 use Illuminate\Config\Repository;
@@ -67,31 +64,28 @@ class AgentApplicationTest extends TestCase
         foreach (['2026_08_24_140000_create_agent_system_tables.php', '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
         }
+        // Pre-upgrade flow commission structures; 2026_11_07 removes them again.
         Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
             $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
         });
         Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
-            $t->id(); $t->unsignedBigInteger('agent_id'); $t->unsignedBigInteger('flow_commission_profile_id');
-            $t->string('profile_name'); $t->date('effective_from'); $t->unsignedBigInteger('created_by')->nullable();
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
-            $t->timestamps(); $t->unique(['agent_id', 'effective_from']);
+            $t->id(); $t->unsignedBigInteger('agent_id'); $t->date('effective_from'); $t->timestamps();
         });
         Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable());
-        foreach (['agent_flow_commission_profiles', 'agent_profit_commission_profiles'] as $table) Schema::table($table, fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
-        DB::table('agent_flow_commission_profiles')->insert(['name' => 'Default', 'is_default' => 1]);
+        Schema::table('agent_profit_commission_profiles', fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
         Schema::create('operation_audits', function (Blueprint $t) {
             $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action'); $t->json('changes');
             $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'] as $file) {
+        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'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
         }
         foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([
             'id' => $id, 'user_id' => (string) (10000 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
             'first_name' => 'Member ' . $id, 'from' => 1, 'language' => $language, 'agent_id' => 99,
         ]);
-        $this->agents = new AgentService(new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
+        $this->agents = new AgentService();
         $this->service = new AgentApplicationService();
         $this->request();
     }
@@ -135,8 +129,8 @@ class AgentApplicationTest extends TestCase
         $this->assertTrue(Hash::check('login-secret', $agent->password));
         $this->assertTrue(Hash::check('withdraw-secret', $agent->withdraw_password));
         $this->assertSame(99, (int) DB::table('users')->where('id', 11)->value('agent_id'));
-        $this->assertNotNull($agent->flow_commission_profile_id);
-        $this->assertNotNull($agent->profit_commission_profile_id);
+        $this->assertSame(Agent::TYPE_JOINT, $agent->type);
+        $this->assertNull($agent->profit_commission_profile_id, 'the agent applies for a plan in its own backend');
         $application->refresh();
         $this->assertSame('approved', $application->status);
         $this->assertNull($application->password_hash); $this->assertNull($application->withdraw_password_hash);
@@ -182,7 +176,6 @@ class AgentApplicationTest extends TestCase
             $this->fail('Notification failure must roll back approval');
         } catch (QueryException $e) { $this->assertStringContainsString('notice failure', $e->getMessage()); }
         $this->assertSame(0, Agent::count());
-        $this->assertSame(0, DB::table('agent_flow_commission_assignments')->count());
         $this->assertSame(0, DB::table('agent_profit_commission_assignments')->count());
         $this->assertSame('pending', $application->refresh()->status);
         $this->assertNotNull($application->password_hash); $this->assertSame(11, (int) $application->pending_user_id);
@@ -214,19 +207,16 @@ class AgentApplicationTest extends TestCase
         $this->assertSame(1, (new SiteInboxService())->unread('user', 33)['count']);
     }
 
-    public function test_admin_update_binds_existing_identity_and_rolls_back_an_attempted_reassignment(): void
+    public function test_admin_update_no_longer_binds_member_identity(): void
     {
         $agent = $this->agents->create(['username' => 'oldaccount', 'password' => 'legacy-secret', 'real_name' => 'Original']);
-        $this->request(['id' => $agent->id, 'owner_user_id' => 11]);
+        $this->request(['id' => $agent->id, 'owner_user_id' => 11, 'real_name' => 'Renamed']);
         $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
         $this->assertSame(0, $response['code'], $response['msg']);
-        $this->assertSame(11, $response['data']['owner_user_id']);
-        $this->assertArrayHasKey('flow_commission_profile', $response['data']);
-        $this->request(['id' => $agent->id, 'owner_user_id' => 22, 'real_name' => 'Unexpected']);
-        $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
-        $this->assertNotSame(0, $response['code']);
-        $this->assertSame('Original', $agent->refresh()->real_name);
-        $this->assertSame(11, $agent->owner_user_id);
+        $this->assertNull($response['data']['owner_user_id']);
+        $this->assertArrayHasKey('profit_commission_profile', $response['data']);
+        $this->assertSame('Renamed', $agent->refresh()->real_name);
+        $this->assertNull($agent->owner_user_id);
     }
 
     public function test_application_routes_keep_the_existing_admin_auth_and_agent_management_restriction(): void

+ 293 - 0
tests/Integration/AgentJointVentureTest.php

@@ -0,0 +1,293 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Agent as AdminAgent;
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentEquityOrder;
+use App\Models\Agent\AgentProfitCommissionProfile;
+use App\Models\Agent\AgentProfitEntry;
+use App\Models\Agent\AgentRebateRecord;
+use App\Models\Agent\AgentRecharge;
+use App\Models\Agent\AgentSetting;
+use App\Models\Agent\AgentWithdrawAccount;
+use App\Services\Agent\AgentCommissionService;
+use App\Services\Agent\AgentEquityService;
+use App\Services\Agent\AgentPlanApplicationService;
+use App\Services\Agent\AgentProfitCommissionAssignmentService;
+use App\Services\Agent\AgentProfitLedgerService;
+use App\Services\Agent\AgentRechargeService;
+use App\Services\Agent\AgentWalletService;
+use App\Services\Agent\AgentWithdrawalService;
+use App\Services\MemberDailyReportService;
+use App\Services\Sharing\ShareDefaults;
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+class AgentJointVentureTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private AgentWalletService $wallets;
+    private AgentProfitLedgerService $ledger;
+    private AgentRechargeService $recharges;
+    private AgentPlanApplicationService $plans;
+
+    private function services(): void
+    {
+        $assignments = new AgentProfitCommissionAssignmentService();
+        $this->wallets = new AgentWalletService();
+        $this->ledger = new AgentProfitLedgerService($this->wallets, $assignments);
+        $this->recharges = new AgentRechargeService($this->wallets, new AgentEquityService($this->wallets));
+        $this->plans = new AgentPlanApplicationService($assignments, $this->wallets);
+        DB::table('config')->insert([['field' => 'receiving_type', 'val' => '2'], ['field' => 'exchange_rate_rmb', 'val' => '7'],
+            ['field' => 'exchange_rate_difference', 'val' => '0.5']]);
+    }
+
+    private function profile(string $margin = '7000', string $rate = '10'): AgentProfitCommissionProfile
+    {
+        $values = ['name' => 'Plan ' . $margin, 'margin_amount' => $margin];
+        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) $values[$field] = $rate;
+        return AgentProfitCommissionProfile::query()->create($values);
+    }
+
+    /** 合营代理:申请方案并审核通过,再充值 $usdt USDT(汇率 7)。 */
+    private function jointAgent(AgentProfitCommissionProfile $profile, string $usdt): Agent
+    {
+        $agent = $this->agent();
+        $application = $this->plans->apply($agent, (int) $profile->id);
+        $this->plans->audit((int) $application->id, 'approved', 1);
+        $this->recharge($agent, $usdt);
+        return $agent->fresh();
+    }
+
+    private function recharge(Agent $agent, string $usdt): void
+    {
+        $record = $this->recharges->submitProof($agent, ['net' => 'trc20', 'usdt_amount' => $usdt,
+            'to_address' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'image' => 'https://example.test/proof.png']);
+        $this->recharges->audit((int) $record->id, AgentRecharge::STATUS_SUCCESS, 1);
+    }
+
+    private function member(Agent $agent): int
+    {
+        static $number = 0;
+        return (int) DB::table('users')->insertGetId(['member_id' => 'jv' . ++$number, 'username' => 'jv' . $number,
+            'agent_id' => $agent->id, 'created_at' => now()]);
+    }
+
+    private function fact(int $userId, string $net, string $category = 'self:lottery', string $settledAt = '2026-09-07 13:00:00'): object
+    {
+        $id = DB::table('share_bet_facts')->insertGetId(['source' => 'test', 'source_id' => uniqid('', true), 'user_id' => $userId,
+            'category' => $category, 'bet_at' => $settledAt, 'bet_date' => substr($settledAt, 0, 10), 'settled_at' => $settledAt,
+            'bet_amount' => 100, 'effective_amount' => 100, 'net_profit' => $net, 'settled' => 1, 'voided' => 0,
+            'created_at' => now(), 'updated_at' => now()]);
+        return DB::table('share_bet_facts')->find($id);
+    }
+
+    private function process(object $fact, Agent $agent): string
+    {
+        return $this->ledger->process($fact, (int) $agent->id);
+    }
+
+    public function test_recharge_becomes_margin_and_activates_plan(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('7000'), '999');
+        $this->assertSame('6993.0000', $agent->balance);
+        $this->assertSame('6993.0000', $agent->margin_amount);
+        $this->assertSame(0, $agent->profit_active, 'below the plan margin the plan stays inactive');
+        $this->recharge($agent, '1');
+        $agent = $agent->fresh();
+        $this->assertSame('7000.0000', $agent->margin_amount);
+        $this->assertSame(1, $agent->profit_active);
+        $this->assertSame('0.0000', $this->wallets->withdrawable($agent), 'margin is never withdrawable');
+        $this->assertSame('0', DB::table('recharges')->count() . '', 'agent recharges never land in member recharges');
+    }
+
+    public function test_share_is_two_sided_and_daily_credit_keeps_realtime_invariant(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
+        $userId = $this->member($agent);
+        $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent));
+        $this->assertSame('counted', $this->process($this->fact($userId, '500', 'third:1'), $agent));
+        $agent = $agent->fresh();
+        $this->assertSame('6960.0000', $agent->realtime_balance);
+        $this->assertSame('7000.0000', $agent->balance, 'balance only moves at the daily credit');
+
+        Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
+        $result = (new AgentCommissionService($this->wallets))->settle('2026-09-07');
+        $this->assertSame(['created' => 2, 'credited' => 2], $result);
+        $agent = $agent->fresh();
+        $this->assertSame('6960.0000', $agent->balance);
+        $this->assertSame('6960.0000', $agent->realtime_balance);
+        $this->assertSame('-40.0000', bcadd((string) AgentRebateRecord::query()->sum('profit_commission'), '0', 4));
+        $this->assertSame(0, AgentProfitEntry::query()->whereNull('rebate_record_id')->count());
+        $this->assertSame(['created' => 0, 'credited' => 0], (new AgentCommissionService($this->wallets))->settle('2026-09-07'));
+    }
+
+    public function test_dropping_below_stop_percent_skips_later_bets_until_margin_is_refilled(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
+        $userId = $this->member($agent);
+        // Member wins 64000: agent share -6400, realtime margin 600 < 10% of 7000.
+        $this->process($this->fact($userId, '64000'), $agent);
+        $agent = $agent->fresh();
+        $this->assertSame('600.0000', $agent->realtime_balance);
+        $this->assertSame(0, $agent->profit_active);
+        $this->assertSame('skipped', $this->process($this->fact($userId, '-1000'), $agent));
+        $this->assertSame('600.0000', $agent->fresh()->realtime_balance);
+
+        $this->recharge($agent, '900'); // +6300: realtime 6900 < 7000, still stopped
+        $this->assertSame(0, $agent->fresh()->profit_active);
+        $this->recharge($agent, '100'); // realtime 7600 >= 7000
+        $agent = $agent->fresh();
+        $this->assertSame(1, $agent->profit_active);
+        $this->assertSame('14000.0000', $agent->margin_amount);
+        $this->assertSame('counted', $this->process($this->fact($userId, '-100'), $agent));
+    }
+
+    public function test_loss_is_capped_at_zero_balance(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('0', '50'), '10'); // 70 margin, no margin requirement
+        $userId = $this->member($agent);
+        $this->process($this->fact($userId, '1000'), $agent);
+        $this->assertSame('0.0000', $agent->fresh()->realtime_balance);
+        $this->assertSame('-70.0000', AgentProfitEntry::query()->value('share_amount'));
+    }
+
+    public function test_changed_payout_is_corrected_in_place_then_by_revision_after_credit(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
+        $userId = $this->member($agent);
+        $fact = $this->fact($userId, '-100');
+        $this->process($fact, $agent);
+        DB::table('share_bet_facts')->where('id', $fact->id)->update(['net_profit' => -300]);
+        $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent));
+        $this->assertSame(1, AgentProfitEntry::query()->count());
+        $this->assertSame('30.0000', AgentProfitEntry::query()->value('share_amount'));
+
+        Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
+        (new AgentCommissionService($this->wallets))->settle('2026-09-07');
+        DB::table('share_bet_facts')->where('id', $fact->id)->update(['voided' => 1]);
+        $this->assertSame('corrected', $this->process(DB::table('share_bet_facts')->find($fact->id), $agent));
+        $revision = AgentProfitEntry::query()->where('revision', 1)->firstOrFail();
+        $this->assertSame('-30.0000', $revision->share_amount);
+        $this->assertNull($revision->rebate_record_id);
+        $this->assertSame('7000.0000', $agent->fresh()->realtime_balance);
+    }
+
+    public function test_only_profit_above_margin_is_withdrawable_and_pending_losses_are_reserved(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('7000', '10'), '1000');
+        $userId = $this->member($agent);
+        Agent::query()->whereKey($agent->id)->update(['withdraw_password' => \Illuminate\Support\Facades\Hash::make('pw')]);
+        $this->process($this->fact($userId, '-15000'), $agent); // +1500
+        Carbon::setTestNow(Carbon::parse('2026-09-08 04:10:00'));
+        (new AgentCommissionService($this->wallets))->settle('2026-09-07');
+        $agent = $agent->fresh();
+        $this->assertSame('1500.0000', $this->wallets->withdrawable($agent));
+        // Today's pending loss (-500) reduces what may leave now.
+        $this->process($this->fact($userId, '5000', 'self:lottery', '2026-09-08 01:00:00'), $agent);
+        $agent = $agent->fresh();
+        $this->assertSame('1000.0000', $this->wallets->withdrawable($agent));
+        $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
+            'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
+        $service = new AgentWithdrawalService($this->wallets);
+        $withdrawal = $service->request($agent, $account->id, '100', 'pw', 'ok'); // 100 USDT * 7.5
+        $this->assertSame('750.0000', $withdrawal->amount);
+        $this->assertSame('7.5000', $withdrawal->exchange_rate);
+        $this->expectExceptionMessage('可提现盈利不足');
+        $service->request($agent->fresh(), $account->id, '100', 'pw', 'too-much');
+    }
+
+    public function test_equity_order_waits_for_recharge_then_opens_without_review(): void
+    {
+        $this->services();
+        AgentSetting::current()->update(['equity_enabled' => 1, 'equity_price_per_percent' => 70000,
+            'equity_min_percent' => 0.1, 'equity_max_percent' => 1]);
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $equity = new AgentEquityService($this->wallets);
+        $order = $equity->order($agent, '0.5');
+        $this->assertSame(AgentEquityOrder::STATUS_PENDING, $order->status);
+        $this->assertSame('35000.0000', $order->amount);
+        $this->recharge($agent, '5000');
+        $agent = $agent->fresh();
+        $this->assertSame('0.5000', $agent->equity_percent);
+        $this->assertSame('0.0000', $agent->balance);
+        $this->assertSame('0.0000', $agent->margin_amount, 'equity recharges are not margin');
+        $this->assertSame(AgentEquityOrder::STATUS_COMPLETED, $order->fresh()->status);
+        $this->expectExceptionMessage('累计持股不能超过');
+        $equity->order($agent, '0.6');
+    }
+
+    public function test_admin_create_ignores_removed_hierarchy_and_plan_fields(): void
+    {
+        $request = Request::create('/admin/agent/create', 'POST', ['username' => 'newagent', 'password' => 'secret1',
+            'type' => 2, 'parent_id' => 99, 'owner_user_id' => 5, 'flow_commission_profile_id' => 1,
+            'profit_commission_profile_id' => 1]);
+        $request->user = (object) ['id' => 1];
+        $this->app->instance('request', $request);
+        $data = (new AdminAgent())->store($request, $this->agents)->getData(true);
+        $this->assertSame(0, $data['code'], (string) $data['msg']);
+        $agent = Agent::query()->where('username', 'newagent')->firstOrFail();
+        $this->assertSame(Agent::TYPE_EQUITY, $agent->type);
+        $this->assertNull($agent->parent_id);
+        $this->assertNull($agent->owner_user_id);
+        $this->assertNull($agent->profit_commission_profile_id);
+        $this->assertSame('入股', $agent->type_text);
+        $this->assertArrayNotHasKey('realtime_balance', $agent->toArray());
+    }
+
+    public function test_member_daily_report_aggregates_bets_funds_and_agent_share(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('0', '10'), '10');
+        $userId = $this->member($agent);
+        $memberId = DB::table('users')->where('id', $userId)->value('member_id');
+        $this->process($this->fact($userId, '-100'), $agent);
+        $this->fact($userId, '40', 'third:2');
+        DB::table('recharges')->insert(['member_id' => $memberId, 'status' => 1, 'amount' => 300, 'created_at' => '2026-09-07 09:00:00']);
+        $service = new MemberDailyReportService();
+        $this->assertSame(1, $service->refresh('2026-09-07'));
+        $row = DB::table('member_daily_reports')->first();
+        $this->assertSame('300.0000', bcadd((string) $row->deposit_amount, '0', 4));
+        $this->assertSame('-60.0000', bcadd((string) $row->win_loss, '0', 4));
+        $this->assertSame('10.0000', bcadd((string) $row->agent_share, '0', 4));
+        $this->assertSame(2, (int) $row->bet_count);
+        $this->assertSame(1, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $agent)['total']);
+        $this->assertSame(0, $service->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07'], $this->agent())['total']);
+    }
+
+    public function test_sync_reads_new_facts_once_and_ignores_non_joint_members(): void
+    {
+        $this->services();
+        $agent = $this->jointAgent($this->profile('0', '10'), '10');
+        $this->fact($this->member($agent), '-100');
+        $this->fact($this->member($this->agent(Agent::TYPE_EQUITY)), '-100');
+        $this->fact((int) DB::table('users')->insertGetId(['member_id' => 'plain', 'username' => 'plain']), '-100');
+        $first = $this->ledger->sync();
+        $this->assertSame(1, $first['counted']);
+        $this->assertSame(0, $first['failed']);
+        $this->assertSame(1, $this->ledger->sync()['unchanged'], 'overlap window re-reads the fact without double counting');
+        $this->assertSame('80.0000', $agent->fresh()->realtime_balance);
+    }
+
+    public function test_commission_categories_drop_third_party_lottery(): void
+    {
+        $this->assertCount(8, ShareDefaults::commissionCategories());
+        $this->assertArrayNotHasKey('third:3', ShareDefaults::commissionCategories());
+        $this->assertSame(array_keys(ShareDefaults::commissionCategories()), array_keys(array_intersect_key(
+            ShareDefaults::commissionCategories(), AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS)));
+        $this->assertCount(8, AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS);
+        $rules = json_decode(DB::table('share_policies')->value('rules'), true);
+        $this->assertArrayNotHasKey('third:3', $rules, 'migration removes third-party lottery from share rules');
+    }
+}

+ 1 - 1
tests/Integration/SharingSettlementTest.php

@@ -72,7 +72,7 @@ class SharingSettlementTest extends TestCase
 
     public function test_nine_categories_have_the_exact_twelve_tiers_and_both_qualifiers_are_required(): void
     {
-        $this->assertCount(9,ShareDefaults::rules());
+        $this->assertCount(8,ShareDefaults::rules());
         $rules=ShareDefaults::rules()['third:2']; $this->assertCount(12,$rules);
         $this->assertSame('0.25',ShareSettlementService::rate($rules,1,'1000000'));
         $this->assertSame('0',ShareSettlementService::rate($rules,0,'99.99'));

+ 143 - 0
tests/Support/AgentTestEnvironment.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace Tests\Support;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentToken;
+use App\Services\Agent\AgentService;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Hashing\BcryptHasher;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+
+/**
+ * 代理模块集成测试环境:隔离 SQLite(表前缀 bot_),按生产顺序跑代理迁移,
+ * 包括把旧的流水佣金/二级代理结构迁移到合营/入股结构的 2026_11_07 迁移。
+ */
+trait AgentTestEnvironment
+{
+    private Application $app;
+    private AgentService $agents;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Carbon::setTestNow(Carbon::parse('2026-09-07 12:00:00'));
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh'], 'agent' => []]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($this->app));
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $validator = new Factory($translator, $this->app);
+        $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
+        $this->app->instance('validator', $validator);
+        $this->app->instance('request', Request::create('/'));
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new JsonResponseFactory());
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('username'); $t->string('first_name')->default('');
+            $t->integer('status')->default(1); $t->string('register_ip')->default('');
+            $t->string('register_domain')->default(''); $t->timestamps();
+        });
+        // The profit migration inspects MySQL index metadata before creating indexes.
+        DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
+        DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
+        DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)');
+        foreach (['2026_08_24_140000_create_agent_system_tables.php',
+            '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
+        }
+        // Pre-upgrade flow commission and owner columns (their MySQL-only migrations use information_schema).
+        Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
+            $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
+        });
+        Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('agent_id'); $t->date('effective_from'); $t->timestamps();
+        });
+        Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable()->index());
+        Schema::table('agents', fn (Blueprint $t) => $t->string('language', 8)->default('zh'));
+        Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('owner_user_id')->nullable());
+        Schema::table('agent_profit_commission_profiles', fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
+        Schema::create('share_system_state', function (Blueprint $t) {
+            $t->string('key')->primary(); $t->text('value')->nullable(); $t->timestamps();
+        });
+        Schema::create('share_policies', function (Blueprint $t) {
+            $t->id(); $t->date('effective_date'); $t->json('rules'); $t->timestamps();
+        });
+        DB::table('share_policies')->insert(['effective_date' => '1970-01-01',
+            'rules' => json_encode(['third:1' => [], 'third:3' => [], 'self:lottery' => []])]);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php')->up();
+
+        foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
+            Schema::create($table, function (Blueprint $t) {
+                $t->id(); $t->string('member_id'); $t->integer('status'); $t->string('type')->nullable();
+                $t->decimal('amount', 18, 4); $t->timestamps();
+            });
+        }
+        Schema::create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('available_balance', 18, 4)->default(0);
+            $t->decimal('frozen_balance', 18, 4)->default(0);
+        });
+        Schema::create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('change_type');
+            $t->integer('related_id')->nullable(); $t->decimal('amount', 18, 4); $t->timestamps();
+        });
+        Schema::create('config', function (Blueprint $t) {
+            $t->id(); $t->string('field'); $t->string('val')->nullable();
+        });
+        Schema::create('share_bet_facts', function (Blueprint $t) {
+            $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id');
+            $t->string('member_id', 64)->default(''); $t->string('category', 32); $t->dateTime('bet_at');
+            $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
+            foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
+            $t->boolean('settled')->default(false); $t->boolean('voided')->default(false); $t->timestamps();
+        });
+        $this->agents = new AgentService();
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        Carbon::setTestNow();
+        parent::tearDown();
+    }
+
+    private function agent(int $type = Agent::TYPE_JOINT): Agent
+    {
+        static $number = 0;
+        return $this->agents->create([
+            'username' => 'agent' . ++$number, 'password' => 'old-password',
+            'withdraw_password' => 'withdraw-old', 'type' => $type,
+        ]);
+    }
+
+    private function token(Agent $agent): string
+    {
+        $plain = 'test-token-' . $agent->id;
+        AgentToken::query()->create(['agent_id' => $agent->id, 'token_hash' => hash('sha256', $plain),
+            'expires_at' => now()->addDay(), 'last_used_at' => now(), 'ip' => '', 'device' => 'pc']);
+        return $plain;
+    }
+}