校友遗漏

master
lion 9 hours ago
parent 9024174feb
commit d378d6899c

@ -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;
}
}

@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Models\Course;
use App\Services\SchoolmateEnrollmentService;
use Illuminate\Console\Command;
class BackfillSchoolmate extends Command
{
protected $signature = 'schoolmate:backfill
{course_ids : 课程ID,多个用英文逗号分隔}
{--execute : 正式补齐;不传时只检查并输出遗漏名单}';
protected $description = '按指定课程检查或补齐审核通过学员的校友资格';
public function handle(SchoolmateEnrollmentService $service)
{
$raw = (string) $this->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;
}
}

@ -0,0 +1,55 @@
<?php
namespace App\Services;
use App\Models\Course;
use App\Models\CourseSign;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class SchoolmateEnrollmentService
{
/**
* 仅选择未删除、尚未成为校友且有有效审核通过报名的学员。
* 多门课程取最早开课日期;指定课程补齐不受自动入库开关限制。
*/
public function pendingUsers($courses)
{
$sources = CourseSign::query()
->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);
});
}
}

@ -0,0 +1,121 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SchoolmateEnrollmentCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => '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'));
}
}
Loading…
Cancel
Save