Browse Source

代理申请

doge 5 giờ trước cách đây
mục cha
commit
9f239792e7

+ 16 - 6
app/Http/Controllers/admin/Agent.php

@@ -64,6 +64,9 @@ class Agent extends AgentApiController
             $data = $request->validate($this->rules());
             return DB::transaction(function () use ($data, $service) {
                 $agent = $service->create($data, (int) request()->user->id);
+                if (!empty($data['owner_user_id'])) {
+                    $agent = app(\App\Services\Agent\AgentApplicationService::class)->bindMember($agent, (int) $data['owner_user_id']);
+                }
                 if (bccomp((string) ($data['initial_balance'] ?? 0), '0', 4) > 0) {
                     app(AgentWalletService::class)->adjust(
                         (int) $agent->id,
@@ -85,12 +88,18 @@ class Agent extends AgentApiController
     {
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate(array_merge(['id' => ['required', 'integer', 'exists:agents,id']], $this->rules(true)));
-            return $service->update(
-                AgentModel::query()->findOrFail($data['id']),
-                $data,
-                null,
-                (int) request()->user->id
-            );
+            return DB::transaction(function () use ($service, $data) {
+                $agent = $service->update(
+                    AgentModel::query()->findOrFail($data['id']),
+                    $data,
+                    null,
+                    (int) request()->user->id
+                );
+                if (!empty($data['owner_user_id'])) {
+                    $agent = app(\App\Services\Agent\AgentApplicationService::class)->bindMember($agent, (int) $data['owner_user_id']);
+                }
+                return $agent;
+            });
         });
     }
 
@@ -333,6 +342,7 @@ class Agent extends AgentApiController
     {
         return [
             'language' => ['sometimes', 'required', 'in:zh,en,vi'],
+            'owner_user_id' => ['nullable', 'integer', 'min:1', 'exists:users,id'],
             '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'],

+ 20 - 0
app/Http/Controllers/admin/AgentApplication.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\Agent\AgentApplicationService;
+use App\Services\Agent\AgentService;
+
+class AgentApplication extends AgentApiController
+{
+    public function index(AgentApplicationService $service)
+    {
+        return $this->execute(fn () => $service->listing(request()->all()));
+    }
+
+    public function audit(AgentApplicationService $service, AgentService $agents)
+    {
+        return $this->execute(fn () => $service->audit(request()->all(), $agents));
+    }
+}

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

@@ -24,6 +24,7 @@ class Agent extends BaseModel
     protected $hidden = ['password', 'withdraw_password'];
     protected $appends = ['level_text', 'can_create_sub_agent'];
     protected $casts = [
+        'owner_user_id' => 'integer',
         'parent_id' => 'integer', 'level' => 'integer', 'status' => 'integer',
         'balance' => 'decimal:4', 'frozen_balance' => 'decimal:4',
         'deposit_fee_rate' => 'decimal:4', 'withdraw_fee_rate' => 'decimal:4',

+ 13 - 0
app/Models/Agent/AgentApplication.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentApplication extends BaseModel
+{
+    protected $table = 'agent_applications';
+    protected $guarded = ['id'];
+    protected $hidden = ['password_hash', 'withdraw_password_hash', 'pending_user_id', 'pending_username'];
+    protected $casts = ['user_id' => 'integer', 'agent_id' => 'integer', 'reviewed_by' => 'integer'];
+}

+ 122 - 0
app/Services/Agent/AgentApplicationService.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentApplication;
+use App\Models\User;
+use App\Services\OperationAuditService;
+use App\Services\SiteRealtimeService;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+
+class AgentApplicationService
+{
+    public function listing(array $input): array
+    {
+        $data = Validator::make($input, [
+            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'between:1,100'],
+            'status' => ['nullable', 'in:pending,approved,rejected'], 'username' => ['nullable', 'string', 'max:64'],
+            'member_id' => ['nullable', 'string', 'max:64'],
+        ])->validate();
+        $query = DB::table('agent_applications as a')->join('users as u', 'u.id', '=', 'a.user_id');
+        if (!empty($data['status'])) $query->where('a.status', $data['status']);
+        if (!empty($data['username'])) $query->where('a.username', 'like', '%' . $data['username'] . '%');
+        if (!empty($data['member_id'])) $query->where('u.member_id', $data['member_id']);
+        $total = (clone $query)->count();
+        $rows = $query->select('a.id', 'a.user_id', 'u.member_id', 'u.account as member_account', 'a.username',
+            'a.status', 'a.agent_id', 'a.reject_reason', 'a.reviewed_by', 'a.reviewed_at', 'a.created_at')
+            ->orderByDesc('a.id')->forPage((int) ($data['page'] ?? 1), (int) ($data['limit'] ?? 20))->get();
+        return ['total' => $total, 'data' => $rows];
+    }
+
+    public function audit(array $input, AgentService $agents): array
+    {
+        $data = Validator::make($input, [
+            'id' => ['required', 'integer', 'min:1'], 'status' => ['required', 'in:approved,rejected'],
+            'remark' => ['required_if:status,rejected', 'nullable', 'string', 'max:500'],
+            'flow_commission_profile_id' => ['nullable', 'integer', Rule::exists('agent_flow_commission_profiles', 'id')->whereNull('owner_agent_id')],
+            'profit_commission_profile_id' => ['nullable', 'integer', Rule::exists('agent_profit_commission_profiles', 'id')->whereNull('owner_agent_id')],
+        ])->validate();
+        return DB::transaction(function () use ($data, $agents) {
+            $found = AgentApplication::findOrFail($data['id']);
+            $member = User::where('id', $found->user_id)->lockForUpdate()->firstOrFail();
+            $application = AgentApplication::where('id', $found->id)->lockForUpdate()->firstOrFail();
+            if ($application->status !== 'pending') {
+                if ($application->status === $data['status']) return $application->toArray();
+                throw new \RuntimeException('申请已审核,不能修改审核结果');
+            }
+            $this->assertMember($member, $data['status'] === 'rejected');
+            $actor = OperationAuditService::actor();
+            if (!$actor['operator_id']) throw new \RuntimeException('请使用后台管理员身份');
+            $before = $application->toArray();
+            if ($data['status'] === 'approved') {
+                if (Agent::where('owner_user_id', $member->id)->exists()) throw new \RuntimeException('该会员已是代理');
+                if (Agent::whereRaw('LOWER(username) = ?', [strtolower($application->username)])->exists()) throw new \RuntimeException('代理账号已被使用,请驳回后重新申请');
+                $options = array_filter(array_intersect_key($data, array_flip(['flow_commission_profile_id', 'profit_commission_profile_id'])), fn ($value) => $value !== null);
+                $agent = $agents->createFromApplication($application, $options + [
+                    'real_name' => $member->first_name ?: '', 'language' => in_array($member->language, ['zh', 'en', 'vi'], true) ? $member->language : 'zh',
+                ], (int) $actor['operator_id']);
+                $this->bindMember($agent, (int) $member->id);
+                $application->agent_id = $agent->id;
+            }
+            $application->status = $data['status'];
+            $application->reject_reason = $data['status'] === 'rejected' ? trim((string) $data['remark']) : '';
+            if ($data['status'] === 'rejected' && $application->reject_reason === '') throw new \RuntimeException('请填写驳回原因');
+            $application->reviewed_by = $actor['operator_id'];
+            $application->reviewed_at = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+            $application->password_hash = $application->withdraw_password_hash = null;
+            $application->pending_user_id = $application->pending_username = null;
+            $application->save();
+            $this->notify($application, $member, $actor);
+            OperationAuditService::record('agent_application', $application->id, $application->status, $before, $application);
+            return $application->toArray();
+        }, 3);
+    }
+
+    public function bindMember(Agent $agent, int $userId): Agent
+    {
+        return DB::transaction(function () use ($agent, $userId) {
+            $member = User::where('id', $userId)->lockForUpdate()->firstOrFail();
+            $this->assertMember($member);
+            $agent = Agent::where('id', $agent->id)->lockForUpdate()->firstOrFail();
+            if ($agent->owner_user_id !== null && (int) $agent->owner_user_id !== $userId) throw new \RuntimeException('代理已绑定其他会员');
+            if (Agent::where('owner_user_id', $userId)->where('id', '<>', $agent->id)->exists()) throw new \RuntimeException('该会员已绑定其他代理账号');
+            $agent->forceFill(['owner_user_id' => $userId])->save();
+            return $agent->load(['flowCommissionProfile', 'profitCommissionProfile']);
+        }, 3);
+    }
+
+    private function assertMember(User $member, bool $allowBanned = false): void
+    {
+        if ((int) $member->from !== 1 || !empty($member->delete_time) || (!$allowBanned && (int) $member->is_banned === 1)) {
+            throw new \RuntimeException('会员状态不允许申请或绑定代理');
+        }
+    }
+
+    private function notify(AgentApplication $application, User $member, array $actor): void
+    {
+        $approved = $application->status === 'approved';
+        $account = htmlspecialchars($application->username, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        $reason = htmlspecialchars($application->reject_reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        $texts = [
+            'zh' => ['title' => $approved ? '代理申请已通过' : '代理申请未通过', 'content' => $approved ? "您的代理申请已通过,代理账号:{$account}。请使用申请时设置的密码登录代理后台。" : "您的代理申请未通过,原因:{$reason}。您可以重新申请。"],
+            'en' => ['title' => $approved ? 'Agent application approved' : 'Agent application rejected', 'content' => $approved ? "Your agent account {$account} is ready. Sign in to the agent portal with the password you set when applying." : "Your agent application was rejected. Reason: {$reason}. You may apply again."],
+            'vi' => ['title' => $approved ? 'Đơn đăng ký đại lý được duyệt' : 'Đơn đăng ký đại lý bị từ chối', 'content' => $approved ? "Tài khoản đại lý {$account} đã sẵn sàng. Đăng nhập bằng mật khẩu đã đặt khi đăng ký." : "Đơn đăng ký đại lý bị từ chối. Lý do: {$reason}. Bạn có thể đăng ký lại."],
+        ];
+        $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+        $language = in_array($member->language, ['zh', 'en', 'vi'], true) ? $member->language : 'zh';
+        $messageId = DB::table('site_messages')->insertGetId([
+            'category' => 'internal', 'recipient_type' => 'specified_users', 'recipient_ids' => json_encode([$member->id]),
+            'status' => 'sent', 'send_at' => $now, 'sent_at' => $now, 'recipient_count' => 1, 'skipped_count' => 0,
+            'sender_id' => $actor['operator_id'], 'sender_name' => $actor['operator_name'],
+            'request_id' => 'agent-application:' . $application->id, 'request_hash' => hash('sha256', json_encode($texts)),
+            'created_at' => $now, 'updated_at' => $now,
+        ]);
+        foreach ($texts as $lang => $text) DB::table('site_message_translations')->insert($text + ['subtitle' => '', 'language' => $lang, 'message_id' => $messageId]);
+        DB::table('site_message_recipients')->insert(['message_id' => $messageId, 'recipient_kind' => 'user', 'recipient_id' => $member->id, 'language' => $language, 'created_at' => $now]);
+        OperationAuditService::record('site_message', $messageId, 'send', [], ['recipient_type' => 'specified_users', 'recipient_ids' => [$member->id], 'category' => 'internal', 'status' => 'sent']);
+        SiteRealtimeService::changed('message', $messageId, 'send');
+    }
+}

+ 19 - 3
app/Services/Agent/AgentService.php

@@ -6,6 +6,7 @@ use App\Models\Agent\Agent;
 use App\Models\Agent\AgentFlowCommissionProfile;
 use App\Models\Agent\AgentProfitCommissionProfile;
 use App\Models\Agent\AgentToken;
+use App\Models\Agent\AgentApplication;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
@@ -20,7 +21,22 @@ class AgentService
 
     public function create(array $data, ?int $createdBy = null, ?Agent $scopeAgent = null): Agent
     {
-        return DB::transaction(function () use ($data, $createdBy, $scopeAgent): Agent {
+        return $this->createAccount($data, $createdBy, $scopeAgent);
+    }
+
+    public function createFromApplication(AgentApplication $application, array $options, int $createdBy): Agent
+    {
+        if ($application->status !== 'pending' || !$application->password_hash || !$application->withdraw_password_hash) {
+            throw new \RuntimeException('申请已处理或凭证不完整');
+        }
+        return $this->createAccount(['username' => $application->username] + $options, $createdBy, null, [
+            'password' => $application->password_hash, 'withdraw_password' => $application->withdraw_password_hash,
+        ]);
+    }
+
+    private function createAccount(array $data, ?int $createdBy, ?Agent $scopeAgent, ?array $hashes = null): Agent
+    {
+        return DB::transaction(function () use ($data, $createdBy, $scopeAgent, $hashes): Agent {
             $parentId = isset($data['parent_id']) ? (int) $data['parent_id'] : null;
             if ($scopeAgent) {
                 if ((int) $scopeAgent->level !== Agent::LEVEL_ONE) {
@@ -60,8 +76,8 @@ class AgentService
                 'path' => '/',
                 'level' => $level,
                 'username' => trim((string) $data['username']),
-                'password' => Hash::make((string) $data['password']),
-                'withdraw_password' => empty($data['withdraw_password']) ? '' : Hash::make((string) $data['withdraw_password']),
+                'password' => $hashes['password'] ?? Hash::make((string) $data['password']),
+                'withdraw_password' => $hashes['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),

+ 35 - 0
database/migrations/2026_10_29_140000_create_agent_applications.php

@@ -0,0 +1,35 @@
+<?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::hasColumn('agents', 'owner_user_id')) {
+            Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('owner_user_id')->nullable()->unique());
+        }
+        if (Schema::hasTable('agent_applications')) return;
+        Schema::create('agent_applications', function (Blueprint $t) {
+            $t->id();
+            $t->unsignedBigInteger('user_id')->index();
+            $t->string('username', 64);
+            $t->unsignedBigInteger('pending_user_id')->nullable()->unique();
+            $t->string('pending_username', 64)->nullable()->unique();
+            $t->string('password_hash')->nullable();
+            $t->string('withdraw_password_hash')->nullable();
+            $t->string('status', 16)->default('pending')->index();
+            $t->unsignedBigInteger('agent_id')->nullable()->unique();
+            $t->string('reject_reason', 500)->default('');
+            $t->unsignedBigInteger('reviewed_by')->nullable();
+            $t->dateTime('reviewed_at')->nullable();
+            $t->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        // Keep reviewed identity links and application history on a code rollback.
+    }
+};

+ 35 - 0
docs/前端接口/独立代理申请-会员接入.md

@@ -0,0 +1,35 @@
+# 独立代理申请-会员端接入
+
+沿用会员登录态和现有请求封装,成功为 `code=1`,业务数据在 `data`。
+
+## 1. 查询状态
+
+`GET /api/agent/status`
+
+| 字段 | 用途 |
+|---|---|
+| `is_agent` | 是否已有自己的代理账号 |
+| `agent_id` | 代理账号 ID,没有时为 `null` |
+| `agent_status` | `1` 正常、`0` 停用;不是代理时为 `null` |
+| `username` | 申请或已开通的代理账号,没有时为 `null` |
+| `review_status` | `none` 未申请、`pending` 待审核、`approved` 已通过、`rejected` 已驳回 |
+| `application_id` | 最近一次申请 ID,没有时为 `null` |
+| `reject_reason` | 驳回原因 |
+| `can_apply` | 是否可显示申请按钮 |
+| `applied_at` / `reviewed_at` | 申请时间、审核时间,没有时为 `null` |
+
+优先按 `is_agent` 显示代理状态。已有代理正常时显示代理后台入口,停用时显示“账号已停用”;待审核时显示“审核中”;驳回时显示原因并允许重新申请。
+
+## 2. 提交申请
+
+`POST /api/agent/apply`
+
+```json
+{"username":"myagent","password":"Login123!","withdraw_password":"Withdraw123!"}
+```
+
+三项必填:`username` 为代理账号,`password` 为登录密码,`withdraw_password` 为提现密码。账号支持 4~64 位字母、数字、下划线和短横线,不区分大小写;两个密码均为 6~72 位。
+
+成功后返回与状态接口相同的数据,页面切换为“审核中”。审核期间不能重复申请;驳回后可重新提交。代理账号使用申请时设置的密码登录代理后台。
+
+审核通过或驳回后会自动收到站内信,沿用 `GET /api/message/lists?category=internal` 查看;页面重新进入时刷新申请状态即可。

+ 37 - 0
docs/总后台/独立代理申请-审核接入.md

@@ -0,0 +1,37 @@
+# 独立代理申请-后台审核接入
+
+沿用后台登录凭证,成功为 `code=0`。使用已有代理管理权限。
+
+## 1. 申请列表
+
+`GET /admin/agent/applications?page=1&limit=20&status=pending`
+
+可选筛选:`status`(`pending/approved/rejected`)、`username`(代理账号)、`member_id`(会员编号)。
+
+列表在 `data.data[]`,总数为 `data.total`。每项包含 `id`(申请 ID)、`user_id`(会员 ID)、`member_id`(会员编号)、`member_account`、`username`、`status`、`agent_id`、`reject_reason`、`created_at`、`reviewed_at`、`reviewed_by`。
+
+## 2. 审核
+
+`POST /admin/agent/applicationAudit`
+
+通过:
+
+```json
+{"id":123,"status":"approved"}
+```
+
+驳回:
+
+```json
+{"id":123,"status":"rejected","remark":"请核对申请资料后重新提交"}
+```
+
+`id` 取申请列表的申请 ID;驳回必须填写 `remark`,最多 500 字。通过时可选传 `flow_commission_profile_id`、`profit_commission_profile_id`,不传使用默认方案。
+
+审核成功后刷新列表即可。通过会开通独立一级代理;通过、驳回都会自动发送站内信。已审核的申请不能改成另一审核结果。
+
+## 3. 绑定已有代理账号
+
+已有代理在 `POST /admin/agent/update` 的原请求中增加 `owner_user_id`,值取会员列表的 `id`,不是 `member_id`。`/admin/agent/create` 也支持此字段。
+
+绑定后该会员查询状态即可显示“已是代理”。一个会员只能绑定一个代理账号,已绑定账号不能改绑其他会员。

+ 2 - 0
routes/admin.php

@@ -142,6 +142,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
 
         Route::prefix('/agent')->middleware('agent.admin')->group(function () {
+            Route::get('/applications', [\App\Http\Controllers\admin\AgentApplication::class, 'index'])->defaults('permission_uri', 'admin/agent');
+            Route::post('/applicationAudit', [\App\Http\Controllers\admin\AgentApplication::class, 'audit'])->defaults('permission_uri', 'admin/agent/update');
             Route::get('/', [AgentAdmin::class, 'index']);
             Route::get('/show', [AgentAdmin::class, 'show']);
             Route::post('/create', [AgentAdmin::class, 'store']);

+ 5 - 0
tests/Integration/AgentApiRegressionTest.php

@@ -81,6 +81,11 @@ class AgentApiRegressionTest extends TestCase
             $t->integer('status')->default(1); $t->string('register_ip')->default('');
             $t->string('register_domain')->default(''); $t->timestamps();
         });
+        // The profit migration now inspects MySQL index metadata before creating
+        // indexes. Keep the business schema real while providing empty metadata.
+        DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
+        DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
+        DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)');
         foreach (['2026_08_24_140000_create_agent_system_tables.php',
             '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();

+ 269 - 0
tests/Integration/AgentApplicationTest.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\AgentApplication as AdminApplication;
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentApplication;
+use App\Models\Agent\AgentFlowCommissionProfile;
+use App\Services\Agent\AgentApplicationService;
+use App\Services\Agent\AgentFlowCommissionAssignmentService;
+use App\Services\Agent\AgentProfitCommissionAssignmentService;
+use App\Services\Agent\AgentService;
+use App\Services\SiteInboxService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\QueryException;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Hashing\BcryptHasher;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+
+class AgentApplicationTest extends TestCase
+{
+    private Application $app;
+    private AgentService $agents;
+    private AgentApplicationService $service;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $validator = new Factory($translator, $this->app);
+        $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
+        $this->app->instance('validator', $validator);
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('user_id')->unique(); $t->string('member_id')->unique(); $t->string('account');
+            $t->string('username')->default(''); $t->string('first_name')->default(''); $t->integer('from')->default(1);
+            $t->integer('is_banned')->default(0); $t->dateTime('delete_time')->nullable(); $t->string('language')->nullable(); $t->timestamps();
+        });
+        // Metadata only: the existing profit-profile migration inspects MySQL
+        // indexes; the application and all business queries still use real SQLite.
+        DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
+        DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
+        DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)');
+        foreach (['2026_08_24_140000_create_agent_system_tables.php', '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
+        }
+        Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
+            $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
+            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
+        });
+        Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('agent_id'); $t->unsignedBigInteger('flow_commission_profile_id');
+            $t->string('profile_name'); $t->date('effective_from'); $t->unsignedBigInteger('created_by')->nullable();
+            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
+            $t->timestamps(); $t->unique(['agent_id', 'effective_from']);
+        });
+        Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable());
+        foreach (['agent_flow_commission_profiles', 'agent_profit_commission_profiles'] as $table) Schema::table($table, fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
+        DB::table('agent_flow_commission_profiles')->insert(['name' => 'Default', 'is_default' => 1]);
+        Schema::create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action'); $t->json('changes');
+            $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+        foreach (['2026_10_26_110000_create_site_messages.php', '2026_10_27_150000_create_site_realtime_events.php', '2026_10_29_140000_create_agent_applications.php'] as $file) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
+        }
+        foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([
+            'id' => $id, 'user_id' => (string) (10000 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
+            'first_name' => 'Member ' . $id, 'from' => 1, 'language' => $language, 'agent_id' => 99,
+        ]);
+        $this->agents = new AgentService(new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
+        $this->service = new AgentApplicationService();
+        $this->request();
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function request(array $params = []): void
+    {
+        $request = Request::create('/admin/agent/applicationAudit', 'POST', $params);
+        $request->user = (object) ['id' => 1, 'username' => 'admin'];
+        $this->app->instance('request', $request);
+    }
+
+    private function application(int $userId = 11, string $username = 'newagent'): AgentApplication
+    {
+        return AgentApplication::create([
+            'user_id' => $userId, 'pending_user_id' => $userId, 'username' => $username, 'pending_username' => $username,
+            'password_hash' => password_hash('login-secret', PASSWORD_BCRYPT, ['cost' => 4]),
+            'withdraw_password_hash' => password_hash('withdraw-secret', PASSWORD_BCRYPT, ['cost' => 4]), 'status' => 'pending',
+        ]);
+    }
+
+    private function audit(int $id, string $status = 'approved', array $extra = []): array
+    {
+        $this->request(['id' => $id, 'status' => $status] + $extra);
+        return (new AdminApplication())->audit($this->service, $this->agents)->getData(true);
+    }
+
+    public function test_approval_creates_bound_first_level_agent_keeps_passwords_and_sends_one_private_message(): void
+    {
+        $application = $this->application();
+        $response = $this->audit($application->id);
+        $this->assertSame(0, $response['code'], $response['msg']);
+        $agent = Agent::firstOrFail();
+        $this->assertSame(11, $agent->owner_user_id);
+        $this->assertSame(1, $agent->level);
+        $this->assertSame(1, $agent->status);
+        $this->assertTrue(Hash::check('login-secret', $agent->password));
+        $this->assertTrue(Hash::check('withdraw-secret', $agent->withdraw_password));
+        $this->assertSame(99, (int) DB::table('users')->where('id', 11)->value('agent_id'));
+        $this->assertNotNull($agent->flow_commission_profile_id);
+        $this->assertNotNull($agent->profit_commission_profile_id);
+        $application->refresh();
+        $this->assertSame('approved', $application->status);
+        $this->assertNull($application->password_hash); $this->assertNull($application->withdraw_password_hash);
+        $this->assertNull($application->pending_user_id); $this->assertNull($application->pending_username);
+        $inbox = new SiteInboxService();
+        $message = DB::table('site_messages')->first();
+        $this->assertStringContainsString('newagent', $inbox->detail('user', 11, $message->id)['content']);
+        $this->assertSame(1, $inbox->unread('user', 11)['count']);
+        $this->assertSame(0, $inbox->unread('user', 22)['count']);
+        $this->assertSame(1, DB::table('site_realtime_events')->count());
+        $this->assertSame(0, $this->audit($application->id)['code']);
+        $this->assertSame(1, Agent::count()); $this->assertSame(1, DB::table('site_messages')->count());
+        $this->assertNotSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Cannot reverse'])['code']);
+        $payload = json_encode($response) . DB::table('operation_audits')->get()->toJson() . DB::table('site_message_translations')->get()->toJson();
+        foreach (['login-secret', 'withdraw-secret', 'password_hash', 'withdraw_password_hash'] as $secret) $this->assertStringNotContainsString($secret, $payload);
+    }
+
+    public function test_rejection_requires_reason_releases_pending_keys_and_notifies_in_member_language(): void
+    {
+        $application = $this->application(22);
+        $this->assertNotSame(0, $this->audit($application->id, 'rejected')['code']);
+        $this->assertSame('pending', $application->refresh()->status);
+        $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
+        $this->assertSame(0, Agent::count());
+        $this->assertSame('rejected', $application->refresh()->status);
+        $this->assertNull($application->pending_username); $this->assertNull($application->pending_user_id);
+        $receipt = DB::table('site_message_recipients')->first();
+        $this->assertSame('en', $receipt->language); $this->assertSame(22, (int) $receipt->recipient_id);
+        $content = (new SiteInboxService())->detail('user', 22, $receipt->message_id)['content'];
+        $this->assertStringContainsString('Please update your information', $content);
+        $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
+        $this->assertSame(1, DB::table('site_messages')->count());
+        $this->application(22);
+        $this->assertSame(2, AgentApplication::count());
+    }
+
+    public function test_notification_failure_rolls_back_agent_approval_and_profile_assignments(): void
+    {
+        $application = $this->application();
+        DB::statement("CREATE TRIGGER reject_agent_notice BEFORE INSERT ON bot_site_messages BEGIN SELECT RAISE(ABORT, 'notice failure'); END");
+        try {
+            $this->service->audit(['id' => $application->id, 'status' => 'approved'], $this->agents);
+            $this->fail('Notification failure must roll back approval');
+        } catch (QueryException $e) { $this->assertStringContainsString('notice failure', $e->getMessage()); }
+        $this->assertSame(0, Agent::count());
+        $this->assertSame(0, DB::table('agent_flow_commission_assignments')->count());
+        $this->assertSame(0, DB::table('agent_profit_commission_assignments')->count());
+        $this->assertSame('pending', $application->refresh()->status);
+        $this->assertNotNull($application->password_hash); $this->assertSame(11, (int) $application->pending_user_id);
+        $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::transactionLevel());
+    }
+
+    public function test_existing_agents_can_be_bound_explicitly_but_identity_cannot_be_reassigned(): void
+    {
+        $agent = $this->agents->create(['username' => 'legacyagent', 'password' => 'legacy-secret']);
+        $this->assertSame(11, $this->service->bindMember($agent, 11)->owner_user_id);
+        $this->assertSame(11, $this->service->bindMember($agent, 11)->owner_user_id);
+        try { $this->service->bindMember($agent, 22); $this->fail('Agent identity cannot be reassigned'); }
+        catch (\RuntimeException $e) { $this->assertStringContainsString('已绑定', $e->getMessage()); }
+        $another = $this->agents->create(['username' => 'anotheragent', 'password' => 'legacy-secret']);
+        try { $this->service->bindMember($another, 11); $this->fail('One member cannot own two agents'); }
+        catch (\RuntimeException $e) { $this->assertStringContainsString('已绑定', $e->getMessage()); }
+        $this->assertNull($another->refresh()->owner_user_id);
+    }
+
+    public function test_listing_hides_credentials_and_unknown_member_language_gets_a_private_fallback_notice(): void
+    {
+        $application = $this->application(33);
+        $listing = $this->service->listing(['status' => 'pending', 'member_id' => 'M33']);
+        $this->assertSame(1, $listing['total']);
+        $this->assertSame('member33', $listing['data'][0]->member_account);
+        $this->assertStringNotContainsString('password', json_encode($listing));
+        $this->assertSame(0, $this->audit($application->id)['code']);
+        $this->assertSame('zh', DB::table('site_message_recipients')->value('language'));
+        $this->assertSame(1, (new SiteInboxService())->unread('user', 33)['count']);
+    }
+
+    public function test_admin_update_binds_existing_identity_and_rolls_back_an_attempted_reassignment(): void
+    {
+        $agent = $this->agents->create(['username' => 'oldaccount', 'password' => 'legacy-secret', 'real_name' => 'Original']);
+        $this->request(['id' => $agent->id, 'owner_user_id' => 11]);
+        $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
+        $this->assertSame(0, $response['code'], $response['msg']);
+        $this->assertSame(11, $response['data']['owner_user_id']);
+        $this->assertArrayHasKey('flow_commission_profile', $response['data']);
+        $this->request(['id' => $agent->id, 'owner_user_id' => 22, 'real_name' => 'Unexpected']);
+        $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
+        $this->assertNotSame(0, $response['code']);
+        $this->assertSame('Original', $agent->refresh()->real_name);
+        $this->assertSame(11, $agent->owner_user_id);
+    }
+
+    public function test_application_routes_keep_the_existing_admin_auth_and_agent_management_restriction(): void
+    {
+        $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
+        $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        foreach ([['/admin/agent/applications', 'GET'], ['/admin/agent/applicationAudit', 'POST']] as [$path, $method]) {
+            $route = $router->getRoutes()->match(Request::create($path, $method));
+            $this->assertContains('admin.jwt', $route->gatherMiddleware());
+            $this->assertContains('agent.admin', $route->gatherMiddleware());
+        }
+        $this->request(); request()->user = (object) ['id' => 20, 'username' => 'ordinary-admin'];
+        $called = false;
+        $response = (new \App\Http\Middleware\AgentAdminMiddleware())->handle(request(), function () use (&$called) { $called = true; });
+        $this->assertFalse($called); $this->assertNotSame(0, $response->getData(true)['code']);
+    }
+
+    public function test_newly_unavailable_account_or_username_cannot_be_approved(): void
+    {
+        $application = $this->application();
+        DB::table('users')->where('id', 11)->update(['is_banned' => 1]);
+        $this->assertNotSame(0, $this->audit($application->id)['code']);
+        $this->assertSame('pending', $application->refresh()->status);
+        $this->assertSame(0, Agent::count());
+        $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => '账号已停用'])['code']);
+        $this->assertSame(1, DB::table('site_messages')->count());
+
+        $other = $this->application(22, 'takenname');
+        $this->agents->create(['username' => 'TakenName', 'password' => 'existing-secret']);
+        $response = $this->audit($other->id);
+        $this->assertNotSame(0, $response['code']);
+        $this->assertStringContainsString('账号已被使用', $response['msg']);
+        $this->assertSame('pending', $other->refresh()->status);
+        $this->assertSame(1, Agent::count());
+        $this->assertSame(1, DB::table('site_messages')->count());
+    }
+}

+ 28 - 0
tests/fixtures/review_agent_application.php

@@ -0,0 +1,28 @@
+<?php
+
+/** Optional cross-project API test bridge; only opens its disposable SQLite file. */
+$database = $argv[1] ?? '';
+$id = filter_var($argv[2] ?? '', FILTER_VALIDATE_INT);
+if (!str_starts_with($database, sys_get_temp_dir() . '/agent-api-test-') || !is_file($database) || !$id || $id < 1) {
+    throw new RuntimeException('A disposable agent API test database and application ID are required');
+}
+require dirname(__DIR__) . '/bootstrap-agent.php';
+
+$app = new Illuminate\Foundation\Application(dirname(__DIR__, 2));
+$app->instance('config', new Illuminate\Config\Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+Illuminate\Support\Facades\Facade::setFacadeApplication($app);
+$db = new Illuminate\Database\Capsule\Manager($app);
+$db->addConnection(['driver' => 'sqlite', 'database' => $database, 'prefix' => 'bot_']);
+$db->setAsGlobal(); $db->bootEloquent();
+$app->instance('db', $db->getDatabaseManager());
+$app->bind('db.schema', fn () => $db->schema());
+$app->instance('hash', new Illuminate\Hashing\BcryptHasher(['rounds' => 4]));
+$app->instance('translator', new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'zh'));
+$app->instance('validator', new Illuminate\Validation\Factory($app['translator'], $app));
+$app->instance('log', new Psr\Log\NullLogger());
+$request = Illuminate\Http\Request::create('/admin/agent/applicationAudit', 'POST');
+$request->user = (object) ['id' => 1, 'username' => 'test-admin'];
+$app->instance('request', $request);
+$agents = new App\Services\Agent\AgentService(new App\Services\Agent\AgentFlowCommissionAssignmentService(), new App\Services\Agent\AgentProfitCommissionAssignmentService());
+$result = (new App\Services\Agent\AgentApplicationService())->audit(['id' => $id, 'status' => 'approved'], $agents);
+echo json_encode($result, JSON_THROW_ON_ERROR);