浏览代码

模拟修改

doge 16 小时之前
父节点
当前提交
b775105a1c

+ 12 - 0
app/Http/Controllers/admin/Simulation.php

@@ -73,6 +73,8 @@ class Simulation extends Controller
             $runs = DB::table('simulation_task_runs')->whereIn('status', ['queued', 'running', 'paused'])->orderByDesc('id')->limit(10)->get();
             return [
                 'enabled' => SimulationSettings::enabled(), 'migrated' => MemberAudience::enabled(), 'start_date' => $start,
+                'schedule' => ['enabled' => SimulationSettings::get('simulation_schedule_enabled') === '1', 'start' => SimulationSettings::get('simulation_schedule_start'),
+                    'end' => SimulationSettings::get('simulation_schedule_end'), 'gap_minutes' => (int) SimulationSettings::get('simulation_schedule_gap_minutes')],
                 'members' => ['total' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count(),
                     'new' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('created_at', '>=', $start)->where('created_at', '<', $end)->count(),
                     'online' => SimulationMemberService::onlineUserIds()->count()],
@@ -343,6 +345,16 @@ class Simulation extends Controller
         });
     }
 
+    /** 一键启动:按模拟设置里的默认任务参数跑一遍完整流程。 */
+    public function quickStart()
+    {
+        return $this->run(function () {
+            $id = SimulationTaskService::quickStart(request()->user->id ?? null);
+            $this->audit('task', $id, 'run_quick_start');
+            return ['id' => $id];
+        });
+    }
+
     public function runStatus()
     {
         return $this->run(function () {

+ 6 - 0
app/Services/Simulation/SimulationRunner.php

@@ -20,6 +20,12 @@ final class SimulationRunner
     public static function tick(): int
     {
         if (!SimulationSettings::enabled()) return 0;
+        try {
+            SimulationTaskService::scheduleNext();
+        } catch (Throwable $e) {
+            // 持续运行这一轮没能启动(例如默认参数超过上限);不影响已有任务,下次调度再试
+            \Illuminate\Support\Facades\Log::warning('simulation_schedule_failed', ['error' => $e->getMessage()]);
+        }
         $settings = SimulationSettings::all();
         $slots = (int) $settings['simulation_max_concurrency'] - DB::table('simulation_task_runs')->where('status', 'running')->count();
         if ($slots > 0) {

+ 29 - 2
app/Services/Simulation/SimulationSettings.php

@@ -21,6 +21,19 @@ final class SimulationSettings
         'simulation_initial_balance' => ['0', 0, 1000000],
         'simulation_wager_bypass' => ['1', 0, 1],
         'simulation_default_domain' => ['sim.local', null, null],
+        // 一键启动的默认任务:注册 → 登录 → 充值 → 下注 → 结算 → 提现 → 退出
+        'simulation_task_members' => ['5', 1, 200],
+        'simulation_task_recharge' => ['1000', 1, 1000000],
+        'simulation_task_bet_category' => ['sport', null, null],
+        'simulation_task_bet_amount' => ['100', 1, 1000000],
+        'simulation_task_bet_count' => ['3', 1, 50],
+        'simulation_task_withdraw' => ['200', 0, 1000000],
+        'simulation_task_interval' => ['2', 0, 3600],
+        // 持续运行:在日期区间内一轮接一轮地自动启动上面的默认任务
+        'simulation_schedule_enabled' => ['0', 0, 1],
+        'simulation_schedule_start' => ['', null, null],
+        'simulation_schedule_end' => ['', null, null],
+        'simulation_schedule_gap_minutes' => ['10', 0, 1440],
     ];
 
     public static function all(): array
@@ -38,11 +51,18 @@ final class SimulationSettings
 
     public static function save(array $input): array
     {
+        return DB::transaction(function () use ($input) {
         $before = self::all();
         foreach (self::KEYS as $key => [, $min, $max]) {
             if (!array_key_exists($key, $input)) continue;
             $value = trim((string) $input[$key]);
-            if ($min === null) {
+            if ($key === 'simulation_task_bet_category') {
+                if (!in_array($value, ['sport', 'lottery'], true)) throw new Exception('默认任务的下注分类只能是体育或彩票', HttpStatus::CUSTOM_ERROR);
+            } elseif (in_array($key, ['simulation_schedule_start', 'simulation_schedule_end'], true)) {
+                if ($value !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) || date('Y-m-d', (int) strtotime($value)) !== $value)) {
+                    throw new Exception('持续运行的日期格式应为 YYYY-MM-DD', HttpStatus::CUSTOM_ERROR);
+                }
+            } elseif ($min === null) {
                 if ($value === '' || strlen($value) > 100) throw new Exception('域名格式不正确', HttpStatus::CUSTOM_ERROR);
             } elseif (!is_numeric($value) || $value < $min || $value > $max) {
                 throw new Exception("{$key} 应在 {$min} 到 {$max} 之间", HttpStatus::CUSTOM_ERROR);
@@ -51,7 +71,14 @@ final class SimulationSettings
             if ($exists) DB::table('config')->where('field', $key)->update(['val' => $value]);
             else DB::table('config')->insert(['field' => $key, 'val' => $value]);
         }
-        return ['before' => $before, 'after' => self::all()];
+        $after = self::all();
+        if ($after['simulation_schedule_enabled'] === '1') {
+            [$start, $end] = [$after['simulation_schedule_start'], $after['simulation_schedule_end']];
+            if ($start === '' || $end === '' || $start > $end) throw new Exception('开启持续运行前,请填写正确的开始和结束日期', HttpStatus::CUSTOM_ERROR);
+            if ((strtotime($end) - strtotime($start)) / 86400 > 92) throw new Exception('持续运行的日期区间不能超过 92 天', HttpStatus::CUSTOM_ERROR);
+        }
+        return ['before' => $before, 'after' => $after];
+        });
     }
 
     public static function enabled(): bool

+ 58 - 0
app/Services/Simulation/SimulationTaskService.php

@@ -88,6 +88,64 @@ final class SimulationTaskService
             'next_run_at' => now(), 'created_by' => $adminId, 'created_at' => now(), 'updated_at' => now()]);
     }
 
+    /**
+     * 一键启动:按“模拟设置”里的默认任务参数,跑一遍完整流程(注册 → 登录 → 充值 → 下注 → 结算 → 提现 → 退出)。
+     * 提现金额设为 0 时不提现。
+     */
+    public static function quickStart(?int $adminId, bool $scheduled = false): int
+    {
+        $s = SimulationSettings::all();
+        $interval = (int) $s['simulation_task_interval'];
+        $max = (float) $s['simulation_max_amount'];
+        foreach (['simulation_task_recharge' => '充值金额', 'simulation_task_bet_amount' => '下注金额', 'simulation_task_withdraw' => '提现金额'] as $key => $label) {
+            if ((float) $s[$key] > $max) throw new Exception("默认任务的{$label}超过了单次金额上限 {$s['simulation_max_amount']},请在模拟设置中调整", HttpStatus::CUSTOM_ERROR);
+        }
+        if ((int) $s['simulation_task_members'] > (int) $s['simulation_max_members_per_task']) {
+            throw new Exception('默认任务的会员数超过了单任务会员数上限,请在模拟设置中调整', HttpStatus::CUSTOM_ERROR);
+        }
+        $steps = [
+            ['type' => 'register', 'interval' => $interval],
+            ['type' => 'login', 'interval' => $interval],
+            ['type' => 'recharge', 'amount' => $s['simulation_task_recharge'], 'interval' => $interval],
+            ['type' => 'bet', 'category' => $s['simulation_task_bet_category'], 'amount' => $s['simulation_task_bet_amount'],
+                'count' => (int) $s['simulation_task_bet_count'], 'interval' => $interval],
+            ['type' => 'settle', 'settle_mode' => 'random', 'interval' => $interval],
+        ];
+        if ((float) $s['simulation_task_withdraw'] > 0) $steps[] = ['type' => 'withdraw', 'amount' => $s['simulation_task_withdraw'], 'interval' => $interval];
+        $steps[] = ['type' => 'logout', 'interval' => $interval];
+        $count = (int) $s['simulation_task_members'];
+        $input = ['name' => ($scheduled ? self::SCHEDULED_PREFIX : '一键任务') . ' ' . now()->format('m-d H:i:s'), 'steps' => $steps, 'member_count' => $count];
+        // 持续运行时会员总数到上限后不再注册,改为随机抽已有的模拟会员继续跑,否则跑不满整个区间
+        $existing = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count();
+        if ($scheduled && $existing + $count > (int) $s['simulation_max_members_total']) {
+            $input['member_ids'] = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('is_banned', 0)
+                ->inRandomOrder()->limit($count)->pluck('member_id')->all();
+            $input['steps'] = array_values(array_filter($steps, fn ($step) => $step['type'] !== 'register'));
+        }
+        return self::start($input, $adminId);
+    }
+
+    public const SCHEDULED_PREFIX = '持续任务';
+
+    /**
+     * 持续运行:开关打开且今天在设置的日期区间内时,上一轮结束并隔够间隔后自动启动下一轮。由执行器每次调度时调用。
+     * @return int|null 新启动的运行编号;没到启动条件时为 null
+     */
+    public static function scheduleNext(): ?int
+    {
+        $s = SimulationSettings::all();
+        if ($s['simulation_enabled'] !== '1' || $s['simulation_schedule_enabled'] !== '1') return null;
+        $today = now()->toDateString();
+        if ($s['simulation_schedule_start'] === '' || $today < $s['simulation_schedule_start'] || $today > $s['simulation_schedule_end']) return null;
+        $last = DB::table('simulation_task_runs')->where('name', 'like', self::SCHEDULED_PREFIX . ' %')->orderByDesc('id')->first();
+        if ($last) {
+            if (in_array($last->status, ['queued', 'running', 'paused'], true)) return null;
+            $since = $last->finished_at ?? $last->updated_at;
+            if ($since && now()->lt(\Carbon\Carbon::parse($since)->addMinutes((int) $s['simulation_schedule_gap_minutes']))) return null;
+        }
+        return self::quickStart(null, true);
+    }
+
     /** 暂停 / 继续 / 停止;下一次调度时生效。 */
     public static function setStatus(int $id, string $action): object
     {

+ 11 - 0
database/migrations/2026_11_11_130000_create_simulation_tables.php

@@ -17,6 +17,17 @@ return new class extends Migration {
         'simulation_initial_balance' => ['0', '新建模拟会员的初始余额'],
         'simulation_wager_bypass' => ['1', '模拟提现是否跳过打码量:1 跳过'],
         'simulation_default_domain' => ['sim.local', '模拟会员默认注册/登录域名'],
+        'simulation_task_members' => ['5', '一键任务:注册的模拟会员数'],
+        'simulation_task_recharge' => ['1000', '一键任务:每人充值金额'],
+        'simulation_task_bet_category' => ['sport', '一键任务:下注分类 sport 体育 / lottery 彩票'],
+        'simulation_task_bet_amount' => ['100', '一键任务:单次下注金额'],
+        'simulation_task_bet_count' => ['3', '一键任务:每人下注次数'],
+        'simulation_task_withdraw' => ['200', '一键任务:每人提现金额,0 为不提现'],
+        'simulation_task_interval' => ['2', '一键任务:每个动作之间的间隔秒数'],
+        'simulation_schedule_enabled' => ['0', '持续运行开关:1 在日期区间内一轮接一轮自动启动一键任务'],
+        'simulation_schedule_start' => ['', '持续运行开始日期 YYYY-MM-DD'],
+        'simulation_schedule_end' => ['', '持续运行结束日期 YYYY-MM-DD(含当天)'],
+        'simulation_schedule_gap_minutes' => ['10', '持续运行:上一轮结束到下一轮开始的间隔分钟数'],
     ];
 
     public function up(): void

+ 1 - 1
database/migrations/2026_11_11_140000_add_simulation_admin_menus.php

@@ -22,7 +22,7 @@ return new class extends Migration {
         'SimulationBets' => ['模拟注单', ['admin/simulation/bets' => '查看', 'admin/simulation/bets/lottery' => '彩票下注', 'admin/simulation/bets/sport' => '体育下注',
             'admin/simulation/bets/sport/settle' => '体育结算', 'admin/simulation/bets/sport/cancel' => '体育撤单']],
         'SimulationTasks' => ['自动任务', ['admin/simulation/tasks' => '查看', 'admin/simulation/tasks/templates' => '保存模板',
-            'admin/simulation/tasks/templates/delete' => '删除模板', 'admin/simulation/tasks/runs' => '启动任务', 'admin/simulation/tasks/runs/status' => '暂停/继续/停止']],
+            'admin/simulation/tasks/templates/delete' => '删除模板', 'admin/simulation/tasks/runs' => '启动任务', 'admin/simulation/tasks/quick-start' => '一键启动', 'admin/simulation/tasks/runs/status' => '暂停/继续/停止']],
         'SimulationReports' => ['模拟报表', ['admin/simulation/reports' => '查看', 'admin/simulation/reports/refresh' => '立即刷新']],
         'SimulationLogs' => ['操作日志', ['admin/simulation/logs' => '查看']],
         'SimulationSettings' => ['模拟设置', ['admin/simulation/settings' => '查看', 'admin/simulation/settings/save' => '保存设置', 'admin/simulation/reset' => '重置模拟数据']],

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

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 自动任务“一键启动”的按钮权限(挂在 自动任务 菜单下)。菜单迁移已执行过的环境靠这个补上。 */
+return new class extends Migration {
+    private const URI = 'admin/simulation/tasks/quick-start';
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus') || DB::table('menus')->where('uri', self::URI)->exists()) return;
+        $menu = DB::table('menus')->where('uri', 'SimulationTasks')->where('type', 1)->first(['id']);
+        if (!$menu) return;
+        $now = date('Y-m-d H:i:s');
+        DB::table('menus')->insert(['parent_id' => $menu->id, 'title' => '自动任务-一键启动', 'icon' => null, 'uri' => self::URI,
+            'permission_name' => str_replace('/', '.', self::URI), 'sort' => 9, 'status' => 1, 'type' => 2, 'created_at' => $now, 'updated_at' => $now]);
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $ids = DB::table('menus')->where('uri', self::URI)->pluck('id');
+        if (Schema::hasTable('role_menu')) DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+        DB::table('menus')->whereIn('id', $ids)->delete();
+    }
+};

+ 1 - 0
routes/admin.php

@@ -619,6 +619,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/tasks/runs', [\App\Http\Controllers\admin\Simulation::class, 'runs'])->defaults('permission_uri', 'admin/simulation/tasks');
             Route::post('/tasks/runs', [\App\Http\Controllers\admin\Simulation::class, 'startRun']);
             Route::post('/tasks/runs/status', [\App\Http\Controllers\admin\Simulation::class, 'runStatus']);
+            Route::post('/tasks/quick-start', [\App\Http\Controllers\admin\Simulation::class, 'quickStart']);
             Route::get('/tasks/events', [\App\Http\Controllers\admin\Simulation::class, 'events'])->defaults('permission_uri', 'admin/simulation/tasks');
             Route::get('/reports/users', [\App\Http\Controllers\admin\Simulation::class, 'reportUsers'])->defaults('permission_uri', 'admin/simulation/reports');
             Route::get('/reports/funds', [\App\Http\Controllers\admin\Simulation::class, 'reportFunds'])->defaults('permission_uri', 'admin/simulation/reports');

+ 48 - 2
tests/Integration/SimulationTest.php

@@ -330,14 +330,60 @@ class SimulationTest extends TestCase
 
         $this->assertSame($before, $this->realView(), 'the task run did not leak either');
 
+        // 一键启动:按模拟设置的默认任务参数跑完整流程
+        SimulationSettings::save(['simulation_task_members' => 2, 'simulation_task_recharge' => 500, 'simulation_task_bet_amount' => 50,
+            'simulation_task_bet_count' => 2, 'simulation_task_withdraw' => 100, 'simulation_task_interval' => 0]);
+        $quickId = SimulationTaskService::quickStart(1);
+        for ($i = 0; $i < 60 && in_array(DB::table('simulation_task_runs')->where('id', $quickId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();
+        $quick = DB::table('simulation_task_runs')->where('id', $quickId)->first();
+        $this->assertSame(['completed', 16, 16, 0], [$quick->status, (int) $quick->total, (int) $quick->completed, (int) $quick->failed], (string) $quick->last_error);
+        $this->assertSame(['register', 'login', 'recharge', 'bet', 'settle', 'withdraw', 'logout'], array_column(json_decode($quick->steps, true), 'type'));
+        SimulationSettings::save(['simulation_task_recharge' => 20000]);
+        try { SimulationTaskService::quickStart(1); $this->fail('defaults above the amount cap are refused'); }
+        catch (\Exception $e) { $this->assertStringContainsString('单次金额上限', $e->getMessage()); }
+        $this->assertSame($before, $this->realView());
+
+        // 持续运行:日期区间内一轮接一轮;区间外、间隔未到、上一轮没结束时都不启动
+        SimulationSettings::save(['simulation_task_recharge' => 500]);
+        try { SimulationSettings::save(['simulation_schedule_enabled' => 1, 'simulation_schedule_start' => '', 'simulation_schedule_end' => '']); $this->fail('dates are required'); }
+        catch (\Exception $e) { $this->assertStringContainsString('开始和结束日期', $e->getMessage()); }
+        $this->assertSame('0', SimulationSettings::get('simulation_schedule_enabled'), 'a refused save changes nothing');
+        $today = now()->toDateString();
+        SimulationSettings::save(['simulation_schedule_enabled' => 1, 'simulation_schedule_start' => now()->addDay()->toDateString(), 'simulation_schedule_end' => now()->addDays(30)->toDateString(), 'simulation_schedule_gap_minutes' => 10]);
+        $this->assertNull(SimulationTaskService::scheduleNext(), 'not started before the range');
+        SimulationSettings::save(['simulation_schedule_start' => $today]);
+        $runsBefore = DB::table('simulation_task_runs')->count();
+        SimulationRunner::tick();
+        $first = DB::table('simulation_task_runs')->orderByDesc('id')->first();
+        $this->assertSame([$runsBefore + 1, true], [DB::table('simulation_task_runs')->count(), str_starts_with($first->name, '持续任务 ')]);
+        $this->assertNull(SimulationTaskService::scheduleNext(), 'one round at a time');
+        for ($i = 0; $i < 60 && DB::table('simulation_task_runs')->where('id', $first->id)->value('status') === 'running'; $i++) SimulationRunner::tick();
+        $this->assertSame('completed', DB::table('simulation_task_runs')->where('id', $first->id)->value('status'));
+        $this->assertNull(SimulationTaskService::scheduleNext(), 'waits for the gap after a round');
+        \Carbon\Carbon::setTestNow(now()->addMinutes(11));
+        // 会员数到上限后不再注册,改用已有模拟会员
+        SimulationSettings::save(['simulation_max_members_total' => DB::table('users')->where('user_type', 2)->count()]);
+        $second = SimulationTaskService::scheduleNext();
+        $this->assertNotNull($second);
+        $secondRun = DB::table('simulation_task_runs')->where('id', $second)->first();
+        $this->assertNotContains('register', array_column(json_decode($secondRun->steps, true), 'type'));
+        $this->assertCount(2, json_decode($secondRun->member_ids, true));
+        \Carbon\Carbon::setTestNow(now()->addDays(40));
+        SimulationTaskService::setStatus($second, 'stop');
+        $this->assertNull(SimulationTaskService::scheduleNext(), 'not started after the range');
+        \Carbon\Carbon::setTestNow();
+        SimulationSettings::save(['simulation_schedule_enabled' => 0]);
+        $this->assertSame($before, $this->realView());
+        $simulatedMembers = DB::table('users')->where('user_type', 2)->count();
+
         // 重置:只删模拟数据
         $preview = SimulationResetService::preview();
-        $this->assertSame(3, $preview['counts']['users']);
+        $this->assertSame([7, 7], [$simulatedMembers, $preview['counts']['users']]);
         try { SimulationResetService::reset('wrong', SimulationResetService::CONFIRM_TEXT); $this->fail('wrong token must be refused'); }
         catch (\Exception $e) { $this->assertStringContainsString('失效', $e->getMessage()); }
         $preview = SimulationResetService::preview();
         $deleted = SimulationResetService::reset($preview['confirm_token'], $preview['confirm_text']);
-        $this->assertSame(3, $deleted['users']);
+        $this->assertSame(7, $deleted['users']);
         $this->assertSame(0, DB::table('users')->where('user_type', 2)->count());
         $this->assertSame([2, 2, 4, 2, 2], [DB::table('users')->count(), DB::table('wallets')->count(), DB::table('balance_logs')->count(),
             DB::table('payment_orders')->count(), DB::table('lhc_order')->count()], 'real and guest rows are untouched');