SimulationTaskService.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. <?php
  2. namespace App\Services\Simulation;
  3. use App\Constants\HttpStatus;
  4. use App\Support\MemberAudience;
  5. use Exception;
  6. use Illuminate\Support\Facades\DB;
  7. /**
  8. * 自动任务:模板(simulation_tasks)和运行(simulation_task_runs)。
  9. * 一次运行按步骤顺序执行,每一步对每个模拟会员做 count 次;实际执行在 SimulationRunner。
  10. */
  11. final class SimulationTaskService
  12. {
  13. public const STEP_TYPES = ['register', 'login', 'recharge', 'bet', 'settle', 'withdraw', 'logout'];
  14. /** 每个会员只做一次的步骤,count 不生效。 */
  15. public const ONCE = ['register', 'login', 'logout', 'settle'];
  16. public static function normalizeSteps($steps): array
  17. {
  18. if (!is_array($steps) || $steps === []) throw new Exception('请至少添加一个任务步骤', HttpStatus::CUSTOM_ERROR);
  19. if (count($steps) > 20) throw new Exception('任务步骤不能超过 20 个', HttpStatus::CUSTOM_ERROR);
  20. $out = [];
  21. foreach ($steps as $step) {
  22. $type = (string) ($step['type'] ?? '');
  23. if (!in_array($type, self::STEP_TYPES, true)) throw new Exception('任务步骤类型不正确:' . $type, HttpStatus::CUSTOM_ERROR);
  24. $row = ['type' => $type, 'count' => in_array($type, self::ONCE, true) ? 1 : min(50, max(1, (int) ($step['count'] ?? 1))),
  25. 'interval' => min(3600, max(0, (int) ($step['interval'] ?? 2)))];
  26. if (in_array($type, ['recharge', 'bet', 'withdraw'], true)) $row['amount'] = SimulationSettings::assertAmount($step['amount'] ?? 100);
  27. if ($type === 'bet') $row['category'] = ($step['category'] ?? 'lottery') === 'sport' ? 'sport' : 'lottery';
  28. if ($type === 'settle') $row['settle_mode'] = in_array($step['settle_mode'] ?? 'random', ['random', 'win', 'lose'], true) ? ($step['settle_mode'] ?? 'random') : 'random';
  29. if (in_array($type, ['register', 'login'], true)) $row['platform'] = substr((string) ($step['platform'] ?? 'H5'), 0, 16);
  30. $out[] = $row;
  31. }
  32. return $out;
  33. }
  34. public static function saveTemplate(array $input, ?int $adminId): int
  35. {
  36. $name = trim((string) ($input['name'] ?? ''));
  37. if ($name === '' || mb_strlen($name) > 100) throw new Exception('请填写模板名称(不超过 100 字)', HttpStatus::CUSTOM_ERROR);
  38. $row = ['name' => $name, 'steps' => json_encode(self::normalizeSteps($input['steps'] ?? null), JSON_UNESCAPED_UNICODE),
  39. 'target_mode' => ($input['target_mode'] ?? 'new') === 'existing' ? 'existing' : 'new',
  40. 'member_count' => self::memberCount($input['member_count'] ?? 1), 'remark' => mb_substr((string) ($input['remark'] ?? ''), 0, 255), 'updated_at' => now()];
  41. if (!empty($input['id'])) {
  42. if (!DB::table('simulation_tasks')->where('id', (int) $input['id'])->update($row)) throw new Exception('模板不存在', HttpStatus::CUSTOM_ERROR);
  43. return (int) $input['id'];
  44. }
  45. return (int) DB::table('simulation_tasks')->insertGetId($row + ['created_by' => $adminId, 'created_at' => now()]);
  46. }
  47. private static function memberCount($count): int
  48. {
  49. $count = (int) $count;
  50. $max = (int) SimulationSettings::get('simulation_max_members_per_task');
  51. if ($count < 1 || $count > $max) throw new Exception("任务会员数应在 1 到 {$max} 之间", HttpStatus::CUSTOM_ERROR);
  52. return $count;
  53. }
  54. /** 新建一次运行并排队。来自模板(task_id)或直接给步骤;existing 模式要给 member_ids。 */
  55. public static function start(array $input, ?int $adminId): int
  56. {
  57. SimulationSettings::assertEnabled();
  58. $task = !empty($input['task_id']) ? DB::table('simulation_tasks')->where('id', (int) $input['task_id'])->first() : null;
  59. if (!empty($input['task_id']) && !$task) throw new Exception('模板不存在', HttpStatus::CUSTOM_ERROR);
  60. $steps = self::normalizeSteps($input['steps'] ?? ($task ? json_decode($task->steps, true) : null));
  61. $memberIds = array_values(array_unique(array_map('strval', (array) ($input['member_ids'] ?? []))));
  62. $registers = in_array('register', array_column($steps, 'type'), true);
  63. if ($registers) {
  64. if ($steps[0]['type'] !== 'register' || count(array_keys(array_column($steps, 'type'), 'register')) > 1) {
  65. throw new Exception('“注册”只能作为任务的第一个步骤,且只能有一个', HttpStatus::CUSTOM_ERROR);
  66. }
  67. $memberIds = [];
  68. $count = self::memberCount($input['member_count'] ?? ($task->member_count ?? 1));
  69. } else {
  70. if ($memberIds === []) throw new Exception('任务没有“注册”步骤时,请选择要使用的模拟会员', HttpStatus::CUSTOM_ERROR);
  71. $count = self::memberCount(count($memberIds));
  72. $valid = DB::table('users')->whereIn('member_id', $memberIds)->where('user_type', MemberAudience::TYPE_SIMULATED)->count();
  73. if ($valid !== count($memberIds)) throw new Exception('所选会员中包含非模拟会员', HttpStatus::CUSTOM_ERROR);
  74. }
  75. $total = 0;
  76. foreach ($steps as $step) $total += $step['count'] * $count;
  77. $name = trim((string) ($input['name'] ?? '')) ?: ($task->name ?? '临时任务');
  78. return (int) DB::table('simulation_task_runs')->insertGetId(['task_id' => $task->id ?? null, 'name' => mb_substr($name, 0, 100),
  79. 'steps' => json_encode($steps, JSON_UNESCAPED_UNICODE), 'member_ids' => json_encode($memberIds), 'member_count' => $count,
  80. 'status' => 'queued', 'total' => $total, 'cursor' => json_encode(['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0]),
  81. 'next_run_at' => now(), 'created_by' => $adminId, 'created_at' => now(), 'updated_at' => now()]);
  82. }
  83. /** 暂停 / 继续 / 停止;下一次调度时生效。 */
  84. public static function setStatus(int $id, string $action): object
  85. {
  86. $run = DB::table('simulation_task_runs')->where('id', $id)->first();
  87. if (!$run) throw new Exception('运行记录不存在', HttpStatus::CUSTOM_ERROR);
  88. $next = match (true) {
  89. $action === 'pause' && in_array($run->status, ['queued', 'running'], true) => 'paused',
  90. $action === 'resume' && $run->status === 'paused' => 'running',
  91. $action === 'stop' && in_array($run->status, ['queued', 'running', 'paused'], true) => 'stopped',
  92. default => throw new Exception('当前状态不能执行该操作', HttpStatus::CUSTOM_ERROR),
  93. };
  94. $update = ['status' => $next, 'updated_at' => now()];
  95. if ($next === 'running') { SimulationSettings::assertEnabled(); $update['next_run_at'] = now(); }
  96. if ($next === 'stopped') $update['finished_at'] = now();
  97. DB::table('simulation_task_runs')->where('id', $id)->where('status', $run->status)->update($update);
  98. return DB::table('simulation_task_runs')->where('id', $id)->first();
  99. }
  100. public static function present(object $run): array
  101. {
  102. $row = (array) $run;
  103. foreach (['steps', 'member_ids', 'cursor'] as $key) $row[$key] = json_decode((string) ($row[$key] ?? 'null'), true);
  104. $row['progress'] = $run->total > 0 ? (int) floor(($run->completed + $run->failed) * 100 / $run->total) : 0;
  105. return $row;
  106. }
  107. }