mirror of
https://github.com/lkddi/Xboard.git
synced 2026-04-05 12:40:52 +08:00
415 lines
12 KiB
PHP
415 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Plan;
|
|
use App\Models\TrafficResetLog;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use App\Services\Plugin\HookManager;
|
|
|
|
/**
|
|
* Service for handling traffic reset.
|
|
*/
|
|
class TrafficResetService
|
|
{
|
|
/**
|
|
* Check if a user's traffic should be reset and perform the reset.
|
|
*/
|
|
public function checkAndReset(User $user, string $triggerSource = TrafficResetLog::SOURCE_AUTO): bool
|
|
{
|
|
if (!$user->shouldResetTraffic()) {
|
|
return false;
|
|
}
|
|
|
|
return $this->performReset($user, $triggerSource);
|
|
}
|
|
|
|
/**
|
|
* Perform the traffic reset for a user.
|
|
*/
|
|
public function performReset(User $user, string $triggerSource = TrafficResetLog::SOURCE_MANUAL): bool
|
|
{
|
|
try {
|
|
return DB::transaction(function () use ($user, $triggerSource) {
|
|
$oldUpload = $user->u ?? 0;
|
|
$oldDownload = $user->d ?? 0;
|
|
$oldTotal = $oldUpload + $oldDownload;
|
|
|
|
$nextResetTime = $this->calculateNextResetTime($user);
|
|
|
|
$user->update([
|
|
'u' => 0,
|
|
'd' => 0,
|
|
'last_reset_at' => time(),
|
|
'reset_count' => $user->reset_count + 1,
|
|
'next_reset_at' => $nextResetTime ? $nextResetTime->timestamp : null,
|
|
]);
|
|
|
|
$this->recordResetLog($user, [
|
|
'reset_type' => $this->getResetTypeFromPlan($user->plan),
|
|
'trigger_source' => $triggerSource,
|
|
'old_upload' => $oldUpload,
|
|
'old_download' => $oldDownload,
|
|
'old_total' => $oldTotal,
|
|
'new_upload' => 0,
|
|
'new_download' => 0,
|
|
'new_total' => 0,
|
|
]);
|
|
|
|
$this->clearUserCache($user);
|
|
HookManager::call('traffic.reset.after', $user);
|
|
return true;
|
|
});
|
|
} catch (\Exception $e) {
|
|
Log::error(__('traffic_reset.reset_failed'), [
|
|
'user_id' => $user->id,
|
|
'email' => $user->email,
|
|
'error' => $e->getMessage(),
|
|
'trigger_source' => $triggerSource,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate the next traffic reset time for a user.
|
|
*/
|
|
public function calculateNextResetTime(User $user): ?Carbon
|
|
{
|
|
if (
|
|
!$user->plan
|
|
|| $user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_NEVER
|
|
|| ($user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM
|
|
&& (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY) === Plan::RESET_TRAFFIC_NEVER)
|
|
|| $user->expired_at === NULL
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$resetMethod = $user->plan->reset_traffic_method;
|
|
|
|
if ($resetMethod === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) {
|
|
$resetMethod = (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY);
|
|
}
|
|
|
|
$now = Carbon::now(config('app.timezone'));
|
|
|
|
return match ($resetMethod) {
|
|
Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => $this->getNextMonthFirstDay($now),
|
|
Plan::RESET_TRAFFIC_MONTHLY => $this->getNextMonthlyReset($user, $now),
|
|
Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => $this->getNextYearFirstDay($now),
|
|
Plan::RESET_TRAFFIC_YEARLY => $this->getNextYearlyReset($user, $now),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the first day of the next month.
|
|
*/
|
|
private function getNextMonthFirstDay(Carbon $from): Carbon
|
|
{
|
|
return $from->copy()->addMonth()->startOfMonth();
|
|
}
|
|
|
|
/**
|
|
* Get the next monthly reset time based on the user's expiration date.
|
|
*
|
|
* Logic:
|
|
* 1. If the user has no expiration date, reset on the 1st of each month.
|
|
* 2. If the user has an expiration date, use the day of that date as the monthly reset day.
|
|
* 3. Prioritize the reset day in the current month if it has not passed yet.
|
|
* 4. Handle cases where the day does not exist in a month (e.g., 31st in February).
|
|
*/
|
|
private function getNextMonthlyReset(User $user, Carbon $from): Carbon
|
|
{
|
|
$expiredAt = Carbon::createFromTimestamp($user->expired_at, config('app.timezone'));
|
|
$resetDay = $expiredAt->day;
|
|
$resetTime = [$expiredAt->hour, $expiredAt->minute, $expiredAt->second];
|
|
|
|
$currentMonthTarget = $from->copy()->day($resetDay)->setTime(...$resetTime);
|
|
if ($currentMonthTarget->timestamp > $from->timestamp) {
|
|
return $currentMonthTarget;
|
|
}
|
|
|
|
$nextMonthTarget = $from->copy()->startOfMonth()->addMonths(1)->day($resetDay)->setTime(...$resetTime);
|
|
|
|
if ($nextMonthTarget->month !== ($from->month % 12) + 1) {
|
|
$nextMonth = ($from->month % 12) + 1;
|
|
$nextYear = $from->year + ($from->month === 12 ? 1 : 0);
|
|
$lastDayOfNextMonth = Carbon::create($nextYear, $nextMonth, 1)->endOfMonth()->day;
|
|
$targetDay = min($resetDay, $lastDayOfNextMonth);
|
|
$nextMonthTarget = Carbon::create($nextYear, $nextMonth, $targetDay)->setTime(...$resetTime);
|
|
}
|
|
|
|
return $nextMonthTarget;
|
|
}
|
|
|
|
/**
|
|
* Get the first day of the next year.
|
|
*/
|
|
private function getNextYearFirstDay(Carbon $from): Carbon
|
|
{
|
|
return $from->copy()->addYear()->startOfYear();
|
|
}
|
|
|
|
/**
|
|
* Get the next yearly reset time based on the user's expiration date.
|
|
*
|
|
* Logic:
|
|
* 1. If the user has no expiration date, reset on January 1st of each year.
|
|
* 2. If the user has an expiration date, use the month and day of that date as the yearly reset date.
|
|
* 3. Prioritize the reset date in the current year if it has not passed yet.
|
|
* 4. Handle the case of February 29th in a leap year.
|
|
*/
|
|
private function getNextYearlyReset(User $user, Carbon $from): Carbon
|
|
{
|
|
$expiredAt = Carbon::createFromTimestamp($user->expired_at, config('app.timezone'));
|
|
$resetMonth = $expiredAt->month;
|
|
$resetDay = $expiredAt->day;
|
|
$resetTime = [$expiredAt->hour, $expiredAt->minute, $expiredAt->second];
|
|
|
|
$currentYearTarget = $from->copy()->month($resetMonth)->day($resetDay)->setTime(...$resetTime);
|
|
if ($currentYearTarget->timestamp > $from->timestamp) {
|
|
return $currentYearTarget;
|
|
}
|
|
|
|
$nextYearTarget = $from->copy()->startOfYear()->addYears(1)->month($resetMonth)->day($resetDay)->setTime(...$resetTime);
|
|
|
|
if ($nextYearTarget->month !== $resetMonth) {
|
|
$nextYear = $from->year + 1;
|
|
$lastDayOfMonth = Carbon::create($nextYear, $resetMonth, 1)->endOfMonth()->day;
|
|
$targetDay = min($resetDay, $lastDayOfMonth);
|
|
$nextYearTarget = Carbon::create($nextYear, $resetMonth, $targetDay)->setTime(...$resetTime);
|
|
}
|
|
|
|
return $nextYearTarget;
|
|
}
|
|
|
|
|
|
/**
|
|
* Record the traffic reset log.
|
|
*/
|
|
private function recordResetLog(User $user, array $data): void
|
|
{
|
|
TrafficResetLog::create([
|
|
'user_id' => $user->id,
|
|
'reset_type' => $data['reset_type'],
|
|
'reset_time' => now(),
|
|
'old_upload' => $data['old_upload'],
|
|
'old_download' => $data['old_download'],
|
|
'old_total' => $data['old_total'],
|
|
'new_upload' => $data['new_upload'],
|
|
'new_download' => $data['new_download'],
|
|
'new_total' => $data['new_total'],
|
|
'trigger_source' => $data['trigger_source'],
|
|
'metadata' => $data['metadata'] ?? null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get the reset type from the user's plan.
|
|
*/
|
|
private function getResetTypeFromPlan(?Plan $plan): string
|
|
{
|
|
if (!$plan) {
|
|
return TrafficResetLog::TYPE_MANUAL;
|
|
}
|
|
|
|
$resetMethod = $plan->reset_traffic_method;
|
|
|
|
if ($resetMethod === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) {
|
|
$resetMethod = (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY);
|
|
}
|
|
|
|
return match ($resetMethod) {
|
|
Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => TrafficResetLog::TYPE_FIRST_DAY_MONTH,
|
|
Plan::RESET_TRAFFIC_MONTHLY => TrafficResetLog::TYPE_MONTHLY,
|
|
Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => TrafficResetLog::TYPE_FIRST_DAY_YEAR,
|
|
Plan::RESET_TRAFFIC_YEARLY => TrafficResetLog::TYPE_YEARLY,
|
|
Plan::RESET_TRAFFIC_NEVER => TrafficResetLog::TYPE_MANUAL,
|
|
default => TrafficResetLog::TYPE_MANUAL,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear user-related cache.
|
|
*/
|
|
private function clearUserCache(User $user): void
|
|
{
|
|
$cacheKeys = [
|
|
"user_traffic_{$user->id}",
|
|
"user_reset_status_{$user->id}",
|
|
"user_subscription_{$user->token}",
|
|
];
|
|
|
|
foreach ($cacheKeys as $key) {
|
|
Cache::forget($key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Batch check and reset users. Processes all eligible users in batches.
|
|
*/
|
|
public function batchCheckReset(int $batchSize = 100, ?callable $progressCallback = null): array
|
|
{
|
|
$startTime = microtime(true);
|
|
$totalResetCount = 0;
|
|
$totalProcessedCount = 0;
|
|
$batchNumber = 1;
|
|
$errors = [];
|
|
$lastProcessedId = 0;
|
|
|
|
try {
|
|
do {
|
|
$users = User::where('next_reset_at', '<=', time())
|
|
->whereNotNull('next_reset_at')
|
|
->where('id', '>', $lastProcessedId)
|
|
->where(function ($query) {
|
|
$query->where('expired_at', '>', time())
|
|
->orWhereNull('expired_at');
|
|
})
|
|
->where('banned', 0)
|
|
->whereNotNull('plan_id')
|
|
->orderBy('id')
|
|
->limit($batchSize)
|
|
->get();
|
|
|
|
if ($users->isEmpty()) {
|
|
break;
|
|
}
|
|
|
|
$batchResetCount = 0;
|
|
|
|
if ($progressCallback) {
|
|
$progressCallback([
|
|
'batch_number' => $batchNumber,
|
|
'batch_size' => $users->count(),
|
|
'total_processed' => $totalProcessedCount,
|
|
]);
|
|
}
|
|
|
|
foreach ($users as $user) {
|
|
try {
|
|
if ($this->checkAndReset($user, TrafficResetLog::SOURCE_CRON)) {
|
|
$batchResetCount++;
|
|
$totalResetCount++;
|
|
}
|
|
$totalProcessedCount++;
|
|
$lastProcessedId = $user->id;
|
|
} catch (\Exception $e) {
|
|
$error = [
|
|
'user_id' => $user->id,
|
|
'email' => $user->email,
|
|
'error' => $e->getMessage(),
|
|
'batch' => $batchNumber,
|
|
'timestamp' => now()->toDateTimeString(),
|
|
];
|
|
$batchErrors[] = $error;
|
|
$errors[] = $error;
|
|
|
|
Log::error('User traffic reset failed', $error);
|
|
|
|
$totalProcessedCount++;
|
|
$lastProcessedId = $user->id;
|
|
}
|
|
}
|
|
|
|
$batchNumber++;
|
|
|
|
if ($batchNumber % 10 === 0) {
|
|
gc_collect_cycles();
|
|
}
|
|
|
|
if ($batchNumber % 5 === 0) {
|
|
usleep(100000);
|
|
}
|
|
|
|
} while (true);
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Batch traffic reset task failed with an exception', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
'total_processed' => $totalProcessedCount,
|
|
'total_reset' => $totalResetCount,
|
|
'last_processed_id' => $lastProcessedId,
|
|
]);
|
|
|
|
$errors[] = [
|
|
'type' => 'system_error',
|
|
'error' => $e->getMessage(),
|
|
'batch' => $batchNumber,
|
|
'last_processed_id' => $lastProcessedId,
|
|
'timestamp' => now()->toDateTimeString(),
|
|
];
|
|
}
|
|
|
|
$totalDuration = round(microtime(true) - $startTime, 2);
|
|
|
|
$result = [
|
|
'total_processed' => $totalProcessedCount,
|
|
'total_reset' => $totalResetCount,
|
|
'total_batches' => $batchNumber - 1,
|
|
'error_count' => count($errors),
|
|
'errors' => $errors,
|
|
'duration' => $totalDuration,
|
|
'batch_size' => $batchSize,
|
|
'last_processed_id' => $lastProcessedId,
|
|
'completed_at' => now()->toDateTimeString(),
|
|
];
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Set the initial reset time for a new user.
|
|
*/
|
|
public function setInitialResetTime(User $user): void
|
|
{
|
|
if ($user->next_reset_at !== null) {
|
|
return;
|
|
}
|
|
|
|
$nextResetTime = $this->calculateNextResetTime($user);
|
|
|
|
if ($nextResetTime) {
|
|
$user->update(['next_reset_at' => $nextResetTime->timestamp]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the user's traffic reset history.
|
|
*/
|
|
public function getUserResetHistory(User $user, int $limit = 10): \Illuminate\Database\Eloquent\Collection
|
|
{
|
|
return $user->trafficResetLogs()
|
|
->orderBy('reset_time', 'desc')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Check if the user is eligible for traffic reset.
|
|
*/
|
|
public function canReset(User $user): bool
|
|
{
|
|
return $user->isActive() && $user->plan !== null;
|
|
}
|
|
|
|
/**
|
|
* Manually reset a user's traffic (Admin function).
|
|
*/
|
|
public function manualReset(User $user, array $metadata = []): bool
|
|
{
|
|
if (!$this->canReset($user)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->performReset($user, TrafficResetLog::SOURCE_MANUAL);
|
|
}
|
|
} |