feat: enhance plan validation, traffic system and email verification

- feat: add plan price validation
- feat: make traffic packages stackable
- feat: add commission and invite info to admin order details
- feat: apply email whitelist to verification code API
- fix: subscription link copy compatibility for non-HTTPS
- fix: resolve route editing 500 error in certain cases
- refactor: restructure traffic reset logic
This commit is contained in:
xboard 2025-06-22 01:18:38 +08:00
parent 77d1a51fd8
commit 8f07e03b6e
34 changed files with 2176 additions and 539 deletions

View File

@ -2,175 +2,204 @@
namespace App\Console\Commands;
use App\Models\Plan;
use App\Services\TrafficResetService;
use App\Utils\Helper;
use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ResetTraffic extends Command
{
/**
* @var Builder
* The name and signature of the console command.
*/
protected $builder;
protected $signature = 'reset:traffic {--batch-size=100 : 分批处理的批次大小} {--dry-run : 预演模式,不实际执行重置} {--max-time=300 : 最大执行时间(秒)}';
/**
* @var string
* The console command description.
*/
protected $signature = 'reset:traffic';
protected $description = '流量重置 - 分批处理所有需要重置的用户';
/**
* @var string
* 流量重置服务
*/
protected $description = '流量清空';
private TrafficResetService $trafficResetService;
public function __construct()
/**
* Create a new command instance.
*/
public function __construct(TrafficResetService $trafficResetService)
{
parent::__construct();
$this->builder = User::where('expired_at', '!=', NULL)
->where('expired_at', '>', time());
$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');
$this->info('🚀 开始执行流量重置任务...');
$this->info("批次大小: {$batchSize} 用户/批");
$this->info("最大执行时间: {$maxTime} 秒");
if ($dryRun) {
$this->warn('⚠️ 预演模式 - 不会实际执行重置操作');
}
/**
* 执行流量重置命令
*/
public function handle()
{
ini_set('memory_limit', -1);
// 设置最大执行时间
set_time_limit($maxTime);
// 按重置方法分组查询所有套餐
$resetMethods = Plan::select(
DB::raw("GROUP_CONCAT(`id`) as plan_ids"),
DB::raw("reset_traffic_method as method")
)
->groupBy('reset_traffic_method')
->get()
->toArray();
$startTime = microtime(true);
// 使用闭包直接引用方法
$resetHandlers = [
Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => fn($builder) => $this->resetByMonthFirstDay($builder),
Plan::RESET_TRAFFIC_MONTHLY => fn($builder) => $this->resetByExpireDay($builder),
Plan::RESET_TRAFFIC_NEVER => null,
Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => fn($builder) => $this->resetByYearFirstDay($builder),
Plan::RESET_TRAFFIC_YEARLY => fn($builder) => $this->resetByExpireYear($builder),
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);
return self::SUCCESS;
} catch (\Exception $e) {
$this->error("❌ 任务执行失败: {$e->getMessage()}");
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
{
$this->info("✅ 任务完成!");
$this->line('');
if ($dryRun) {
$this->info("📊 预演结果统计:");
$this->info("📋 待处理用户数: {$result['total_found']}");
$this->info("⏱️ 预计处理时间: ~{$result['estimated_duration']} 秒");
$this->info("🗂️ 预计批次数: {$result['estimated_batches']}");
} 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} 秒/用户");
}
}
}
/**
* 执行预演模式
*/
private function performDryRun(int $batchSize): array
{
$this->info("🔍 扫描需要重置的用户...");
$totalUsers = \App\Models\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 = \App\Models\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',
'下次重置' => $user->next_reset_at->format('m-d H:i'),
'当前流量' => Helper::trafficConvert(($user->u ?? 0) + ($user->d ?? 0)),
'重置次数' => $user->reset_count,
];
}
// 处理每种重置方法
foreach ($resetMethods as $resetMethod) {
$planIds = explode(',', $resetMethod['plan_ids']);
// 获取重置方法
$method = $resetMethod['method'];
if ($method === NULL) {
$method = (int) admin_setting('reset_traffic_method', 0);
}
// 跳过不重置的方法
if ($method === 2) {
continue;
}
// 获取该方法的处理器
$handler = $resetHandlers[$method] ?? null;
if (!$handler) {
continue;
}
// 创建查询构建器并执行重置
$userQuery = (clone $this->builder)->whereIn('plan_id', $planIds);
$handler($userQuery);
}
if (!empty($table)) {
$this->info("📋 示例用户列表" . ($totalUsers > 20 ? "(显示前20个):" : ":"));
$this->table([
'ID',
'邮箱',
'套餐',
'下次重置',
'当前流量',
'重置次数'
], $table);
}
}
/**
* 按用户年度到期日重置流量
*/
private function resetByExpireYear(Builder $builder): void
{
$today = date('m-d');
$this->resetUsersByDateCondition($builder, function ($user) use ($today) {
return date('m-d', $user->expired_at) === $today;
});
}
/**
* 按新年第一天重置流量
*/
private function resetByYearFirstDay(Builder $builder): void
{
$isNewYear = date('md') === '0101';
if (!$isNewYear) {
return;
}
$this->resetAllUsers($builder);
}
/**
* 按月初第一天重置流量
*/
private function resetByMonthFirstDay(Builder $builder): void
{
$isFirstDayOfMonth = date('d') === '01';
if (!$isFirstDayOfMonth) {
return;
}
$this->resetAllUsers($builder);
}
/**
* 按用户到期日重置流量
*/
private function resetByExpireDay(Builder $builder): void
{
$today = date('d');
$lastDay = date('d', strtotime('last day of +0 months'));
$this->resetUsersByDateCondition($builder, function ($user) use ($today, $lastDay) {
$expireDay = date('d', $user->expired_at);
return $expireDay === $today || ($today === $lastDay && $expireDay >= $today);
});
}
/**
* 重置所有符合条件的用户流量
*/
private function resetAllUsers(Builder $builder): void
{
$this->resetUsersByDateCondition($builder, function () {
return true;
});
}
/**
* 根据日期条件重置用户流量
* @param Builder $builder 用户查询构建器
* @param callable $condition 日期条件回调
*/
private function resetUsersByDateCondition(Builder $builder, callable $condition): void
{
/** @var \App\Models\User[] $users */
$users = $builder->with('plan')->get();
$usersToUpdate = [];
foreach ($users as $user) {
if ($condition($user)) {
$usersToUpdate[] = [
'id' => $user->id,
'transfer_enable' => $user->plan->transfer_enable
];
}
}
foreach ($usersToUpdate as $userData) {
User::where('id', $userData['id'])->update([
'transfer_enable' => (intval($userData['transfer_enable']) * 1073741824),
'u' => 0,
'd' => 0
]);
}
}
}
return [
'total_found' => $totalUsers,
'estimated_duration' => $estimatedDuration,
'estimated_batches' => $estimatedBatches,
];
}
}

View File

@ -35,7 +35,7 @@ class Kernel extends ConsoleKernel
$schedule->command('check:commission')->everyMinute()->onOneServer();
$schedule->command('check:ticket')->everyMinute()->onOneServer();
// reset
$schedule->command('reset:traffic')->daily()->onOneServer();
$schedule->command('reset:traffic')->everyMinute()->onOneServer();
$schedule->command('reset:log')->daily()->onOneServer();
// send
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\V1\Guest;
use App\Http\Controllers\Controller;
use App\Utils\Dict;
use App\Utils\Helper;
use Illuminate\Support\Facades\Http;
class CommController extends Controller
@ -12,12 +13,12 @@ class CommController extends Controller
{
$data = [
'tos_url' => admin_setting('tos_url'),
'is_email_verify' => (int)admin_setting('email_verify', 0) ? 1 : 0,
'is_invite_force' => (int)admin_setting('invite_force', 0) ? 1 : 0,
'email_whitelist_suffix' => (int)admin_setting('email_whitelist_enable', 0)
? $this->getEmailSuffix()
'is_email_verify' => (int) admin_setting('email_verify', 0) ? 1 : 0,
'is_invite_force' => (int) admin_setting('invite_force', 0) ? 1 : 0,
'email_whitelist_suffix' => (int) admin_setting('email_whitelist_enable', 0)
? Helper::getEmailSuffix()
: 0,
'is_recaptcha' => (int)admin_setting('recaptcha_enable', 0) ? 1 : 0,
'is_recaptcha' => (int) admin_setting('recaptcha_enable', 0) ? 1 : 0,
'recaptcha_site_key' => admin_setting('recaptcha_site_key'),
'app_description' => admin_setting('app_description'),
'app_url' => admin_setting('app_url'),
@ -25,13 +26,4 @@ class CommController extends Controller
];
return $this->success($data);
}
private function getEmailSuffix()
{
$suffix = admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT);
if (!is_array($suffix)) {
return preg_split('/,/', $suffix);
}
return $suffix;
}
}

View File

@ -2,13 +2,13 @@
namespace App\Http\Controllers\V1\Passport;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Passport\CommSendEmailVerify;
use App\Jobs\SendEmailJob;
use App\Models\InviteCode;
use App\Models\User;
use App\Utils\CacheKey;
use App\Utils\Dict;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use ReCaptcha\ReCaptcha;
@ -25,7 +25,22 @@ class CommController extends Controller
return $this->fail([400, __('Invalid code is incorrect')]);
}
}
$email = $request->input('email');
// 检查白名单后缀限制
if ((int) admin_setting('email_whitelist_enable', 0)) {
$isRegisteredEmail = User::where('email', $email)->exists();
if (!$isRegisteredEmail) {
$allowedSuffixes = Helper::getEmailSuffix();
$emailSuffix = substr(strrchr($email, '@'), 1);
if (!in_array($emailSuffix, $allowedSuffixes)) {
return $this->fail([400, __('Email suffix is not in whitelist')]);
}
}
}
if (Cache::get(CacheKey::get('LAST_SEND_EMAIL_VERIFY_TIMESTAMP', $email))) {
return $this->fail([400, __('Email verification code has been sent, please request again later')]);
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\UserChangePassword;
use App\Http\Requests\User\UserTransfer;
@ -15,7 +14,6 @@ use App\Services\AuthService;
use App\Services\UserService;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Auth;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

View File

@ -22,7 +22,7 @@ class OrderController extends Controller
public function detail(Request $request)
{
$order = Order::with(['user', 'plan', 'commission_log'])->find($request->input('id'));
$order = Order::with(['user', 'plan', 'commission_log', 'invite_user'])->find($request->input('id'));
if (!$order)
return $this->fail([400202, '订单不存在']);
if ($order->surplus_order_ids) {

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\PlanSave;
use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
@ -32,27 +33,17 @@ class PlanController extends Controller
return $this->success($plans);
}
public function save(Request $request)
public function save(PlanSave $request)
{
$params = $request->validate([
'id' => 'nullable|integer',
'name' => 'required|string',
'content' => 'nullable|string',
'reset_traffic_method' => 'integer|nullable',
'transfer_enable' => 'integer|required',
'prices' => 'array|nullable',
'group_id' => 'integer|nullable',
'speed_limit' => 'integer|nullable',
'device_limit' => 'integer|nullable',
'capacity_limit' => 'integer|nullable',
]);
$params = $request->validated();
if ($request->input('id')) {
$plan = Plan::find($request->input('id'));
if (!$plan) {
return $this->fail([400202, '该订阅不存在']);
}
DB::beginTransaction();
// update user group id and transfer
try {
if ($request->input('force_update')) {
User::where('plan_id', $plan->id)->update([

View File

@ -6,18 +6,13 @@ use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\ServerRoute;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class RouteController extends Controller
{
public function fetch(Request $request)
{
$routes = ServerRoute::get();
// TODO: remove on 1.8.0
foreach ($routes as $k => $route) {
$array = json_decode($route->match, true);
if (is_array($array)) $routes[$k]['match'] = $array;
}
// TODO: remove on 1.8.0
return [
'data' => $routes
];
@ -38,15 +33,13 @@ class RouteController extends Controller
]);
$params['match'] = array_filter($params['match']);
// TODO: remove on 1.8.0
$params['match'] = json_encode($params['match']);
// TODO: remove on 1.8.0
if ($request->input('id')) {
try {
$route = ServerRoute::find($request->input('id'));
$route->update($params);
return $this->success(true);
} catch (\Exception $e) {
\Log::error($e);
Log::error($e);
return $this->fail([500,'保存失败']);
}
}
@ -54,7 +47,7 @@ class RouteController extends Controller
ServerRoute::create($params);
return $this->success(true);
}catch(\Exception $e){
\Log::error($e);
Log::error($e);
return $this->fail([500,'创建失败']);
}
}

View File

@ -0,0 +1,234 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\TrafficResetLog;
use App\Services\TrafficResetService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
/**
* 流量重置管理控制器
*/
class TrafficResetController extends Controller
{
private TrafficResetService $trafficResetService;
public function __construct(TrafficResetService $trafficResetService)
{
$this->trafficResetService = $trafficResetService;
}
/**
* 获取流量重置日志列表
*/
public function logs(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'nullable|integer',
'user_email' => 'nullable|string',
'reset_type' => 'nullable|string|in:' . implode(',', array_keys(TrafficResetLog::getResetTypeNames())),
'trigger_source' => 'nullable|string|in:' . implode(',', array_keys(TrafficResetLog::getSourceNames())),
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'per_page' => 'nullable|integer|min:1|max:10000',
'page' => 'nullable|integer|min:1',
]);
$query = TrafficResetLog::with(['user:id,email'])
->orderBy('reset_time', 'desc');
// 筛选条件
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('user_email')) {
$query->whereHas('user', function ($query) use ($request) {
$query->where('email', 'like', '%' . $request->user_email . '%');
});
}
if ($request->filled('reset_type')) {
$query->where('reset_type', $request->reset_type);
}
if ($request->filled('trigger_source')) {
$query->where('trigger_source', $request->trigger_source);
}
if ($request->filled('start_date')) {
$query->where('reset_time', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('reset_time', '<=', $request->end_date . ' 23:59:59');
}
$perPage = $request->get('per_page', 20);
$logs = $query->paginate($perPage);
// 格式化数据
$logs->getCollection()->transform(function ($log) {
return [
'id' => $log->id,
'user_id' => $log->user_id,
'user_email' => $log->user->email ?? 'N/A',
'reset_type' => $log->reset_type,
'reset_type_name' => $log->getResetTypeName(),
'reset_time' => $log->reset_time,
'old_traffic' => [
'upload' => $log->old_upload,
'download' => $log->old_download,
'total' => $log->old_total,
'formatted' => $log->formatTraffic($log->old_total),
],
'new_traffic' => [
'upload' => $log->new_upload,
'download' => $log->new_download,
'total' => $log->new_total,
'formatted' => $log->formatTraffic($log->new_total),
],
'trigger_source' => $log->trigger_source,
'trigger_source_name' => $log->getSourceName(),
'metadata' => $log->metadata,
'created_at' => $log->created_at,
];
});
return response()->json([
'data' => $logs->items(),
'pagination' => [
'current_page' => $logs->currentPage(),
'last_page' => $logs->lastPage(),
'per_page' => $logs->perPage(),
'total' => $logs->total(),
],
]);
}
/**
* 获取流量重置统计信息
*/
public function stats(Request $request): JsonResponse
{
$request->validate([
'days' => 'nullable|integer|min:1|max:365',
]);
$days = $request->get('days', 30);
$startDate = now()->subDays($days)->startOfDay();
$stats = [
'total_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)->count(),
'auto_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)
->where('trigger_source', TrafficResetLog::SOURCE_AUTO)
->count(),
'manual_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)
->where('trigger_source', TrafficResetLog::SOURCE_MANUAL)
->count(),
'cron_resets' => TrafficResetLog::where('reset_time', '>=', $startDate)
->where('trigger_source', TrafficResetLog::SOURCE_CRON)
->count(),
];
return response()->json([
'data' => $stats
]);
}
/**
* 手动重置用户流量
*/
public function resetUser(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|integer|exists:v2_user,id',
'reason' => 'nullable|string|max:255',
]);
$user = User::find($request->user_id);
if (!$this->trafficResetService->canReset($user)) {
return response()->json([
'message' => __('traffic_reset.user_cannot_reset')
], 400);
}
$metadata = [];
if ($request->filled('reason')) {
$metadata['reason'] = $request->reason;
$metadata['admin_id'] = auth()->user()?->id;
}
$success = $this->trafficResetService->manualReset($user, $metadata);
if (!$success) {
return response()->json([
'message' => __('traffic_reset.reset_failed')
], 500);
}
return response()->json([
'message' => __('traffic_reset.reset_success'),
'data' => [
'user_id' => $user->id,
'email' => $user->email,
'reset_time' => now(),
'next_reset_at' => $user->fresh()->next_reset_at,
]
]);
}
/**
* 获取用户重置历史
*/
public function userHistory(Request $request, int $userId): JsonResponse
{
$request->validate([
'limit' => 'nullable|integer|min:1|max:50',
]);
$user = User::findOrFail($userId);
$limit = $request->get('limit', 10);
$history = $this->trafficResetService->getUserResetHistory($user, $limit);
$data = $history->map(function ($log) {
return [
'id' => $log->id,
'reset_type' => $log->reset_type,
'reset_type_name' => $log->getResetTypeName(),
'reset_time' => $log->reset_time,
'old_traffic' => [
'upload' => $log->old_upload,
'download' => $log->old_download,
'total' => $log->old_total,
'formatted' => $log->formatTraffic($log->old_total),
],
'trigger_source' => $log->trigger_source,
'trigger_source_name' => $log->getSourceName(),
'metadata' => $log->metadata,
];
});
return response()->json([
"data" => [
'user' => [
'id' => $user->id,
'email' => $user->email,
'reset_count' => $user->reset_count,
'last_reset_at' => $user->last_reset_at,
'next_reset_at' => $user->next_reset_at,
],
'history' => $data,
]
]);
}
}

View File

@ -2,56 +2,154 @@
namespace App\Http\Requests\Admin;
use App\Models\Plan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class PlanSave extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
* Determine if the user is authorized to make this request.
*/
public function rules()
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => 'required',
'content' => '',
'group_id' => 'required',
'transfer_enable' => 'required',
'month_price' => 'nullable|integer',
'quarter_price' => 'nullable|integer',
'half_year_price' => 'nullable|integer',
'year_price' => 'nullable|integer',
'two_year_price' => 'nullable|integer',
'three_year_price' => 'nullable|integer',
'onetime_price' => 'nullable|integer',
'reset_price' => 'nullable|integer',
'reset_traffic_method' => 'nullable|integer|in:0,1,2,3,4',
'capacity_limit' => 'nullable|integer',
'speed_limit' => 'nullable|integer'
'id' => 'nullable|integer',
'name' => 'required|string|max:255',
'content' => 'nullable|string',
'reset_traffic_method' => 'integer|nullable',
'transfer_enable' => 'integer|required|min:1',
'prices' => 'nullable|array',
'prices.*' => 'nullable|numeric|min:0',
'group_id' => 'integer|nullable',
'speed_limit' => 'integer|nullable|min:0',
'device_limit' => 'integer|nullable|min:0',
'capacity_limit' => 'integer|nullable|min:0',
];
}
public function messages()
/**
* Configure the validator instance.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$this->validatePrices($validator);
});
}
/**
* 验证价格配置
*/
protected function validatePrices(Validator $validator): void
{
$prices = $this->input('prices', []);
if (empty($prices)) {
return;
}
// 获取所有有效的周期
$validPeriods = array_keys(Plan::getAvailablePeriods());
foreach ($prices as $period => $price) {
// 验证周期是否有效
if (!in_array($period, $validPeriods)) {
$validator->errors()->add(
"prices.{$period}",
"不支持的订阅周期: {$period}"
);
continue;
}
// 价格可以为 null、空字符串或大于 0 的数字
if ($price !== null && $price !== '') {
// 转换为数字进行验证
$numericPrice = is_numeric($price) ? (float) $price : null;
if ($numericPrice === null) {
$validator->errors()->add(
"prices.{$period}",
"价格必须是数字格式"
);
} elseif ($numericPrice <= 0) {
$validator->errors()->add(
"prices.{$period}",
"价格必须大于 0(如不需要此周期请留空或设为 null)"
);
}
}
}
}
/**
* 处理验证后的数据
*/
protected function passedValidation(): void
{
// 清理和格式化价格数据
$prices = $this->input('prices', []);
$cleanedPrices = [];
foreach ($prices as $period => $price) {
// 只保留有效的正数价格
if ($price !== null && $price !== '' && is_numeric($price)) {
$numericPrice = (float) $price;
if ($numericPrice > 0) {
// 转换为浮点数并保留两位小数
$cleanedPrices[$period] = round($numericPrice, 2);
}
}
}
// 更新请求中的价格数据
$this->merge(['prices' => $cleanedPrices]);
}
/**
* Get custom error messages for validator errors.
*/
public function messages(): array
{
return [
'name.required' => '套餐名称不能为空',
'type.required' => '套餐类型不能为空',
'type.in' => '套餐类型格式有误',
'group_id.required' => '权限组不能为空',
'transfer_enable.required' => '流量不能为空',
'month_price.integer' => '月付金额格式有误',
'quarter_price.integer' => '季付金额格式有误',
'half_year_price.integer' => '半年付金额格式有误',
'year_price.integer' => '年付金额格式有误',
'two_year_price.integer' => '两年付金额格式有误',
'three_year_price.integer' => '三年付金额格式有误',
'onetime_price.integer' => '一次性金额有误',
'reset_price.integer' => '流量重置包金额有误',
'reset_traffic_method.integer' => '流量重置方式格式有误',
'reset_traffic_method.in' => '流量重置方式格式有误',
'capacity_limit.integer' => '容纳用户量限制格式有误',
'speed_limit.integer' => '限速格式有误'
'name.max' => '套餐名称不能超过 255 个字符',
'transfer_enable.required' => '流量配额不能为空',
'transfer_enable.integer' => '流量配额必须是整数',
'transfer_enable.min' => '流量配额必须大于 0',
'prices.array' => '价格配置格式错误',
'prices.*.numeric' => '价格必须是数字',
'prices.*.min' => '价格不能为负数',
'group_id.integer' => '权限组ID必须是整数',
'speed_limit.integer' => '速度限制必须是整数',
'speed_limit.min' => '速度限制不能为负数',
'device_limit.integer' => '设备限制必须是整数',
'device_limit.min' => '设备限制不能为负数',
'capacity_limit.integer' => '容量限制必须是整数',
'capacity_limit.min' => '容量限制不能为负数',
];
}
/**
* Handle a failed validation attempt.
*/
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(
response()->json([
'data' => false,
'message' => $validator->errors()->first(),
'errors' => $validator->errors()->toArray()
], 422)
);
}
}

View File

@ -16,6 +16,7 @@ use App\Http\Controllers\V2\Admin\KnowledgeController;
use App\Http\Controllers\V2\Admin\PaymentController;
use App\Http\Controllers\V2\Admin\SystemController;
use App\Http\Controllers\V2\Admin\ThemeController;
use App\Http\Controllers\V2\Admin\TrafficResetController;
use Illuminate\Contracts\Routing\Registrar;
class AdminRoute
@ -229,6 +230,16 @@ class AdminRoute
$router->get('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'getConfig']);
$router->post('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'updateConfig']);
});
// 流量重置管理
$router->group([
'prefix' => 'traffic-reset'
], function ($router) {
$router->get('logs', [TrafficResetController::class, 'logs']);
$router->get('stats', [TrafficResetController::class, 'stats']);
$router->get('user/{userId}/history', [TrafficResetController::class, 'userHistory']);
$router->post('reset-user', [TrafficResetController::class, 'resetUser']);
});
});
}

View File

@ -88,6 +88,14 @@ class Order extends Model
return $this->belongsTo(User::class, 'user_id', 'id');
}
/**
* 获取邀请人
*/
public function invite_user(): BelongsTo
{
return $this->belongsTo(User::class, 'invite_user_id', 'id');
}
/**
* 获取与订单关联的套餐
*/

View File

@ -115,72 +115,6 @@ class Plan extends Model
];
}
/**
* 获取下一次流量重置时间
*
* @param Carbon|null $from 计算起始时间,默认为当前时间
* @return Carbon|null 下次重置时间,如果不重置则返回null
*/
public function getNextResetTime(?Carbon $from = null): ?Carbon
{
$from = $from ?? Carbon::now();
switch ($this->reset_traffic_method) {
case self::RESET_TRAFFIC_FIRST_DAY_MONTH:
return $from->copy()->addMonth()->startOfMonth();
case self::RESET_TRAFFIC_MONTHLY:
return $from->copy()->addMonth()->startOfDay();
case self::RESET_TRAFFIC_FIRST_DAY_YEAR:
return $from->copy()->addYear()->startOfYear();
case self::RESET_TRAFFIC_YEARLY:
return $from->copy()->addYear()->startOfDay();
case self::RESET_TRAFFIC_NEVER:
return null;
case self::RESET_TRAFFIC_FOLLOW_SYSTEM:
default:
// 这里需要实现获取系统设置的逻辑
// 可以通过系统配置或其他方式获取
return null;
}
}
/**
* 检查是否需要重置流量
*
* @param Carbon|null $checkTime 检查时间点,默认为当前时间
* @return bool
*/
public function shouldResetTraffic(?Carbon $checkTime = null): bool
{
if ($this->reset_traffic_method === self::RESET_TRAFFIC_NEVER) {
return false;
}
$checkTime = $checkTime ?? Carbon::now();
$nextResetTime = $this->getNextResetTime($checkTime);
if ($nextResetTime === null) {
return false;
}
return $checkTime->greaterThanOrEqualTo($nextResetTime);
}
/**
* 获取流量重置方式的描述
*
* @return string
*/
public function getResetTrafficMethodName(): string
{
return self::getResetTrafficMethods()[$this->reset_traffic_method] ?? '未知';
}
/**
* 获取所有可用的订阅周期
*

View File

@ -12,5 +12,6 @@ class ServerRoute extends Model
protected $casts = [
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
'match' => 'array'
];
}

View File

@ -0,0 +1,147 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 流量重置记录模型
*
* @property int $id
* @property int $user_id 用户ID
* @property string $reset_type 重置类型
* @property \Carbon\Carbon $reset_time 重置时间
* @property int $old_upload 重置前上传流量
* @property int $old_download 重置前下载流量
* @property int $old_total 重置前总流量
* @property int $new_upload 重置后上传流量
* @property int $new_download 重置后下载流量
* @property int $new_total 重置后总流量
* @property string $trigger_source 触发来源
* @property array|null $metadata 额外元数据
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*
* @property-read User $user 关联用户
*/
class TrafficResetLog extends Model
{
protected $table = 'v2_traffic_reset_logs';
protected $fillable = [
'user_id',
'reset_type',
'reset_time',
'old_upload',
'old_download',
'old_total',
'new_upload',
'new_download',
'new_total',
'trigger_source',
'metadata',
];
protected $casts = [
'reset_time' => 'datetime',
'metadata' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// 重置类型常量
public const TYPE_MONTHLY = 'monthly';
public const TYPE_FIRST_DAY_MONTH = 'first_day_month';
public const TYPE_YEARLY = 'yearly';
public const TYPE_FIRST_DAY_YEAR = 'first_day_year';
public const TYPE_MANUAL = 'manual';
public const TYPE_PURCHASE = 'purchase';
// 触发来源常量
public const SOURCE_AUTO = 'auto';
public const SOURCE_MANUAL = 'manual';
public const SOURCE_API = 'api';
public const SOURCE_CRON = 'cron';
public const SOURCE_USER_ACCESS = 'user_access';
/**
* 获取重置类型的多语言名称
*/
public static function getResetTypeNames(): array
{
return [
self::TYPE_MONTHLY => __('traffic_reset.reset_type.monthly'),
self::TYPE_FIRST_DAY_MONTH => __('traffic_reset.reset_type.first_day_month'),
self::TYPE_YEARLY => __('traffic_reset.reset_type.yearly'),
self::TYPE_FIRST_DAY_YEAR => __('traffic_reset.reset_type.first_day_year'),
self::TYPE_MANUAL => __('traffic_reset.reset_type.manual'),
self::TYPE_PURCHASE => __('traffic_reset.reset_type.purchase'),
];
}
/**
* 获取触发来源的多语言名称
*/
public static function getSourceNames(): array
{
return [
self::SOURCE_AUTO => __('traffic_reset.source.auto'),
self::SOURCE_MANUAL => __('traffic_reset.source.manual'),
self::SOURCE_API => __('traffic_reset.source.api'),
self::SOURCE_CRON => __('traffic_reset.source.cron'),
self::SOURCE_USER_ACCESS => __('traffic_reset.source.user_access'),
];
}
/**
* 关联用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
/**
* 获取重置类型名称
*/
public function getResetTypeName(): string
{
return self::getResetTypeNames()[$this->reset_type] ?? $this->reset_type;
}
/**
* 获取触发来源名称
*/
public function getSourceName(): string
{
return self::getSourceNames()[$this->trigger_source] ?? $this->trigger_source;
}
/**
* 获取重置的流量差值
*/
public function getTrafficDiff(): array
{
return [
'upload_diff' => $this->new_upload - $this->old_upload,
'download_diff' => $this->new_download - $this->old_download,
'total_diff' => $this->new_total - $this->old_total,
];
}
/**
* 格式化流量大小
*/
public function formatTraffic(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}
}

View File

@ -37,6 +37,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $last_login_at 最后登录时间
* @property int|null $parent_id 父账户ID
* @property int|null $is_admin 是否管理员
* @property \Carbon\Carbon|null $next_reset_at 下次流量重置时间
* @property \Carbon\Carbon|null $last_reset_at 上次流量重置时间
* @property int $reset_count 流量重置次数
* @property int $created_at
* @property int $updated_at
* @property bool $commission_auto_check 是否自动计算佣金
@ -48,6 +51,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property-read \Illuminate\Database\Eloquent\Collection<int, Order> $orders 订单列表
* @property-read \Illuminate\Database\Eloquent\Collection<int, StatUser> $stat 统计信息
* @property-read \Illuminate\Database\Eloquent\Collection<int, Ticket> $tickets 工单列表
* @property-read \Illuminate\Database\Eloquent\Collection<int, TrafficResetLog> $trafficResetLogs 流量重置记录
* @property-read User|null $parent 父账户
* @property-read string $subscribe_url 订阅链接(动态生成)
*/
@ -64,7 +68,9 @@ class User extends Authenticatable
'remind_expire' => 'boolean',
'remind_traffic' => 'boolean',
'commission_auto_check' => 'boolean',
'commission_rate' => 'float'
'commission_rate' => 'float',
'next_reset_at' => 'timestamp',
'last_reset_at' => 'timestamp',
];
protected $hidden = ['password'];
@ -72,7 +78,6 @@ class User extends Authenticatable
public const COMMISSION_TYPE_PERIOD = 1;
public const COMMISSION_TYPE_ONETIME = 2;
// 获取邀请人信息
public function invite_user(): BelongsTo
{
@ -120,6 +125,14 @@ class User extends Authenticatable
return $this->belongsTo(self::class, 'parent_id', 'id');
}
/**
* 关联流量重置记录
*/
public function trafficResetLogs(): HasMany
{
return $this->hasMany(TrafficResetLog::class, 'user_id', 'id');
}
/**
* 获取订阅链接属性
*/
@ -127,4 +140,56 @@ class User extends Authenticatable
{
return Helper::getSubscribeUrl($this->token);
}
/**
* 检查用户是否处于活跃状态
*/
public function isActive(): bool
{
return !$this->banned &&
($this->expired_at === null || $this->expired_at > time()) &&
$this->plan_id !== null;
}
/**
* 检查是否需要重置流量
*/
public function shouldResetTraffic(): bool
{
return $this->isActive() &&
$this->next_reset_at !== null &&
$this->next_reset_at <= time();
}
/**
* 获取总使用流量
*/
public function getTotalUsedTraffic(): int
{
return ($this->u ?? 0) + ($this->d ?? 0);
}
/**
* 获取剩余流量
*/
public function getRemainingTraffic(): int
{
$used = $this->getTotalUsedTraffic();
$total = $this->transfer_enable ?? 0;
return max(0, $total - $used);
}
/**
* 获取流量使用百分比
*/
public function getTrafficUsagePercentage(): float
{
$total = $this->transfer_enable ?? 0;
if ($total <= 0) {
return 0;
}
$used = $this->getTotalUsedTraffic();
return min(100, ($used / $total) * 100);
}
}

View File

@ -0,0 +1,93 @@
<?php
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;
}
/**
* 监听用户更新事件
* 当 expired_at 或 plan_id 发生变化时,重新计算下次重置时间
*/
public function updating(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->next_reset_at = $nextResetTime->timestamp;
} else {
// 如果计算结果为空,清除重置时间
$user->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(),
]);
}
}
}
}

View File

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

View File

@ -8,6 +8,7 @@ use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderService
{
@ -67,7 +68,7 @@ class OrderService
}
$this->setSpeedLimit($plan->speed_limit);
$this->setDeviceLimit($plan->device_limit);
$this->setDeviceLimit($plan->device_limit);
if (!$this->user->save()) {
throw new \Exception('用户信息保存失败');
@ -79,7 +80,7 @@ class OrderService
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
\Log::error($e);
Log::error($e);
throw new ApiException('开通失败');
}
}
@ -238,7 +239,7 @@ class OrderService
try {
OrderHandleJob::dispatchSync($order->trade_no);
} catch (\Exception $e) {
\Log::error($e);
Log::error($e);
return false;
}
return true;
@ -305,7 +306,7 @@ class OrderService
private function buyByOneTime(Plan $plan)
{
$this->buyByResetTraffic();
$this->user->transfer_enable = $plan->transfer_enable * 1073741824;
$this->user->transfer_enable += $plan->transfer_enable * 1073741824;
$this->user->plan_id = $plan->id;
$this->user->group_id = $plan->group_id;
$this->user->expired_at = NULL;

View File

@ -0,0 +1,461 @@
<?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;
/**
* 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);
Log::info(__('traffic_reset.reset_success'), [
'user_id' => $user->id,
'email' => $user->email,
'old_traffic' => $oldTotal,
'trigger_source' => $triggerSource,
]);
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();
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),
Plan::RESET_TRAFFIC_NEVER => null,
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);
$resetDay = $expiredAt->day;
return $this->getNextResetByDay($from, $resetDay);
}
/**
* Get the next reset time based on a specific day of the month.
*/
private function getNextResetByDay(Carbon $from, int $targetDay): Carbon
{
$currentMonthTarget = $this->getValidDayInMonth($from->copy(), $targetDay);
if ($currentMonthTarget->timestamp > $from->timestamp) {
return $currentMonthTarget;
}
$nextMonth = $from->copy()->addMonth();
return $this->getValidDayInMonth($nextMonth, $targetDay);
}
/**
* Get a valid day in a given month, handling non-existent dates.
*/
private function getValidDayInMonth(Carbon $month, int $targetDay): Carbon
{
$lastDayOfMonth = $month->copy()->endOfMonth()->day;
if ($targetDay > $lastDayOfMonth) {
return $month->endOfMonth()->startOfDay();
}
return $month->day($targetDay)->startOfDay();
}
/**
* 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);
$currentYearTarget = $this->getValidYearDate($from->copy(), $expiredAt);
if ($currentYearTarget->timestamp > $from->timestamp) {
return $currentYearTarget;
}
return $this->getValidYearDate($from->copy()->addYear(), $expiredAt);
}
/**
* Get a valid date in a given year, handling leap year cases for Feb 29th.
*/
private function getValidYearDate(Carbon $year, Carbon $expiredAt): Carbon
{
$target = $year->month($expiredAt->month)->day($expiredAt->day)->startOfDay();
if ($expiredAt->month === 2 && $expiredAt->day === 29 && !$target->isLeapYear()) {
$target->day(28);
}
return $target;
}
/**
* 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,
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;
Log::info('Starting batch traffic reset task.', [
'batch_size' => $batchSize,
'start_time' => now()->toDateTimeString(),
]);
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;
}
$batchStartTime = microtime(true);
$batchResetCount = 0;
$batchErrors = [];
if ($progressCallback) {
$progressCallback([
'batch_number' => $batchNumber,
'batch_size' => $users->count(),
'total_processed' => $totalProcessedCount,
]);
}
Log::info("Processing batch #{$batchNumber}", [
'batch_number' => $batchNumber,
'batch_size' => $users->count(),
'total_processed' => $totalProcessedCount,
'id_range' => $users->first()->id . '-' . $users->last()->id,
]);
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;
}
}
$batchDuration = round(microtime(true) - $batchStartTime, 2);
Log::info("Batch #{$batchNumber} processing complete", [
'batch_number' => $batchNumber,
'processed_count' => $users->count(),
'reset_count' => $batchResetCount,
'error_count' => count($batchErrors),
'duration' => $batchDuration,
'last_processed_id' => $lastProcessedId,
]);
$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(),
];
Log::info('Batch traffic reset task completed', $result);
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);
}
}

View File

@ -9,76 +9,37 @@ use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Services\TrafficResetService;
use App\Models\TrafficResetLog;
class UserService
{
private function calcResetDayByMonthFirstDay(): int
{
$today = (int) date('d');
$lastDay = (int) date('d', strtotime('last day of +0 months'));
return $lastDay - $today;
}
private function calcResetDayByExpireDay(int $expiredAt)
{
$day = (int) date('d', $expiredAt);
$today = (int) date('d');
$lastDay = (int) date('d', strtotime('last day of +0 months'));
if ($day >= $today && $day >= $lastDay) {
return $lastDay - $today;
}
if ($day >= $today) {
return $day - $today;
}
return $lastDay - $today + $day;
}
private function calcResetDayByYearFirstDay(): int
{
$nextYear = strtotime(date("Y-01-01", strtotime('+1 year')));
return (int) (($nextYear - time()) / 86400);
}
private function calcResetDayByYearExpiredAt(int $expiredAt): int
{
$md = date('m-d', $expiredAt);
$nowYear = strtotime(date("Y-{$md}"));
$nextYear = strtotime('+1 year', $nowYear);
if ($nowYear > time()) {
return (int) (($nowYear - time()) / 86400);
}
return (int) (($nextYear - time()) / 86400);
}
/**
* Get the remaining days until the next traffic reset for a user.
* This method reuses the TrafficResetService logic for consistency.
*/
public function getResetDay(User $user): ?int
{
// 前置条件检查
if ($user->expired_at <= time() || $user->expired_at === null) {
return null;
}
// 获取重置方式逻辑统一
$resetMethod = $user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM
? (int)admin_setting('reset_traffic_method', 0)
: $user->plan->reset_traffic_method;
// 验证重置方式有效性
if (!in_array($resetMethod, array_keys(Plan::getResetTrafficMethods()), true)) {
return null;
}
// 方法映射表
$methodHandlers = [
Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => fn() => $this->calcResetDayByMonthFirstDay(),
Plan::RESET_TRAFFIC_MONTHLY => fn() => $this->calcResetDayByExpireDay($user->expired_at),
Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => fn() => $this->calcResetDayByYearFirstDay(),
Plan::RESET_TRAFFIC_YEARLY => fn() => $this->calcResetDayByYearExpiredAt($user->expired_at),
];
$handler = $methodHandlers[$resetMethod] ?? null;
// Use TrafficResetService to calculate the next reset time
$trafficResetService = app(TrafficResetService::class);
$nextResetTime = $trafficResetService->calculateNextResetTime($user);
return $handler ? $handler() : null;
if (!$nextResetTime) {
return null;
}
// Calculate the remaining days from now to the next reset time
$now = time();
$resetTimestamp = $nextResetTime->timestamp;
if ($resetTimestamp <= $now) {
return 0; // Reset time has passed or is now
}
// Calculate the difference in days (rounded up)
$daysDifference = ceil(($resetTimestamp - $now) / 86400);
return (int) $daysDifference;
}
public function isAvailable(User $user)
@ -165,4 +126,28 @@ class UserService
StatServerJob::dispatch($server, $chunk->toArray(), $protocol, 'd');
});
}
/**
* 获取用户流量信息(增加重置检查)
*/
public function getUserTrafficInfo(User $user): array
{
// 检查是否需要重置流量
app(TrafficResetService::class)->checkAndReset($user, TrafficResetLog::SOURCE_USER_ACCESS);
// 重新获取用户数据(可能已被重置)
$user->refresh();
return [
'upload' => $user->u ?? 0,
'download' => $user->d ?? 0,
'total_used' => $user->getTotalUsedTraffic(),
'total_available' => $user->transfer_enable ?? 0,
'remaining' => $user->getRemainingTraffic(),
'usage_percentage' => $user->getTrafficUsagePercentage(),
'next_reset_at' => $user->next_reset_at?->timestamp,
'last_reset_at' => $user->last_reset_at?->timestamp,
'reset_count' => $user->reset_count,
];
}
}

View File

@ -132,7 +132,7 @@ class Setting
CASE_LOWER
);
});
// 处理JSON格式的值
foreach ($settings as $key => $value) {
if (is_string($value) && $value !== null) {

View File

@ -202,4 +202,13 @@ class Helper
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
return strtr(rawurlencode($str), $revert);
}
public static function getEmailSuffix(): array|bool
{
$suffix = admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT);
if (!is_array($suffix)) {
return preg_split('/,/', $suffix);
}
return $suffix;
}
}

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTrafficResetLogsTable extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v2_traffic_reset_logs', function (Blueprint $table) {
$table->id();
$table->bigInteger('user_id')->comment('用户ID');
$table->string('reset_type', 50)->comment('重置类型');
$table->timestamp('reset_time')->comment('重置时间');
$table->bigInteger('old_upload')->default(0)->comment('重置前上传流量');
$table->bigInteger('old_download')->default(0)->comment('重置前下载流量');
$table->bigInteger('old_total')->default(0)->comment('重置前总流量');
$table->bigInteger('new_upload')->default(0)->comment('重置后上传流量');
$table->bigInteger('new_download')->default(0)->comment('重置后下载流量');
$table->bigInteger('new_total')->default(0)->comment('重置后总流量');
$table->string('trigger_source', 50)->comment('触发来源');
$table->json('metadata')->nullable()->comment('额外元数据');
$table->timestamps();
// 添加索引
$table->index('user_id', 'idx_user_id');
$table->index('reset_time', 'idx_reset_time');
$table->index(['user_id', 'reset_time'], 'idx_user_reset_time');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v2_traffic_reset_logs');
}
}

View File

@ -0,0 +1,100 @@
<?php
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;
class AddTrafficResetFieldsToUsers extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
ini_set('memory_limit', '-1');
if (!Schema::hasColumn('v2_user', 'next_reset_at')) {
Schema::table('v2_user', function (Blueprint $table) {
$table->integer('next_reset_at')->nullable()->after('expired_at')->comment('下次流量重置时间');
$table->integer('last_reset_at')->nullable()->after('next_reset_at')->comment('上次流量重置时间');
$table->integer('reset_count')->default(0)->after('last_reset_at')->comment('流量重置次数');
$table->index('next_reset_at', 'idx_next_reset_at');
});
}
// 为现有用户设置初始重置时间
$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) {
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v2_user', function (Blueprint $table) {
$table->dropIndex('idx_next_reset_at');
$table->dropColumn(['next_reset_at', 'last_reset_at', 'reset_count']);
});
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -162,6 +162,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"orderManagement": "Order Management",
"couponManagement": "Coupon Management",
"userManagement": "User Management",
"trafficResetLogs": "Traffic Reset Logs",
"ticketManagement": "Ticket Management"
},
"plugin": {
@ -1216,6 +1217,8 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"basicInfo": "Basic Information",
"amountInfo": "Amount Information",
"timeInfo": "Time Information",
"commissionInfo": "Commission Information",
"commissionStatusActive": "Active",
"addOrder": "Add Order",
"assignOrder": "Assign Order",
"fields": {
@ -1229,7 +1232,12 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"refundAmount": "Refund Amount",
"deductionAmount": "Deduction Amount",
"createdAt": "Created At",
"updatedAt": "Updated At"
"updatedAt": "Updated At",
"commissionStatus": "Commission Status",
"commissionAmount": "Commission Amount",
"actualCommissionAmount": "Actual Commission",
"inviteUser": "Inviter",
"inviteUserId": "Inviter ID"
},
"placeholders": {
"email": "Please enter user email",
@ -2000,6 +2008,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"orders": "Orders",
"invites": "Invites",
"traffic_records": "Traffic Records",
"reset_traffic": "Reset Traffic",
"delete": "Delete",
"delete_confirm_title": "Confirm Delete User",
"delete_confirm_description": "This action will permanently delete user {{email}} and all associated data, including orders, coupons, traffic records, and support tickets. This action cannot be undone. Do you want to continue?"
@ -2136,6 +2145,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"title": "Actions",
"send_email": "Send Email",
"export_csv": "Export CSV",
"traffic_reset_stats": "Traffic Reset Stats",
"batch_ban": "Batch Ban",
"confirm_ban": {
"title": "Confirm Batch Ban",
@ -2163,6 +2173,111 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"required_fields": "Please fill in all required fields"
}
},
"traffic_reset": {
"title": "Traffic Reset",
"description": "Reset traffic usage for user {{email}}",
"tabs": {
"reset": "Reset Traffic",
"history": "Reset History"
},
"user_info": "User Information",
"warning": {
"title": "Important Notice",
"irreversible": "Traffic reset operation is irreversible, please proceed with caution",
"reset_to_zero": "After reset, user's upload and download traffic will be cleared to zero",
"logged": "All reset operations will be logged in the system"
},
"reason": {
"label": "Reset Reason",
"placeholder": "Please enter the reason for traffic reset (optional)",
"optional": "This field is optional and used to record the reason for reset"
},
"confirm_reset": "Confirm Reset",
"resetting": "Resetting...",
"reset_success": "Traffic reset successful",
"reset_failed": "Traffic reset failed",
"history": {
"summary": "Reset Overview",
"reset_count": "Reset Count",
"last_reset": "Last Reset",
"next_reset": "Next Reset",
"never": "Never Reset",
"no_schedule": "No Scheduled Reset",
"records": "Reset Records",
"recent_records": "Recent 10 Reset Records",
"no_records": "No reset records",
"reset_time": "Reset Time",
"traffic_cleared": "Traffic Cleared"
},
"stats": {
"title": "Traffic Reset Statistics",
"description": "View system traffic reset statistics",
"time_range": "Statistics Time Range",
"total_resets": "Total Resets",
"auto_resets": "Auto Resets",
"manual_resets": "Manual Resets",
"cron_resets": "Cron Resets",
"in_period": "In the last {{days}} days",
"breakdown": "Reset Type Breakdown",
"breakdown_description": "Percentage breakdown of different reset operation types",
"auto_percentage": "Auto Reset Percentage",
"manual_percentage": "Manual Reset Percentage",
"cron_percentage": "Cron Reset Percentage",
"days_options": {
"week": "Last Week",
"month": "Last Month",
"quarter": "Last Quarter",
"year": "Last Year"
}
}
},
"traffic_reset_logs": {
"title": "Traffic Reset Logs",
"description": "View detailed records of all traffic reset operations in the system",
"columns": {
"id": "Log ID",
"user": "User",
"reset_type": "Reset Type",
"trigger_source": "Trigger Source",
"cleared_traffic": "Cleared Traffic",
"cleared": "Cleared",
"upload": "Upload",
"download": "Download",
"reset_time": "Reset Time",
"log_time": "Log Time"
},
"filters": {
"search_user": "Search user email...",
"reset_type": "Reset Type",
"trigger_source": "Trigger Source",
"all_types": "All Types",
"all_sources": "All Sources",
"start_date": "Start Date",
"end_date": "End Date",
"apply_date": "Apply Filter",
"reset": "Reset Filter",
"filter_title": "Filter Options",
"filter_description": "Set filter conditions to find specific traffic reset records",
"reset_types": {
"monthly": "Monthly Reset",
"first_day_month": "First Day of Month Reset",
"yearly": "Yearly Reset",
"first_day_year": "First Day of Year Reset",
"manual": "Manual Reset"
},
"trigger_sources": {
"auto": "Auto Trigger",
"manual": "Manual Trigger",
"cron": "Cron Job"
}
},
"actions": {
"export": "Export Logs",
"exporting": "Exporting...",
"export_success": "Export successful",
"export_failed": "Export failed"
}
},
"send_mail": {
"title": "Send Email",
"description": "Send email to selected or filtered users",

View File

@ -162,6 +162,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"orderManagement": "주문 관리",
"couponManagement": "쿠폰 관리",
"userManagement": "사용자 관리",
"trafficResetLogs": "트래픽 재설정 로그",
"ticketManagement": "티켓 관리"
},
"plugin": {
@ -1232,6 +1233,8 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"basicInfo": "기본 정보",
"amountInfo": "금액 정보",
"timeInfo": "시간 정보",
"commissionInfo": "수수료 정보",
"commissionStatusActive": "활성",
"addOrder": "주문 추가",
"assignOrder": "주문 할당",
"fields": {
@ -1245,7 +1248,12 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"refundAmount": "환불 금액",
"deductionAmount": "차감 금액",
"createdAt": "생성 시간",
"updatedAt": "업데이트 시간"
"updatedAt": "업데이트 시간",
"commissionStatus": "수수료 상태",
"commissionAmount": "수수료 금액",
"actualCommissionAmount": "실제 수수료",
"inviteUser": "초대자",
"inviteUserId": "초대자 ID"
},
"placeholders": {
"email": "사용자 이메일을 입력해주세요",
@ -1969,6 +1977,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"orders": "주문 내역",
"invites": "초대 내역",
"traffic_records": "트래픽 기록",
"reset_traffic": "트래픽 재설정",
"delete": "삭제",
"delete_confirm_title": "사용자 삭제 확인",
"delete_confirm_description": "이 작업은 사용자 {{email}}와 관련된 모든 데이터(주문, 쿠폰, 트래픽 기록, 지원 티켓 등)를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다. 계속하시겠습니까?"
@ -2105,6 +2114,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"title": "작업",
"send_email": "이메일 보내기",
"export_csv": "CSV 내보내기",
"traffic_reset_stats": "트래픽 재설정 통계",
"batch_ban": "일괄 차단",
"confirm_ban": {
"title": "일괄 차단 확인",
@ -2132,6 +2142,111 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"required_fields": "모든 필수 항목을 입력해주세요"
}
},
"traffic_reset": {
"title": "트래픽 재설정",
"description": "사용자 {{email}}의 트래픽 사용량 재설정",
"tabs": {
"reset": "트래픽 재설정",
"history": "재설정 기록"
},
"user_info": "사용자 정보",
"warning": {
"title": "중요 안내",
"irreversible": "트래픽 재설정 작업은 되돌릴 수 없습니다. 신중하게 진행해주세요",
"reset_to_zero": "재설정 후 사용자의 업로드 및 다운로드 트래픽이 0으로 초기화됩니다",
"logged": "모든 재설정 작업은 시스템 로그에 기록됩니다"
},
"reason": {
"label": "재설정 사유",
"placeholder": "트래픽 재설정 사유를 입력해주세요 (선택사항)",
"optional": "이 필드는 선택사항이며 재설정 사유를 기록하는 데 사용됩니다"
},
"confirm_reset": "재설정 확인",
"resetting": "재설정 중...",
"reset_success": "트래픽 재설정 성공",
"reset_failed": "트래픽 재설정 실패",
"history": {
"summary": "재설정 개요",
"reset_count": "재설정 횟수",
"last_reset": "마지막 재설정",
"next_reset": "다음 재설정",
"never": "재설정된 적 없음",
"no_schedule": "예약된 재설정 없음",
"records": "재설정 기록",
"recent_records": "최근 10번의 재설정 기록",
"no_records": "재설정 기록이 없습니다",
"reset_time": "재설정 시간",
"traffic_cleared": "삭제된 트래픽"
},
"stats": {
"title": "트래픽 재설정 통계",
"description": "시스템 트래픽 재설정 통계 정보 보기",
"time_range": "통계 시간 범위",
"total_resets": "총 재설정 횟수",
"auto_resets": "자동 재설정",
"manual_resets": "수동 재설정",
"cron_resets": "예약 재설정",
"in_period": "최근 {{days}}일",
"breakdown": "재설정 유형별 분석",
"breakdown_description": "다양한 재설정 작업 유형의 백분율 분석",
"auto_percentage": "자동 재설정 비율",
"manual_percentage": "수동 재설정 비율",
"cron_percentage": "예약 재설정 비율",
"days_options": {
"week": "지난 주",
"month": "지난 달",
"quarter": "지난 분기",
"year": "지난 해"
}
}
},
"traffic_reset_logs": {
"title": "트래픽 재설정 로그",
"description": "시스템의 모든 트래픽 재설정 작업에 대한 상세 기록 보기",
"columns": {
"id": "로그 ID",
"user": "사용자",
"reset_type": "재설정 유형",
"trigger_source": "트리거 소스",
"cleared_traffic": "삭제된 트래픽",
"cleared": "삭제됨",
"upload": "업로드",
"download": "다운로드",
"reset_time": "재설정 시간",
"log_time": "로그 시간"
},
"filters": {
"search_user": "사용자 이메일 검색...",
"reset_type": "재설정 유형",
"trigger_source": "트리거 소스",
"all_types": "모든 유형",
"all_sources": "모든 소스",
"start_date": "시작 날짜",
"end_date": "종료 날짜",
"apply_date": "필터 적용",
"reset": "필터 초기화",
"filter_title": "필터 옵션",
"filter_description": "특정 트래픽 재설정 기록을 찾기 위한 필터 조건을 설정하세요",
"reset_types": {
"monthly": "월별 재설정",
"first_day_month": "매월 1일 재설정",
"yearly": "연별 재설정",
"first_day_year": "매년 1월 1일 재설정",
"manual": "수동 재설정"
},
"trigger_sources": {
"auto": "자동 트리거",
"manual": "수동 트리거",
"cron": "예약 작업"
}
},
"actions": {
"export": "로그 내보내기",
"exporting": "내보내는 중...",
"export_success": "내보내기 성공",
"export_failed": "내보내기 실패"
}
},
"send_mail": {
"title": "이메일 보내기",
"description": "선택하거나 필터링된 사용자에게 이메일 보내기",

View File

@ -162,7 +162,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"orderManagement": "订单管理",
"couponManagement": "优惠券管理",
"userManagement": "用户管理",
"ticketManagement": "工单管理"
"ticketManagement": "工单管理",
"trafficResetLogs": "流量重置日志"
},
"plugin": {
"title": "插件管理",
@ -1208,6 +1209,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"basicInfo": "基本信息",
"amountInfo": "金额信息",
"timeInfo": "时间信息",
"commissionInfo": "佣金信息",
"commissionStatusActive": "有效",
"addOrder": "添加订单",
"assignOrder": "订单分配",
"fields": {
@ -1221,7 +1224,12 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"refundAmount": "退回金额",
"deductionAmount": "折抵金额",
"createdAt": "创建时间",
"updatedAt": "更新时间"
"updatedAt": "更新时间",
"commissionStatus": "佣金状态",
"commissionAmount": "佣金金额",
"actualCommissionAmount": "实际佣金",
"inviteUser": "邀请人",
"inviteUserId": "邀请人ID"
},
"placeholders": {
"email": "请输入用户邮箱",
@ -1963,6 +1971,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"assign_order": "分配订单",
"copy_url": "复制订阅URL",
"reset_secret": "重置UUID及订阅URL",
"reset_traffic": "重置流量",
"orders": "TA的订单",
"invites": "TA的邀请",
"traffic_records": "TA的流量记录",
@ -2102,6 +2111,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"title": "操作",
"send_email": "发送邮件",
"export_csv": "导出 CSV",
"traffic_reset_stats": "流量重置统计",
"batch_ban": "批量封禁",
"confirm_ban": {
"title": "确认批量封禁",
@ -2112,6 +2122,111 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"banning": "封禁中..."
}
},
"traffic_reset": {
"title": "流量重置",
"description": "为用户 {{email}} 重置流量使用量",
"tabs": {
"reset": "重置流量",
"history": "重置历史"
},
"user_info": "用户信息",
"warning": {
"title": "重要提醒",
"irreversible": "流量重置操作不可逆,请谨慎操作",
"reset_to_zero": "重置后用户的上传和下载流量将清零",
"logged": "所有重置操作都会被记录在系统日志中"
},
"reason": {
"label": "重置原因",
"placeholder": "请输入重置流量的原因(可选)",
"optional": "此字段为可选项,用于记录重置原因"
},
"confirm_reset": "确认重置",
"resetting": "重置中...",
"reset_success": "流量重置成功",
"reset_failed": "流量重置失败",
"history": {
"summary": "重置概览",
"reset_count": "重置次数",
"last_reset": "最后重置",
"next_reset": "下次重置",
"never": "从未重置",
"no_schedule": "无定时重置",
"records": "重置记录",
"recent_records": "最近10次重置记录",
"no_records": "暂无重置记录",
"reset_time": "重置时间",
"traffic_cleared": "清除流量"
},
"stats": {
"title": "流量重置统计",
"description": "查看系统流量重置的统计信息",
"time_range": "统计时间范围",
"total_resets": "总重置次数",
"auto_resets": "自动重置",
"manual_resets": "手动重置",
"cron_resets": "定时重置",
"in_period": "最近 {{days}} 天",
"breakdown": "重置类型分布",
"breakdown_description": "各类型重置操作的百分比分布",
"auto_percentage": "自动重置占比",
"manual_percentage": "手动重置占比",
"cron_percentage": "定时重置占比",
"days_options": {
"week": "最近一周",
"month": "最近一月",
"quarter": "最近三月",
"year": "最近一年"
}
}
},
"traffic_reset_logs": {
"title": "流量重置日志",
"description": "查看系统中所有流量重置操作的详细记录",
"columns": {
"id": "日志ID",
"user": "用户",
"reset_type": "重置类型",
"trigger_source": "触发源",
"cleared_traffic": "清除流量",
"cleared": "已清除",
"upload": "上传",
"download": "下载",
"reset_time": "重置时间",
"log_time": "记录时间"
},
"filters": {
"search_user": "搜索用户邮箱...",
"reset_type": "重置类型",
"trigger_source": "触发源",
"all_types": "全部类型",
"all_sources": "全部来源",
"start_date": "开始日期",
"end_date": "结束日期",
"apply_date": "应用筛选",
"reset": "重置筛选",
"filter_title": "筛选条件",
"filter_description": "设置筛选条件来查找特定的流量重置记录",
"reset_types": {
"monthly": "按月重置",
"first_day_month": "每月1号重置",
"yearly": "按年重置",
"first_day_year": "每年1月1日重置",
"manual": "手动重置"
},
"trigger_sources": {
"auto": "自动触发",
"manual": "手动触发",
"cron": "定时任务"
}
},
"actions": {
"export": "导出日志",
"exporting": "导出中...",
"export_success": "导出成功",
"export_failed": "导出失败"
}
},
"messages": {
"success": "成功",
"error": "错误",

View File

@ -7,6 +7,7 @@
"Currency conversion has timed out, please try again later": "Currency conversion has timed out, please try again later",
"Email already exists": "Email already exists",
"Email suffix is not in the Whitelist": "Email suffix is not in the Whitelist",
"Email suffix is not in whitelist": "Email suffix is not in whitelist",
"Email verification code": "Email verification code",
"Email verification code cannot be empty": "Email verification code cannot be empty",
"Email verification code has been sent, please request again later": "Email verification code has been sent, please request again later",
@ -129,5 +130,19 @@
"update.code_update_failed": "Code update failed: :error",
"update.migration_failed": "Database migration failed: :error",
"update.cache_clear_failed": "Cache clearing failed: :error",
"update.flag_create_failed": "Failed to create update flag: :error"
"update.flag_create_failed": "Failed to create update flag: :error",
"traffic_reset.reset_type.monthly": "Monthly Reset",
"traffic_reset.reset_type.first_day_month": "First Day of Month Reset",
"traffic_reset.reset_type.yearly": "Yearly Reset",
"traffic_reset.reset_type.first_day_year": "First Day of Year Reset",
"traffic_reset.reset_type.manual": "Manual Reset",
"traffic_reset.reset_type.purchase": "Purchase Reset Package",
"traffic_reset.source.auto": "Auto Trigger",
"traffic_reset.source.manual": "Manual Trigger",
"traffic_reset.source.api": "API Call",
"traffic_reset.source.cron": "Cron Job",
"traffic_reset.source.user_access": "User Access",
"traffic_reset.reset_success": "Traffic reset successful",
"traffic_reset.reset_failed": "Traffic reset failed, please check logs for details",
"traffic_reset.user_cannot_reset": "User cannot reset traffic (user not activated or no valid plan)"
}

View File

@ -51,6 +51,7 @@
"Invalid coupon": "优惠券无效",
"Invalid code is incorrect": "验证码有误",
"Email suffix is not in the Whitelist": "邮箱后缀不处于白名单中",
"Email suffix is not in whitelist": "邮箱后缀不在白名单中",
"Gmail alias is not supported": "不支持 Gmail 别名邮箱",
"Registration has closed": "本站已关闭注册",
"You must use the invitation code to register": "必须使用邀请码才可以注册",
@ -129,5 +130,19 @@
"update.code_update_failed": "代码更新失败: :error",
"update.migration_failed": "数据库迁移失败: :error",
"update.cache_clear_failed": "缓存清理失败: :error",
"update.flag_create_failed": "创建更新标记失败: :error"
"update.flag_create_failed": "创建更新标记失败: :error",
"traffic_reset.reset_type.monthly": "按月重置",
"traffic_reset.reset_type.first_day_month": "每月1号重置",
"traffic_reset.reset_type.yearly": "按年重置",
"traffic_reset.reset_type.first_day_year": "每年1月1日重置",
"traffic_reset.reset_type.manual": "手动重置",
"traffic_reset.reset_type.purchase": "购买重置包",
"traffic_reset.source.auto": "自动触发",
"traffic_reset.source.manual": "手动触发",
"traffic_reset.source.api": "API调用",
"traffic_reset.source.cron": "定时任务",
"traffic_reset.source.user_access": "用户访问",
"traffic_reset.reset_success": "流量重置成功",
"traffic_reset.reset_failed": "流量重置失败,请查看日志获取详细信息",
"traffic_reset.user_cannot_reset": "该用户当前不能重置流量(用户未激活或无有效套餐)"
}

View File

@ -51,6 +51,7 @@
"Invalid coupon": "優惠券無效",
"Invalid code is incorrect": "驗證碼有誤",
"Email suffix is not in the Whitelist": "郵箱後綴不處於白名單中",
"Email suffix is not in whitelist": "郵箱後綴不在白名單中",
"Gmail alias is not supported": "不支持 Gmail 別名郵箱",
"Registration has closed": "本站已關閉註冊",
"You must use the invitation code to register": "必須使用邀請碼才可以註冊",
@ -129,5 +130,19 @@
"update.code_update_failed": "代碼更新失敗: :error",
"update.migration_failed": "數據庫遷移失敗: :error",
"update.cache_clear_failed": "緩存清理失敗: :error",
"update.flag_create_failed": "創建更新標記失敗: :error"
"update.flag_create_failed": "創建更新標記失敗: :error",
"traffic_reset.reset_type.monthly": "按月重置",
"traffic_reset.reset_type.first_day_month": "每月1號重置",
"traffic_reset.reset_type.yearly": "按年重置",
"traffic_reset.reset_type.first_day_year": "每年1月1日重置",
"traffic_reset.reset_type.manual": "手動重置",
"traffic_reset.reset_type.purchase": "購買重置包",
"traffic_reset.source.auto": "自動觸發",
"traffic_reset.source.manual": "手動觸發",
"traffic_reset.source.api": "API調用",
"traffic_reset.source.cron": "定時任務",
"traffic_reset.source.user_access": "用戶訪問",
"traffic_reset.reset_success": "流量重置成功",
"traffic_reset.reset_failed": "流量重置失敗,請查看日誌獲取詳細信息",
"traffic_reset.user_cannot_reset": "該用戶當前不能重置流量(用戶未激活或無有效套餐)"
}