diff --git a/app/Console/Commands/AutoSchoolmate.php b/app/Console/Commands/AutoSchoolmate.php index 2e8ba10..fa27f25 100755 --- a/app/Console/Commands/AutoSchoolmate.php +++ b/app/Console/Commands/AutoSchoolmate.php @@ -2,106 +2,26 @@ namespace App\Console\Commands; -use App\Models\Course; -use App\Models\CourseSign; -use App\Models\User; +use App\Services\SchoolmateEnrollmentService; use Illuminate\Console\Command; - class AutoSchoolmate extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'auto_schoolmate'; - /** - * The console command description. - * - * @var string - */ - protected $description = '已审核学员自动进入校友库'; - - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } + protected $description = '已审核学员自动进入校友库(含历史遗漏及开课后审核通过人员)'; - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() + public function handle(SchoolmateEnrollmentService $service) { - // 获取所有明天开课且需要自动加入校友库的课程(只处理存在 start_date 的课程) - $tomorrow = date('Y-m-d', strtotime('+1 day')); - $courses = Course::where('auto_schoolmate', 1) - ->whereNotNull('start_date') - ->where('start_date', $tomorrow) - ->get(); - - if ($courses->isEmpty()) { - return $this->info('没有需要处理的课程'); - } - - $this->info("找到 {$courses->count()} 个需要处理的课程"); - - $totalUpdated = 0; - $totalSkipped = 0; - $processedUserIds = []; // 记录已处理的用户ID,避免重复处理 - - foreach ($courses as $course) { - // 获取报名通过的学员,并排除已经是校友的用户 - $courseSigns = CourseSign::where('course_id', $course->id) - ->where('status', 1) - ->whereHas('user', function ($query) { - // 排除已经是校友的用户(is_schoolmate = 1) - $query->whereRaw('COALESCE(is_schoolmate, 0) != 1'); - })->get(); - - if ($courseSigns->isEmpty()) { - continue; - } - - // 获取需要更新的用户ID,并排除已经在本脚本中处理过的用户 - $userIds = $courseSigns->pluck('user_id') - ->unique() - ->filter(function ($userId) use (&$processedUserIds) { - // 如果已经处理过,跳过 - if (in_array($userId, $processedUserIds)) { - return false; - } - $processedUserIds[] = $userId; - return true; - }) - ->values() - ->toArray(); - - if (empty($userIds)) { - continue; - } - - // 批量更新:只更新还不是校友的学员;使用课程开课时间作为 schoolmate_time - $updated = User::batchUpdateToSchoolmate($userIds, $course->start_date); - - $totalUpdated += $updated; - $skipped = count($userIds) - $updated; - $totalSkipped += $skipped; - - if ($updated > 0) { - $this->info("课程【{$course->name}】: 更新 {$updated} 位学员,跳过 {$skipped} 位已处理学员"); - } - } - - return $this->info("更新完成,共处理 {$totalUpdated} 位学员,跳过 {$totalSkipped} 位已处理学员"); + $updated = 0; + $service->pendingUsers($service->automaticCourses()) + ->chunkById(200, function ($users) use ($service, &$updated) { + foreach ($users as $user) { + $updated += $service->promote($user->id, $user->source_start_date); + } + }, 'users.id', 'id'); + + $this->info("自动入库完成,共补齐 {$updated} 位学员。"); + return 0; } - } diff --git a/app/Console/Commands/BackfillSchoolmate.php b/app/Console/Commands/BackfillSchoolmate.php new file mode 100644 index 0000000..cdae480 --- /dev/null +++ b/app/Console/Commands/BackfillSchoolmate.php @@ -0,0 +1,60 @@ +argument('course_ids'); + if (!preg_match('/^[1-9][0-9]*(,[1-9][0-9]*)*$/D', $raw)) { + $this->error('课程ID必须为正整数,多个用英文逗号分隔。'); + return 1; + } + $ids = array_values(array_unique(explode(',', $raw))); + $courses = Course::whereIn('id', $ids); + $found = (clone $courses)->get(['id', 'name', 'start_date']); + $missingIds = array_diff($ids, $found->pluck('id')->all()); + if ($missingIds) { + $this->error('课程不存在或已删除:' . implode(',', $missingIds)); + return 1; + } + // 缺少开课日期时无法确定历史入库时间,整批停止,避免只补一部分。 + foreach ($found as $course) { + if (!$course->start_date) { + $this->error("课程 {$course->id}【{$course->name}】缺少开课日期,请先补全。"); + return 1; + } + } + $this->table(['课程ID', '课程名称', '开课日期'], $found->map(function ($course) { + return [$course->id, $course->name, $course->start_date]; + })->all()); + $execute = (bool) $this->option('execute'); + $this->info($execute ? '正式执行:仅补齐尚非校友的审核通过学员。' : '检查模式:不会修改任何学员数据。'); + $total = 0; + $updated = 0; + $service->pendingUsers($courses)->chunkById(200, function ($users) use ($service, $execute, &$total, &$updated) { + $rows = []; + foreach ($users as $user) { + $total++; + $rows[] = [$user->id, $user->name, $user->mobile, $user->source_start_date]; + if ($execute) { + $updated += $service->promote($user->id, $user->source_start_date); + } + } + $this->table(['学员ID', '姓名', '手机号', '拟入库日期(所选课程最早开课日)'], $rows); + }, 'users.id', 'id'); + $this->info("发现遗漏 {$total} 人" . ($execute ? ",实际补齐 {$updated} 人。" : ';正式补齐请追加 --execute。')); + return 0; + } +} diff --git a/app/Services/SchoolmateEnrollmentService.php b/app/Services/SchoolmateEnrollmentService.php new file mode 100644 index 0000000..2f57540 --- /dev/null +++ b/app/Services/SchoolmateEnrollmentService.php @@ -0,0 +1,55 @@ +join('courses', 'courses.id', '=', 'course_signs.course_id') + ->whereNull('courses.deleted_at') + ->whereIn('courses.id', (clone $courses)->select('courses.id')) + ->where('course_signs.status', 1) + ->select('course_signs.user_id') + ->selectRaw('MIN(courses.start_date) as source_start_date') + ->groupBy('course_signs.user_id'); + + return User::query() + ->joinSub($sources, 'schoolmate_sources', function ($join) { + $join->on('users.id', '=', 'schoolmate_sources.user_id'); + }) + ->whereRaw('COALESCE(users.is_schoolmate, 0) != 1') + ->select('users.*', 'schoolmate_sources.source_start_date'); + } + + public function automaticCourses() + { + // 保留开课前一天入库的原行为,同时补查历史及开课后审核通过的学员。 + return Course::where('auto_schoolmate', 1) + ->whereNotNull('start_date') + ->whereDate('start_date', '<=', now()->addDay()->toDateString()); + } + + public function promote(int $userId, string $startDate): int + { + return DB::transaction(function () use ($userId, $startDate) { + // 手工补齐和自动任务同时运行时,不覆盖已有校友的入库时间。 + $user = User::whereKey($userId)->lockForUpdate()->first(); + if (!$user || $user->is_schoolmate == 1) { + return 0; + } + + return User::batchUpdateToSchoolmate([$userId], $startDate); + }); + } +} diff --git a/tests/Feature/SchoolmateEnrollmentCommandTest.php b/tests/Feature/SchoolmateEnrollmentCommandTest.php new file mode 100644 index 0000000..bae7e75 --- /dev/null +++ b/tests/Feature/SchoolmateEnrollmentCommandTest.php @@ -0,0 +1,121 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]); + DB::purge('sqlite'); + Carbon::setTestNow('2026-10-15 10:00:00'); + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name')->default('测试学员'); + $table->string('mobile')->nullable(); + $table->integer('is_schoolmate')->nullable(); + $table->dateTime('schoolmate_time')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('courses', function (Blueprint $table) { + $table->id(); + $table->string('name')->default('测试课程'); + $table->date('start_date')->nullable(); + $table->integer('auto_schoolmate')->default(0); + $table->softDeletes(); + }); + Schema::create('course_signs', function (Blueprint $table) { + $table->id(); + $table->integer('course_id'); + $table->integer('user_id'); + $table->integer('status')->default(1); + $table->softDeletes(); + }); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + parent::tearDown(); + } + + private function course(int $id, ?string $date, int $auto = 0): void + { + DB::table('courses')->insert(['id' => $id, 'start_date' => $date, 'auto_schoolmate' => $auto]); + } + + private function sign(int $userId, int $courseId, int $status = 1, ?string $deleted = null): void + { + DB::table('course_signs')->insert(['user_id' => $userId, 'course_id' => $courseId, 'status' => $status, 'deleted_at' => $deleted]); + } + + public function test_preview_does_not_write_and_execute_is_idempotent_and_preserves_existing_schoolmates(): void + { + $this->course(1, '2025-06-01'); + $this->course(2, '2024-03-01'); + DB::table('users')->insert([ + ['id' => 1, 'is_schoolmate' => null, 'schoolmate_time' => null, 'deleted_at' => null], + ['id' => 2, 'is_schoolmate' => 1, 'schoolmate_time' => '2020-01-01 00:00:00', 'deleted_at' => null], + ['id' => 3, 'is_schoolmate' => 0, 'schoolmate_time' => null, 'deleted_at' => '2025-01-01 00:00:00'], + ['id' => 4, 'is_schoolmate' => 0, 'schoolmate_time' => null, 'deleted_at' => null], + ['id' => 5, 'is_schoolmate' => 0, 'schoolmate_time' => null, 'deleted_at' => null], + ]); + foreach ([1, 2, 3] as $id) $this->sign($id, 1); + $this->sign(1, 2); + $this->sign(1, 2); // 同人重复报名只补一次 + $this->sign(4, 1, 0); + $this->sign(5, 1, 1, '2025-01-01 00:00:00'); + $this->artisan('schoolmate:backfill', ['course_ids' => '1,2'])->expectsOutputToContain('发现遗漏 1 人')->assertExitCode(0); + $this->assertNull(DB::table('users')->where('id', 1)->value('is_schoolmate')); + User::withoutEvents(function () { + $this->artisan('schoolmate:backfill', ['course_ids' => '1,2', '--execute' => true])->expectsOutputToContain('实际补齐 1 人')->assertExitCode(0); + }); + $this->assertSame('2024-03-01 00:00:00', DB::table('users')->where('id', 1)->value('schoolmate_time')); + $this->assertSame('2020-01-01 00:00:00', DB::table('users')->where('id', 2)->value('schoolmate_time')); + foreach ([3, 4, 5] as $id) $this->assertEquals(0, DB::table('users')->where('id', $id)->value('is_schoolmate')); + $this->artisan('schoolmate:backfill', ['course_ids' => '1,2', '--execute' => true])->expectsOutputToContain('实际补齐 0 人')->assertExitCode(0); + } + + public function test_automatic_enrollment_catches_history_and_late_approval_but_respects_course_switch_and_date(): void + { + $this->course(1, '2024-01-01', 1); + $this->course(2, '2026-10-16', 1); + $this->course(3, '2026-10-17', 1); + $this->course(4, '2024-01-01', 0); + foreach ([1, 2, 3, 4, 5] as $id) { + DB::table('users')->insert(['id' => $id, 'is_schoolmate' => 0]); + $this->sign($id, min($id, 4), $id === 5 ? 0 : 1); + } + User::withoutEvents(function () { + $this->artisan('auto_schoolmate')->expectsOutputToContain('共补齐 2 位')->assertExitCode(0); + // 开课后才通过审核的历史课程报名,下次任务仍能处理。 + $this->sign(5, 1); + $this->artisan('auto_schoolmate')->expectsOutputToContain('共补齐 1 位')->assertExitCode(0); + }); + foreach ([1, 2, 5] as $id) $this->assertEquals(1, DB::table('users')->where('id', $id)->value('is_schoolmate')); + foreach ([3, 4] as $id) $this->assertEquals(0, DB::table('users')->where('id', $id)->value('is_schoolmate')); + } + + public function test_invalid_missing_deleted_or_undated_courses_abort_without_partial_updates(): void + { + $this->course(1, '2024-01-01'); + $this->course(2, null); + $this->course(3, '2024-01-01'); + DB::table('courses')->where('id', 3)->update(['deleted_at' => '2025-01-01']); + DB::table('users')->insert(['id' => 1, 'is_schoolmate' => 0]); + $this->sign(1, 1); + foreach (['1,0', '1,999', '1,2', '1,3'] as $ids) { + $this->artisan('schoolmate:backfill', ['course_ids' => $ids, '--execute' => true])->assertExitCode(1); + } + $this->assertEquals(0, DB::table('users')->where('id', 1)->value('is_schoolmate')); + } +}