fix: resolve traffic reset time generation and refactor reset logic

- Add fix-null mode to ResetTraffic command
- Refactor reset logic for better separation of concerns
- Update migration to reuse fix functionality
This commit is contained in:
xboard
2025-07-19 14:22:01 +08:00
parent 063a10f6bb
commit bcfda44730
6 changed files with 190 additions and 296 deletions

View File

@@ -3,70 +3,36 @@
namespace App\Console\Commands;
use App\Models\User;
use App\Models\TrafficResetLog;
use App\Services\TrafficResetService;
use App\Utils\Helper;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ResetTraffic extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'reset:traffic {--batch-size=100 : 分批处理的批次大小} {--dry-run : 预演模式,不实际执行重置} {--max-time=300 : 最大执行时间(秒)}';
protected $signature = 'reset:traffic {--fix-null : 修正模式重新计算next_reset_at为null的用户}';
/**
* The console command description.
*/
protected $description = '流量重置 - 分批处理所有需要重置的用户';
protected $description = '流量重置 - 处理所有需要重置的用户';
/**
* 流量重置服务
*/
private TrafficResetService $trafficResetService;
/**
* Create a new command instance.
*/
public function __construct(TrafficResetService $trafficResetService)
{
public function __construct(
private readonly TrafficResetService $trafficResetService
) {
parent::__construct();
$this->trafficResetService = $trafficResetService;
}
/**
* Execute the console command.
*/
public function handle(): int
{
$batchSize = (int) $this->option('batch-size');
$dryRun = $this->option('dry-run');
$maxTime = (int) $this->option('max-time');
$fixNull = $this->option('fix-null');
$this->info('🚀 开始执行流量重置任务...');
$this->info("批次大小: {$batchSize} 用户/批");
$this->info("最大执行时间: {$maxTime}");
if ($dryRun) {
$this->warn('⚠️ 预演模式 - 不会实际执行重置操作');
if ($fixNull) {
$this->warn('🔧 修正模式 - 将重新计算next_reset_at为null的用户');
}
// 设置最大执行时间
set_time_limit($maxTime);
try {
if ($dryRun) {
$result = $this->performDryRun($batchSize);
} else {
// 使用游标分页和进度回调
$result = $this->trafficResetService->batchCheckReset($batchSize, function ($progress) {
$this->info("📦 处理第 {$progress['batch_number']} 批 ({$progress['batch_size']} 用户) - 已处理: {$progress['total_processed']}");
});
}
$this->displayResults($result, $dryRun);
$result = $fixNull ? $this->performFix() : $this->performReset();
$this->displayResults($result, $fixNull);
return self::SUCCESS;
} catch (\Exception $e) {
@@ -75,131 +41,185 @@ class ResetTraffic extends Command
Log::error('流量重置命令执行失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'options' => [
'batch_size' => $batchSize,
'dry_run' => $dryRun,
'max_time' => $maxTime,
],
]);
return self::FAILURE;
}
}
/**
* 显示执行结果
*/
private function displayResults(array $result, bool $dryRun): void
private function displayResults(array $result, bool $fixNull): void
{
$this->info("✅ 任务完成!");
$this->line('');
$this->info("✅ 任务完成!\n");
if ($dryRun) {
$this->info("📊 预演结果统计:");
$this->info("📋 待处理用户数: {$result['total_found']}");
$this->info("⏱️ 预计处理时间: ~{$result['estimated_duration']}");
$this->info("🗂️ 预计批次数: {$result['estimated_batches']}");
if ($fixNull) {
$this->displayFixResults($result);
} else {
$this->info("📊 执行结果统计:");
$this->info("👥 处理用户总数: {$result['total_processed']}");
$this->info("🔄 重置用户数量: {$result['total_reset']}");
$this->info("📦 处理批次数量: {$result['total_batches']}");
$this->info("⏱️ 总执行时间: {$result['duration']}");
if ($result['error_count'] > 0) {
$this->warn("⚠️ 错误数量: {$result['error_count']}");
$this->warn("详细错误信息请查看日志");
} else {
$this->info("✨ 无错误发生");
}
// 显示性能指标
if ($result['total_processed'] > 0) {
$avgTime = round($result['duration'] / $result['total_processed'], 4);
$this->info("⚡ 平均处理速度: {$avgTime} 秒/用户");
}
$this->displayExecutionResults($result);
}
}
/**
* 执行预演模式
*/
private function performDryRun(int $batchSize): array
private function displayFixResults(array $result): void
{
$this->info("🔍 扫描需要重置的用户...");
$this->info("📊 修正结果统计:");
$this->info("🔍 发现用户总数: {$result['total_found']}");
$this->info("✅ 成功修正数量: {$result['total_fixed']}");
$this->info("⏱️ 总执行时间: {$result['duration']}");
$totalUsers = User::where('next_reset_at', '<=', time())
if ($result['error_count'] > 0) {
$this->warn("⚠️ 错误数量: {$result['error_count']}");
$this->warn("详细错误信息请查看日志");
} else {
$this->info("✨ 无错误发生");
}
if ($result['total_found'] > 0) {
$avgTime = round($result['duration'] / $result['total_found'], 4);
$this->info("⚡ 平均处理速度: {$avgTime} 秒/用户");
}
}
private function displayExecutionResults(array $result): void
{
$this->info("📊 执行结果统计:");
$this->info("👥 处理用户总数: {$result['total_processed']}");
$this->info("🔄 重置用户数量: {$result['total_reset']}");
$this->info("⏱️ 总执行时间: {$result['duration']}");
if ($result['error_count'] > 0) {
$this->warn("⚠️ 错误数量: {$result['error_count']}");
$this->warn("详细错误信息请查看日志");
} else {
$this->info("✨ 无错误发生");
}
if ($result['total_processed'] > 0) {
$avgTime = round($result['duration'] / $result['total_processed'], 4);
$this->info("⚡ 平均处理速度: {$avgTime} 秒/用户");
}
}
private function performReset(): array
{
$startTime = microtime(true);
$totalResetCount = 0;
$errors = [];
$users = $this->getResetQuery()->get();
if ($users->isEmpty()) {
$this->info("😴 当前没有需要重置的用户");
return [
'total_processed' => 0,
'total_reset' => 0,
'error_count' => 0,
'duration' => round(microtime(true) - $startTime, 2),
];
}
$this->info("找到 {$users->count()} 个需要重置的用户");
foreach ($users as $user) {
try {
$totalResetCount += (int) $this->trafficResetService->checkAndReset($user, TrafficResetLog::SOURCE_CRON);
} catch (\Exception $e) {
$errors[] = [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
];
Log::error('用户流量重置失败', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
}
}
return [
'total_processed' => $users->count(),
'total_reset' => $totalResetCount,
'error_count' => count($errors),
'duration' => round(microtime(true) - $startTime, 2),
];
}
private function performFix(): array
{
$startTime = microtime(true);
$nullUsers = $this->getNullResetTimeUsers();
if ($nullUsers->isEmpty()) {
$this->info("✅ 没有发现next_reset_at为null的用户");
return [
'total_found' => 0,
'total_fixed' => 0,
'error_count' => 0,
'duration' => round(microtime(true) - $startTime, 2),
];
}
$this->info("🔧 发现 {$nullUsers->count()} 个next_reset_at为null的用户开始修正...");
$fixedCount = 0;
$errors = [];
foreach ($nullUsers as $user) {
try {
$nextResetTime = $this->trafficResetService->calculateNextResetTime($user);
if ($nextResetTime) {
$user->next_reset_at = $nextResetTime->timestamp;
$user->save();
$fixedCount++;
}
} catch (\Exception $e) {
$errors[] = [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
];
Log::error('修正用户next_reset_at失败', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
}
}
return [
'total_found' => $nullUsers->count(),
'total_fixed' => $fixedCount,
'error_count' => count($errors),
'duration' => round(microtime(true) - $startTime, 2),
];
}
private function getResetQuery()
{
return User::where('next_reset_at', '<=', time())
->whereNotNull('next_reset_at')
->where(function ($query) {
$query->where('expired_at', '>', time())
->orWhereNull('expired_at');
})
->where('banned', 0)
->whereNotNull('plan_id')
->count();
if ($totalUsers === 0) {
$this->info("😴 当前没有需要重置的用户");
return [
'total_found' => 0,
'estimated_duration' => 0,
'estimated_batches' => 0,
];
}
$this->info("找到 {$totalUsers} 个需要重置的用户");
// 预计批次数
$estimatedBatches = ceil($totalUsers / $batchSize);
// 预计执行时间基于经验值每个用户平均0.1秒)
$estimatedDuration = round($totalUsers * 0.1, 1);
$this->info("将分 {$estimatedBatches} 个批次处理(每批 {$batchSize} 用户)");
// 显示前几个用户的详情作为示例
if ($this->option('verbose') || $totalUsers <= 20) {
$sampleUsers = User::where('next_reset_at', '<=', time())
->whereNotNull('next_reset_at')
->where(function ($query) {
$query->where('expired_at', '>', time())
->orWhereNull('expired_at');
})
->where('banned', 0)
->whereNotNull('plan_id')
->with('plan')
->limit(min(20, $totalUsers))
->get();
$table = [];
foreach ($sampleUsers as $user) {
$table[] = [
'ID' => $user->id,
'邮箱' => substr($user->email, 0, 20) . (strlen($user->email) > 20 ? '...' : ''),
'套餐' => $user->plan->name ?? 'N/A',
'下次重置' => Carbon::createFromTimestamp($user->next_reset_at)->format('Y-m-d H:i:s'),
'当前流量' => Helper::trafficConvert(($user->u ?? 0) + ($user->d ?? 0)),
'重置次数' => $user->reset_count,
];
}
if (!empty($table)) {
$this->info("📋 示例用户列表" . ($totalUsers > 20 ? "显示前20个" : ""));
$this->table([
'ID',
'邮箱',
'套餐',
'下次重置',
'当前流量',
'重置次数'
], $table);
}
}
return [
'total_found' => $totalUsers,
'estimated_duration' => $estimatedDuration,
'estimated_batches' => $estimatedBatches,
];
->whereNotNull('plan_id');
}
private function getNullResetTimeUsers()
{
return User::whereNull('next_reset_at')
->whereNotNull('plan_id')
->where(function ($query) {
$query->where('expired_at', '>', time())
->orWhereNull('expired_at');
})
->where('banned', 0)
->with('plan:id,name,reset_traffic_method')
->get();
}
}

View File

@@ -45,6 +45,7 @@ class XboardUpdate extends Command
Artisan::call("migrate");
$this->info(Artisan::output());
Artisan::call('horizon:terminate');
Artisan::call('reset:traffic', ['--fix-null' => true]);
$updateService = new UpdateService();
$updateService->updateVersionCache();
$themeService = app(ThemeService::class);

View File

@@ -138,7 +138,10 @@ class UserController extends Controller
'd',
'transfer_enable',
'email',
'uuid'
'uuid',
'device_limit',
'speed_limit',
'next_reset_at'
])
->first();
if (!$user) {

View File

@@ -4,90 +4,23 @@ namespace App\Observers;
use App\Models\User;
use App\Services\TrafficResetService;
use Illuminate\Support\Facades\Log;
/**
* 用户模型观察者
* 主要用于监听用户到期时间变化,自动更新流量重置时间
*/
class UserObserver
{
/**
* 流量重置服务
*/
private TrafficResetService $trafficResetService;
public function __construct(TrafficResetService $trafficResetService)
{
$this->trafficResetService = $trafficResetService;
public function __construct(
private readonly TrafficResetService $trafficResetService
) {
}
/**
* 监听用户更新事件
* 当 expired_at 或 plan_id 发生变化时,重新计算下次重置时间
*/
public function updating(User $user): void
public function updated(User $user): void
{
// 检查是否有相关字段发生变化
$relevantFields = ['expired_at', 'plan_id'];
$hasRelevantChanges = false;
foreach ($relevantFields as $field) {
if ($user->isDirty($field)) {
$hasRelevantChanges = true;
break;
}
}
if (!$hasRelevantChanges) {
return; // 没有相关字段变化,直接返回
}
try {
if (!$user->plan_id) {
$user->next_reset_at = null;
return;
}
// 重新计算下次重置时间
$nextResetTime = $this->trafficResetService->calculateNextResetTime($user);
if ($nextResetTime) {
$user->setAttribute('next_reset_at', $nextResetTime->timestamp);
} else {
// 如果计算结果为空,清除重置时间
$user->setAttribute('next_reset_at', null);
}
} catch (\Exception $e) {
Log::error('更新用户流量重置时间失败', [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 不阻止用户更新操作,只记录错误
}
}
/**
* 监听用户创建事件
* 为新用户设置初始的重置时间
*/
public function created(User $user): void
{
// 如果用户有套餐和到期时间,设置初始重置时间
if ($user->plan_id && $user->expired_at) {
try {
$this->trafficResetService->setInitialResetTime($user);
} catch (\Exception $e) {
Log::error('设置新用户流量重置时间失败', [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
]);
}
if ($user->isDirty(['plan_id', 'expired_at'])) {
$user->refresh();
User::withoutEvents(function () use ($user) {
$nextResetTime = $this->trafficResetService->calculateNextResetTime($user);
$user->next_reset_at = $nextResetTime?->timestamp;
$user->save();
});
}
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Providers;
use App\Models\User;
use App\Observers\UserObserver;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
@@ -24,7 +23,6 @@ class EventServiceProvider extends ServiceProvider
{
parent::boot();
// 注册用户模型观察者
User::observe(UserObserver::class);
}
}

View File

@@ -3,10 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Models\User;
use App\Services\TrafficResetService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Artisan;
class AddTrafficResetFieldsToUsers extends Migration
{
@@ -25,66 +22,8 @@ class AddTrafficResetFieldsToUsers extends Migration
});
}
// 为现有用户设置初始重置时间
$this->migrateExistingUsers();
}
/**
* 为现有用户迁移流量重置数据
*/
private function migrateExistingUsers(): void
{
try {
// 获取所有需要迁移的用户ID避免查询条件变化
$userIds = User::whereNotNull('plan_id')
->where('banned', 0)
->whereNull('next_reset_at')
->pluck('id')
->toArray();
$totalUsers = count($userIds);
if ($totalUsers === 0) {
return;
}
echo "开始迁移 {$totalUsers} 个用户的流量重置数据...\n";
$trafficResetService = app(TrafficResetService::class);
$processedCount = 0;
$failedCount = 0;
// 分批处理用户ID
$chunks = array_chunk($userIds, 200);
foreach ($chunks as $chunkIds) {
$users = User::whereIn('id', $chunkIds)
->with('plan:id,reset_traffic_method')
->get();
foreach ($users as $user) {
try {
$trafficResetService->setInitialResetTime($user);
$processedCount++;
} catch (\Exception $e) {
$failedCount++;
Log::error('迁移用户流量重置时间失败', [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
]);
}
// 每 100 个用户显示一次进度
if (($processedCount + $failedCount) % 100 === 0 || ($processedCount + $failedCount) === $totalUsers) {
$currentTotal = $processedCount + $failedCount;
$percentage = round(($currentTotal / $totalUsers) * 100, 1);
echo "进度: {$currentTotal}/{$totalUsers} ({$percentage}%) [成功: {$processedCount}, 失败: {$failedCount}]\n";
}
}
}
echo "迁移完成!总计 {$totalUsers} 个用户,成功: {$processedCount},失败: {$failedCount}\n";
} catch (\Exception $e) {
}
// Set initial reset time for existing users
Artisan::call('reset:traffic', ['--fix-null' => true]);
}
/**