doge hai 23 horas
pai
achega
82c50fae5f

+ 61 - 0
app/Console/Commands/UnlinkAgentOwners.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class UnlinkAgentOwners extends Command
+{
+    protected $signature = 'agent:unlink-owners
+        {--username= : 只处理指定代理账号}
+        {--all : 处理全部代理账号}
+        {--write : 实际解除关联,默认只预览}';
+
+    protected $description = '预览或清理代理账号历史保存的会员身份关联';
+
+    public function handle(): int
+    {
+        $username = trim((string) $this->option('username'));
+        $all = $this->option('all');
+        $write = $this->option('write');
+        if ($this->input->hasParameterOption('--username') && ($username === '' || $all)) {
+            $this->error('请填写代理账号,或单独使用 --all');
+            return self::INVALID;
+        }
+        if ($write && $username === '' && !$all) {
+            $this->error('写入时请指定 --username 或 --all');
+            return self::INVALID;
+        }
+
+        $connection = DB::connection();
+        $table = $connection->getTablePrefix() . 'agents';
+        $this->info('模式:' . ($write ? '写入' : '预览'));
+        $this->line('数据库:' . $connection->getDatabaseName());
+        $this->line('处理原因:代理账号独立管理,清除历史会员关联。');
+        $this->line("修改范围:{$table}.owner_user_id → NULL");
+
+        $query = $connection->table('agents')->whereNotNull('owner_user_id');
+        if ($username !== '') $query->where('username', $username);
+
+        $process = function () use ($query, $write) {
+            $targets = (clone $query)->orderBy('id');
+            if ($write) $targets->lockForUpdate();
+            $rows = $targets->get(['id', 'username', 'owner_user_id']);
+            $updated = $write && $rows->isNotEmpty()
+                ? $query->whereIntegerInRaw('id', $rows->pluck('id')->all())->update(['owner_user_id' => null])
+                : 0;
+            return [$rows, $updated];
+        };
+
+        [$rows, $updated] = $write ? $connection->transaction($process) : $process();
+        $this->line('检查到关联:' . $rows->count() . ' 条');
+        if ($rows->isNotEmpty()) {
+            $this->table(['表', '代理 ID', '代理账号', '字段', '原值', '新值'], $rows->map(
+                fn ($row) => [$table, $row->id, $row->username, 'owner_user_id', $row->owner_user_id, 'NULL']
+            )->all());
+        }
+        $this->line("实际更新:{$updated} 条");
+        return self::SUCCESS;
+    }
+}

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

@@ -24,10 +24,9 @@ class Agent extends BaseModel
         'profit_commission_profile_id', 'type', 'is_test',
     ];
     // realtime_balance 是系统内部的实时保证金依据,不对代理端返回。
-    protected $hidden = ['password', 'withdraw_password', 'realtime_balance'];
+    protected $hidden = ['password', 'withdraw_password', 'realtime_balance', 'owner_user_id'];
     protected $appends = ['type_text'];
     protected $casts = [
-        'owner_user_id' => 'integer',
         'parent_id' => 'integer', 'level' => 'integer', 'status' => 'integer', 'type' => 'integer', 'is_test' => 'integer',
         'balance' => 'decimal:4', 'frozen_balance' => 'decimal:4',
         'margin_amount' => 'decimal:4', 'realtime_balance' => 'decimal:4',

+ 3 - 15
app/Services/Agent/AgentApplicationService.php

@@ -57,13 +57,14 @@ class AgentApplicationService
             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 (AgentApplication::where('user_id', $member->id)->where('status', 'approved')->exists()) {
+                    throw new \RuntimeException('该会员已有通过的代理申请');
+                }
                 if (Agent::whereRaw('LOWER(username) = ?', [strtolower($application->username)])->exists()) throw new \RuntimeException('代理账号已被使用,请驳回后重新申请');
                 $agent = $agents->createFromApplication($application, [
                     'type' => $application->type ?: Agent::TYPE_JOINT,
                     'real_name' => $member->first_name ?: '', 'language' => in_array($member->language, ['zh', 'en', 'vi'], true) ? $member->language : 'zh',
                 ], (int) $actor['operator_id']);
-                $this->bindMember($agent, (int) $member->id);
                 $application->agent_id = $agent->id;
             }
             $application->status = $data['status'];
@@ -80,19 +81,6 @@ class AgentApplicationService
         }, 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(['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)) {

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

@@ -23,7 +23,7 @@
 
 | 字段 | 用途 |
 |---|---|
-| `is_agent` | 是否已有自己的代理账号 |
+| `is_agent` | 当前会员是否已有通过申请开通的代理账号 |
 | `agent_id` | 代理账号 ID,没有时为 `null` |
 | `agent_status` | `1` 正常、`0` 停用;不是代理时为 `null` |
 | `username` | 申请或已开通的代理账号,没有时为 `null` |

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

@@ -34,4 +34,4 @@
 
 原来的 `flow_commission_profile_id`、`profit_commission_profile_id` 参数已删除:通过后合营代理没有分成方案,由代理在代理后台自己申请。
 
-审核成功后刷新列表即可。通过会开通独立一级代理;通过、驳回都会自动发送站内信。已审核的申请不能改成另一审核结果。
+审核成功后刷新列表即可。通过会开通独立一级代理,申请记录保留开通结果。代理使用独立账号登录。通过、驳回都会自动发送站内信。已审核的申请不能改成另一审核结果。

+ 48 - 13
tests/Integration/AgentApplicationTest.php

@@ -2,6 +2,7 @@
 
 namespace Tests\Integration;
 
+use App\Console\Commands\UnlinkAgentOwners;
 use App\Http\Controllers\admin\AgentApplication as AdminApplication;
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentApplication;
@@ -24,6 +25,7 @@ use Illuminate\Translation\ArrayLoader;
 use Illuminate\Translation\Translator;
 use Illuminate\Validation\Factory;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
 
 class AgentApplicationTest extends TestCase
 {
@@ -118,13 +120,13 @@ class AgentApplicationTest extends TestCase
         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
+    public function test_approval_creates_independent_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->assertNull($agent->owner_user_id);
         $this->assertSame(1, $agent->level);
         $this->assertSame(1, $agent->status);
         $this->assertTrue(Hash::check('login-secret', $agent->password));
@@ -134,6 +136,7 @@ class AgentApplicationTest extends TestCase
         $this->assertNull($agent->profit_commission_profile_id, 'the agent applies for a plan in its own backend');
         $application->refresh();
         $this->assertSame('approved', $application->status);
+        $this->assertSame($agent->id, (int) $application->agent_id);
         $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();
@@ -183,17 +186,49 @@ class AgentApplicationTest extends TestCase
         $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
+    public function test_already_approved_member_cannot_open_a_second_agent_through_another_application(): 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);
+        $application = $this->application();
+        $this->assertSame(0, $this->audit($application->id)['code']);
+        $another = $this->application(11, 'anotheragent');
+        $this->assertSame('该会员已有通过的代理申请', $this->audit($another->id)['msg']);
+        $this->assertSame(1, Agent::count());
+        $this->assertSame('pending', $another->refresh()->status);
+    }
+
+    public function test_unlink_command_previews_and_only_updates_explicitly_selected_agent_owners(): void
+    {
+        $first = $this->agents->create(['username' => 'aa123', 'password' => 'legacy-secret']);
+        $second = $this->agents->create(['username' => 'anotheragent', 'password' => 'legacy-secret']);
+        DB::table('agents')->where('id', $first->id)->update(['owner_user_id' => 11]);
+        DB::table('agents')->where('id', $second->id)->update(['owner_user_id' => 22]);
+        $before = DB::table('agents')->where('id', $first->id)->first();
+        $users = DB::table('users')->orderBy('id')->get()->toJson();
+        $command = new UnlinkAgentOwners();
+        $command->setLaravel($this->app);
+        $tester = new CommandTester($command);
+
+        $this->assertSame(0, $tester->execute([]));
+        $preview = $tester->getDisplay();
+        foreach (['检查到关联:2 条', '实际更新:0 条', 'bot_agents', 'owner_user_id', 'aa123', 'anotheragent', 'NULL'] as $value) {
+            $this->assertStringContainsString($value, $preview);
+        }
+        $this->assertEquals($before, DB::table('agents')->where('id', $first->id)->first());
+        $this->assertSame(2, $tester->execute(['--write' => true]));
+
+        $this->assertSame(0, $tester->execute(['--username' => 'aa123', '--write' => true]));
+        $this->assertStringContainsString('实际更新:1 条', $tester->getDisplay());
+        $this->assertStringNotContainsString('anotheragent', $tester->getDisplay());
+        $before->owner_user_id = null;
+        $this->assertEquals($before, DB::table('agents')->where('id', $first->id)->first());
+        $this->assertSame(22, (int) DB::table('agents')->where('id', $second->id)->value('owner_user_id'));
+
+        $this->assertSame(0, $tester->execute(['--all' => true, '--write' => true]));
+        $this->assertStringContainsString('实际更新:1 条', $tester->getDisplay());
+        $this->assertSame(0, DB::table('agents')->whereNotNull('owner_user_id')->count());
+        $this->assertSame($users, DB::table('users')->orderBy('id')->get()->toJson());
+        $this->assertSame(0, $tester->execute(['--all' => true, '--write' => true]));
+        $this->assertStringContainsString('实际更新:0 条', $tester->getDisplay());
     }
 
     public function test_listing_hides_credentials_and_unknown_member_language_gets_a_private_fallback_notice(): void
@@ -214,7 +249,7 @@ class AgentApplicationTest extends TestCase
         $this->request(['id' => $agent->id, 'owner_user_id' => 11, 'real_name' => 'Renamed']);
         $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
         $this->assertSame(0, $response['code'], $response['msg']);
-        $this->assertNull($response['data']['owner_user_id']);
+        $this->assertArrayNotHasKey('owner_user_id', $response['data']);
         $this->assertArrayHasKey('profit_commission_profile', $response['data']);
         $this->assertSame('Renamed', $agent->refresh()->real_name);
         $this->assertNull($agent->owner_user_id);

+ 1 - 0
tests/fixtures/review_agent_application.php

@@ -16,6 +16,7 @@ $db->addConnection(['driver' => 'sqlite', 'database' => $database, 'prefix' => '
 $db->setAsGlobal(); $db->bootEloquent();
 $app->instance('db', $db->getDatabaseManager());
 $app->bind('db.schema', fn () => $db->schema());
+(require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_120000_add_is_test_to_agents.php')->up();
 $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));