Jelajahi Sumber

feat: 新增代理系统及管理 API

doge 3 jam lalu
induk
melakukan
1d1a479c00
72 mengubah file dengan 4918 tambahan dan 1 penghapusan
  1. 30 0
      app/Console/Commands/AgentDailySettle.php
  2. 3 0
      app/Console/Kernel.php
  3. 38 0
      app/Http/Controllers/AgentApiController.php
  4. 297 0
      app/Http/Controllers/admin/Agent.php
  5. 37 0
      app/Http/Controllers/agent/AuthController.php
  6. 66 0
      app/Http/Controllers/agent/CommissionController.php
  7. 43 0
      app/Http/Controllers/agent/MemberController.php
  8. 113 0
      app/Http/Controllers/agent/ProfileController.php
  9. 60 0
      app/Http/Controllers/agent/ReferralController.php
  10. 22 0
      app/Http/Controllers/agent/ReportController.php
  11. 103 0
      app/Http/Controllers/agent/SubAgentController.php
  12. 104 0
      app/Http/Controllers/agent/WalletController.php
  13. 2 0
      app/Http/Kernel.php
  14. 23 0
      app/Http/Middleware/AgentAdminMiddleware.php
  15. 37 0
      app/Http/Middleware/AgentAuthMiddleware.php
  16. 53 0
      app/Models/Agent/Agent.php
  17. 10 0
      app/Models/Agent/AgentBalanceLog.php
  18. 15 0
      app/Models/Agent/AgentCommission.php
  19. 15 0
      app/Models/Agent/AgentCommissionRule.php
  20. 10 0
      app/Models/Agent/AgentDailyStat.php
  21. 10 0
      app/Models/Agent/AgentDomain.php
  22. 21 0
      app/Models/Agent/AgentLoginLog.php
  23. 10 0
      app/Models/Agent/AgentToken.php
  24. 10 0
      app/Models/Agent/AgentWithdrawAccount.php
  25. 15 0
      app/Models/Agent/AgentWithdrawal.php
  26. 7 1
      app/Models/User.php
  27. 4 0
      app/Providers/RouteServiceProvider.php
  28. 28 0
      app/Rules/DecimalNumber.php
  29. 77 0
      app/Services/Agent/AgentAuthService.php
  30. 159 0
      app/Services/Agent/AgentCommissionService.php
  31. 37 0
      app/Services/Agent/AgentContextService.php
  32. 90 0
      app/Services/Agent/AgentDomainService.php
  33. 121 0
      app/Services/Agent/AgentMemberService.php
  34. 248 0
      app/Services/Agent/AgentReportService.php
  35. 163 0
      app/Services/Agent/AgentService.php
  36. 133 0
      app/Services/Agent/AgentWalletService.php
  37. 166 0
      app/Services/Agent/AgentWithdrawalService.php
  38. 13 0
      config/agent.php
  39. 217 0
      database/migrations/2026_08_24_140000_create_agent_system_tables.php
  40. 57 0
      docs/代理/个人信息.md
  41. 69 0
      docs/代理/代理佣金比例.md
  42. 61 0
      docs/代理/代理报表.md
  43. 45 0
      docs/代理/代理首页.md
  44. 29 0
      docs/代理/修改密码.md
  45. 75 0
      docs/代理/其他账户管理.md
  46. 146 0
      docs/代理/所有代理.md
  47. 52 0
      docs/代理/所有会员.md
  48. 27 0
      docs/代理/推广域名.md
  49. 87 0
      docs/代理/推广注册.md
  50. 93 0
      docs/代理/提现.md
  51. 74 0
      docs/代理/注单统计.md
  52. 57 0
      docs/代理/登录.md
  53. 54 0
      docs/代理/登录日志.md
  54. 69 0
      docs/代理/虚拟币账户管理.md
  55. 62 0
      docs/代理/财务统计.md
  56. 61 0
      docs/代理/资金流水.md
  57. 71 0
      docs/代理/资金账户.md
  58. 54 0
      docs/代理/返佣记录.md
  59. 71 0
      docs/代理/银行卡管理.md
  60. 356 0
      docs/总后台/代理列表.md
  61. 80 0
      docs/总后台/代理报表.md
  62. 112 0
      docs/总后台/代理提现列表.md
  63. 82 0
      docs/总后台/代理流水佣金比例.md
  64. 56 0
      docs/总后台/代理登录日志.md
  65. 74 0
      docs/总后台/代理盈亏佣金比例.md
  66. 64 0
      docs/总后台/代理返佣记录.md
  67. 20 0
      routes/admin.php
  68. 46 0
      routes/agent.php
  69. 41 0
      tests/Unit/AgentContextServiceTest.php
  70. 26 0
      tests/Unit/AgentDomainServiceTest.php
  71. 17 0
      tests/Unit/AgentReportFormulaTest.php
  72. 20 0
      tests/Unit/DecimalNumberTest.php

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

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Agent\AgentCommissionService;
+use App\Services\Agent\AgentReportService;
+use Illuminate\Console\Command;
+
+class AgentDailySettle extends Command
+{
+    protected $signature = 'agent:settle {date? : Y-m-d,默认昨天} {--no-credit : 只生成佣金记录,不入账}';
+
+    protected $description = '生成代理日报并结算流水/盈亏佣金';
+
+    public function handle(AgentReportService $reports, AgentCommissionService $commissions): int
+    {
+        $date = (string) ($this->argument('date') ?: now()->subDay()->toDateString());
+        try {
+            $date = \Carbon\Carbon::createFromFormat('Y-m-d', $date)->toDateString();
+            $stats = $reports->refreshDaily($date);
+            $result = $commissions->settle($date, !$this->option('no-credit'));
+            $this->info("代理日报 {$stats} 条,佣金新建 {$result['created']} 条,入账 {$result['credited']} 条");
+            return self::SUCCESS;
+        } catch (\Throwable $e) {
+            $this->error($e->getMessage());
+            report($e);
+            return self::FAILURE;
+        }
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -29,6 +29,9 @@ class Kernel extends ConsoleKernel
         $schedule->command('third-game:sync-orders')
             ->everyMinute()
             ->withoutOverlapping(5);
+        $schedule->command('agent:settle')
+            ->dailyAt('04:10')
+            ->withoutOverlapping(180);
     }
 
     /**

+ 38 - 0
app/Http/Controllers/AgentApiController.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Constants\HttpStatus;
+use App\Models\Agent\Agent;
+use Closure;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Validation\ValidationException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
+
+abstract class AgentApiController extends Controller
+{
+    protected function execute(Closure $callback): JsonResponse
+    {
+        try {
+            return $this->success($callback());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (ModelNotFoundException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, '数据不存在');
+        } catch (\InvalidArgumentException | \RuntimeException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        } catch (\Throwable $e) {
+            report($e);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '系统处理失败,请稍后重试');
+        }
+    }
+
+    protected function currentAgent(): Agent
+    {
+        $agent = request()->attributes->get('agent');
+        if (!$agent instanceof Agent) {
+            throw new \RuntimeException('请先登录');
+        }
+        return $agent;
+    }
+}

+ 297 - 0
app/Http/Controllers/admin/Agent.php

@@ -0,0 +1,297 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\AgentApiController;
+use App\Models\Agent\Agent as AgentModel;
+use App\Models\Agent\AgentCommission;
+use App\Models\Agent\AgentCommissionRule;
+use App\Models\Agent\AgentLoginLog;
+use App\Models\Agent\AgentWithdrawal;
+use App\Models\EgameItem;
+use App\Models\User;
+use App\Services\Agent\AgentCommissionService;
+use App\Services\Agent\AgentDomainService;
+use App\Services\Agent\AgentReportService;
+use App\Services\Agent\AgentService;
+use App\Services\Agent\AgentWalletService;
+use App\Services\Agent\AgentWithdrawalService;
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use Illuminate\Support\Facades\DB;
+use App\Rules\DecimalNumber;
+use App\Constants\Util;
+
+class Agent extends AgentApiController
+{
+    public function index(Request $request, AgentDomainService $domains)
+    {
+        return $this->execute(function () use ($request, $domains) {
+            $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'],
+                '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'])->withCount(['children', '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 (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');
+            $total = (clone $query)->count();
+            $list = $query->orderByDesc('id')->forPage($page, $limit)->get()->map(fn(AgentModel $agent) => $this->formatAgent($agent, $domains));
+            return compact('total', 'page', 'limit', 'list');
+        });
+    }
+
+    public function show(Request $request, AgentDomainService $domains)
+    {
+        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'])->withCount(['children', 'members'])->findOrFail($data['id']);
+            return $this->formatAgent($agent, $domains);
+        });
+    }
+
+    public function store(Request $request, AgentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate($this->rules());
+            return DB::transaction(function () use ($data, $service) {
+                $agent = $service->create($data, (int) request()->user->id);
+                if (bccomp((string) ($data['initial_balance'] ?? 0), '0', 4) > 0) {
+                    app(AgentWalletService::class)->adjust(
+                        (int) $agent->id,
+                        (string) $data['initial_balance'],
+                        'credit',
+                        '新增代理初始额度',
+                        'agent-initial:' . $agent->id,
+                        'admin',
+                        (int) request()->user->id
+                    );
+                    $agent = $agent->fresh();
+                }
+                return $agent;
+            });
+        });
+    }
+
+    public function update(Request $request, AgentService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(array_merge(['id' => ['required', 'integer', 'exists:agents,id']], $this->rules(true)));
+            return $service->update(AgentModel::query()->findOrFail($data['id']), $data);
+        });
+    }
+
+    public function domains(Request $request, AgentDomainService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'id' => ['required', 'integer', 'exists:agents,id'],
+                'pc' => ['nullable', 'string', 'max:255'], 'h5' => ['nullable', 'string', 'max:255'],
+            ]);
+            return $service->save(AgentModel::query()->findOrFail($data['id']), $data);
+        });
+    }
+
+    public function adjust(Request $request, AgentWalletService $wallets)
+    {
+        return $this->execute(function () use ($request, $wallets) {
+            $data = $request->validate([
+                'agent_id' => ['required', 'integer', 'exists:agents,id'], 'type' => ['required', 'in:credit,debit'],
+                'amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()], 'remark' => ['required', 'string', 'max:500'],
+                'request_id' => ['required', 'string', 'max:64'],
+            ]);
+            return $wallets->adjust(
+                (int) $data['agent_id'], (string) $data['amount'], $data['type'], $data['remark'],
+                'admin:' . request()->user->id . ':' . $data['request_id'], 'admin', (int) request()->user->id
+            );
+        });
+    }
+
+    public function quota(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate(['agent_id' => ['required', 'integer', 'exists:agents,id']]);
+            $agent = AgentModel::query()->findOrFail($data['agent_id']);
+            $platforms = EgameItem::query()->where('item_type', EgameItem::TYPE_PLATFORM)->where('game_type', 0)
+                ->where('status', EgameItem::STATUS_ENABLED)->orderByDesc('sort')->get(['plat_type', 'name', 'logo'])
+                ->map(function (EgameItem $platform) {
+                    $platform->logo = Util::ensureUrl($platform->logo);
+                    return $platform;
+                });
+            return [
+                'balance' => (string) $agent->balance,
+                'frozen_balance' => (string) $agent->frozen_balance,
+                'quota_mode' => 'internal',
+                'provider_wallet_supported' => false,
+                'platforms' => $platforms,
+            ];
+        });
+    }
+
+    public function bindMember(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'user_id' => ['required', 'integer', 'exists:users,id'],
+                'agent_id' => ['nullable', 'integer', 'exists:agents,id'],
+            ]);
+            $user = User::query()->findOrFail($data['user_id']);
+            $user->agent_id = $data['agent_id'] ?? null;
+            $user->save();
+            return ['user_id' => (int) $user->id, 'agent_id' => $user->agent_id === null ? null : (int) $user->agent_id];
+        });
+    }
+
+    public function loginLogs(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'], 'username' => ['nullable', 'string', 'max:64'],
+                'ip' => ['nullable', 'string', 'max:64'], 'device' => ['nullable', 'in:mobile,pc'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentLoginLog::query();
+            foreach (['agent_id', 'username', 'ip', 'device'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
+            return $this->paginate($query, $data, 'login_at');
+        });
+    }
+
+    public function withdrawals(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'],
+                'status' => ['nullable', 'in:pending,approved,rejected,paid'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentWithdrawal::query()->with('agent:id,username,real_name');
+            foreach (['agent_id', 'order_no', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
+            return $this->paginate($query, $data);
+        });
+    }
+
+    public function auditWithdrawal(Request $request, AgentWithdrawalService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'id' => ['required', 'integer', 'exists:agent_withdrawals,id'],
+                'action' => ['required', 'in:approve,reject,paid'], 'remark' => ['nullable', 'string', 'max:500'],
+            ]);
+            return $service->audit((int) $data['id'], $data['action'], (int) request()->user->id, (string) ($data['remark'] ?? ''));
+        });
+    }
+
+    public function reports(Request $request, AgentReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:20'],
+                'username' => ['nullable', 'string', 'max:64'], 'start_date' => ['required', 'date_format:Y-m-d'],
+                'end_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $reports->reportRows(null, $data);
+        });
+    }
+
+    public function commissionRules(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate(['agent_id' => ['nullable', 'integer']]);
+            return AgentCommissionRule::query()->with('agent:id,username')->when(!empty($data['agent_id']), fn($q) => $q->where('agent_id', $data['agent_id']))
+                ->orderByDesc('effective_from')->get();
+        });
+    }
+
+    public function saveFlowCommissionRule(Request $request, AgentCommissionService $commissions)
+    {
+        return $this->execute(function () use ($request, $commissions) {
+            $data = $request->validate([
+                'agent_id' => ['required', 'integer', 'exists:agents,id'],
+                'flow_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'effective_from' => ['required', 'date_format:Y-m-d', 'date_equals:' . now()->toDateString()],
+            ]);
+            return $commissions->saveFlowRate(AgentModel::query()->findOrFail($data['agent_id']), (string) $data['flow_rate'], $data['effective_from'], (int) request()->user->id);
+        });
+    }
+
+    public function saveProfitCommissionRule(Request $request, AgentCommissionService $commissions)
+    {
+        return $this->execute(function () use ($request, $commissions) {
+            $data = $request->validate([
+                'agent_id' => ['required', 'integer', 'exists:agents,id'],
+                'profit_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'effective_from' => ['required', 'date_format:Y-m-d', 'date_equals:' . now()->toDateString()],
+            ]);
+            return $commissions->saveProfitRate(AgentModel::query()->findOrFail($data['agent_id']), (string) $data['profit_rate'], $data['effective_from'], (int) request()->user->id);
+        });
+    }
+
+    public function commissions(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'], 'type' => ['nullable', 'in:flow,profit'], 'status' => ['nullable', 'in:pending,credited'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentCommission::query()->with('agent:id,username');
+            foreach (['agent_id', 'type', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
+            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->paginate($query, $data, 'settlement_date', false);
+        });
+    }
+
+    private function rules(bool $update = false): array
+    {
+        return [
+            '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' => ['nullable', 'integer', 'exists:agents,id'], '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' => $update ? ['prohibited'] : ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+            'profit_commission_rate' => $update ? ['prohibited'] : ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+            'status' => ['nullable', 'integer', 'in:0,1'],
+            'initial_balance' => $update ? ['prohibited'] : ['nullable', 'numeric', 'min:0', new DecimalNumber()],
+        ];
+    }
+
+    private function paginate($query, array $data, string $orderBy = 'id', bool $filterCreatedAt = true): array
+    {
+        $page = max(1, (int) ($data['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
+        if ($filterCreatedAt && !empty($data['start_date'])) $query->where('created_at', '>=', $data['start_date'] . ' 00:00:00');
+        if ($filterCreatedAt && !empty($data['end_date'])) $query->where('created_at', '<=', $data['end_date'] . ' 23:59:59');
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc($orderBy)->orderByDesc('id')->forPage($page, $limit)->get();
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    private function formatAgent(AgentModel $agent, AgentDomainService $domains): array
+    {
+        $token = $agent->token;
+        $online = $token
+            && $token->expires_at->isFuture()
+            && $token->last_used_at
+            && $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), [
+            'login_status' => $online ? 1 : 0,
+            'login_status_text' => $online ? '在线' : '离线',
+            'last_active_at' => $token?->last_used_at?->toDateTimeString(),
+        ]);
+    }
+}

+ 37 - 0
app/Http/Controllers/agent/AuthController.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\Agent\AgentAuthService;
+use Illuminate\Http\Request;
+
+class AuthController extends AgentApiController
+{
+    public function login(Request $request, AgentAuthService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'username' => ['required', 'string', 'max:64'],
+                'password' => ['required', 'string', 'max:100'],
+            ]);
+            $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,
+                'invitation_code' => $agent->invitation_code,
+            ];
+            return $result;
+        });
+    }
+
+    public function logout(AgentAuthService $service)
+    {
+        return $this->execute(function () use ($service) {
+            $service->logout(request()->attributes->get('agent_token'));
+            return [];
+        });
+    }
+}

+ 66 - 0
app/Http/Controllers/agent/CommissionController.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentCommission;
+use App\Models\Agent\AgentCommissionRule;
+use App\Services\Agent\AgentCommissionService;
+use App\Services\Agent\AgentReportService;
+use App\Services\Agent\AgentService;
+use Illuminate\Http\Request;
+use App\Rules\DecimalNumber;
+
+class CommissionController extends AgentApiController
+{
+    public function records(Request $request, AgentReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'type' => ['nullable', 'in:flow,profit'], 'status' => ['nullable', 'in:pending,credited'],
+                '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 = !empty($data['include_children']) ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
+            $query = AgentCommission::query()->with('agent:id,username')->whereIn('agent_id', $ids);
+            if (!empty($data['type'])) $query->where('type', $data['type']);
+            if (!empty($data['status'])) $query->where('status', $data['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']);
+            $total = (clone $query)->count();
+            $list = $query->orderByDesc('settlement_date')->orderByDesc('id')->forPage($page, $limit)->get();
+            return compact('total', 'page', 'limit', 'list');
+        });
+    }
+
+    public function rules(Request $request, AgentReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate(['include_children' => ['nullable', 'boolean']]);
+            $agent = $this->currentAgent();
+            $ids = !empty($data['include_children']) ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
+            return AgentCommissionRule::query()->with('agent:id,username')->whereIn('agent_id', $ids)->orderByDesc('effective_from')->get();
+        });
+    }
+
+    public function saveRule(Request $request, AgentService $agents, AgentCommissionService $commissions)
+    {
+        return $this->execute(function () use ($request, $agents, $commissions) {
+            $root = $this->currentAgent();
+            $data = $request->validate([
+                'agent_id' => ['required', 'integer', 'exists:agents,id'],
+                'flow_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'profit_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'effective_from' => ['required', 'date_format:Y-m-d', 'date_equals:' . now()->toDateString()],
+            ]);
+            if ((int) $data['agent_id'] === (int) $root->id) throw new \RuntimeException('代理不能修改自己的佣金比例');
+            $agents->assertVisible($root, (int) $data['agent_id']);
+            return $commissions->saveRule(Agent::query()->findOrFail($data['agent_id']), (string) $data['flow_rate'], (string) $data['profit_rate'], $data['effective_from'], null);
+        });
+    }
+}

+ 43 - 0
app/Http/Controllers/agent/MemberController.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\Agent\AgentMemberService;
+use Illuminate\Http\Request;
+
+class MemberController extends AgentApiController
+{
+    public function index(Request $request, AgentMemberService $service)
+    {
+        return $this->execute(fn() => $service->paginate($this->currentAgent(), $this->validateList($request)));
+    }
+
+    public function finance(Request $request, AgentMemberService $service)
+    {
+        return $this->execute(fn() => $service->finance($this->currentAgent(), $this->validateList($request)));
+    }
+
+    public function bets(Request $request, AgentMemberService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'source' => ['nullable', 'in:pc28,sport,jisu,lhc,third'], 'member_id' => ['nullable', 'string', 'max:64'],
+                'username' => ['nullable', 'string', 'max:128'], 'status' => ['nullable', 'integer'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $service->bets($this->currentAgent(), $data);
+        });
+    }
+
+    private function validateList(Request $request): array
+    {
+        return $request->validate([
+            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+            'keyword' => ['nullable', 'string', 'max:128'], 'agent_id' => ['nullable', 'integer'],
+            'status' => ['nullable', 'integer'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
+            'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+        ]);
+    }
+}

+ 113 - 0
app/Http/Controllers/agent/ProfileController.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Models\Agent\AgentLoginLog;
+use App\Services\Agent\AgentDomainService;
+use App\Services\Agent\AgentReportService;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Hash;
+use App\Models\Agent\AgentToken;
+use Illuminate\Support\Facades\DB;
+
+class ProfileController extends AgentApiController
+{
+    public function me(AgentDomainService $domains)
+    {
+        return $this->execute(function () use ($domains) {
+            $agent = $this->currentAgent()->load('parent:id,username');
+            return array_merge([
+                'id' => (int) $agent->id,
+                'username' => $agent->username,
+                'real_name' => $agent->real_name,
+                'parent_id' => $agent->parent_id,
+                'parent_username' => $agent->parent?->username,
+                '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,
+                'flow_commission_rate' => (string) $agent->flow_commission_rate,
+                'profit_commission_rate' => (string) $agent->profit_commission_rate,
+                'last_login_at' => $agent->last_login_at?->toDateTimeString(),
+                'last_login_ip' => $agent->last_login_ip,
+            ], $domains->format($agent));
+        });
+    }
+
+    public function update(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate(['real_name' => ['nullable', 'string', 'max:128']]);
+            $agent = $this->currentAgent();
+            $agent->real_name = trim((string) ($data['real_name'] ?? ''));
+            $agent->save();
+            return [];
+        });
+    }
+
+    public function password(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'type' => ['required', 'in:login,withdraw'],
+                'old_password' => ['required', 'string'],
+                'password' => ['required', 'string', 'min:6', 'max:100', 'confirmed'],
+            ]);
+            DB::transaction(function () use ($data) {
+                $agent = \App\Models\Agent\Agent::query()->lockForUpdate()->findOrFail($this->currentAgent()->id);
+                if (!Hash::check($data['old_password'], (string) $agent->password)) {
+                    throw new \InvalidArgumentException('当前登录密码错误');
+                }
+                if ($data['type'] === 'login') {
+                    $agent->password = Hash::make($data['password']);
+                    AgentToken::query()->where('agent_id', $agent->id)->delete();
+                } else {
+                    $agent->withdraw_password = Hash::make($data['password']);
+                }
+                $agent->save();
+            });
+            return [];
+        });
+    }
+
+    public function domains(AgentDomainService $service)
+    {
+        return $this->execute(fn() => $service->format($this->currentAgent()));
+    }
+
+    public function loginLogs(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'ip' => ['nullable', 'string', 'max:64'], 'device' => ['nullable', 'in:mobile,pc'],
+                '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 = AgentLoginLog::query()->where('agent_id', $this->currentAgent()->id);
+            if (!empty($data['ip'])) $query->where('ip', $data['ip']);
+            if (!empty($data['device'])) $query->where('device', $data['device']);
+            if (!empty($data['start_date'])) $query->where('login_at', '>=', $data['start_date'] . ' 00:00:00');
+            if (!empty($data['end_date'])) $query->where('login_at', '<=', $data['end_date'] . ' 23:59:59');
+            $total = (clone $query)->count();
+            $list = $query->orderByDesc('id')->forPage($page, $limit)->get();
+            return compact('total', 'page', 'limit', 'list');
+        });
+    }
+
+    public function dashboard(Request $request, AgentReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate([
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $start = $data['start_date'] ?? now()->toDateString();
+            $end = $data['end_date'] ?? $start;
+            return $reports->summaryForTree($this->currentAgent(), $start, $end);
+        });
+    }
+}

+ 60 - 0
app/Http/Controllers/agent/ReferralController.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\Agent\AgentDomainService;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Models\User;
+use Carbon\Carbon;
+
+class ReferralController extends AgentApiController
+{
+    public function resolve(Request $request, AgentDomainService $domains)
+    {
+        return $this->execute(function () use ($request, $domains) {
+            $data = $request->validate([
+                'agent_code' => ['nullable', 'string', 'max:32', 'required_without:domain'],
+                'domain' => ['nullable', 'string', 'max:255', 'required_without:agent_code'],
+            ]);
+            $agent = $domains->resolve($data['agent_code'] ?? null, $data['domain'] ?? null);
+            if (!$agent) throw new \RuntimeException('代理推广信息不存在或已禁用');
+            return [
+                'agent_id' => (int) $agent->id,
+                'agent_code' => $agent->invitation_code,
+                'agent_name' => $agent->username,
+            ];
+        });
+    }
+
+    public function bind(Request $request, AgentDomainService $domains)
+    {
+        return $this->execute(function () use ($request, $domains) {
+            $data = $request->validate([
+                'agent_code' => ['nullable', 'string', 'max:32', 'required_without:domain'],
+                'domain' => ['nullable', 'string', 'max:255', 'required_without:agent_code'],
+            ]);
+            $agent = $domains->resolve($data['agent_code'] ?? null, $data['domain'] ?? null);
+            if (!$agent) {
+                throw new \RuntimeException('代理推广信息不存在或已禁用');
+            }
+            return DB::transaction(function () use ($request, $agent) {
+                $user = User::query()->lockForUpdate()->findOrFail($request->user->id);
+                if ($user->agent_id !== null && (int) $user->agent_id !== (int) $agent->id) {
+                    throw new \RuntimeException('会员已绑定其他代理,不能重复修改');
+                }
+                if ($user->agent_id !== null) {
+                    return ['user_id' => (int) $user->id, 'agent_id' => (int) $agent->id];
+                }
+                $createdAt = $user->getRawOriginal('created_at');
+                if (!$createdAt || Carbon::parse($createdAt)->lt(now()->subMinutes(config('agent.referral_bind_minutes', 30)))) {
+                    throw new \RuntimeException('推广归属只能在新会员注册后及时绑定');
+                }
+                $user->agent_id = $agent->id;
+                $user->save();
+                return ['user_id' => (int) $user->id, 'agent_id' => (int) $agent->id];
+            });
+        });
+    }
+}

+ 22 - 0
app/Http/Controllers/agent/ReportController.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\Agent\AgentReportService;
+use Illuminate\Http\Request;
+
+class ReportController extends AgentApiController
+{
+    public function index(Request $request, AgentReportService $reports)
+    {
+        return $this->execute(function () use ($request, $reports) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:20'],
+                'username' => ['nullable', 'string', 'max:64'], 'start_date' => ['required', 'date_format:Y-m-d'],
+                'end_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $reports->reportRows($this->currentAgent(), $data);
+        });
+    }
+}

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

@@ -0,0 +1,103 @@
+<?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');
+            if (!empty($data['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' => ['nullable', 'integer', 'exists:agents,id'],
+                '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' => ['nullable', 'numeric', 'between:0,' . $root->flow_commission_rate, new DecimalNumber(3, 4)],
+                'profit_commission_rate' => ['nullable', 'numeric', 'between:0,' . $root->profit_commission_rate, new DecimalNumber(3, 4)],
+                '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)],
+                '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);
+        });
+    }
+}

+ 104 - 0
app/Http/Controllers/agent/WalletController.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Models\Agent\AgentBalanceLog;
+use App\Models\Agent\AgentWithdrawAccount;
+use App\Models\Agent\AgentWithdrawal;
+use App\Services\Agent\AgentWithdrawalService;
+use Illuminate\Http\Request;
+use App\Rules\DecimalNumber;
+
+class WalletController extends AgentApiController
+{
+    public function logs(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'type' => ['nullable', 'string', 'max:32'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $this->pageQuery(AgentBalanceLog::query()->where('agent_id', $this->currentAgent()->id), $data);
+        });
+    }
+
+    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();
+        });
+    }
+
+    public function saveAccount(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $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'],
+            ]);
+            $agent = $this->currentAgent();
+            $account = empty($data['id']) ? new AgentWithdrawAccount() : AgentWithdrawAccount::query()->where('agent_id', $agent->id)->findOrFail($data['id']);
+            $account->fill($data);
+            $account->agent_id = $agent->id;
+            $account->save();
+            return $account;
+        });
+    }
+
+    public function deleteAccount(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate(['id' => ['required', 'integer']]);
+            $account = AgentWithdrawAccount::query()->where('agent_id', $this->currentAgent()->id)->findOrFail($data['id']);
+            if (AgentWithdrawal::query()->where('account_id', $account->id)->whereIn('status', ['pending', 'approved'])->exists()) {
+                throw new \RuntimeException('该账户有处理中的提现,不能删除');
+            }
+            $account->delete();
+            return [];
+        });
+    }
+
+    public function withdrawals(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', 'in:pending,approved,rejected,paid'], 'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            return $this->pageQuery(AgentWithdrawal::query()->where('agent_id', $this->currentAgent()->id), $data);
+        });
+    }
+
+    public function requestWithdrawal(Request $request, AgentWithdrawalService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate([
+                'account_id' => ['required', 'integer'], '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'] ?? ''));
+        });
+    }
+
+    private function pageQuery($query, array $data): array
+    {
+        $page = max(1, (int) ($data['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
+        if (!empty($data['type'])) $query->where('type', $data['type']);
+        if (!empty($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');
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc('id')->forPage($page, $limit)->get();
+        return compact('total', 'page', 'limit', 'list');
+    }
+}

+ 2 - 0
app/Http/Kernel.php

@@ -68,6 +68,8 @@ class Kernel extends HttpKernel
         'jwt' => \App\Http\Middleware\JwtMiddleware::class,
         'check.button.uri' => \App\Http\Middleware\CheckButtonPermission::class,
         'check.token' => \App\Http\Middleware\CheckToken::class,
+        'agent.auth' => \App\Http\Middleware\AgentAuthMiddleware::class,
+        'agent.admin' => \App\Http\Middleware\AgentAdminMiddleware::class,
 
         // 系统默认的中间件
         'auth' => Authenticate::class,

+ 23 - 0
app/Http/Middleware/AgentAdminMiddleware.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+class AgentAdminMiddleware
+{
+    public function handle(Request $request, Closure $next)
+    {
+        $adminId = (int) ($request->user->id ?? 0);
+        if (!in_array($adminId, config('agent.admin_ids', [1]), true)) {
+            return response()->json([
+                'code' => -1,
+                'timestamp' => time(),
+                'msg' => '无代理管理权限',
+                'data' => [],
+            ]);
+        }
+        return $next($request);
+    }
+}

+ 37 - 0
app/Http/Middleware/AgentAuthMiddleware.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use App\Constants\HttpStatus;
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentToken;
+use Closure;
+use Illuminate\Http\Request;
+
+class AgentAuthMiddleware
+{
+    public function handle(Request $request, Closure $next)
+    {
+        $plainToken = preg_replace('/^Bearer\s+/i', '', (string) $request->header('Authorization'));
+        if ($plainToken === '') {
+            return response()->json(['code' => HttpStatus::AUTHORIZATION_HEADER_NOT_FOUND, 'timestamp' => time(), 'msg' => '请先登录', 'data' => []]);
+        }
+
+        $token = AgentToken::query()
+            ->where('token_hash', hash('sha256', $plainToken))
+            ->where('expires_at', '>', now())
+            ->first();
+        $agent = $token ? Agent::query()->find($token->agent_id) : null;
+        if (!$token || !$agent || (int) $agent->status !== Agent::STATUS_ENABLED) {
+            return response()->json(['code' => HttpStatus::AUTHORIZATION_HEADER_NOT_FOUND, 'timestamp' => time(), 'msg' => '登录已失效', 'data' => []]);
+        }
+
+        if (!$token->last_used_at || $token->last_used_at->lt(now()->subMinute())) {
+            $token->last_used_at = now();
+            $token->save();
+        }
+        $request->attributes->set('agent', $agent);
+        $request->attributes->set('agent_token', $token);
+        return $next($request);
+    }
+}

+ 53 - 0
app/Models/Agent/Agent.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+use App\Models\User;
+
+class Agent extends BaseModel
+{
+    public const STATUS_DISABLED = 0;
+    public const STATUS_ENABLED = 1;
+
+    protected $table = 'agents';
+    protected $fillable = [
+        'parent_id', 'path', 'level', 'username', 'password', 'withdraw_password',
+        'real_name', 'invitation_code', 'balance', 'frozen_balance',
+        'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_rate',
+        'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
+    ];
+    protected $hidden = ['password', 'withdraw_password'];
+    protected $casts = [
+        'parent_id' => 'integer', 'level' => 'integer', 'status' => 'integer',
+        'balance' => 'decimal:4', 'frozen_balance' => 'decimal:4',
+        'deposit_fee_rate' => 'decimal:4', 'withdraw_fee_rate' => 'decimal:4',
+        'flow_commission_rate' => 'decimal:4', 'profit_commission_rate' => 'decimal:4',
+        'last_login_at' => 'datetime',
+    ];
+
+    public function parent()
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    public function domains()
+    {
+        return $this->hasMany(AgentDomain::class);
+    }
+
+    public function token()
+    {
+        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');
+    }
+}

+ 10 - 0
app/Models/Agent/AgentBalanceLog.php

@@ -0,0 +1,10 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentBalanceLog extends BaseModel
+{
+    protected $table = 'agent_balance_logs';
+    protected $fillable = ['agent_id', 'type', 'amount', 'before_balance', 'after_balance', 'reference_type', 'reference_id', 'operator_type', 'operator_id', 'idempotency_key', 'remark'];
+    protected $hidden = ['idempotency_key'];
+    protected $casts = ['agent_id' => 'integer', 'amount' => 'decimal:4', 'before_balance' => 'decimal:4', 'after_balance' => 'decimal:4'];
+}

+ 15 - 0
app/Models/Agent/AgentCommission.php

@@ -0,0 +1,15 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentCommission extends BaseModel
+{
+    protected $table = 'agent_commissions';
+    protected $fillable = ['settlement_date', 'agent_id', 'type', 'base_amount', 'rate', 'amount', 'bet_count', 'status', 'snapshot', 'credited_at'];
+    protected $hidden = [];
+    protected $casts = ['settlement_date' => 'date', 'agent_id' => 'integer', 'base_amount' => 'decimal:4', 'rate' => 'decimal:4', 'amount' => 'decimal:4', 'snapshot' => 'array', 'credited_at' => 'datetime'];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+}

+ 15 - 0
app/Models/Agent/AgentCommissionRule.php

@@ -0,0 +1,15 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentCommissionRule extends BaseModel
+{
+    protected $table = 'agent_commission_rules';
+    protected $fillable = ['agent_id', 'flow_rate', 'profit_rate', 'effective_from', 'status', 'created_by'];
+    protected $hidden = [];
+    protected $casts = ['agent_id' => 'integer', 'flow_rate' => 'decimal:4', 'profit_rate' => 'decimal:4', 'effective_from' => 'date', 'status' => 'integer'];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+}

+ 10 - 0
app/Models/Agent/AgentDailyStat.php

@@ -0,0 +1,10 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentDailyStat extends BaseModel
+{
+    protected $table = 'agent_daily_stats';
+    protected $guarded = [];
+    protected $hidden = [];
+    protected $casts = ['stat_date' => 'date', 'agent_id' => 'integer'];
+}

+ 10 - 0
app/Models/Agent/AgentDomain.php

@@ -0,0 +1,10 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentDomain extends BaseModel
+{
+    protected $table = 'agent_domains';
+    protected $fillable = ['agent_id', 'type', 'domain', 'host', 'status'];
+    protected $hidden = [];
+    protected $casts = ['agent_id' => 'integer', 'status' => 'integer'];
+}

+ 21 - 0
app/Models/Agent/AgentLoginLog.php

@@ -0,0 +1,21 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentLoginLog extends BaseModel
+{
+    protected $table = 'agent_login_logs';
+    protected $fillable = ['agent_id', 'username', 'domain', 'ip', 'country', 'region', 'city', 'device', 'user_agent', 'login_at'];
+    protected $hidden = [];
+    protected $appends = ['device_text', 'location'];
+    protected $casts = ['agent_id' => 'integer', 'login_at' => 'datetime'];
+
+    public function getDeviceTextAttribute(): string
+    {
+        return $this->device === 'mobile' ? '手机端' : '电脑端';
+    }
+
+    public function getLocationAttribute(): string
+    {
+        return implode(' ', array_filter([$this->country, $this->region, $this->city]));
+    }
+}

+ 10 - 0
app/Models/Agent/AgentToken.php

@@ -0,0 +1,10 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentToken extends BaseModel
+{
+    protected $table = 'agent_tokens';
+    protected $fillable = ['agent_id', 'token_hash', 'expires_at', 'last_used_at', 'ip', 'device'];
+    protected $hidden = ['token_hash'];
+    protected $casts = ['expires_at' => 'datetime', 'last_used_at' => 'datetime'];
+}

+ 10 - 0
app/Models/Agent/AgentWithdrawAccount.php

@@ -0,0 +1,10 @@
+<?php
+namespace App\Models\Agent;
+use App\Models\BaseModel;
+class AgentWithdrawAccount extends BaseModel
+{
+    protected $table = 'agent_withdraw_accounts';
+    protected $fillable = ['agent_id', 'type', 'name', 'account', 'bank_name', 'network', 'details', 'status'];
+    protected $hidden = [];
+    protected $casts = ['agent_id' => 'integer', 'details' => 'array', 'status' => 'integer'];
+}

+ 15 - 0
app/Models/Agent/AgentWithdrawal.php

@@ -0,0 +1,15 @@
+<?php
+namespace App\Models\Agent;
+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 $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'];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+}

+ 7 - 1
app/Models/User.php

@@ -6,6 +6,7 @@ namespace App\Models;
 // use Illuminate\Database\Eloquent\Builder;
 
 use Carbon\Carbon;
+use App\Models\Agent\Agent as SystemAgent;
 
 /**
  * @property int $id
@@ -24,7 +25,7 @@ class User extends BaseModel
 {
     protected $table = 'users';
     protected $fillable = ['usdt', 'is_banned', 'last_active_time', 'visitor_id', 'register_ip', 'register_domain', 'status', 'admin_note', 'member_id', 'first_name', 
-    'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code','level','recharge_channel_group_id'];
+    'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code', 'agent_id', 'level','recharge_channel_group_id'];
     protected $attributes = [
         'language' => 'zh',
     ];
@@ -73,6 +74,11 @@ class User extends BaseModel
         return $this->belongsTo(User::class, 'agent_user_code', 'user_code')->select('id', 'user_code', 'account','first_name','member_id');
     }
 
+    public function systemAgent()
+    {
+        return $this->belongsTo(SystemAgent::class, 'agent_id');
+    }
+
     public function setLanguage($language): void
     {
         $this->language = $language;

+ 4 - 0
app/Providers/RouteServiceProvider.php

@@ -38,6 +38,10 @@ class RouteServiceProvider extends ServiceProvider
                 ->prefix('admin')
                 ->group(base_path('routes/admin.php'));
 
+            Route::middleware('api')
+                ->prefix('agent')
+                ->group(base_path('routes/agent.php'));
+
             Route::middleware('web')
                 ->group(base_path('routes/web.php'));
         });

+ 28 - 0
app/Rules/DecimalNumber.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class DecimalNumber implements Rule
+{
+    public function __construct(private int $integerDigits = 14, private int $decimalPlaces = 4)
+    {
+    }
+
+    public function passes($attribute, $value): bool
+    {
+        if (!is_string($value) && !is_int($value) && !is_float($value)) {
+            return false;
+        }
+        return preg_match(
+            '/^\d{1,' . $this->integerDigits . '}(?:\.\d{1,' . $this->decimalPlaces . '})?$/D',
+            (string) $value
+        ) === 1;
+    }
+
+    public function message(): string
+    {
+        return ':attribute 格式错误,最多 ' . $this->integerDigits . ' 位整数和 ' . $this->decimalPlaces . ' 位小数';
+    }
+}

+ 77 - 0
app/Services/Agent/AgentAuthService.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentLoginLog;
+use App\Models\Agent\AgentToken;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+
+class AgentAuthService
+{
+    public function __construct(private AgentContextService $context)
+    {
+    }
+
+    public function login(string $username, string $password, Request $request): array
+    {
+        $agent = Agent::query()->where('username', $username)->first();
+        if (!$agent || !Hash::check($password, (string) $agent->password)) {
+            throw new \InvalidArgumentException('代理账号或密码错误');
+        }
+        if ((int) $agent->status !== Agent::STATUS_ENABLED) {
+            throw new \InvalidArgumentException('代理账号已禁用');
+        }
+
+        return DB::transaction(function () use ($agent, $password, $request): array {
+            $lockedAgent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            if (!Hash::check($password, (string) $lockedAgent->password)) {
+                throw new \InvalidArgumentException('代理账号或密码错误');
+            }
+            if ((int) $lockedAgent->status !== Agent::STATUS_ENABLED) {
+                throw new \InvalidArgumentException('代理账号已禁用');
+            }
+            $plainToken = bin2hex(random_bytes(32));
+            $device = $this->context->device($request);
+            $location = $this->context->location($request);
+            $now = now();
+
+            AgentToken::query()->where('agent_id', $lockedAgent->id)->delete();
+            AgentToken::query()->create([
+                'agent_id' => $lockedAgent->id,
+                'token_hash' => hash('sha256', $plainToken),
+                'expires_at' => $now->copy()->addDays(config('agent.token_ttl_days', 7)),
+                'last_used_at' => $now,
+                'ip' => $request->ip() ?: '',
+                'device' => $device,
+            ]);
+
+            AgentLoginLog::query()->create(array_merge($location, [
+                'agent_id' => $lockedAgent->id,
+                'username' => $lockedAgent->username,
+                'domain' => $this->context->domain($request),
+                'ip' => $request->ip() ?: '',
+                'device' => $device,
+                'user_agent' => mb_substr((string) $request->userAgent(), 0, 500),
+                'login_at' => $now,
+            ]));
+
+            $lockedAgent->last_login_at = $now;
+            $lockedAgent->last_login_ip = $request->ip() ?: '';
+            $lockedAgent->save();
+
+            return [
+                'token' => $plainToken,
+                'token_type' => 'Bearer',
+                'expires_at' => $now->copy()->addDays(config('agent.token_ttl_days', 7))->toDateTimeString(),
+            ];
+        });
+    }
+
+    public function logout(AgentToken $token): void
+    {
+        $token->delete();
+    }
+}

+ 159 - 0
app/Services/Agent/AgentCommissionService.php

@@ -0,0 +1,159 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentCommission;
+use App\Models\Agent\AgentCommissionRule;
+use App\Models\Agent\AgentDailyStat;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class AgentCommissionService
+{
+    public function __construct(private AgentReportService $reports, private AgentWalletService $wallets)
+    {
+    }
+
+    public function saveRule(Agent $agent, string $flowRate, string $profitRate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
+    {
+        return $this->saveRates($agent, $flowRate, $profitRate, $effectiveFrom, $adminId);
+    }
+
+    public function saveFlowRate(Agent $agent, string $rate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
+    {
+        return $this->saveRates($agent, $rate, null, $effectiveFrom, $adminId);
+    }
+
+    public function saveProfitRate(Agent $agent, string $rate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
+    {
+        return $this->saveRates($agent, null, $rate, $effectiveFrom, $adminId);
+    }
+
+    public function settle(string $date, bool $credit = true): array
+    {
+        $day = Carbon::parse($date)->toDateString();
+        $created = 0;
+        $credited = 0;
+        Agent::query()->where('status', Agent::STATUS_ENABLED)->orderBy('id')->chunkById(100, function ($agents) use ($day, $credit, &$created, &$credited) {
+            foreach ($agents as $agent) {
+                $descendantIds = $this->reports->visibleAgentIds($agent);
+                $base = AgentDailyStat::query()->where('stat_date', $day)->whereIn('agent_id', $descendantIds)
+                    ->selectRaw('COALESCE(SUM(valid_bet_amount),0) valid_bet_amount, COALESCE(SUM(company_profit),0) company_profit, COALESCE(SUM(bet_count),0) bet_count')
+                    ->first();
+                $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
+                    ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
+                if (!$rule) {
+                    throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的佣金比例记录");
+                }
+                $rates = [
+                    'flow' => (string) $rule->flow_rate,
+                    'profit' => (string) $rule->profit_rate,
+                ];
+                $bases = [
+                    'flow' => (string) ($base->valid_bet_amount ?? 0),
+                    'profit' => max('0', (string) ($base->company_profit ?? 0)),
+                ];
+                foreach (['flow', 'profit'] as $type) {
+                    $amount = bcdiv(bcmul($bases[$type], $rates[$type], 8), '100', 4);
+                    $commission = AgentCommission::query()->firstOrNew([
+                        'settlement_date' => $day,
+                        'agent_id' => $agent->id,
+                        'type' => $type,
+                    ]);
+                    $isNew = !$commission->exists;
+                    if ($isNew || $commission->status === 'pending') {
+                        $commission->fill([
+                            'base_amount' => $bases[$type], 'rate' => $rates[$type], 'amount' => $amount,
+                            'bet_count' => (int) ($base->bet_count ?? 0), 'status' => 'pending',
+                            'snapshot' => ['agent_ids' => $descendantIds],
+                        ]);
+                        $commission->save();
+                    }
+                    if ($isNew) {
+                        $created++;
+                    }
+                    if ($credit && $commission->status === 'pending' && bccomp((string) $commission->amount, '0', 4) > 0) {
+                        $this->wallets->adjust(
+                            (int) $agent->id,
+                            (string) $commission->amount,
+                            'commission',
+                            $day . ($type === 'flow' ? '流水佣金' : '盈亏佣金'),
+                            'commission:' . $commission->id,
+                            'system',
+                            null,
+                            'agent_commission',
+                            (int) $commission->id
+                        );
+                        $commission->status = 'credited';
+                        $commission->credited_at = now();
+                        $commission->save();
+                        $credited++;
+                    } elseif ($credit && $commission->status === 'pending') {
+                        $commission->status = 'credited';
+                        $commission->credited_at = now();
+                        $commission->save();
+                    }
+                }
+                AgentDailyStat::query()->where('stat_date', $day)->where('agent_id', $agent->id)->update([
+                    'commission_amount' => AgentCommission::query()->where('settlement_date', $day)
+                        ->where('agent_id', $agent->id)->where('status', 'credited')->sum('amount'),
+                ]);
+            }
+        });
+        return compact('created', 'credited');
+    }
+
+    private function rateAt(Agent $agent, string $field, string $day): string
+    {
+        $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
+            ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
+        if ($rule) {
+            return (string) $rule->{$field};
+        }
+        $agentField = $field === 'flow_rate' ? 'flow_commission_rate' : 'profit_commission_rate';
+        return (string) $agent->{$agentField};
+    }
+
+    private function saveRates(Agent $agent, ?string $flowRate, ?string $profitRate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
+    {
+        foreach (array_filter([$flowRate, $profitRate], fn($rate) => $rate !== null) as $rate) {
+            if (bccomp((string) $rate, '0', 4) < 0 || bccomp((string) $rate, '100', 4) > 0) {
+                throw new \InvalidArgumentException('佣金比例必须在0到100之间');
+            }
+        }
+        $day = Carbon::parse($effectiveFrom)->toDateString();
+        return DB::transaction(function () use ($agent, $flowRate, $profitRate, $day, $adminId) {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            if (AgentCommission::query()->where('agent_id', $agent->id)->where('settlement_date', '>=', $day)->where('status', 'credited')->exists()) {
+                throw new \RuntimeException('该日期佣金已入账,不能修改比例');
+            }
+            $existing = AgentCommissionRule::query()->where('agent_id', $agent->id)
+                ->where('effective_from', $day)->lockForUpdate()->first();
+            $flowRate = $flowRate ?? (string) ($existing?->flow_rate ?? $this->rateAt($agent, 'flow_rate', $day));
+            $profitRate = $profitRate ?? (string) ($existing?->profit_rate ?? $this->rateAt($agent, 'profit_rate', $day));
+
+            $parent = $agent->parent;
+            if ($parent && (
+                bccomp($flowRate, $this->rateAt($parent, 'flow_rate', $day), 4) > 0
+                || bccomp($profitRate, $this->rateAt($parent, 'profit_rate', $day), 4) > 0
+            )) {
+                throw new \RuntimeException('下级代理佣金比例不能高于上级');
+            }
+            $children = Agent::query()->where('parent_id', $agent->id)->get();
+            $maxFlow = $children->map(fn(Agent $child) => $this->rateAt($child, 'flow_rate', $day))->max() ?? 0;
+            $maxProfit = $children->map(fn(Agent $child) => $this->rateAt($child, 'profit_rate', $day))->max() ?? 0;
+            if (bccomp($flowRate, (string) $maxFlow, 4) < 0 || bccomp($profitRate, (string) $maxProfit, 4) < 0) {
+                throw new \RuntimeException('代理佣金比例不能低于现有下级比例');
+            }
+
+            $agent->flow_commission_rate = $flowRate;
+            $agent->profit_commission_rate = $profitRate;
+            $agent->save();
+            return AgentCommissionRule::query()->updateOrCreate(
+                ['agent_id' => $agent->id, 'effective_from' => $day],
+                ['flow_rate' => $flowRate, 'profit_rate' => $profitRate, 'status' => 1, 'created_by' => $adminId]
+            );
+        });
+    }
+}

+ 37 - 0
app/Services/Agent/AgentContextService.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Services\Agent;
+
+use Illuminate\Http\Request;
+
+class AgentContextService
+{
+    public function device(Request $request): string
+    {
+        $ua = strtolower((string) $request->userAgent());
+        return preg_match('/mobile|android|iphone|ipad|ipod|windows phone|harmonyos/', $ua)
+            ? 'mobile'
+            : 'pc';
+    }
+
+    public function domain(Request $request): string
+    {
+        foreach ([(string) $request->header('Origin'), (string) $request->header('Referer')] as $url) {
+            $host = parse_url($url, PHP_URL_HOST);
+            if (is_string($host) && $host !== '') {
+                return strtolower($host);
+            }
+        }
+        return strtolower((string) $request->getHost());
+    }
+
+    public function location(Request $request): array
+    {
+        // 优先读取 CDN/网关在边缘完成的 IP 归属地;没有时保留空值,避免登录依赖外部接口。
+        return [
+            'country' => mb_substr((string) ($request->header('CF-IPCountry') ?: $request->header('X-IP-Country', '')), 0, 128),
+            'region' => mb_substr((string) $request->header('X-IP-Region', ''), 0, 128),
+            'city' => mb_substr((string) $request->header('X-IP-City', ''), 0, 128),
+        ];
+    }
+}

+ 90 - 0
app/Services/Agent/AgentDomainService.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentDomain;
+use Illuminate\Support\Facades\DB;
+
+class AgentDomainService
+{
+    public function save(Agent $agent, array $domains): array
+    {
+        return DB::transaction(function () use ($agent, $domains) {
+            foreach (['pc', 'h5'] as $type) {
+                if (!array_key_exists($type, $domains)) {
+                    continue;
+                }
+                $url = $this->normalize((string) $domains[$type]);
+                if ($url === '') {
+                    AgentDomain::query()->where('agent_id', $agent->id)->where('type', $type)->delete();
+                    continue;
+                }
+                $host = $this->host($url);
+                $owner = AgentDomain::query()->where('host', $host)->lockForUpdate()->first();
+                if ($owner && (int) $owner->agent_id !== (int) $agent->id) {
+                    throw new \RuntimeException('该域名已绑定其他代理');
+                }
+                AgentDomain::query()->updateOrCreate(
+                    ['agent_id' => $agent->id, 'type' => $type],
+                    ['domain' => $url, 'host' => $host, 'status' => 1]
+                );
+            }
+            return $this->format($agent->fresh());
+        });
+    }
+
+    public function format(Agent $agent): array
+    {
+        $domains = $agent->domains()->where('status', 1)->pluck('domain', 'type');
+        $pc = (string) ($domains['pc'] ?? '');
+        $h5 = (string) ($domains['h5'] ?? '');
+        return [
+            'pc_domain' => $pc,
+            'h5_domain' => $h5,
+            'pc_register_url' => $pc === '' ? '' : $this->registerUrl($pc, (string) config('agent.pc_register_path'), $agent->invitation_code),
+            'h5_register_url' => $h5 === '' ? '' : $this->registerUrl($h5, (string) config('agent.h5_register_path'), $agent->invitation_code),
+        ];
+    }
+
+    public function resolve(?string $invitationCode, ?string $domain): ?Agent
+    {
+        if ($invitationCode) {
+            return Agent::query()->where('invitation_code', strtoupper(trim($invitationCode)))
+                ->where('status', Agent::STATUS_ENABLED)->first();
+        }
+        $normalized = $domain ? $this->normalize($domain) : '';
+        if ($normalized === '') {
+            return null;
+        }
+        $domainRow = AgentDomain::query()->where('host', $this->host($normalized))->where('status', 1)->first();
+        return $domainRow ? Agent::query()->whereKey($domainRow->agent_id)->where('status', Agent::STATUS_ENABLED)->first() : null;
+    }
+
+    private function normalize(string $value): string
+    {
+        $value = trim($value);
+        if ($value === '') {
+            return '';
+        }
+        $candidate = preg_match('#^https?://#i', $value) ? $value : 'https://' . $value;
+        $scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));
+        $host = strtolower((string) parse_url($candidate, PHP_URL_HOST));
+        $port = parse_url($candidate, PHP_URL_PORT);
+        if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
+            throw new \InvalidArgumentException('域名格式错误');
+        }
+        return $scheme . '://' . $host . ($port ? ':' . $port : '');
+    }
+
+    private function registerUrl(string $domain, string $path, string $code): string
+    {
+        $separator = str_contains($path, '?') ? '&' : '?';
+        return rtrim($domain, '/') . '/' . ltrim($path, '/') . $separator . 'agent_code=' . urlencode($code);
+    }
+
+    private function host(string $url): string
+    {
+        return strtolower((string) parse_url($url, PHP_URL_HOST));
+    }
+}

+ 121 - 0
app/Services/Agent/AgentMemberService.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\User;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class AgentMemberService
+{
+    public function __construct(private AgentReportService $reports)
+    {
+    }
+
+    public function paginate(Agent $root, array $params): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $wallets = DB::table('wallets')->selectRaw('member_id, COALESCE(SUM(available_balance),0) available_balance, COALESCE(SUM(frozen_balance),0) frozen_balance')->groupBy('member_id');
+        $query = User::query()->leftJoinSub($wallets, 'wallets', 'wallets.member_id', '=', 'users.member_id')
+            ->whereIn('users.agent_id', $this->reports->visibleAgentIds($root));
+        if (!empty($params['keyword'])) {
+            $keyword = '%' . $params['keyword'] . '%';
+            $query->where(function ($query) use ($keyword) {
+                $query->where('users.username', 'like', $keyword)
+                    ->orWhere('users.first_name', 'like', $keyword)
+                    ->orWhere('users.member_id', 'like', $keyword);
+            });
+        }
+        if (!empty($params['agent_id'])) {
+            $query->where('users.agent_id', (int) $params['agent_id']);
+        }
+        if (isset($params['status']) && $params['status'] !== '') {
+            $query->where('users.status', (int) $params['status']);
+        }
+        if (!empty($params['start_date'])) {
+            $query->where('users.created_at', '>=', Carbon::parse($params['start_date'])->startOfDay());
+        }
+        if (!empty($params['end_date'])) {
+            $query->where('users.created_at', '<=', Carbon::parse($params['end_date'])->endOfDay());
+        }
+        $total = (clone $query)->count('users.id');
+        $list = $query->leftJoin('agents', 'agents.id', '=', 'users.agent_id')
+            ->select([
+                'users.id', 'users.member_id', 'users.username', 'users.first_name', 'users.status',
+                'users.agent_id', 'users.register_ip', 'users.register_domain', 'users.created_at',
+                'wallets.available_balance', 'wallets.frozen_balance', 'agents.username as agent_username',
+            ])->orderByDesc('users.id')->forPage($page, $limit)->get();
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    public function finance(Agent $root, array $params): array
+    {
+        $listParams = $params;
+        unset($listParams['start_date'], $listParams['end_date']);
+        $listParams['limit'] = min(20, max(1, (int) ($params['limit'] ?? 10)));
+        $result = $this->paginate($root, $listParams);
+        $start = Carbon::parse($params['start_date'] ?? now()->toDateString())->startOfDay();
+        $end = Carbon::parse($params['end_date'] ?? now()->toDateString())->endOfDay();
+        $result['list'] = collect($result['list'])->map(function ($member) use ($start, $end) {
+            $memberId = $member->member_id;
+            $summary = $this->reports->summaryForMemberIds([$memberId], $start->toDateString(), $end->toDateString());
+            foreach ([
+                'recharge_amount' => 'deposit_amount', 'withdraw_amount' => 'withdraw_amount',
+                'manual_credit' => 'manual_credit', 'manual_debit' => 'manual_debit',
+                'bonus_amount' => 'bonus_amount', 'rebate_amount' => 'rebate_amount',
+                'bet_count' => 'bet_count', 'bet_amount' => 'bet_amount',
+                'valid_bet_amount' => 'valid_bet_amount', 'win_loss' => 'win_loss',
+            ] as $target => $source) {
+                $member->{$target} = $summary[$source];
+            }
+            $member->recharge_count = DB::table('recharges')->where('member_id', $memberId)->where('status', 1)->whereBetween('created_at', [$start, $end])->count();
+            $member->withdraw_count = DB::table('withdraws')->where('member_id', $memberId)->where('status', 1)->whereBetween('created_at', [$start, $end])->count();
+            return $member;
+        })->all();
+        return $result;
+    }
+
+    public function bets(Agent $root, array $params): array
+    {
+        $source = (string) ($params['source'] ?? 'third');
+        $definitions = [
+            'pc28' => ['table' => 'bets', 'status' => 'status', 'time' => 'created_at', 'member' => 'member_id'],
+            'sport' => ['table' => 'sport_game_order', 'status' => 'status', 'time' => 'created_at', 'member' => 'member_id'],
+            'jisu' => ['table' => 'jisu_game_order', 'status' => 'status', 'time' => 'created_at', 'member' => 'member_id'],
+            'lhc' => ['table' => 'lhc_order', 'status' => 'lottery_status', 'time' => 'created_at', 'member' => 'member_id'],
+            'third' => ['table' => 'third_game_orders', 'status' => 'status', 'time' => 'last_update_time', 'member' => 'member_id'],
+        ];
+        if (!isset($definitions[$source])) {
+            throw new \InvalidArgumentException('投注来源错误');
+        }
+        $definition = $definitions[$source];
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = DB::table($definition['table'])
+            ->join('users', 'users.member_id', '=', $definition['table'] . '.' . $definition['member'])
+            ->whereIn('users.agent_id', $this->reports->visibleAgentIds($root));
+        if (!empty($params['member_id'])) {
+            $query->where('users.member_id', (string) $params['member_id']);
+        }
+        if (!empty($params['username'])) {
+            $query->where('users.username', 'like', '%' . $params['username'] . '%');
+        }
+        if (isset($params['status']) && $params['status'] !== '') {
+            $query->where($definition['table'] . '.' . $definition['status'], (int) $params['status']);
+        }
+        if (!empty($params['start_date'])) {
+            $start = Carbon::parse($params['start_date'])->startOfDay();
+            $query->where($definition['table'] . '.' . $definition['time'], '>=', $source === 'lhc' ? $start->timestamp : $start);
+        }
+        if (!empty($params['end_date'])) {
+            $end = Carbon::parse($params['end_date'])->endOfDay();
+            $query->where($definition['table'] . '.' . $definition['time'], '<=', $source === 'lhc' ? $end->timestamp : $end);
+        }
+        $total = (clone $query)->count($definition['table'] . '.id');
+        $list = $query->select($definition['table'] . '.*', 'users.username', 'users.first_name', 'users.agent_id')
+            ->orderByDesc($definition['table'] . '.id')->forPage($page, $limit)->get();
+        return compact('source', 'total', 'page', 'limit', 'list');
+    }
+}

+ 248 - 0
app/Services/Agent/AgentReportService.php

@@ -0,0 +1,248 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentDailyStat;
+use App\Models\User;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+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('path', 'like', rtrim((string) $root->path, '/') . '/%'))
+            ->pluck('id')->map(fn($id) => (int) $id)->all();
+    }
+
+    public function summaryForTree(Agent $root, string $startDate, string $endDate): array
+    {
+        $ids = $this->visibleAgentIds($root);
+        $summary = $this->summaryForAgentIds($ids, $startDate, $endDate);
+        $summary['agent_count'] = max(0, count($ids) - 1);
+        return $summary;
+    }
+
+    public function summaryForAgentIds(array $agentIds, string $startDate, string $endDate): array
+    {
+        if ($agentIds === []) {
+            return $this->emptySummary();
+        }
+        return $this->aggregate(
+            User::query()->whereIn('agent_id', $agentIds),
+            count($agentIds),
+            $agentIds,
+            $startDate,
+            $endDate
+        );
+    }
+
+    public function summaryForMemberIds(array $memberIds, string $startDate, string $endDate): array
+    {
+        if ($memberIds === []) {
+            return $this->emptySummary();
+        }
+        return $this->aggregate(User::query()->whereIn('member_id', $memberIds), 0, [], $startDate, $endDate);
+    }
+
+    private function aggregate($members, int $agentCount, array $commissionAgentIds, string $startDate, string $endDate): array
+    {
+        $start = Carbon::parse($startDate)->startOfDay();
+        $end = Carbon::parse($endDate)->endOfDay();
+        if ($start->gt($end)) {
+            throw new \InvalidArgumentException('开始日期不能大于结束日期');
+        }
+        if ($start->diffInDays($end) > 366) {
+            throw new \InvalidArgumentException('单次报表查询不能超过366天');
+        }
+        $memberCount = (clone $members)->count();
+        $memberIds = (clone $members)->select('member_id');
+        $memberBalance = DB::table('wallets')->whereIn('member_id', clone $memberIds)->sum('available_balance');
+        $depositAmount = DB::table('recharges')->whereIn('member_id', clone $memberIds)
+            ->where('status', 1)->whereBetween('created_at', [$start, $end])->sum('amount');
+        $withdrawAmount = DB::table('withdraws')->whereIn('member_id', clone $memberIds)
+            ->where('status', 1)->whereBetween('created_at', [$start, $end])->sum('amount');
+        if (Schema::hasTable('payment_orders')) {
+            $depositAmount = bcadd((string) $depositAmount, (string) DB::table('payment_orders')
+                ->whereIn('member_id', clone $memberIds)
+                ->where('status', PaymentOrderService::STATUS_SUCCESS)
+                ->where('type', PaymentOrderService::TYPE_PAY)
+                ->whereBetween('created_at', [$start, $end])->sum('amount'), 4);
+            $withdrawAmount = bcadd((string) $withdrawAmount, (string) DB::table('payment_orders')
+                ->whereIn('member_id', clone $memberIds)
+                ->where('status', PaymentOrderService::STATUS_SUCCESS)
+                ->whereIn('type', [PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT])
+                ->whereBetween('created_at', [$start, $end])->sum('amount'), 4);
+        }
+
+        $funds = DB::table('balance_logs')->whereIn('member_id', clone $memberIds)
+            ->whereBetween('created_at', [$start, $end])
+            ->selectRaw(<<<'SQL'
+                COALESCE(SUM(CASE WHEN change_type = '人工充值' AND related_id IS NULL 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)->first();
+
+        $bet = $this->betSummary($memberIds, $start, $end);
+        $companyProfit = $this->companyProfit(
+            (string) $depositAmount,
+            (string) $withdrawAmount,
+            (string) ($funds->bonus_amount ?? 0),
+            (string) ($funds->rebate_amount ?? 0)
+        );
+        $commissionAmount = $commissionAgentIds === [] ? 0 : DB::table('agent_commissions')
+            ->whereIn('agent_id', $commissionAgentIds)->where('status', 'credited')
+            ->whereBetween('settlement_date', [$start->toDateString(), $end->toDateString()])->sum('amount');
+
+        return [
+            'agent_count' => $agentCount,
+            'member_count' => $memberCount,
+            'member_balance' => $this->decimal($memberBalance),
+            'deposit_amount' => $this->decimal($depositAmount),
+            'withdraw_amount' => $this->decimal($withdrawAmount),
+            'manual_credit' => $this->decimal($funds->manual_credit ?? 0),
+            'manual_debit' => $this->decimal($funds->manual_debit ?? 0),
+            'bonus_amount' => $this->decimal($funds->bonus_amount ?? 0),
+            'rebate_amount' => $this->decimal($funds->rebate_amount ?? 0),
+            'bet_count' => $bet['bet_count'],
+            'bet_amount' => $bet['bet_amount'],
+            'valid_bet_amount' => $bet['valid_bet_amount'],
+            'win_loss' => $bet['win_loss'],
+            'commission_amount' => $this->decimal($commissionAmount),
+            'company_profit' => $companyProfit,
+        ];
+    }
+
+    public function refreshDaily(string $date): int
+    {
+        $day = Carbon::parse($date)->toDateString();
+        $count = 0;
+        Agent::query()->orderBy('id')->chunkById(100, function ($agents) use ($day, &$count) {
+            foreach ($agents as $agent) {
+                $summary = $this->summaryForAgentIds([(int) $agent->id], $day, $day);
+                AgentDailyStat::query()->updateOrCreate(
+                    ['stat_date' => $day, 'agent_id' => $agent->id],
+                    [
+                        'direct_agents' => Agent::query()->where('parent_id', $agent->id)->count(),
+                        'direct_members' => $summary['member_count'],
+                        'member_balance' => $summary['member_balance'],
+                        'deposit_amount' => $summary['deposit_amount'],
+                        'withdraw_amount' => $summary['withdraw_amount'],
+                        'manual_credit' => $summary['manual_credit'],
+                        'manual_debit' => $summary['manual_debit'],
+                        'bonus_amount' => $summary['bonus_amount'],
+                        'rebate_amount' => $summary['rebate_amount'],
+                        'bet_amount' => $summary['bet_amount'],
+                        'valid_bet_amount' => $summary['valid_bet_amount'],
+                        'bet_count' => $summary['bet_count'],
+                        'win_loss' => $summary['win_loss'],
+                        'commission_amount' => $summary['commission_amount'],
+                        'company_profit' => $summary['company_profit'],
+                    ]
+                );
+                $count++;
+            }
+        });
+        return $count;
+    }
+
+    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');
+        if ($scope) {
+            $ids = $this->visibleAgentIds($scope);
+            $query->whereIn('id', $ids);
+        }
+        if (!empty($params['username'])) {
+            $query->where('username', 'like', '%' . $params['username'] . '%');
+        }
+        $total = (clone $query)->count();
+        $startDate = (string) ($params['start_date'] ?? now()->toDateString());
+        $endDate = (string) ($params['end_date'] ?? $startDate);
+        $list = $query->orderBy('id')->forPage($page, $limit)->get()->map(function (Agent $agent) use ($startDate, $endDate) {
+            $row = $this->summaryForTree($agent, $startDate, $endDate);
+            return array_merge([
+                'id' => (int) $agent->id,
+                'username' => $agent->username,
+                'real_name' => $agent->real_name,
+                'parent_username' => $agent->parent?->username,
+                'balance' => (string) $agent->balance,
+                'frozen_balance' => (string) $agent->frozen_balance,
+            ], $row);
+        })->all();
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    private function betSummary($memberIds, Carbon $start, Carbon $end): array
+    {
+        $total = ['bet_count' => 0, 'bet_amount' => '0.0000', 'valid_bet_amount' => '0.0000', 'win_loss' => '0.0000'];
+        $sources = [];
+        if (Schema::hasTable('bets')) {
+            $sources[] = DB::table('bets')->whereIn('member_id', clone $memberIds)->where('status', 2)
+                ->whereBetween('created_at', [$start, $end])
+                ->selectRaw('COUNT(*) bet_count, COALESCE(SUM(amount),0) bet_amount, COALESCE(SUM(amount),0) valid_bet_amount, COALESCE(SUM(profit - amount),0) win_loss')->first();
+        }
+        foreach (['sport_game_order', 'jisu_game_order'] as $table) {
+            if (!Schema::hasTable($table)) {
+                continue;
+            }
+            $sources[] = DB::table($table)->whereIn('member_id', clone $memberIds)->whereIn('status', [1, 2])
+                ->whereBetween('created_at', [$start, $end])
+                ->selectRaw('COUNT(*) bet_count, COALESCE(SUM(amount),0) bet_amount, COALESCE(SUM(amount),0) valid_bet_amount, COALESCE(SUM(CASE WHEN status = 1 THEN -amount ELSE profit_and_loss END),0) win_loss')->first();
+        }
+        if (Schema::hasTable('lhc_order')) {
+            $amountExpression = Schema::hasColumn('lhc_order', 'total_amount') ? 'COALESCE(total_amount, amount)' : 'amount';
+            $winLossExpression = '(COALESCE(win_amount, 0) - ' . $amountExpression . ')';
+            $sources[] = DB::table('lhc_order')->whereIn('member_id', clone $memberIds)->whereIn('lottery_status', [1, 2])
+                ->whereBetween('created_at', [$start->timestamp, $end->timestamp])
+                ->selectRaw("COUNT(*) bet_count, COALESCE(SUM({$amountExpression}),0) bet_amount, COALESCE(SUM({$amountExpression}),0) valid_bet_amount, COALESCE(SUM({$winLossExpression}),0) win_loss")->first();
+        }
+        if (Schema::hasTable('third_game_orders')) {
+            $sources[] = DB::table('third_game_orders')->whereIn('member_id', clone $memberIds)->where('status', 1)
+                ->whereBetween('last_update_time', [$start, $end])
+                ->selectRaw('COUNT(*) bet_count, COALESCE(SUM(bet_amount),0) bet_amount, COALESCE(SUM(valid_amount),0) valid_bet_amount, COALESCE(SUM(settled_amount),0) win_loss')->first();
+        }
+        foreach ($sources as $row) {
+            $total['bet_count'] += (int) ($row->bet_count ?? 0);
+            foreach (['bet_amount', 'valid_bet_amount', 'win_loss'] as $field) {
+                $total[$field] = bcadd($total[$field], (string) ($row->{$field} ?? 0), 4);
+            }
+        }
+        return $total;
+    }
+
+    private function emptySummary(): array
+    {
+        return [
+            'agent_count' => 0, 'member_count' => 0, 'member_balance' => '0.0000',
+            'deposit_amount' => '0.0000', 'withdraw_amount' => '0.0000',
+            'manual_credit' => '0.0000', 'manual_debit' => '0.0000',
+            'bonus_amount' => '0.0000', 'rebate_amount' => '0.0000',
+            'bet_count' => 0, 'bet_amount' => '0.0000', 'valid_bet_amount' => '0.0000',
+            'win_loss' => '0.0000', 'commission_amount' => '0.0000', 'company_profit' => '0.0000',
+        ];
+    }
+
+    private function decimal($value): string
+    {
+        return bcadd((string) ($value ?? 0), '0', 4);
+    }
+
+    public function companyProfit(string $deposit, string $withdraw, string $bonus, string $rebate): string
+    {
+        return bcsub(bcsub($deposit, $withdraw, 4), bcadd($bonus, $rebate, 4), 4);
+    }
+}

+ 163 - 0
app/Services/Agent/AgentService.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentCommissionRule;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+
+class AgentService
+{
+    public function create(array $data, ?int $createdBy = null, ?Agent $scopeAgent = null): Agent
+    {
+        return DB::transaction(function () use ($data, $createdBy, $scopeAgent): Agent {
+            $parentId = isset($data['parent_id']) ? (int) $data['parent_id'] : null;
+            if ($scopeAgent) {
+                $parentId = $parentId ?: (int) $scopeAgent->id;
+                $this->assertVisible($scopeAgent, $parentId);
+            }
+            $parent = $parentId ? Agent::query()->lockForUpdate()->findOrFail($parentId) : null;
+            if ($scopeAgent && $parent && (int) $parent->status !== Agent::STATUS_ENABLED) {
+                throw new \RuntimeException('不能在已禁用代理下新增账号');
+            }
+            if ($parent) {
+                $this->assertRatesWithinParent($parent, $data);
+            }
+
+            $agent = Agent::query()->create([
+                'parent_id' => $parent?->id,
+                'path' => '/',
+                'level' => $parent ? ((int) $parent->level + 1) : 1,
+                'username' => trim((string) $data['username']),
+                'password' => Hash::make((string) $data['password']),
+                'withdraw_password' => empty($data['withdraw_password']) ? '' : Hash::make((string) $data['withdraw_password']),
+                'real_name' => trim((string) ($data['real_name'] ?? '')),
+                'invitation_code' => $this->invitationCode(),
+                'deposit_fee_rate' => (string) ($data['deposit_fee_rate'] ?? 0),
+                'withdraw_fee_rate' => (string) ($data['withdraw_fee_rate'] ?? 0),
+                'flow_commission_rate' => (string) ($data['flow_commission_rate'] ?? 0),
+                'profit_commission_rate' => (string) ($data['profit_commission_rate'] ?? 0),
+                'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
+                'created_by' => $createdBy,
+            ]);
+            $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
+            $agent->save();
+            AgentCommissionRule::query()->create([
+                'agent_id' => $agent->id,
+                'flow_rate' => $agent->flow_commission_rate,
+                'profit_rate' => $agent->profit_commission_rate,
+                'effective_from' => now()->toDateString(),
+                'status' => 1,
+                'created_by' => $createdBy,
+            ]);
+            return $agent->fresh();
+        });
+    }
+
+    public function update(Agent $agent, array $data, ?Agent $scopeAgent = null): Agent
+    {
+        return DB::transaction(function () use ($agent, $data, $scopeAgent): Agent {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            if ($scopeAgent) {
+                $this->assertVisible($scopeAgent, (int) $agent->id);
+                if ($agent->parent) {
+                    $this->assertRatesWithinParent($agent->parent, $data);
+                }
+                $this->assertRatesCoverChildren($agent, $data);
+            } elseif (array_key_exists('parent_id', $data)) {
+                $this->move($agent, $data['parent_id'] === null ? null : (int) $data['parent_id']);
+            }
+            foreach (['username', 'real_name', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_rate', 'profit_commission_rate'] as $field) {
+                if (array_key_exists($field, $data)) {
+                    $agent->{$field} = $data[$field];
+                }
+            }
+            if (!empty($data['password'])) {
+                $agent->password = Hash::make((string) $data['password']);
+            }
+            if (!empty($data['withdraw_password'])) {
+                $agent->withdraw_password = Hash::make((string) $data['withdraw_password']);
+            }
+            $agent->save();
+            return $agent->fresh();
+        });
+    }
+
+    public function assertVisible(Agent $root, int $targetId): 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('无权访问该代理');
+        }
+    }
+
+    private function invitationCode(): string
+    {
+        do {
+            $code = strtoupper(Str::random(10));
+        } while (Agent::query()->where('invitation_code', $code)->exists());
+        return $code;
+    }
+
+    private function assertRatesWithinParent(Agent $parent, array $data): void
+    {
+        foreach (['flow_commission_rate', 'profit_commission_rate'] as $field) {
+            if (array_key_exists($field, $data) && bccomp((string) $data[$field], (string) $parent->{$field}, 4) > 0) {
+                throw new \RuntimeException('下级代理佣金比例不能高于上级');
+            }
+        }
+    }
+
+    private function assertRatesCoverChildren(Agent $agent, array $data): void
+    {
+        foreach (['flow_commission_rate', 'profit_commission_rate'] as $field) {
+            if (!array_key_exists($field, $data)) {
+                continue;
+            }
+            $maxChildRate = Agent::query()->where('parent_id', $agent->id)->max($field) ?? 0;
+            if (bccomp((string) $data[$field], (string) $maxChildRate, 4) < 0) {
+                throw new \RuntimeException('代理佣金比例不能低于现有下级比例');
+            }
+        }
+    }
+
+    private function move(Agent $agent, ?int $parentId): 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) {
+            $this->assertRatesWithinParent($parent, [
+                'flow_commission_rate' => $agent->flow_commission_rate,
+                'profit_commission_rate' => $agent->profit_commission_rate,
+            ]);
+        }
+        $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();
+
+        $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();
+        }
+    }
+}

+ 133 - 0
app/Services/Agent/AgentWalletService.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentBalanceLog;
+use Illuminate\Support\Facades\DB;
+
+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');
+        }
+
+        return DB::transaction(function () use ($fromAgentId, $toAgentId, $amount, $remark, $idempotencyKey, $operatorId) {
+            $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey . ':out')->first();
+            if ($existing) {
+                $this->assertSameTransfer($existing, $fromAgentId, $toAgentId, $amount);
+                return [
+                    'from_balance' => (string) Agent::query()->findOrFail($fromAgentId)->balance,
+                    'to_balance' => (string) Agent::query()->findOrFail($toAgentId)->balance,
+                ];
+            }
+            $locked = Agent::query()->whereIn('id', [$fromAgentId, $toAgentId])->orderBy('id')->lockForUpdate()->get()->keyBy('id');
+            $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey . ':out')->first();
+            if ($existing) {
+                $this->assertSameTransfer($existing, $fromAgentId, $toAgentId, $amount);
+                return [
+                    'from_balance' => (string) Agent::query()->findOrFail($fromAgentId)->balance,
+                    'to_balance' => (string) Agent::query()->findOrFail($toAgentId)->balance,
+                ];
+            }
+            $from = $locked->get($fromAgentId);
+            $to = $locked->get($toAgentId);
+            if (!$from || !$to) {
+                throw new \RuntimeException('代理不存在');
+            }
+            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 function adjust(
+        int $agentId,
+        string $amount,
+        string $type,
+        string $remark,
+        string $idempotencyKey,
+        string $operatorType,
+        ?int $operatorId,
+        string $referenceType = '',
+        ?int $referenceId = null
+    ): AgentBalanceLog {
+        if (!in_array($type, ['credit', 'debit', 'commission', 'withdraw_refund'], true)) {
+            throw new \InvalidArgumentException('资金变动类型错误');
+        }
+        if (bccomp($amount, '0', 4) <= 0) {
+            throw new \InvalidArgumentException('金额必须大于0');
+        }
+
+        return DB::transaction(function () use ($agentId, $amount, $type, $remark, $idempotencyKey, $operatorType, $operatorId, $referenceType, $referenceId) {
+            $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->first();
+            if ($existing) {
+                $this->assertSameAdjustment($existing, $agentId, $type, $amount);
+                return $existing;
+            }
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
+            $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->first();
+            if ($existing) {
+                $this->assertSameAdjustment($existing, $agentId, $type, $amount);
+                return $existing;
+            }
+            $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) {
+                throw new \RuntimeException('代理余额不足');
+            }
+            $agent->balance = $after;
+            $agent->save();
+            return 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,
+            ]);
+        });
+    }
+
+    private function assertSameTransfer(AgentBalanceLog $log, int $fromAgentId, int $toAgentId, string $amount): void
+    {
+        $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 已用于其他资金操作');
+        }
+    }
+
+    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 已用于其他资金操作');
+        }
+    }
+}

+ 166 - 0
app/Services/Agent/AgentWithdrawalService.php

@@ -0,0 +1,166 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentBalanceLog;
+use App\Models\Agent\AgentWithdrawAccount;
+use App\Models\Agent\AgentWithdrawal;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+
+class AgentWithdrawalService
+{
+    public function request(Agent $agent, int $accountId, string $amount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
+    {
+        if (bccomp($amount, '0', 4) <= 0) {
+            throw new \InvalidArgumentException('提现金额必须大于0');
+        }
+        if ($agent->withdraw_password === '' || !Hash::check($withdrawPassword, (string) $agent->withdraw_password)) {
+            throw new \InvalidArgumentException('提现密码错误');
+        }
+
+        $requestKey = 'agent:' . $agent->id . ':' . $requestId;
+        return DB::transaction(function () use ($agent, $accountId, $amount, $remark, $requestKey): AgentWithdrawal {
+            $existing = AgentWithdrawal::query()->where('request_key', $requestKey)->first();
+            if ($existing) {
+                $this->assertSameRequest($existing, $accountId, $amount);
+                return $existing;
+            }
+            $lockedAgent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            $existing = AgentWithdrawal::query()->where('request_key', $requestKey)->first();
+            if ($existing) {
+                $this->assertSameRequest($existing, $accountId, $amount);
+                return $existing;
+            }
+            $account = AgentWithdrawAccount::query()
+                ->where('agent_id', $lockedAgent->id)
+                ->where('status', 1)
+                ->findOrFail($accountId);
+            if (bccomp((string) $lockedAgent->balance, $amount, 4) < 0) {
+                throw new \RuntimeException('代理余额不足');
+            }
+
+            $fee = bcdiv(bcmul($amount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', 4);
+            $actual = bcsub($amount, $fee, 4);
+            if (bccomp($actual, '0', 4) <= 0) {
+                throw new \RuntimeException('扣除手续费后到账金额必须大于0');
+            }
+
+            $before = bcadd((string) $lockedAgent->balance, '0', 4);
+            $lockedAgent->balance = bcsub($before, $amount, 4);
+            $lockedAgent->frozen_balance = bcadd((string) $lockedAgent->frozen_balance, $amount, 4);
+            $lockedAgent->save();
+
+            $withdrawal = AgentWithdrawal::query()->create([
+                'order_no' => 'AW' . now()->format('YmdHis') . strtoupper(Str::random(8)),
+                'request_key' => $requestKey,
+                'agent_id' => $lockedAgent->id,
+                'account_id' => $account->id,
+                'account_type' => $account->type,
+                'account_snapshot' => [
+                    '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,
+                'status' => 'pending',
+                'remark' => $remark,
+            ]);
+
+            AgentBalanceLog::query()->create([
+                'agent_id' => $lockedAgent->id,
+                'type' => 'withdraw_freeze',
+                'amount' => bcsub('0', $amount, 4),
+                'before_balance' => $before,
+                'after_balance' => $lockedAgent->balance,
+                'reference_type' => 'agent_withdrawal',
+                'reference_id' => $withdrawal->id,
+                'operator_type' => 'agent',
+                'operator_id' => $lockedAgent->id,
+                'idempotency_key' => 'withdraw-freeze:' . $withdrawal->id,
+                'remark' => $remark,
+            ]);
+
+            return $withdrawal->fresh();
+        });
+    }
+
+    public function audit(int $withdrawalId, string $action, int $adminId, string $remark = ''): AgentWithdrawal
+    {
+        if (!in_array($action, ['approve', 'reject', 'paid'], true)) {
+            throw new \InvalidArgumentException('审核动作错误');
+        }
+
+        return DB::transaction(function () use ($withdrawalId, $action, $adminId, $remark): AgentWithdrawal {
+            $withdrawal = AgentWithdrawal::query()->lockForUpdate()->findOrFail($withdrawalId);
+            $agent = Agent::query()->lockForUpdate()->findOrFail($withdrawal->agent_id);
+
+            if ($action === 'approve') {
+                if ($withdrawal->status !== 'pending') {
+                    throw new \RuntimeException('只有待审核订单可以通过');
+                }
+                $withdrawal->status = 'approved';
+                $withdrawal->audited_at = now();
+                $withdrawal->audit_admin_id = $adminId;
+            } elseif ($action === 'reject') {
+                if (!in_array($withdrawal->status, ['pending', 'approved'], true)) {
+                    throw new \RuntimeException('当前订单不能驳回');
+                }
+                $before = bcadd((string) $agent->balance, '0', 4);
+                if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
+                    throw new \RuntimeException('代理冻结余额异常');
+                }
+                $agent->balance = bcadd($before, (string) $withdrawal->amount, 4);
+                $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
+                $agent->save();
+                AgentBalanceLog::query()->firstOrCreate(
+                    ['idempotency_key' => 'withdraw-refund:' . $withdrawal->id],
+                    [
+                        'agent_id' => $agent->id,
+                        'type' => 'withdraw_refund',
+                        'amount' => $withdrawal->amount,
+                        'before_balance' => $before,
+                        'after_balance' => $agent->balance,
+                        'reference_type' => 'agent_withdrawal',
+                        'reference_id' => $withdrawal->id,
+                        'operator_type' => 'admin',
+                        'operator_id' => $adminId,
+                        'remark' => $remark,
+                    ]
+                );
+                $withdrawal->status = 'rejected';
+                $withdrawal->audited_at = now();
+                $withdrawal->audit_admin_id = $adminId;
+            } else {
+                if ($withdrawal->status !== 'approved') {
+                    throw new \RuntimeException('只有已通过订单可以标记已付款');
+                }
+                if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
+                    throw new \RuntimeException('代理冻结余额异常');
+                }
+                $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
+                $agent->save();
+                $withdrawal->status = 'paid';
+                $withdrawal->paid_at = now();
+            }
+
+            $withdrawal->audit_remark = $remark;
+            $withdrawal->save();
+            return $withdrawal->fresh();
+        });
+    }
+
+    private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $amount): void
+    {
+        if ((int) $withdrawal->account_id !== $accountId || bccomp((string) $withdrawal->amount, $amount, 4) !== 0) {
+            throw new \RuntimeException('request_id 已用于其他提现申请');
+        }
+    }
+}

+ 13 - 0
config/agent.php

@@ -0,0 +1,13 @@
+<?php
+
+return [
+    'admin_ids' => array_values(array_filter(array_map(
+        'intval',
+        explode(',', (string) env('AGENT_ADMIN_IDS', '1'))
+    ))),
+    'token_ttl_days' => (int) env('AGENT_TOKEN_TTL_DAYS', 7),
+    'pc_register_path' => env('AGENT_PC_REGISTER_PATH', '/#/user/register'),
+    'h5_register_path' => env('AGENT_H5_REGISTER_PATH', '/#/pages/login/login?type=reg'),
+    'online_minutes' => max(1, (int) env('AGENT_ONLINE_MINUTES', 5)),
+    'referral_bind_minutes' => max(1, (int) env('AGENT_REFERRAL_BIND_MINUTES', 30)),
+];

+ 217 - 0
database/migrations/2026_08_24_140000_create_agent_system_tables.php

@@ -0,0 +1,217 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('agents')) {
+            Schema::create('agents', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('parent_id')->nullable()->index();
+                $table->string('path', 512)->default('/')->index();
+                $table->unsignedTinyInteger('level')->default(1);
+                $table->string('username', 64)->unique();
+                $table->string('password');
+                $table->string('withdraw_password')->default('');
+                $table->string('real_name', 128)->default('');
+                $table->string('invitation_code', 32)->unique();
+                $table->decimal('balance', 18, 4)->default(0);
+                $table->decimal('frozen_balance', 18, 4)->default(0);
+                $table->decimal('deposit_fee_rate', 8, 4)->default(0);
+                $table->decimal('withdraw_fee_rate', 8, 4)->default(0);
+                $table->decimal('flow_commission_rate', 8, 4)->default(0);
+                $table->decimal('profit_commission_rate', 8, 4)->default(0);
+                $table->unsignedTinyInteger('status')->default(1)->index();
+                $table->timestamp('last_login_at')->nullable();
+                $table->string('last_login_ip', 64)->default('');
+                $table->unsignedBigInteger('created_by')->nullable();
+                $table->timestamps();
+
+                $table->index(['level', 'status']);
+            });
+        }
+
+        if (!Schema::hasTable('agent_tokens')) {
+            Schema::create('agent_tokens', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('agent_id')->unique();
+                $table->char('token_hash', 64)->unique();
+                $table->timestamp('expires_at')->index();
+                $table->timestamp('last_used_at')->nullable();
+                $table->string('ip', 64)->default('');
+                $table->string('device', 16)->default('');
+                $table->timestamps();
+            });
+        }
+
+        if (!Schema::hasTable('agent_domains')) {
+            Schema::create('agent_domains', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->string('type', 16)->comment('pc/h5')->index();
+                $table->string('domain', 255)->index();
+                $table->string('host', 255)->index();
+                $table->unsignedTinyInteger('status')->default(1);
+                $table->timestamps();
+                $table->unique(['agent_id', 'type'], 'uniq_agent_domain_type');
+            });
+        }
+
+        if (!Schema::hasTable('agent_login_logs')) {
+            Schema::create('agent_login_logs', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->string('username', 64);
+                $table->string('domain', 255)->default('');
+                $table->string('ip', 64)->default('')->index();
+                $table->string('country', 128)->default('');
+                $table->string('region', 128)->default('');
+                $table->string('city', 128)->default('');
+                $table->string('device', 16)->default('')->index();
+                $table->string('user_agent', 512)->default('');
+                $table->timestamp('login_at')->index();
+                $table->timestamps();
+            });
+        }
+
+        if (!Schema::hasTable('agent_balance_logs')) {
+            Schema::create('agent_balance_logs', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->string('type', 32)->index();
+                $table->decimal('amount', 18, 4);
+                $table->decimal('before_balance', 18, 4);
+                $table->decimal('after_balance', 18, 4);
+                $table->string('reference_type', 64)->default('');
+                $table->unsignedBigInteger('reference_id')->nullable();
+                $table->string('operator_type', 16)->default('system');
+                $table->unsignedBigInteger('operator_id')->nullable();
+                $table->string('idempotency_key', 96)->unique();
+                $table->string('remark', 500)->default('');
+                $table->timestamps();
+                $table->index(['agent_id', 'created_at']);
+            });
+        }
+
+        if (!Schema::hasTable('agent_withdraw_accounts')) {
+            Schema::create('agent_withdraw_accounts', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->string('type', 32)->index();
+                $table->string('name', 128)->default('');
+                $table->string('account', 255);
+                $table->string('bank_name', 128)->default('');
+                $table->string('network', 32)->default('');
+                $table->json('details')->nullable();
+                $table->unsignedTinyInteger('status')->default(1);
+                $table->timestamps();
+            });
+        }
+
+        if (!Schema::hasTable('agent_withdrawals')) {
+            Schema::create('agent_withdrawals', function (Blueprint $table) {
+                $table->id();
+                $table->string('order_no', 40)->unique();
+                $table->string('request_key', 96)->unique();
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->unsignedBigInteger('account_id')->nullable();
+                $table->string('account_type', 32)->default('');
+                $table->text('account_snapshot');
+                $table->decimal('amount', 18, 4);
+                $table->decimal('fee', 18, 4)->default(0);
+                $table->decimal('actual_amount', 18, 4);
+                $table->string('status', 24)->default('pending')->index();
+                $table->string('remark', 500)->default('');
+                $table->string('audit_remark', 500)->default('');
+                $table->unsignedBigInteger('audit_admin_id')->nullable();
+                $table->timestamp('audited_at')->nullable();
+                $table->timestamp('paid_at')->nullable();
+                $table->timestamps();
+                $table->index(['agent_id', 'created_at']);
+            });
+        }
+
+        if (!Schema::hasTable('agent_commission_rules')) {
+            Schema::create('agent_commission_rules', function (Blueprint $table) {
+                $table->id();
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->decimal('flow_rate', 8, 4)->default(0);
+                $table->decimal('profit_rate', 8, 4)->default(0);
+                $table->date('effective_from')->index();
+                $table->unsignedTinyInteger('status')->default(1);
+                $table->unsignedBigInteger('created_by')->nullable();
+                $table->timestamps();
+                $table->index(['agent_id', 'status', 'effective_from'], 'idx_agent_commission_rule');
+                $table->unique(['agent_id', 'effective_from'], 'uniq_agent_commission_effective');
+            });
+        }
+
+        if (!Schema::hasTable('agent_commissions')) {
+            Schema::create('agent_commissions', function (Blueprint $table) {
+                $table->id();
+                $table->date('settlement_date');
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->string('type', 16)->comment('flow/profit');
+                $table->decimal('base_amount', 18, 4)->default(0);
+                $table->decimal('rate', 8, 4)->default(0);
+                $table->decimal('amount', 18, 4)->default(0);
+                $table->unsignedBigInteger('bet_count')->default(0);
+                $table->string('status', 16)->default('pending')->index();
+                $table->json('snapshot')->nullable();
+                $table->timestamp('credited_at')->nullable();
+                $table->timestamps();
+                $table->unique(['settlement_date', 'agent_id', 'type'], 'uniq_agent_commission_daily');
+            });
+        }
+
+        if (!Schema::hasTable('agent_daily_stats')) {
+            Schema::create('agent_daily_stats', function (Blueprint $table) {
+                $table->id();
+                $table->date('stat_date');
+                $table->unsignedBigInteger('agent_id')->index();
+                $table->unsignedInteger('direct_agents')->default(0);
+                $table->unsignedInteger('direct_members')->default(0);
+                $table->decimal('member_balance', 18, 4)->default(0);
+                $table->decimal('deposit_amount', 18, 4)->default(0);
+                $table->decimal('withdraw_amount', 18, 4)->default(0);
+                $table->decimal('manual_credit', 18, 4)->default(0);
+                $table->decimal('manual_debit', 18, 4)->default(0);
+                $table->decimal('bonus_amount', 18, 4)->default(0);
+                $table->decimal('rebate_amount', 18, 4)->default(0);
+                $table->decimal('bet_amount', 18, 4)->default(0);
+                $table->decimal('valid_bet_amount', 18, 4)->default(0);
+                $table->unsignedBigInteger('bet_count')->default(0);
+                $table->decimal('win_loss', 18, 4)->default(0);
+                $table->decimal('commission_amount', 18, 4)->default(0);
+                $table->decimal('company_profit', 18, 4)->default(0);
+                $table->timestamps();
+                $table->unique(['stat_date', 'agent_id'], 'uniq_agent_daily_stat');
+            });
+        }
+
+        if (Schema::hasTable('users') && !Schema::hasColumn('users', 'agent_id')) {
+            Schema::table('users', function (Blueprint $table) {
+                $table->unsignedBigInteger('agent_id')->nullable()->index();
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasTable('users') && Schema::hasColumn('users', 'agent_id')) {
+            Schema::table('users', function (Blueprint $table) {
+                $table->dropColumn('agent_id');
+            });
+        }
+        foreach ([
+            'agent_daily_stats', 'agent_commissions', 'agent_commission_rules',
+            'agent_withdrawals', 'agent_withdraw_accounts', 'agent_balance_logs',
+            'agent_login_logs', 'agent_domains', 'agent_tokens', 'agents',
+        ] as $table) {
+            Schema::dropIfExists($table);
+        }
+    }
+};

+ 57 - 0
docs/代理/个人信息.md

@@ -0,0 +1,57 @@
+# 代理后台-个人信息
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 1. 获取个人资料
+
+### `GET /agent/profile`
+
+无请求参数。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 12,
+    "username": "agent001",
+    "real_name": "张三",
+    "parent_id": null,
+    "parent_username": null,
+    "invitation_code": "A1B2C3D4E5",
+    "balance": "5500.0000",
+    "frozen_balance": "200.0000",
+    "deposit_fee_rate": "0.0000",
+    "withdraw_fee_rate": "1.5000",
+    "flow_commission_rate": "0.3000",
+    "profit_commission_rate": "10.0000",
+    "last_login_at": "2026-08-24 18:00:00",
+    "last_login_ip": "1.2.3.4",
+    "pc_domain": "https://pc.example.com",
+    "h5_domain": "https://h5.example.com",
+    "pc_register_url": "https://pc.example.com/#/user/register?agent_code=A1B2C3D4E5",
+    "h5_register_url": "https://h5.example.com/#/pages/login/login?type=reg&agent_code=A1B2C3D4E5"
+  }
+}
+```
+
+## 2. 修改个人资料
+
+### `POST /agent/profile`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `real_name` | string/null | 否 | 真实姓名,最长 128;空值保存为空字符串 |
+
+```json
+{"real_name":"张三"}
+```
+
+成功返回:
+
+```json
+{"code":0,"timestamp":1787568000,"msg":"OK","data":[]}
+```
+
+账号、上级、余额和佣金比例不能通过此接口修改。

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

@@ -0,0 +1,69 @@
+# 代理后台-代理佣金比例
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 1. 查看比例历史
+
+### `GET /agent/commission-rules`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `include_children` | boolean | 否 | `false` | `true/1` 查看当前代理及整棵下级树;否则只看自己 |
+
+```http
+GET /agent/commission-rules?include_children=1
+```
+
+成功时 `data` 是数组,不分页:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": [
+    {
+      "id": 20,
+      "agent_id": 15,
+      "flow_rate": "0.2000",
+      "profit_rate": "8.0000",
+      "effective_from": "2026-08-24T00:00:00.000000Z",
+      "status": 1,
+      "created_by": null,
+      "created_at": "2026-08-24 18:00:00",
+      "updated_at": "2026-08-24 18:00:00",
+      "agent": {"id":15,"username":"sub001"}
+    }
+  ]
+}
+```
+
+## 2. 设置下级比例
+
+### `POST /agent/commission-rules`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 是 | 当前代理下级树中的代理 ID,不能是自己 |
+| `flow_rate` | decimal string | 是 | 流水佣金百分比,0~100,最多 4 位小数 |
+| `profit_rate` | decimal string | 是 | 盈亏佣金百分比,0~100,最多 4 位小数 |
+| `effective_from` | string | 是 | 必须是操作当天,`Y-m-d` |
+
+```json
+{
+  "agent_id": 15,
+  "flow_rate": "0.2000",
+  "profit_rate": "8.0000",
+  "effective_from": "2026-08-24"
+}
+```
+
+成功时 `data` 返回保存后的比例记录,字段与查询列表项一致(不附带 `agent` 关系)。
+
+规则:
+
+- 下级比例不能高于其直属上级。
+- 当前代理的比例不能低于已有直属下级比例。
+- 当天佣金已入账后不能修改当天比例。
+- 流水佣金=`有效投注额 × flow_rate ÷ 100`。
+- 盈亏佣金=`max(公司盈亏,0) × profit_rate ÷ 100`。

+ 61 - 0
docs/代理/代理报表.md

@@ -0,0 +1,61 @@
+# 代理后台-代理报表
+
+需要请求头 `Authorization: Bearer <agent_token>`。只返回当前代理及下级树中的代理。
+
+## 代理报表
+
+### `GET /agent/reports`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `10` | 每页 1~20 |
+| `username` | string | 否 | - | 代理账号,模糊查询 |
+| `start_date` | string | 是 | - | 开始日期,`Y-m-d` |
+| `end_date` | string | 是 | - | 结束日期,不能早于开始日期 |
+
+单次统计最长 366 天。
+
+```http
+GET /agent/reports?page=1&limit=20&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 2,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 12,
+        "username": "agent001",
+        "real_name": "张三",
+        "parent_username": null,
+        "balance": "5500.0000",
+        "frozen_balance": "200.0000",
+        "agent_count": 3,
+        "member_count": 25,
+        "member_balance": "100000.0000",
+        "deposit_amount": "50000.0000",
+        "withdraw_amount": "10000.0000",
+        "manual_credit": "2000.0000",
+        "manual_debit": "500.0000",
+        "bonus_amount": "300.0000",
+        "rebate_amount": "100.0000",
+        "bet_count": 188,
+        "bet_amount": "30000.0000",
+        "valid_bet_amount": "28000.0000",
+        "win_loss": "-3000.0000",
+        "commission_amount": "310.0000",
+        "company_profit": "39600.0000"
+      }
+    ]
+  }
+}
+```
+
+`agent_count` 不包含当前行代理。每一行按该代理加其整棵下级树的会员资金统计,因此父代理行和子代理行存在层级包含关系,不能直接把所有行再次相加。公司盈亏按总存款减总提款、优惠/加送和返水计算,手动加扣款不参与。

+ 45 - 0
docs/代理/代理首页.md

@@ -0,0 +1,45 @@
+# 代理后台-代理首页
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 首页汇总
+
+### `GET /agent/dashboard`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `start_date` | string | 否 | 当天 | 开始日期,`Y-m-d` |
+| `end_date` | string | 否 | `start_date` | 结束日期,不能早于开始日期 |
+
+```http
+GET /agent/dashboard?start_date=2026-08-01&end_date=2026-08-24
+```
+
+返回当前代理加整棵下级树的数据:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "agent_count": 3,
+    "member_count": 25,
+    "member_balance": "100000.0000",
+    "deposit_amount": "50000.0000",
+    "withdraw_amount": "10000.0000",
+    "manual_credit": "2000.0000",
+    "manual_debit": "500.0000",
+    "bonus_amount": "300.0000",
+    "rebate_amount": "100.0000",
+    "bet_count": 188,
+    "bet_amount": "30000.0000",
+    "valid_bet_amount": "28000.0000",
+    "win_loss": "-3000.0000",
+    "commission_amount": "310.0000",
+    "company_profit": "39600.0000"
+  }
+}
+```
+
+金额均为 4 位小数字符串。`win_loss` 正数表示会员赢;`agent_count` 是下级代理数,不包含当前代理。`company_profit=deposit_amount-withdraw_amount-bonus_amount-rebate_amount`,手动加扣款不参与公司盈亏。

+ 29 - 0
docs/代理/修改密码.md

@@ -0,0 +1,29 @@
+# 代理后台-修改密码
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 修改登录密码或提现密码
+
+### `POST /agent/profile/password`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `type` | string | 是 | `login` 登录密码;`withdraw` 提现密码 |
+| `old_password` | string | 是 | 当前登录密码;修改提现密码时也用登录密码确认身份 |
+| `password` | string | 是 | 新密码,6~100 位 |
+| `password_confirmation` | string | 是 | 必须与 `password` 相同 |
+
+```json
+{
+  "type": "withdraw",
+  "old_password": "123456",
+  "password": "654321",
+  "password_confirmation": "654321"
+}
+```
+
+```json
+{"code":0,"timestamp":1787568000,"msg":"OK","data":[]}
+```
+
+修改登录密码时,代理的所有 Token 会立即删除;当前页面收到成功响应后应清除本地 Token 并跳转登录页。修改提现密码不会退出登录。

+ 75 - 0
docs/代理/其他账户管理.md

@@ -0,0 +1,75 @@
+# 代理后台-其他账户管理
+
+需要请求头 `Authorization: Bearer <agent_token>`。支付宝、微信和其他代付账户使用统一提现账户接口。
+
+## 1. 账户列表
+
+### `GET /agent/wallet/accounts`
+
+可按类型请求:
+
+- `GET /agent/wallet/accounts?type=alipay`
+- `GET /agent/wallet/accounts?type=wechat`
+- `GET /agent/wallet/accounts?type=other`
+
+`type` 不传时返回全部类型账户。成功时 `data` 是数组:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": [
+    {
+      "id": 5,
+      "agent_id": 12,
+      "type": "alipay",
+      "name": "张三",
+      "account": "13800000000",
+      "bank_name": "",
+      "network": "",
+      "details": {},
+      "status": 1,
+      "created_at": "2026-08-24 18:00:00",
+      "updated_at": "2026-08-24 18:00:00"
+    }
+  ]
+}
+```
+
+## 2. 新增或修改账户
+
+### `POST /agent/wallet/accounts`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 修改时是 | 账户 ID;新增不传 |
+| `type` | string | 是 | `alipay`、`wechat` 或 `other` |
+| `name` | string | 否 | 收款人/账户名称,最长 128 |
+| `account` | string | 是 | 收款账号,最长 255 |
+| `bank_name` | string | 否 | 通常传空 |
+| `network` | string | 否 | 通常传空 |
+| `details` | object | 否 | 额外字段 |
+| `status` | integer | 否 | `1` 启用,`0` 禁用 |
+
+```json
+{
+  "type": "alipay",
+  "name": "张三",
+  "account": "13800000000",
+  "details": {},
+  "status": 1
+}
+```
+
+成功时 `data` 返回保存后的账户对象。
+
+## 3. 删除账户
+
+### `POST /agent/wallet/accounts/delete`
+
+```json
+{"id":5}
+```
+
+成功返回 `data=[]`。有处理中提现时不能删除。

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

@@ -0,0 +1,146 @@
+# 代理后台-所有代理
+
+需要请求头 `Authorization: Bearer <agent_token>`。当前代理只能查看和操作自己的下级树。
+
+## 1. 下级代理列表
+
+### `GET /agent/sub-agents`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `username` | string | 否 | - | 账号模糊查询 |
+| `status` | integer | 否 | - | `1` 启用,`0` 禁用 |
+| `direct_only` | boolean | 否 | `false` | `true/1` 只看直属下级;否则看整棵下级树 |
+
+```http
+GET /agent/sub-agents?page=1&limit=20&direct_only=1
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 15,
+        "parent_id": 12,
+        "path": "/12/15/",
+        "level": 2,
+        "username": "sub001",
+        "real_name": "李四",
+        "invitation_code": "F6G7H8J9K0",
+        "balance": "1000.0000",
+        "frozen_balance": "0.0000",
+        "deposit_fee_rate": "0.0000",
+        "withdraw_fee_rate": "1.0000",
+        "flow_commission_rate": "0.2000",
+        "profit_commission_rate": "8.0000",
+        "status": 1,
+        "last_login_at": null,
+        "last_login_ip": "",
+        "parent": {"id":12,"username":"agent001"},
+        "pc_domain": "",
+        "h5_domain": "",
+        "pc_register_url": "",
+        "h5_register_url": ""
+      }
+    ]
+  }
+}
+```
+
+## 2. 新增下级代理
+
+### `POST /agent/sub-agents`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `username` | string | 是 | 4~64 位,字母/数字/横线/下划线,全局唯一 |
+| `password` | string | 是 | 6~100 位 |
+| `withdraw_password` | string | 否 | 6~100 位 |
+| `real_name` | string | 否 | 最长 128 |
+| `parent_id` | integer | 否 | 不传时为当前代理;传值时必须是当前代理或其下级 |
+| `deposit_fee_rate` | decimal string | 否 | 0~100 |
+| `withdraw_fee_rate` | decimal string | 否 | 0~100 |
+| `flow_commission_rate` | decimal string | 否 | 不能高于上级流水比例 |
+| `profit_commission_rate` | decimal string | 否 | 不能高于上级盈亏比例 |
+| `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
+
+```json
+{
+  "username": "sub001",
+  "password": "123456",
+  "withdraw_password": "654321",
+  "real_name": "李四",
+  "flow_commission_rate": "0.2",
+  "profit_commission_rate": "8",
+  "status": 1
+}
+```
+
+成功时 `data` 返回新代理对象,包括后端生成的 `id`、`path`、`level`、`invitation_code`,初始 `balance` 和 `frozen_balance` 都为 `0.0000`。
+
+## 3. 修改下级代理
+
+### `POST /agent/sub-agents/update`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 是 | 当前代理下级树中的代理 ID,不能是自己 |
+| `password` | string | 否 | 新登录密码,6~100;空值不修改 |
+| `withdraw_password` | string | 否 | 新提现密码,6~100;空值不修改 |
+| `real_name` | string | 否 | 最长 128 |
+| `deposit_fee_rate` | decimal string | 否 | 0~100 |
+| `withdraw_fee_rate` | decimal string | 否 | 0~100 |
+| `status` | integer | 否 | `1` 启用,`0` 禁用 |
+
+账号、上级和佣金比例不能通过此接口修改。佣金比例使用“代理佣金比例”菜单。
+
+```json
+{"id":15,"real_name":"李四","withdraw_fee_rate":"0.8","status":1}
+```
+
+成功时 `data` 返回更新后的代理对象。
+
+## 4. 给下级加款/扣款
+
+### `POST /agent/sub-agents/transfer`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 是 | 下级树中的代理 ID |
+| `direction` | string | 是 | `credit` 给下级加款;`debit` 从下级扣回 |
+| `amount` | decimal string | 是 | 大于 0,最多 14 位整数、4 位小数 |
+| `remark` | string | 是 | 最长 500 |
+| `request_id` | string | 是 | 本次操作唯一 UUID,重试复用同一个值 |
+
+```json
+{
+  "agent_id": 15,
+  "direction": "credit",
+  "amount": "100.0000",
+  "remark": "下发额度",
+  "request_id": "927b21fd-03aa-4aaa-a4a0-222b882cb534"
+}
+```
+
+`credit` 会扣当前代理、加下级;`debit` 会扣下级、退回当前代理。给禁用代理加款会被拒绝,但可以从禁用代理扣回余额。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "from_balance": "5400.0000",
+    "to_balance": "1100.0000"
+  }
+}
+```

+ 52 - 0
docs/代理/所有会员.md

@@ -0,0 +1,52 @@
+# 代理后台-所有会员
+
+需要请求头 `Authorization: Bearer <agent_token>`。只返回当前代理及下级代理名下的会员。
+
+## 会员列表
+
+### `GET /agent/members`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `keyword` | string | 否 | - | 同时模糊搜索账号、昵称、`member_id` |
+| `agent_id` | integer | 否 | - | 直属代理 ID;不在当前代理树中时结果为空 |
+| `status` | integer | 否 | - | 会员状态 |
+| `start_date` | string | 否 | - | 注册开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 注册结束日期,不能早于开始日期 |
+
+```http
+GET /agent/members?page=1&limit=20&keyword=9238&agent_id=12
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 92385294,
+        "member_id": "6574604996",
+        "username": "member001",
+        "first_name": "会员昵称",
+        "status": 1,
+        "agent_id": 12,
+        "agent_username": "agent001",
+        "register_ip": "1.2.3.4",
+        "register_domain": "h5.example.com",
+        "available_balance": "1000.0000000000",
+        "frozen_balance": "0.0000000000",
+        "created_at": "2026-08-24"
+      }
+    ]
+  }
+}
+```
+
+`id` 是 `users.id`;`member_id` 是业务会员/TG ID。第三方游戏平台玩家号按现行规则为 `u + users.id`,例如该示例为 `u92385294`。

+ 27 - 0
docs/代理/推广域名.md

@@ -0,0 +1,27 @@
+# 代理后台-推广域名
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 查看总后台分配的 PC/H5 域名
+
+### `GET /agent/profile/domains`
+
+无请求参数。代理端只能查看,绑定、修改和解绑必须由总后台完成。
+
+成功返回:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "pc_domain": "https://pc.example.com",
+    "h5_domain": "https://h5.example.com",
+    "pc_register_url": "https://pc.example.com/#/user/register?agent_code=A1B2C3D4E5",
+    "h5_register_url": "https://h5.example.com/#/pages/login/login?type=reg&agent_code=A1B2C3D4E5"
+  }
+}
+```
+
+前端复制推广链接时使用返回的 `pc_register_url`/`h5_register_url`,不要自行拼数据库 ID。

+ 87 - 0
docs/代理/推广注册.md

@@ -0,0 +1,87 @@
+# 代理后台-推广注册接入
+
+该文件供主站注册页使用,不需要代理 Token。
+
+## 解析推广码或域名
+
+### `GET /agent/referral/resolve`
+
+`agent_code` 和 `domain` 至少传一个。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_code` | string | 条件必填 | 代理不可猜测邀请码,最长 32 |
+| `domain` | string | 条件必填 | 当前注册页完整 URL 或域名,最长 255 |
+
+按推广码:
+
+```http
+GET /agent/referral/resolve?agent_code=A1B2C3D4E5
+```
+
+按域名:
+
+```http
+GET /agent/referral/resolve?domain=https%3A%2F%2Fh5.example.com
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "agent_id": 12,
+    "agent_code": "A1B2C3D4E5",
+    "agent_name": "agent001"
+  }
+}
+```
+
+代理不存在或已禁用:
+
+```json
+{
+  "code": -3,
+  "timestamp": 1787568000,
+  "msg": "代理推广信息不存在或已禁用",
+  "data": []
+}
+```
+
+## 绑定新会员归属
+
+### `POST /agent/referral/bind`
+
+会员注册并取得主站用户 Token 后调用。请求头使用主站 `check.token` 的原始 Token,不加 `Bearer `:
+
+```http
+Authorization: <user_token>
+Content-Type: application/json
+```
+
+请求体传推广码或当前域名:
+
+```json
+{"agent_code":"A1B2C3D4E5"}
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {"user_id":92385294,"agent_id":12}
+}
+```
+
+该接口只给当前登录的新会员绑定,默认必须在注册后 30 分钟内完成;相同代理重复请求保持幂等,不能自行换绑其他代理。超时时间可由后端 `AGENT_REFERRAL_BIND_MINUTES` 调整。
+
+注册接入顺序:
+
+1. 注册页读取 URL 中的 `agent_code`。
+2. 调用本接口验证并取得 `agent_id`。
+3. 完成主站注册并取得用户 Token。
+4. 立即调用 `POST /agent/referral/bind`,由服务端再次验证推广码并写入 `users.agent_id`。
+
+禁止普通前端调用总后台 `/admin/agent/bind-member`。公开推广链接使用 `agent_code`,不要公开数据库代理 ID。

+ 93 - 0
docs/代理/提现.md

@@ -0,0 +1,93 @@
+# 代理后台-提现
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 1. 我的提现列表
+
+### `GET /agent/wallet/withdrawals`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `status` | string | 否 | - | `pending`、`approved`、`rejected`、`paid` |
+| `start_date` | string | 否 | - | 申请开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 申请结束日期,不能早于开始日期 |
+
+```http
+GET /agent/wallet/withdrawals?page=1&limit=20&status=pending
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 8,
+        "order_no": "AW20260824180000ABC12345",
+        "agent_id": 12,
+        "account_id": 3,
+        "account_type": "usdt",
+        "account_snapshot": {
+          "type": "usdt",
+          "name": "张三",
+          "account": "TXxxxxxxxx",
+          "bank_name": "",
+          "network": "TRC20",
+          "details": {}
+        },
+        "amount": "1000.0000",
+        "fee": "15.0000",
+        "actual_amount": "985.0000",
+        "status": "pending",
+        "remark": "代理提现",
+        "audit_remark": "",
+        "audit_admin_id": null,
+        "audited_at": null,
+        "paid_at": null,
+        "created_at": "2026-08-24 18:00:00",
+        "updated_at": "2026-08-24 18:00:00"
+      }
+    ]
+  }
+}
+```
+
+## 2. 申请提现
+
+### `POST /agent/wallet/withdrawals`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `account_id` | integer | 是 | 当前代理已启用的提现账户 ID |
+| `amount` | decimal string | 是 | 大于 0,最多 14 位整数和 4 位小数 |
+| `withdraw_password` | string | 是 | 提现密码 |
+| `request_id` | string | 是 | 本次申请唯一 UUID,最长 64;重试复用同一个值 |
+| `remark` | string | 否 | 最长 500 |
+
+```json
+{
+  "account_id": 3,
+  "amount": "1000.0000",
+  "withdraw_password": "654321",
+  "request_id": "55c506cf-f724-45e4-9a94-99fe34092193",
+  "remark": "代理提现"
+}
+```
+
+成功时 `data` 返回新提现对象,字段与列表项相同。手续费计算:`fee=amount × withdraw_fee_rate ÷ 100`;`actual_amount=amount-fee`。
+
+申请成功后:
+
+- 可用余额减少 `amount`。
+- 冻结余额增加 `amount`。
+- 总后台驳回时解冻并退回可用余额。
+- 总后台标记已付款时只扣冻结余额,不重复扣可用余额。
+
+相同 `request_id` 重试会返回原申请;同一 `request_id` 改金额或账户会被拒绝。

+ 74 - 0
docs/代理/注单统计.md

@@ -0,0 +1,74 @@
+# 代理后台-注单统计
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 投注列表
+
+### `GET /agent/members/bets`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `source` | string | 否 | `third` | `pc28`、`sport`、`jisu`、`lhc`、`third` |
+| `member_id` | string | 否 | - | 业务会员 ID,精确匹配 |
+| `username` | string | 否 | - | 会员账号,模糊匹配 |
+| `status` | integer | 否 | - | 对应订单表的状态值 |
+| `start_date` | string | 否 | - | 投注开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 投注结束日期,不能早于开始日期 |
+
+```http
+GET /agent/members/bets?source=third&page=1&limit=20&member_id=6574604996&start_date=2026-08-01&end_date=2026-08-24
+```
+
+通用返回:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "source": "third",
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": []
+  }
+}
+```
+
+`list[]` 会返回对应订单表的全部业务字段,另外固定附加 `username`、`first_name`、`agent_id`。不同 `source` 的核心字段如下:
+
+| source | 订单字段 |
+|---|---|
+| `pc28` | `id`、`issue_id`、`issue_no`、`member_id`、`user_id`、`keywords`、`amount`、`odds`、`status`、`profit`、时间 |
+| `sport` | `id`、`ordernum`、`game_id`、`gameplay_id`、`member_id`、`odds`、`amount`、`status`、`win_amount`、`profit_and_loss`、`remark`、时间 |
+| `jisu` | `id`、`ordernum`、`gameplay_id`、`member_id`、`odds`、`amount`、`status`、`win_amount`、`profit_and_loss`、`remark`、时间 |
+| `lhc` | `id`、`issue`、`ordernum`、`member_id`、`game`、`gameplay`、`number`、`odds`、`amount/total_amount`、`lottery_status`、`win_amount`、`profit_and_loss`、`type`、时间 |
+| `third` | `id`、`user_id`、`member_id`、`player_id`、`platform`、`currency`、`game_type`、`game_name`、`bet_amount`、`valid_amount`、`settled_amount`、`status`、`game_order_id`、`bet_time`、`last_update_time` |
+
+第三方示例项:
+
+```json
+{
+  "id": 1001,
+  "user_id": 92385294,
+  "member_id": "6574604996",
+  "player_id": "u92385294",
+  "platform": "ag",
+  "currency": "CNY",
+  "game_type": 1,
+  "game_name": "Baccarat",
+  "bet_amount": "100.0000000000",
+  "valid_amount": "95.0000000000",
+  "settled_amount": "-100.0000000000",
+  "status": 1,
+  "game_order_id": "000001",
+  "username": "member001",
+  "first_name": "会员昵称",
+  "agent_id": 12
+}
+```
+
+第三方接口只读 bot-28 已落库订单,不会因为代理前端查询或翻页而请求 api-bet。

+ 57 - 0
docs/代理/登录.md

@@ -0,0 +1,57 @@
+# 代理后台-登录
+
+代理后台使用独立 Token,不使用总后台管理员 Token。成功外层返回 `code=0`;参数或业务错误为 `code=-3`;登录失效为 `code=101011`。
+
+## 1. 登录
+
+### `POST /agent/auth/login`
+
+该接口不需要 Token,限制同一来源每分钟最多 10 次。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `username` | string | 是 | 代理账号,最长 64 |
+| `password` | string | 是 | 登录密码,最长 100 |
+
+```json
+{"username":"agent001","password":"123456"}
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "token": "64位随机Token",
+    "token_type": "Bearer",
+    "expires_at": "2026-08-31 18:00:00",
+    "agent": {
+      "id": 12,
+      "username": "agent001",
+      "real_name": "张三",
+      "invitation_code": "A1B2C3D4E5"
+    }
+  }
+}
+```
+
+后续请求头:
+
+```http
+Authorization: Bearer <data.token>
+```
+
+同一代理只保留一个有效 Token;再次登录会使旧 Token 失效。默认有效期 7 天,可由后端 `AGENT_TOKEN_TTL_DAYS` 调整。
+
+## 2. 退出
+
+### `POST /agent/auth/logout`
+
+无请求参数,需要代理 Token。
+
+```json
+{"code":0,"timestamp":1787568000,"msg":"OK","data":[]}
+```
+
+成功后当前 Token 立即失效。

+ 54 - 0
docs/代理/登录日志.md

@@ -0,0 +1,54 @@
+# 代理后台-登录日志
+
+需要请求头 `Authorization: Bearer <agent_token>`。该菜单只返回当前代理自己的登录记录。
+
+## 登录日志
+
+### `GET /agent/profile/login-logs`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `ip` | string | 否 | - | IP 精确匹配,最长 64 |
+| `device` | string | 否 | - | `mobile` 或 `pc` |
+| `start_date` | string | 否 | - | 登录开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 登录结束日期,不能早于开始日期 |
+
+```http
+GET /agent/profile/login-logs?page=1&limit=20&device=mobile
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 100,
+        "agent_id": 12,
+        "username": "agent001",
+        "domain": "agent.example.com",
+        "ip": "1.2.3.4",
+        "country": "CN",
+        "region": "Guangdong",
+        "city": "Shenzhen",
+        "device": "mobile",
+        "device_text": "手机端",
+        "location": "CN Guangdong Shenzhen",
+        "user_agent": "Mozilla/5.0 (iPhone...)",
+        "login_at": "2026-08-24T18:00:00.000000Z",
+        "created_at": "2026-08-24 18:00:00",
+        "updated_at": "2026-08-24 18:00:00"
+      }
+    ]
+  }
+}
+```
+
+前端直接展示 `device_text` 和 `location`,不需要点击 IP 查询归属地。

+ 69 - 0
docs/代理/虚拟币账户管理.md

@@ -0,0 +1,69 @@
+# 代理后台-虚拟币账户管理
+
+需要请求头 `Authorization: Bearer <agent_token>`。虚拟币账户固定使用 `type=usdt`。
+
+## 1. 虚拟币账户列表
+
+### `GET /agent/wallet/accounts?type=usdt`
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": [
+    {
+      "id": 4,
+      "agent_id": 12,
+      "type": "usdt",
+      "name": "主USDT账户",
+      "account": "TXxxxxxxxx",
+      "bank_name": "",
+      "network": "TRC20",
+      "details": {"currency":"USDT"},
+      "status": 1,
+      "created_at": "2026-08-24 18:00:00",
+      "updated_at": "2026-08-24 18:00:00"
+    }
+  ]
+}
+```
+
+## 2. 新增或修改虚拟币账户
+
+### `POST /agent/wallet/accounts`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 修改时是 | 账户 ID;新增不传 |
+| `type` | string | 是 | 固定 `usdt` |
+| `name` | string | 否 | 账户名称,最长 128 |
+| `account` | string | 是 | 链上地址,最长 255 |
+| `network` | string | 否 | 例如 `TRC20`、`ERC20`,最长 32 |
+| `bank_name` | string | 否 | 传空字符串 |
+| `details` | object | 否 | 例如 `{"currency":"USDT"}` |
+| `status` | integer | 否 | `1` 启用,`0` 禁用 |
+
+```json
+{
+  "type": "usdt",
+  "name": "主USDT账户",
+  "account": "TXxxxxxxxx",
+  "network": "TRC20",
+  "bank_name": "",
+  "details": {"currency":"USDT"},
+  "status": 1
+}
+```
+
+成功时 `data` 返回保存后的完整账户对象。
+
+## 3. 删除虚拟币账户
+
+### `POST /agent/wallet/accounts/delete`
+
+```json
+{"id":4}
+```
+
+成功返回 `data=[]`。有处理中提现时不能删除。

+ 62 - 0
docs/代理/财务统计.md

@@ -0,0 +1,62 @@
+# 代理后台-财务统计
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 会员财务统计
+
+### `GET /agent/members/finance`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `10` | 每页 1~20;为控制财务聚合查询量设置上限 |
+| `keyword` | string | 否 | - | 会员账号、昵称、`member_id` 模糊搜索 |
+| `agent_id` | integer | 否 | - | 直属代理 ID |
+| `status` | integer | 否 | - | 会员状态 |
+| `start_date` | string | 否 | 当天 | 财务统计开始日期,`Y-m-d` |
+| `end_date` | string | 否 | 当天 | 财务统计结束日期,`Y-m-d` |
+
+这里的日期只控制财务统计区间,不会把区间前注册的老会员排除。
+
+```http
+GET /agent/members/finance?page=1&limit=20&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 92385294,
+        "member_id": "6574604996",
+        "username": "member001",
+        "first_name": "会员昵称",
+        "agent_id": 12,
+        "agent_username": "agent001",
+        "available_balance": "1000.0000000000",
+        "frozen_balance": "0.0000000000",
+        "recharge_amount": "5000.0000",
+        "recharge_count": 3,
+        "withdraw_amount": "1000.0000",
+        "withdraw_count": 1,
+        "manual_credit": "200.0000",
+        "manual_debit": "50.0000",
+        "bonus_amount": "30.0000",
+        "rebate_amount": "10.0000",
+        "bet_count": 25,
+        "bet_amount": "3000.0000",
+        "valid_bet_amount": "2800.0000",
+        "win_loss": "-300.0000"
+      }
+    ]
+  }
+}
+```
+
+`win_loss` 正数表示会员赢,负数表示会员输。金额字段使用字符串显示。

+ 61 - 0
docs/代理/资金流水.md

@@ -0,0 +1,61 @@
+# 代理后台-资金流水
+
+需要请求头 `Authorization: Bearer <agent_token>`。只返回当前代理自己的资金流水。
+
+## 资金流水列表
+
+### `GET /agent/wallet/logs`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `type` | string | 否 | - | 流水类型,最长 32 |
+| `start_date` | string | 否 | - | 开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 结束日期,不能早于开始日期 |
+
+常见 `type`:
+
+- `credit`:总后台加款
+- `debit`:总后台扣款
+- `transfer_in`:上级下发或从下级扣回
+- `transfer_out`:给下级下发或被上级扣回
+- `withdraw_freeze`:提交提现,扣可用余额并转冻结
+- `withdraw_refund`:提现驳回退回
+- `commission`:佣金入账
+
+```http
+GET /agent/wallet/logs?page=1&limit=20&type=commission&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 101,
+        "agent_id": 12,
+        "type": "commission",
+        "amount": "84.0000",
+        "before_balance": "5500.0000",
+        "after_balance": "5584.0000",
+        "reference_type": "agent_commission",
+        "reference_id": 30,
+        "operator_type": "system",
+        "operator_id": null,
+        "remark": "2026-08-23流水佣金",
+        "created_at": "2026-08-24 04:10:00",
+        "updated_at": "2026-08-24 04:10:00"
+      }
+    ]
+  }
+}
+```
+
+`amount` 为带符号金额:正数入账,负数出账。`before_balance`、`after_balance` 都是可用余额,不含冻结余额。

+ 71 - 0
docs/代理/资金账户.md

@@ -0,0 +1,71 @@
+# 代理后台-资金账户
+
+需要请求头 `Authorization: Bearer <agent_token>`。该菜单的数据由个人资料、首页汇总、返佣记录和提现记录组成。
+
+## 1. 当前账户余额
+
+### `GET /agent/profile`
+
+无参数。菜单使用以下返回字段:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 12,
+    "username": "agent001",
+    "balance": "5500.0000",
+    "frozen_balance": "200.0000",
+    "flow_commission_rate": "0.3000",
+    "profit_commission_rate": "10.0000"
+  }
+}
+```
+
+## 2. 下级与经营汇总
+
+### `GET /agent/dashboard`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `start_date` | string | 否 | 当天 | `Y-m-d` |
+| `end_date` | string | 否 | 开始日期 | `Y-m-d`,不能早于开始日期 |
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "agent_count": 3,
+    "member_count": 25,
+    "win_loss": "-3000.0000",
+    "commission_amount": "310.0000",
+    "company_profit": "39600.0000"
+  }
+}
+```
+
+`agent_count` 已经是下级代理人数,不包含当前代理。
+
+## 3. 分开显示流水佣金和盈亏佣金
+
+### `GET /agent/commissions`
+
+参数:`type=flow|profit`、`status=credited`、`start_date`、`end_date`、`page`、`limit`。分别请求 `flow` 和 `profit` 后汇总 `data.list[].amount`。
+
+```http
+GET /agent/commissions?type=flow&status=credited&start_date=2026-08-01&end_date=2026-08-24&limit=100
+```
+
+返回 `data.total/page/limit/list`,每项含 `type`、`base_amount`、`rate`、`amount`、`status`、`settlement_date`、`credited_at`。
+
+## 4. 已提现金额
+
+### `GET /agent/wallet/withdrawals`
+
+使用 `status=paid` 查询,按页面需要的日期范围分页获取并汇总 `data.list[].amount`。
+
+该菜单中的所有金额都按字符串显示,不要用 JavaScript 浮点数直接累计;推荐使用 Decimal 库。

+ 54 - 0
docs/代理/返佣记录.md

@@ -0,0 +1,54 @@
+# 代理后台-返佣记录
+
+需要请求头 `Authorization: Bearer <agent_token>`。
+
+## 返佣记录
+
+### `GET /agent/commissions`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `type` | string | 否 | - | `flow` 流水佣金;`profit` 盈亏佣金 |
+| `status` | string | 否 | - | `pending` 待入账;`credited` 已入账 |
+| `start_date` | string | 否 | - | 结算开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 结算结束日期,不能早于开始日期 |
+| `include_children` | boolean | 否 | `false` | 是否包含整棵下级树的返佣记录 |
+
+```http
+GET /agent/commissions?page=1&limit=20&type=flow&status=credited&include_children=1
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 30,
+        "settlement_date": "2026-08-23T00:00:00.000000Z",
+        "agent_id": 12,
+        "type": "flow",
+        "base_amount": "28000.0000",
+        "rate": "0.3000",
+        "amount": "84.0000",
+        "bet_count": 188,
+        "status": "credited",
+        "snapshot": {"agent_ids":[12,15,16,17]},
+        "credited_at": "2026-08-24T04:10:00.000000Z",
+        "created_at": "2026-08-24 04:10:00",
+        "updated_at": "2026-08-24 04:10:00",
+        "agent": {"id":12,"username":"agent001"}
+      }
+    ]
+  }
+}
+```
+
+`base_amount` 在 `flow` 类型下是有效投注额,在 `profit` 类型下是公司正盈利。每日 04:10 结算前一天数据并写入代理资金流水。

+ 71 - 0
docs/代理/银行卡管理.md

@@ -0,0 +1,71 @@
+# 代理后台-银行卡管理
+
+需要请求头 `Authorization: Bearer <agent_token>`。银行卡记录使用统一提现账户接口,固定 `type=bank`。
+
+## 1. 银行卡列表
+
+### `GET /agent/wallet/accounts?type=bank`
+
+无其他参数,成功时 `data` 是数组,不分页:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": [
+    {
+      "id": 3,
+      "agent_id": 12,
+      "type": "bank",
+      "name": "张三",
+      "account": "622202xxxxxxxxxxxx",
+      "bank_name": "工商银行",
+      "network": "",
+      "details": {"branch":"深圳南山支行","card_type":"银行卡"},
+      "status": 1,
+      "created_at": "2026-08-24 18:00:00",
+      "updated_at": "2026-08-24 18:00:00"
+    }
+  ]
+}
+```
+
+## 2. 新增或修改银行卡
+
+### `POST /agent/wallet/accounts`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 修改时是 | 银行卡记录 ID;新增不传 |
+| `type` | string | 是 | 固定 `bank` |
+| `name` | string | 否 | 开户人姓名,最长 128 |
+| `account` | string | 是 | 银行卡号,最长 255 |
+| `bank_name` | string | 否 | 所属银行,最长 128 |
+| `network` | string | 否 | 银行卡通常传空字符串 |
+| `details` | object | 否 | 可存 `branch` 开户行地址、`card_type` 卡类型 |
+| `status` | integer | 否 | `1` 启用,`0` 禁用 |
+
+```json
+{
+  "type": "bank",
+  "name": "张三",
+  "account": "622202xxxxxxxxxxxx",
+  "bank_name": "工商银行",
+  "network": "",
+  "details": {"branch":"深圳南山支行","card_type":"银行卡"},
+  "status": 1
+}
+```
+
+成功时 `data` 返回保存后的完整账户对象。
+
+## 3. 删除银行卡
+
+### `POST /agent/wallet/accounts/delete`
+
+```json
+{"id":3}
+```
+
+成功返回 `data=[]`。有 `pending` 或 `approved` 提现引用时不能删除。

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

@@ -0,0 +1,356 @@
+# 总后台-代理列表
+
+接口前缀:`/admin/agent`。全部接口需要总后台管理员 Token,且管理员 ID 必须在后端 `AGENT_ADMIN_IDS` 白名单中(默认只有 ID `1`):
+
+```http
+Authorization: Bearer <admin_token>
+Content-Type: application/json
+```
+
+通用外层返回为 `code`、`timestamp`、`msg`、`data`;`code=0` 表示成功,`code=-3` 表示参数或业务错误,`code=101011` 表示管理员登录失效。
+
+## 1. 代理列表
+
+### `GET /admin/agent`
+
+请求参数放在 Query String。
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码,最小 1 |
+| `limit` | integer | 否 | `20` | 每页数量,1~100 |
+| `username` | string | 否 | - | 代理账号,模糊查询,最长 64 |
+| `real_name` | string | 否 | - | 真实姓名,模糊查询,最长 128 |
+| `parent_id` | integer | 否 | - | 直属上级代理 ID |
+| `status` | integer | 否 | - | `1` 启用,`0` 禁用 |
+| `start_date` | string | 否 | - | 注册开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 注册结束日期,`Y-m-d`,不能早于开始日期 |
+
+请求示例:
+
+```http
+GET /admin/agent?page=1&limit=20&username=agent&status=1
+```
+
+成功返回:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 12,
+        "parent_id": null,
+        "path": "/12/",
+        "level": 1,
+        "username": "agent001",
+        "real_name": "张三",
+        "invitation_code": "A1B2C3D4E5",
+        "balance": "5000.0000",
+        "frozen_balance": "200.0000",
+        "deposit_fee_rate": "0.0000",
+        "withdraw_fee_rate": "1.5000",
+        "flow_commission_rate": "0.3000",
+        "profit_commission_rate": "10.0000",
+        "status": 1,
+        "last_login_at": "2026-08-24T10:00:00.000000Z",
+        "last_login_ip": "1.2.3.4",
+        "login_status": 1,
+        "login_status_text": "在线",
+        "last_active_at": "2026-08-24 10:05:00",
+        "created_by": 1,
+        "created_at": "2026-08-24 09:00:00",
+        "updated_at": "2026-08-24 10:00:00",
+        "children_count": 3,
+        "members_count": 25,
+        "parent": null,
+        "pc_domain": "https://pc.example.com",
+        "h5_domain": "https://h5.example.com",
+        "pc_register_url": "https://pc.example.com/#/user/register?agent_code=A1B2C3D4E5",
+        "h5_register_url": "https://h5.example.com/#/pages/login/login?type=reg&agent_code=A1B2C3D4E5"
+      }
+    ]
+  }
+}
+```
+
+`members_count` 是直属会员数,`children_count` 是直属下级代理数。`login_status=1` 表示最近 5 分钟有有效代理 Token 活动,否则为离线。`path`、`level` 由后端维护,前端只展示,不提交。
+
+## 2. 代理详情
+
+### `GET /admin/agent/show`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 是 | 代理 ID,必须存在 |
+
+```http
+GET /admin/agent/show?id=12
+```
+
+成功时 `data` 直接返回一个完整代理对象,字段与“代理列表”的 `data.list[]` 相同,不再包 `list`。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 12,
+    "username": "agent001",
+    "balance": "5000.0000",
+    "children_count": 3,
+    "members_count": 25,
+    "pc_domain": "https://pc.example.com",
+    "h5_domain": "https://h5.example.com"
+  }
+}
+```
+
+## 3. 新增代理
+
+### `POST /admin/agent/create`
+
+| 参数 | 类型 | 必填 | 规则/说明 |
+|---|---|---:|---|
+| `username` | string | 是 | 4~64 位,只允许字母、数字、横线、下划线,全局唯一 |
+| `password` | string | 是 | 6~100 位 |
+| `withdraw_password` | string | 否 | 提现密码,6~100 位;不传时代理之后自行设置 |
+| `real_name` | string | 否 | 真实姓名,最长 128 |
+| `parent_id` | integer/null | 否 | 上级代理 ID;空表示一级代理 |
+| `initial_balance` | decimal string | 否 | 初始站内额度,最小 0,最多 14 位整数和 4 位小数 |
+| `deposit_fee_rate` | decimal string | 否 | 存款手续费百分比,0~100,最多 4 位小数 |
+| `withdraw_fee_rate` | decimal string | 否 | 提现手续费百分比,0~100,最多 4 位小数 |
+| `flow_commission_rate` | decimal string | 否 | 初始流水佣金百分比,0~100 |
+| `profit_commission_rate` | decimal string | 否 | 初始盈亏佣金百分比,0~100 |
+| `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
+
+```json
+{
+  "username": "agent001",
+  "password": "123456",
+  "withdraw_password": "654321",
+  "real_name": "张三",
+  "parent_id": null,
+  "initial_balance": "5000.0000",
+  "deposit_fee_rate": "0",
+  "withdraw_fee_rate": "1.5",
+  "flow_commission_rate": "0.3",
+  "profit_commission_rate": "10",
+  "status": 1
+}
+```
+
+成功时 `data` 返回新代理对象。密码字段永远不会返回。`initial_balance` 会同时生成一条资金流水,不允许前端直接修改 `balance`。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 12,
+    "parent_id": null,
+    "path": "/12/",
+    "level": 1,
+    "username": "agent001",
+    "real_name": "张三",
+    "invitation_code": "A1B2C3D4E5",
+    "balance": "5000.0000",
+    "frozen_balance": "0.0000",
+    "status": 1
+  }
+}
+```
+
+## 4. 修改代理
+
+### `POST /admin/agent/update`
+
+| 参数 | 类型 | 必填 | 规则/说明 |
+|---|---|---:|---|
+| `id` | integer | 是 | 代理 ID |
+| `username` | string | 否 | 4~64 位,全局唯一 |
+| `password` | string | 否 | 新登录密码,6~100 位;空值不修改 |
+| `withdraw_password` | string | 否 | 新提现密码,6~100 位;空值不修改 |
+| `real_name` | string | 否 | 最长 128 |
+| `parent_id` | integer/null | 否 | 修改上级;`null` 移到一级,后端会同步整棵下级路径并阻止循环层级 |
+| `deposit_fee_rate` | decimal string | 否 | 0~100 |
+| `withdraw_fee_rate` | decimal string | 否 | 0~100 |
+| `status` | integer | 否 | `1` 启用,`0` 禁用 |
+
+以下字段禁止在此接口提交:`initial_balance`、`flow_commission_rate`、`profit_commission_rate`。余额走加扣款接口;佣金比例走佣金比例菜单。
+
+```json
+{
+  "id": 12,
+  "username": "agent001-new",
+  "real_name": "张三",
+  "parent_id": 5,
+  "withdraw_fee_rate": "1.2",
+  "status": 1
+}
+```
+
+成功时 `data` 返回修改后的代理对象:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 12,
+    "parent_id": 5,
+    "path": "/5/12/",
+    "level": 2,
+    "username": "agent001-new",
+    "withdraw_fee_rate": "1.2000",
+    "status": 1
+  }
+}
+```
+
+## 5. 绑定域名
+
+### `POST /admin/agent/domains`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 是 | 代理 ID |
+| `pc` | string/null | 否 | PC 域名或 URL,最长 255;传空字符串解绑 |
+| `h5` | string/null | 否 | H5 域名或 URL,最长 255;传空字符串解绑 |
+
+```json
+{
+  "id": 12,
+  "pc": "https://pc.example.com",
+  "h5": "https://h5.example.com"
+}
+```
+
+后端只保存协议、主机、端口,自动去掉输入中的路径和查询参数。一个域名不能绑定给不同代理。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "pc_domain": "https://pc.example.com",
+    "h5_domain": "https://h5.example.com",
+    "pc_register_url": "https://pc.example.com/#/user/register?agent_code=A1B2C3D4E5",
+    "h5_register_url": "https://h5.example.com/#/pages/login/login?type=reg&agent_code=A1B2C3D4E5"
+  }
+}
+```
+
+## 6. 加款/扣款
+
+### `POST /admin/agent/adjust`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 是 | 代理 ID |
+| `type` | string | 是 | `credit` 加款;`debit` 扣款 |
+| `amount` | decimal string | 是 | 大于 0,最多 14 位整数和 4 位小数 |
+| `remark` | string | 是 | 操作备注,最长 500 |
+| `request_id` | string | 是 | 本次操作唯一 UUID,最长 64 |
+
+```json
+{
+  "agent_id": 12,
+  "type": "credit",
+  "amount": "500.0000",
+  "remark": "后台加款",
+  "request_id": "9e8b71a6-01ef-4f91-b949-e72cb879ad61"
+}
+```
+
+相同操作重试必须复用相同 `request_id`;如果同一 `request_id` 被用于不同代理、类型或金额,接口会拒绝。扣款超过可用余额时返回业务错误。
+
+成功时 `data` 返回资金流水:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 101,
+    "agent_id": 12,
+    "type": "credit",
+    "amount": "500.0000",
+    "before_balance": "5000.0000",
+    "after_balance": "5500.0000",
+    "reference_type": "",
+    "reference_id": null,
+    "operator_type": "admin",
+    "operator_id": 1,
+    "remark": "后台加款",
+    "created_at": "2026-08-24 18:00:00",
+    "updated_at": "2026-08-24 18:00:00"
+  }
+}
+```
+
+## 7. 额度管理
+
+### `GET /admin/agent/quota`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 是 | 代理 ID |
+
+```http
+GET /admin/agent/quota?agent_id=12
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "balance": "5500.0000",
+    "frozen_balance": "200.0000",
+    "quota_mode": "internal",
+    "provider_wallet_supported": false,
+    "platforms": [
+      {"plat_type":"ag","name":"AG钱包","logo":"https://api.example.com/static/ag.png"}
+    ]
+  }
+}
+```
+
+当前额度是站内代理额度。api-bet 当前接入没有“每个代理一个三方子钱包”,因此 `provider_wallet_supported` 固定为 `false`,平台项不能显示成代理真实三方余额。
+
+## 8. 绑定/解绑会员
+
+### `POST /admin/agent/bind-member`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `user_id` | integer | 是 | `users.id`,不是 Telegram `member_id` |
+| `agent_id` | integer/null | 否 | 目标代理 ID;传 `null` 解绑代理 |
+
+```json
+{"user_id":92385294,"agent_id":12}
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {"user_id":92385294,"agent_id":12}
+}
+```
+
+该接口用于后台修正历史归属。新会员注册应由注册后端根据推广码写入 `users.agent_id`,不要让普通浏览器调用本管理接口。

+ 80 - 0
docs/总后台/代理报表.md

@@ -0,0 +1,80 @@
+# 总后台-代理报表
+
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+
+## 代理报表
+
+### `GET /admin/agent/reports`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `10` | 每页 1~20 |
+| `username` | string | 否 | - | 代理账号,模糊查询,最长 64 |
+| `start_date` | string | 是 | - | 统计开始日期,`Y-m-d` |
+| `end_date` | string | 是 | - | 统计结束日期,不能早于开始日期 |
+
+单次统计最长 366 天。
+
+```http
+GET /admin/agent/reports?page=1&limit=20&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 12,
+        "username": "agent001",
+        "real_name": "张三",
+        "parent_username": null,
+        "balance": "5500.0000",
+        "frozen_balance": "200.0000",
+        "agent_count": 3,
+        "member_count": 25,
+        "member_balance": "100000.0000",
+        "deposit_amount": "50000.0000",
+        "withdraw_amount": "10000.0000",
+        "manual_credit": "2000.0000",
+        "manual_debit": "500.0000",
+        "bonus_amount": "300.0000",
+        "rebate_amount": "100.0000",
+        "bet_count": 188,
+        "bet_amount": "30000.0000",
+        "valid_bet_amount": "28000.0000",
+        "win_loss": "-3000.0000",
+        "commission_amount": "310.0000",
+        "company_profit": "39600.0000"
+      }
+    ]
+  }
+}
+```
+
+字段口径:
+
+| 字段 | 说明 |
+|---|---|
+| `agent_count` | 整棵下级代理树的代理数,不包含当前代理 |
+| `member_count` | 当前代理及整棵下级树的会员数 |
+| `member_balance` | 这些会员当前站内可用余额合计 |
+| `deposit_amount` | 区间内成功充值金额 |
+| `withdraw_amount` | 区间内成功提现金额 |
+| `manual_credit` | 区间内人工充值 |
+| `manual_debit` | 区间内人工扣款绝对值 |
+| `bonus_amount` | 注册赠送、优惠活动、即充即送、充值返现、老用户回归等优惠 |
+| `rebate_amount` | 比比返、笔笔返、返水、回水 |
+| `bet_amount` | 总投注金额 |
+| `valid_bet_amount` | 有效投注金额 |
+| `win_loss` | 会员输赢;正数会员赢,负数会员输 |
+| `company_profit` | 公司盈亏:总存款-总提款-优惠/加送-返水;手动加扣款不参与 |
+| `commission_amount` | 区间内已入账给该代理树的佣金 |
+
+第三方订单只统计 bot-28 已落库且 `status=1` 的记录,`settled_amount` 按厂商“输赢金额”口径计入 `win_loss`。

+ 112 - 0
docs/总后台/代理提现列表.md

@@ -0,0 +1,112 @@
+# 总后台-代理提现列表
+
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+
+## 1. 提现列表
+
+### `GET /admin/agent/withdrawals`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `agent_id` | integer | 否 | - | 代理 ID |
+| `order_no` | string | 否 | - | 提现订单号,精确匹配,最长 40 |
+| `status` | string | 否 | - | `pending`、`approved`、`rejected`、`paid` |
+| `start_date` | string | 否 | - | 申请开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 申请结束日期,不能早于开始日期 |
+
+```http
+GET /admin/agent/withdrawals?page=1&limit=20&status=pending&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 8,
+        "order_no": "AW20260824180000ABC12345",
+        "agent_id": 12,
+        "account_id": 3,
+        "account_type": "usdt",
+        "account_snapshot": {
+          "type": "usdt",
+          "name": "张三",
+          "account": "TXxxxxxxxx",
+          "bank_name": "",
+          "network": "TRC20",
+          "details": {}
+        },
+        "amount": "1000.0000",
+        "fee": "15.0000",
+        "actual_amount": "985.0000",
+        "status": "pending",
+        "remark": "代理提现",
+        "audit_remark": "",
+        "audit_admin_id": null,
+        "audited_at": null,
+        "paid_at": null,
+        "created_at": "2026-08-24 18:00:00",
+        "updated_at": "2026-08-24 18:00:00",
+        "agent": {"id":12,"username":"agent001","real_name":"张三"}
+      }
+    ]
+  }
+}
+```
+
+`account_snapshot` 是申请时的收款资料快照,即使代理之后修改账户,本订单仍显示原资料。
+
+## 2. 审核、驳回、确认付款
+
+### `POST /admin/agent/withdrawals/audit`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | integer | 是 | 提现记录 ID |
+| `action` | string | 是 | `approve` 通过、`reject` 驳回、`paid` 已付款 |
+| `remark` | string | 否 | 审核备注,最长 500 |
+
+```json
+{"id":8,"action":"approve","remark":"财务审核通过"}
+```
+
+状态流转:
+
+- `pending -> approved -> paid`
+- `pending -> rejected`
+- `approved -> rejected`
+
+驳回会自动从冻结余额解冻并退回可用余额;`paid` 会扣减冻结余额,不会再次扣可用余额。
+
+成功时 `data` 返回更新后的提现对象,字段与列表项相同,但不附带 `agent` 关系:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 8,
+    "order_no": "AW20260824180000ABC12345",
+    "agent_id": 12,
+    "amount": "1000.0000",
+    "fee": "15.0000",
+    "actual_amount": "985.0000",
+    "status": "approved",
+    "audit_remark": "财务审核通过",
+    "audit_admin_id": 1,
+    "audited_at": "2026-08-24T18:05:00.000000Z",
+    "paid_at": null
+  }
+}
+```
+
+重复执行不符合当前状态的动作会返回 `code=-3`,例如已付款订单不能再次付款。

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

@@ -0,0 +1,82 @@
+# 总后台-代理流水佣金比例
+
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+
+流水佣金计算公式:`有效投注额 × flow_rate ÷ 100`。
+
+## 1. 查询比例历史
+
+### `GET /admin/agent/commission-rules`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 否 | 代理 ID;不传返回全部代理比例历史 |
+
+```http
+GET /admin/agent/commission-rules?agent_id=12
+```
+
+成功时 `data` 是数组,不分页:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": [
+    {
+      "id": 20,
+      "agent_id": 12,
+      "flow_rate": "0.3000",
+      "profit_rate": "10.0000",
+      "effective_from": "2026-08-24T00:00:00.000000Z",
+      "status": 1,
+      "created_by": 1,
+      "created_at": "2026-08-24 18:00:00",
+      "updated_at": "2026-08-24 18:00:00",
+      "agent": {"id":12,"username":"agent001"}
+    }
+  ]
+}
+```
+
+## 2. 设置流水佣金比例
+
+### `POST /admin/agent/flow-commission-rule`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 是 | 代理 ID |
+| `flow_rate` | decimal string | 是 | 流水佣金百分比,0~100,最多 4 位小数 |
+| `effective_from` | string | 是 | 必须是操作当天,格式 `Y-m-d` |
+
+```json
+{
+  "agent_id": 12,
+  "flow_rate": "0.3000",
+  "effective_from": "2026-08-24"
+}
+```
+
+限制:下级比例不能高于上级;当前代理比例不能低于已有下级比例;当天佣金已经入账后不能倒改当天比例。
+
+成功时 `data` 返回本次比例记录:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 20,
+    "agent_id": 12,
+    "flow_rate": "0.3000",
+    "profit_rate": "10.0000",
+    "effective_from": "2026-08-24T00:00:00.000000Z",
+    "status": 1,
+    "created_by": 1,
+    "created_at": "2026-08-24 18:00:00",
+    "updated_at": "2026-08-24 18:00:00"
+  }
+}
+```

+ 56 - 0
docs/总后台/代理登录日志.md

@@ -0,0 +1,56 @@
+# 总后台-代理登录日志
+
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+
+## 登录日志列表
+
+### `GET /admin/agent/login-logs`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `agent_id` | integer | 否 | - | 代理 ID |
+| `username` | string | 否 | - | 代理账号,精确匹配,最长 64 |
+| `ip` | string | 否 | - | IP,精确匹配,最长 64 |
+| `device` | string | 否 | - | `mobile` 手机端;`pc` 电脑端 |
+| `start_date` | string | 否 | - | 登录开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 登录结束日期,不能早于开始日期 |
+
+```http
+GET /admin/agent/login-logs?page=1&limit=20&device=mobile&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 100,
+        "agent_id": 12,
+        "username": "agent001",
+        "domain": "agent.example.com",
+        "ip": "1.2.3.4",
+        "country": "CN",
+        "region": "Guangdong",
+        "city": "Shenzhen",
+        "device": "mobile",
+        "user_agent": "Mozilla/5.0 (iPhone...)",
+        "login_at": "2026-08-24T18:00:00.000000Z",
+        "created_at": "2026-08-24 18:00:00",
+        "updated_at": "2026-08-24 18:00:00",
+        "device_text": "手机端",
+        "location": "CN Guangdong Shenzhen"
+      }
+    ]
+  }
+}
+```
+
+前端直接展示 `device_text` 和 `location`,不需要点击 IP 再查询。地区来自登录时网关/CDN 写入的 `CF-IPCountry`、`X-IP-Country`、`X-IP-Region`、`X-IP-City`;未配置时地区字段为空,但 IP 和设备仍会记录。

+ 74 - 0
docs/总后台/代理盈亏佣金比例.md

@@ -0,0 +1,74 @@
+# 总后台-代理盈亏佣金比例
+
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+
+盈亏佣金基数是公司正盈利:`max(company_profit, 0)`;计算公式为 `基数 × profit_rate ÷ 100`。`company_profit` 按总存款减总提款、优惠/加送和返水计算,手动加扣款不参与。
+
+## 1. 查询比例历史
+
+### `GET /admin/agent/commission-rules`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 否 | 代理 ID;不传返回全部代理比例历史 |
+
+返回 `data[]` 字段:`id`、`agent_id`、`flow_rate`、`profit_rate`、`effective_from`、`status`、`created_by`、`created_at`、`updated_at`、`agent`。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": [
+    {
+      "id": 20,
+      "agent_id": 12,
+      "flow_rate": "0.3000",
+      "profit_rate": "10.0000",
+      "effective_from": "2026-08-24T00:00:00.000000Z",
+      "status": 1,
+      "created_by": 1,
+      "agent": {"id":12,"username":"agent001"}
+    }
+  ]
+}
+```
+
+## 2. 设置盈亏佣金比例
+
+### `POST /admin/agent/profit-commission-rule`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `agent_id` | integer | 是 | 代理 ID |
+| `profit_rate` | decimal string | 是 | 盈亏佣金百分比,0~100,最多 4 位小数 |
+| `effective_from` | string | 是 | 必须是操作当天,`Y-m-d` |
+
+```json
+{
+  "agent_id": 12,
+  "profit_rate": "10.0000",
+  "effective_from": "2026-08-24"
+}
+```
+
+成功时 `data` 返回保存后的比例记录:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "id": 20,
+    "agent_id": 12,
+    "flow_rate": "0.3000",
+    "profit_rate": "10.0000",
+    "effective_from": "2026-08-24T00:00:00.000000Z",
+    "status": 1,
+    "created_by": 1
+  }
+}
+```
+
+下级比例不能高于上级,当前代理比例不能低于已有下级比例,已经入账的账期不能修改。

+ 64 - 0
docs/总后台/代理返佣记录.md

@@ -0,0 +1,64 @@
+# 总后台-代理返佣记录
+
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+
+## 返佣记录列表
+
+### `GET /admin/agent/commissions`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `agent_id` | integer | 否 | - | 代理 ID |
+| `type` | string | 否 | - | `flow` 流水佣金;`profit` 盈亏佣金 |
+| `status` | string | 否 | - | `pending` 待入账;`credited` 已入账 |
+| `start_date` | string | 否 | - | 结算开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 结算结束日期,不能早于开始日期 |
+
+```http
+GET /admin/agent/commissions?page=1&limit=20&agent_id=12&type=flow&status=credited&start_date=2026-08-01&end_date=2026-08-24
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787568000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 30,
+        "settlement_date": "2026-08-23T00:00:00.000000Z",
+        "agent_id": 12,
+        "type": "flow",
+        "base_amount": "28000.0000",
+        "rate": "0.3000",
+        "amount": "84.0000",
+        "bet_count": 188,
+        "status": "credited",
+        "snapshot": {"agent_ids":[12,15,16,17]},
+        "credited_at": "2026-08-24T04:10:00.000000Z",
+        "created_at": "2026-08-24 04:10:00",
+        "updated_at": "2026-08-24 04:10:00",
+        "agent": {"id":12,"username":"agent001"}
+      }
+    ]
+  }
+}
+```
+
+字段说明:
+
+- `settlement_date`:所属账期。
+- `base_amount`:流水佣金为有效投注额;盈亏佣金为公司正盈利。
+- `rate`:结算时锁定的比例快照。
+- `amount`:应返佣金额。
+- `bet_count`:参与计算的注单数。
+- `snapshot.agent_ids`:本次佣金覆盖的代理树 ID 快照。
+- `credited_at`:实际入账时间。
+
+每天 04:10 自动结算前一天数据。每个代理、日期、佣金类型只有一条记录,重复执行不会重复入账。

+ 20 - 0
routes/admin.php

@@ -49,6 +49,7 @@ use App\Http\Controllers\admin\Funds;
 use App\Http\Controllers\admin\ManualAudit;
 use App\Http\Controllers\admin\ThirdPartyDeposit;
 use App\Http\Controllers\admin\ThirdGameOrder;
+use App\Http\Controllers\admin\Agent as AgentAdmin;
 
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -73,6 +74,25 @@ Route::middleware(['admin.jwt'])->group(function () {
 
 
     Route::middleware(['check.button.uri'])->group(function () {
+        Route::prefix('/agent')->middleware('agent.admin')->group(function () {
+            Route::get('/', [AgentAdmin::class, 'index']);
+            Route::get('/show', [AgentAdmin::class, 'show']);
+            Route::post('/create', [AgentAdmin::class, 'store']);
+            Route::post('/update', [AgentAdmin::class, 'update']);
+            Route::post('/domains', [AgentAdmin::class, 'domains']);
+            Route::post('/adjust', [AgentAdmin::class, 'adjust']);
+            Route::get('/quota', [AgentAdmin::class, 'quota']);
+            Route::post('/bind-member', [AgentAdmin::class, 'bindMember']);
+            Route::get('/login-logs', [AgentAdmin::class, 'loginLogs']);
+            Route::get('/withdrawals', [AgentAdmin::class, 'withdrawals']);
+            Route::post('/withdrawals/audit', [AgentAdmin::class, 'auditWithdrawal']);
+            Route::get('/reports', [AgentAdmin::class, 'reports']);
+            Route::get('/commission-rules', [AgentAdmin::class, 'commissionRules']);
+            Route::post('/flow-commission-rule', [AgentAdmin::class, 'saveFlowCommissionRule']);
+            Route::post('/profit-commission-rule', [AgentAdmin::class, 'saveProfitCommissionRule']);
+            Route::get('/commissions', [AgentAdmin::class, 'commissions']);
+        });
+
         Route::get('/menu/mineMenu', [Menu::class, 'mineMenu']); // 我的菜单
         Route::post('/menu/check', [Menu::class, 'check']); // 接口权限
         Route::get('/menu/tree', [Menu::class, 'tree']); // 菜单按钮树

+ 46 - 0
routes/agent.php

@@ -0,0 +1,46 @@
+<?php
+
+use App\Http\Controllers\agent\AuthController;
+use App\Http\Controllers\agent\CommissionController;
+use App\Http\Controllers\agent\MemberController;
+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\WalletController;
+use Illuminate\Support\Facades\Route;
+
+Route::post('/auth/login', [AuthController::class, 'login'])->middleware('throttle:10,1');
+Route::get('/referral/resolve', [ReferralController::class, 'resolve'])->middleware('throttle:60,1');
+Route::post('/referral/bind', [ReferralController::class, 'bind'])->middleware(['check.token', 'throttle:20,1']);
+
+Route::middleware('agent.auth')->group(function () {
+    Route::post('/auth/logout', [AuthController::class, 'logout']);
+    Route::get('/profile', [ProfileController::class, 'me']);
+    Route::post('/profile', [ProfileController::class, 'update']);
+    Route::post('/profile/password', [ProfileController::class, 'password']);
+    Route::get('/profile/domains', [ProfileController::class, 'domains']);
+    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('/wallet/logs', [WalletController::class, 'logs']);
+    Route::get('/wallet/accounts', [WalletController::class, 'accounts']);
+    Route::post('/wallet/accounts', [WalletController::class, 'saveAccount']);
+    Route::post('/wallet/accounts/delete', [WalletController::class, 'deleteAccount']);
+    Route::get('/wallet/withdrawals', [WalletController::class, 'withdrawals']);
+    Route::post('/wallet/withdrawals', [WalletController::class, 'requestWithdrawal']);
+
+    Route::get('/commissions', [CommissionController::class, 'records']);
+    Route::get('/commission-rules', [CommissionController::class, 'rules']);
+    Route::post('/commission-rules', [CommissionController::class, 'saveRule']);
+    Route::get('/reports', [ReportController::class, 'index']);
+});

+ 41 - 0
tests/Unit/AgentContextServiceTest.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Agent\AgentContextService;
+use Illuminate\Http\Request;
+use PHPUnit\Framework\TestCase;
+
+class AgentContextServiceTest extends TestCase
+{
+    public function test_mobile_device_and_gateway_location_are_recorded(): void
+    {
+        $request = Request::create('https://api.example.com/agent/auth/login', 'POST', [], [], [], [
+            'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)',
+            'HTTP_ORIGIN' => 'https://agent.example.com',
+            'HTTP_CF_IPCOUNTRY' => 'CN',
+            'HTTP_X_IP_REGION' => 'Guangdong',
+            'HTTP_X_IP_CITY' => 'Shenzhen',
+        ]);
+        $service = new AgentContextService();
+
+        $this->assertSame('mobile', $service->device($request));
+        $this->assertSame('agent.example.com', $service->domain($request));
+        $this->assertSame([
+            'country' => 'CN',
+            'region' => 'Guangdong',
+            'city' => 'Shenzhen',
+        ], $service->location($request));
+    }
+
+    public function test_desktop_device_falls_back_to_request_host(): void
+    {
+        $request = Request::create('https://api.example.com/agent/auth/login', 'POST', [], [], [], [
+            'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
+        ]);
+        $service = new AgentContextService();
+
+        $this->assertSame('pc', $service->device($request));
+        $this->assertSame('api.example.com', $service->domain($request));
+    }
+}

+ 26 - 0
tests/Unit/AgentDomainServiceTest.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Agent\AgentDomainService;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+
+class AgentDomainServiceTest extends TestCase
+{
+    public function test_hash_router_registration_urls_keep_agent_code_inside_fragment_route(): void
+    {
+        $method = new ReflectionMethod(AgentDomainService::class, 'registerUrl');
+        $method->setAccessible(true);
+        $service = new AgentDomainService();
+
+        $this->assertSame(
+            'https://pc.example.com/#/user/register?agent_code=ABC123',
+            $method->invoke($service, 'https://pc.example.com', '/#/user/register', 'ABC123')
+        );
+        $this->assertSame(
+            'https://h5.example.com/#/pages/login/login?type=reg&agent_code=ABC123',
+            $method->invoke($service, 'https://h5.example.com', '/#/pages/login/login?type=reg', 'ABC123')
+        );
+    }
+}

+ 17 - 0
tests/Unit/AgentReportFormulaTest.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Agent\AgentReportService;
+use PHPUnit\Framework\TestCase;
+
+class AgentReportFormulaTest extends TestCase
+{
+    public function test_company_profit_uses_cash_flow_formula_and_excludes_manual_adjustments(): void
+    {
+        $service = new AgentReportService();
+
+        $this->assertSame('39600.0000', $service->companyProfit('50000', '10000', '300', '100'));
+        $this->assertSame('-1100.0000', $service->companyProfit('0', '1000', '50', '50'));
+    }
+}

+ 20 - 0
tests/Unit/DecimalNumberTest.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Rules\DecimalNumber;
+use PHPUnit\Framework\TestCase;
+
+class DecimalNumberTest extends TestCase
+{
+    public function test_accepts_money_without_precision_loss(): void
+    {
+        $rule = new DecimalNumber();
+
+        $this->assertTrue($rule->passes('amount', '10000'));
+        $this->assertTrue($rule->passes('amount', '99999999999999.9999'));
+        $this->assertFalse($rule->passes('amount', '1e3'));
+        $this->assertFalse($rule->passes('amount', '1.00001'));
+        $this->assertFalse($rule->passes('amount', '-1'));
+    }
+}