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:
@@ -2,175 +2,204 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Plan;
|
use App\Services\TrafficResetService;
|
||||||
|
use App\Utils\Helper;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use App\Models\User;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ResetTraffic extends Command
|
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();
|
parent::__construct();
|
||||||
$this->builder = User::where('expired_at', '!=', NULL)
|
$this->trafficResetService = $trafficResetService;
|
||||||
->where('expired_at', '>', time());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行流量重置命令
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle(): int
|
||||||
{
|
{
|
||||||
ini_set('memory_limit', -1);
|
$batchSize = (int) $this->option('batch-size');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$maxTime = (int) $this->option('max-time');
|
||||||
|
|
||||||
// 按重置方法分组查询所有套餐
|
$this->info('🚀 开始执行流量重置任务...');
|
||||||
$resetMethods = Plan::select(
|
$this->info("批次大小: {$batchSize} 用户/批");
|
||||||
DB::raw("GROUP_CONCAT(`id`) as plan_ids"),
|
$this->info("最大执行时间: {$maxTime} 秒");
|
||||||
DB::raw("reset_traffic_method as method")
|
|
||||||
)
|
|
||||||
->groupBy('reset_traffic_method')
|
|
||||||
->get()
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
// 使用闭包直接引用方法
|
if ($dryRun) {
|
||||||
$resetHandlers = [
|
$this->warn('⚠️ 预演模式 - 不会实际执行重置操作');
|
||||||
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),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 处理每种重置方法
|
|
||||||
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) {
|
set_time_limit($maxTime);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取该方法的处理器
|
$startTime = microtime(true);
|
||||||
$handler = $resetHandlers[$method] ?? null;
|
|
||||||
if (!$handler) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建查询构建器并执行重置
|
try {
|
||||||
$userQuery = (clone $this->builder)->whereIn('plan_id', $planIds);
|
if ($dryRun) {
|
||||||
$handler($userQuery);
|
$result = $this->performDryRun($batchSize);
|
||||||
}
|
} else {
|
||||||
}
|
// 使用游标分页和进度回调
|
||||||
|
$result = $this->trafficResetService->batchCheckReset($batchSize, function ($progress) {
|
||||||
/**
|
$this->info("📦 处理第 {$progress['batch_number']} 批 ({$progress['batch_size']} 用户) - 已处理: {$progress['total_processed']}");
|
||||||
* 按用户年度到期日重置流量
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$this->displayResults($result, $dryRun);
|
||||||
* 按新年第一天重置流量
|
|
||||||
*/
|
|
||||||
private function resetByYearFirstDay(Builder $builder): void
|
|
||||||
{
|
|
||||||
$isNewYear = date('md') === '0101';
|
|
||||||
if (!$isNewYear) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->resetAllUsers($builder);
|
return self::SUCCESS;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
} catch (\Exception $e) {
|
||||||
* 按月初第一天重置流量
|
$this->error("❌ 任务执行失败: {$e->getMessage()}");
|
||||||
*/
|
|
||||||
private function resetByMonthFirstDay(Builder $builder): void
|
|
||||||
{
|
|
||||||
$isFirstDayOfMonth = date('d') === '01';
|
|
||||||
if (!$isFirstDayOfMonth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->resetAllUsers($builder);
|
Log::error('流量重置命令执行失败', [
|
||||||
}
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
/**
|
'options' => [
|
||||||
* 按用户到期日重置流量
|
'batch_size' => $batchSize,
|
||||||
*/
|
'dry_run' => $dryRun,
|
||||||
private function resetByExpireDay(Builder $builder): void
|
'max_time' => $maxTime,
|
||||||
{
|
],
|
||||||
$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 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($table)) {
|
||||||
|
$this->info("📋 示例用户列表" . ($totalUsers > 20 ? "(显示前20个):" : ":"));
|
||||||
|
$this->table([
|
||||||
|
'ID',
|
||||||
|
'邮箱',
|
||||||
|
'套餐',
|
||||||
|
'下次重置',
|
||||||
|
'当前流量',
|
||||||
|
'重置次数'
|
||||||
|
], $table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_found' => $totalUsers,
|
||||||
|
'estimated_duration' => $estimatedDuration,
|
||||||
|
'estimated_batches' => $estimatedBatches,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ class Kernel extends ConsoleKernel
|
|||||||
$schedule->command('check:commission')->everyMinute()->onOneServer();
|
$schedule->command('check:commission')->everyMinute()->onOneServer();
|
||||||
$schedule->command('check:ticket')->everyMinute()->onOneServer();
|
$schedule->command('check:ticket')->everyMinute()->onOneServer();
|
||||||
// reset
|
// reset
|
||||||
$schedule->command('reset:traffic')->daily()->onOneServer();
|
$schedule->command('reset:traffic')->everyMinute()->onOneServer();
|
||||||
$schedule->command('reset:log')->daily()->onOneServer();
|
$schedule->command('reset:log')->daily()->onOneServer();
|
||||||
// send
|
// send
|
||||||
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
|
$schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\V1\Guest;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Utils\Dict;
|
use App\Utils\Dict;
|
||||||
|
use App\Utils\Helper;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
class CommController extends Controller
|
class CommController extends Controller
|
||||||
@@ -12,12 +13,12 @@ class CommController extends Controller
|
|||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'tos_url' => admin_setting('tos_url'),
|
'tos_url' => admin_setting('tos_url'),
|
||||||
'is_email_verify' => (int)admin_setting('email_verify', 0) ? 1 : 0,
|
'is_email_verify' => (int) admin_setting('email_verify', 0) ? 1 : 0,
|
||||||
'is_invite_force' => (int)admin_setting('invite_force', 0) ? 1 : 0,
|
'is_invite_force' => (int) admin_setting('invite_force', 0) ? 1 : 0,
|
||||||
'email_whitelist_suffix' => (int)admin_setting('email_whitelist_enable', 0)
|
'email_whitelist_suffix' => (int) admin_setting('email_whitelist_enable', 0)
|
||||||
? $this->getEmailSuffix()
|
? Helper::getEmailSuffix()
|
||||||
: 0,
|
: 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'),
|
'recaptcha_site_key' => admin_setting('recaptcha_site_key'),
|
||||||
'app_description' => admin_setting('app_description'),
|
'app_description' => admin_setting('app_description'),
|
||||||
'app_url' => admin_setting('app_url'),
|
'app_url' => admin_setting('app_url'),
|
||||||
@@ -25,13 +26,4 @@ class CommController extends Controller
|
|||||||
];
|
];
|
||||||
return $this->success($data);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\V1\Passport;
|
namespace App\Http\Controllers\V1\Passport;
|
||||||
|
|
||||||
use App\Exceptions\ApiException;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Passport\CommSendEmailVerify;
|
use App\Http\Requests\Passport\CommSendEmailVerify;
|
||||||
use App\Jobs\SendEmailJob;
|
use App\Jobs\SendEmailJob;
|
||||||
use App\Models\InviteCode;
|
use App\Models\InviteCode;
|
||||||
|
use App\Models\User;
|
||||||
use App\Utils\CacheKey;
|
use App\Utils\CacheKey;
|
||||||
use App\Utils\Dict;
|
use App\Utils\Helper;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use ReCaptcha\ReCaptcha;
|
use ReCaptcha\ReCaptcha;
|
||||||
@@ -25,7 +25,22 @@ class CommController extends Controller
|
|||||||
return $this->fail([400, __('Invalid code is incorrect')]);
|
return $this->fail([400, __('Invalid code is incorrect')]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$email = $request->input('email');
|
$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))) {
|
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')]);
|
return $this->fail([400, __('Email verification code has been sent, please request again later')]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\V1\User;
|
namespace App\Http\Controllers\V1\User;
|
||||||
|
|
||||||
use App\Exceptions\ApiException;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\User\UserChangePassword;
|
use App\Http\Requests\User\UserChangePassword;
|
||||||
use App\Http\Requests\User\UserTransfer;
|
use App\Http\Requests\User\UserTransfer;
|
||||||
@@ -15,7 +14,6 @@ use App\Services\AuthService;
|
|||||||
use App\Services\UserService;
|
use App\Services\UserService;
|
||||||
use App\Utils\CacheKey;
|
use App\Utils\CacheKey;
|
||||||
use App\Utils\Helper;
|
use App\Utils\Helper;
|
||||||
use Auth;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class OrderController extends Controller
|
|||||||
|
|
||||||
public function detail(Request $request)
|
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)
|
if (!$order)
|
||||||
return $this->fail([400202, '订单不存在']);
|
return $this->fail([400202, '订单不存在']);
|
||||||
if ($order->surplus_order_ids) {
|
if ($order->surplus_order_ids) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\V2\Admin;
|
namespace App\Http\Controllers\V2\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\PlanSave;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -32,27 +33,17 @@ class PlanController extends Controller
|
|||||||
return $this->success($plans);
|
return $this->success($plans);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(Request $request)
|
public function save(PlanSave $request)
|
||||||
{
|
{
|
||||||
$params = $request->validate([
|
$params = $request->validated();
|
||||||
'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',
|
|
||||||
]);
|
|
||||||
if ($request->input('id')) {
|
if ($request->input('id')) {
|
||||||
$plan = Plan::find($request->input('id'));
|
$plan = Plan::find($request->input('id'));
|
||||||
if (!$plan) {
|
if (!$plan) {
|
||||||
return $this->fail([400202, '该订阅不存在']);
|
return $this->fail([400202, '该订阅不存在']);
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
// update user group id and transfer
|
|
||||||
try {
|
try {
|
||||||
if ($request->input('force_update')) {
|
if ($request->input('force_update')) {
|
||||||
User::where('plan_id', $plan->id)->update([
|
User::where('plan_id', $plan->id)->update([
|
||||||
|
|||||||
@@ -6,18 +6,13 @@ use App\Exceptions\ApiException;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ServerRoute;
|
use App\Models\ServerRoute;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class RouteController extends Controller
|
class RouteController extends Controller
|
||||||
{
|
{
|
||||||
public function fetch(Request $request)
|
public function fetch(Request $request)
|
||||||
{
|
{
|
||||||
$routes = ServerRoute::get();
|
$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 [
|
return [
|
||||||
'data' => $routes
|
'data' => $routes
|
||||||
];
|
];
|
||||||
@@ -38,15 +33,13 @@ class RouteController extends Controller
|
|||||||
]);
|
]);
|
||||||
$params['match'] = array_filter($params['match']);
|
$params['match'] = array_filter($params['match']);
|
||||||
// TODO: remove on 1.8.0
|
// TODO: remove on 1.8.0
|
||||||
$params['match'] = json_encode($params['match']);
|
|
||||||
// TODO: remove on 1.8.0
|
|
||||||
if ($request->input('id')) {
|
if ($request->input('id')) {
|
||||||
try {
|
try {
|
||||||
$route = ServerRoute::find($request->input('id'));
|
$route = ServerRoute::find($request->input('id'));
|
||||||
$route->update($params);
|
$route->update($params);
|
||||||
return $this->success(true);
|
return $this->success(true);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error($e);
|
Log::error($e);
|
||||||
return $this->fail([500,'保存失败']);
|
return $this->fail([500,'保存失败']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +47,7 @@ class RouteController extends Controller
|
|||||||
ServerRoute::create($params);
|
ServerRoute::create($params);
|
||||||
return $this->success(true);
|
return $this->success(true);
|
||||||
}catch(\Exception $e){
|
}catch(\Exception $e){
|
||||||
\Log::error($e);
|
Log::error($e);
|
||||||
return $this->fail([500,'创建失败']);
|
return $this->fail([500,'创建失败']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,56 +2,154 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Admin;
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||||
|
|
||||||
class PlanSave extends FormRequest
|
class PlanSave extends FormRequest
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Determine if the user is authorized to make this request.
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
public function rules()
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'required',
|
'id' => 'nullable|integer',
|
||||||
'content' => '',
|
'name' => 'required|string|max:255',
|
||||||
'group_id' => 'required',
|
'content' => 'nullable|string',
|
||||||
'transfer_enable' => 'required',
|
'reset_traffic_method' => 'integer|nullable',
|
||||||
'month_price' => 'nullable|integer',
|
'transfer_enable' => 'integer|required|min:1',
|
||||||
'quarter_price' => 'nullable|integer',
|
'prices' => 'nullable|array',
|
||||||
'half_year_price' => 'nullable|integer',
|
'prices.*' => 'nullable|numeric|min:0',
|
||||||
'year_price' => 'nullable|integer',
|
'group_id' => 'integer|nullable',
|
||||||
'two_year_price' => 'nullable|integer',
|
'speed_limit' => 'integer|nullable|min:0',
|
||||||
'three_year_price' => 'nullable|integer',
|
'device_limit' => 'integer|nullable|min:0',
|
||||||
'onetime_price' => 'nullable|integer',
|
'capacity_limit' => 'integer|nullable|min:0',
|
||||||
'reset_price' => 'nullable|integer',
|
|
||||||
'reset_traffic_method' => 'nullable|integer|in:0,1,2,3,4',
|
|
||||||
'capacity_limit' => 'nullable|integer',
|
|
||||||
'speed_limit' => 'nullable|integer'
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [
|
return [
|
||||||
'name.required' => '套餐名称不能为空',
|
'name.required' => '套餐名称不能为空',
|
||||||
'type.required' => '套餐类型不能为空',
|
'name.max' => '套餐名称不能超过 255 个字符',
|
||||||
'type.in' => '套餐类型格式有误',
|
'transfer_enable.required' => '流量配额不能为空',
|
||||||
'group_id.required' => '权限组不能为空',
|
'transfer_enable.integer' => '流量配额必须是整数',
|
||||||
'transfer_enable.required' => '流量不能为空',
|
'transfer_enable.min' => '流量配额必须大于 0',
|
||||||
'month_price.integer' => '月付金额格式有误',
|
'prices.array' => '价格配置格式错误',
|
||||||
'quarter_price.integer' => '季付金额格式有误',
|
'prices.*.numeric' => '价格必须是数字',
|
||||||
'half_year_price.integer' => '半年付金额格式有误',
|
'prices.*.min' => '价格不能为负数',
|
||||||
'year_price.integer' => '年付金额格式有误',
|
'group_id.integer' => '权限组ID必须是整数',
|
||||||
'two_year_price.integer' => '两年付金额格式有误',
|
'speed_limit.integer' => '速度限制必须是整数',
|
||||||
'three_year_price.integer' => '三年付金额格式有误',
|
'speed_limit.min' => '速度限制不能为负数',
|
||||||
'onetime_price.integer' => '一次性金额有误',
|
'device_limit.integer' => '设备限制必须是整数',
|
||||||
'reset_price.integer' => '流量重置包金额有误',
|
'device_limit.min' => '设备限制不能为负数',
|
||||||
'reset_traffic_method.integer' => '流量重置方式格式有误',
|
'capacity_limit.integer' => '容量限制必须是整数',
|
||||||
'reset_traffic_method.in' => '流量重置方式格式有误',
|
'capacity_limit.min' => '容量限制不能为负数',
|
||||||
'capacity_limit.integer' => '容纳用户量限制格式有误',
|
|
||||||
'speed_limit.integer' => '限速格式有误'
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use App\Http\Controllers\V2\Admin\KnowledgeController;
|
|||||||
use App\Http\Controllers\V2\Admin\PaymentController;
|
use App\Http\Controllers\V2\Admin\PaymentController;
|
||||||
use App\Http\Controllers\V2\Admin\SystemController;
|
use App\Http\Controllers\V2\Admin\SystemController;
|
||||||
use App\Http\Controllers\V2\Admin\ThemeController;
|
use App\Http\Controllers\V2\Admin\ThemeController;
|
||||||
|
use App\Http\Controllers\V2\Admin\TrafficResetController;
|
||||||
use Illuminate\Contracts\Routing\Registrar;
|
use Illuminate\Contracts\Routing\Registrar;
|
||||||
|
|
||||||
class AdminRoute
|
class AdminRoute
|
||||||
@@ -229,6 +230,16 @@ class AdminRoute
|
|||||||
$router->get('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'getConfig']);
|
$router->get('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'getConfig']);
|
||||||
$router->post('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'updateConfig']);
|
$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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ class Order extends Model
|
|||||||
return $this->belongsTo(User::class, 'user_id', 'id');
|
return $this->belongsTo(User::class, 'user_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取邀请人
|
||||||
|
*/
|
||||||
|
public function invite_user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'invite_user_id', 'id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取与订单关联的套餐
|
* 获取与订单关联的套餐
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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] ?? '未知';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有可用的订阅周期
|
* 获取所有可用的订阅周期
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ class ServerRoute extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'created_at' => 'timestamp',
|
'created_at' => 'timestamp',
|
||||||
'updated_at' => 'timestamp',
|
'updated_at' => 'timestamp',
|
||||||
|
'match' => 'array'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-2
@@ -37,6 +37,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
* @property int|null $last_login_at 最后登录时间
|
* @property int|null $last_login_at 最后登录时间
|
||||||
* @property int|null $parent_id 父账户ID
|
* @property int|null $parent_id 父账户ID
|
||||||
* @property int|null $is_admin 是否管理员
|
* @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 $created_at
|
||||||
* @property int $updated_at
|
* @property int $updated_at
|
||||||
* @property bool $commission_auto_check 是否自动计算佣金
|
* @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, Order> $orders 订单列表
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, StatUser> $stat 统计信息
|
* @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, Ticket> $tickets 工单列表
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, TrafficResetLog> $trafficResetLogs 流量重置记录
|
||||||
* @property-read User|null $parent 父账户
|
* @property-read User|null $parent 父账户
|
||||||
* @property-read string $subscribe_url 订阅链接(动态生成)
|
* @property-read string $subscribe_url 订阅链接(动态生成)
|
||||||
*/
|
*/
|
||||||
@@ -64,7 +68,9 @@ class User extends Authenticatable
|
|||||||
'remind_expire' => 'boolean',
|
'remind_expire' => 'boolean',
|
||||||
'remind_traffic' => 'boolean',
|
'remind_traffic' => 'boolean',
|
||||||
'commission_auto_check' => 'boolean',
|
'commission_auto_check' => 'boolean',
|
||||||
'commission_rate' => 'float'
|
'commission_rate' => 'float',
|
||||||
|
'next_reset_at' => 'timestamp',
|
||||||
|
'last_reset_at' => 'timestamp',
|
||||||
];
|
];
|
||||||
protected $hidden = ['password'];
|
protected $hidden = ['password'];
|
||||||
|
|
||||||
@@ -72,7 +78,6 @@ class User extends Authenticatable
|
|||||||
public const COMMISSION_TYPE_PERIOD = 1;
|
public const COMMISSION_TYPE_PERIOD = 1;
|
||||||
public const COMMISSION_TYPE_ONETIME = 2;
|
public const COMMISSION_TYPE_ONETIME = 2;
|
||||||
|
|
||||||
|
|
||||||
// 获取邀请人信息
|
// 获取邀请人信息
|
||||||
public function invite_user(): BelongsTo
|
public function invite_user(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -120,6 +125,14 @@ class User extends Authenticatable
|
|||||||
return $this->belongsTo(self::class, 'parent_id', 'id');
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Observers\UserObserver;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
//
|
// 注册用户模型观察者
|
||||||
|
User::observe(UserObserver::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Order;
|
|||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class OrderService
|
class OrderService
|
||||||
{
|
{
|
||||||
@@ -79,7 +80,7 @@ class OrderService
|
|||||||
DB::commit();
|
DB::commit();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
DB::rollBack();
|
DB::rollBack();
|
||||||
\Log::error($e);
|
Log::error($e);
|
||||||
throw new ApiException('开通失败');
|
throw new ApiException('开通失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +239,7 @@ class OrderService
|
|||||||
try {
|
try {
|
||||||
OrderHandleJob::dispatchSync($order->trade_no);
|
OrderHandleJob::dispatchSync($order->trade_no);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error($e);
|
Log::error($e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -305,7 +306,7 @@ class OrderService
|
|||||||
private function buyByOneTime(Plan $plan)
|
private function buyByOneTime(Plan $plan)
|
||||||
{
|
{
|
||||||
$this->buyByResetTraffic();
|
$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->plan_id = $plan->id;
|
||||||
$this->user->group_id = $plan->group_id;
|
$this->user->group_id = $plan->group_id;
|
||||||
$this->user->expired_at = NULL;
|
$this->user->expired_at = NULL;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,76 +9,37 @@ use App\Models\Order;
|
|||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Plugin\HookManager;
|
use App\Services\Plugin\HookManager;
|
||||||
|
use App\Services\TrafficResetService;
|
||||||
|
use App\Models\TrafficResetLog;
|
||||||
|
|
||||||
class UserService
|
class UserService
|
||||||
{
|
{
|
||||||
private function calcResetDayByMonthFirstDay(): int
|
/**
|
||||||
{
|
* Get the remaining days until the next traffic reset for a user.
|
||||||
$today = (int) date('d');
|
* This method reuses the TrafficResetService logic for consistency.
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getResetDay(User $user): ?int
|
public function getResetDay(User $user): ?int
|
||||||
{
|
{
|
||||||
// 前置条件检查
|
// Use TrafficResetService to calculate the next reset time
|
||||||
if ($user->expired_at <= time() || $user->expired_at === null) {
|
$trafficResetService = app(TrafficResetService::class);
|
||||||
|
$nextResetTime = $trafficResetService->calculateNextResetTime($user);
|
||||||
|
|
||||||
|
if (!$nextResetTime) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取重置方式逻辑统一
|
// Calculate the remaining days from now to the next reset time
|
||||||
$resetMethod = $user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM
|
$now = time();
|
||||||
? (int)admin_setting('reset_traffic_method', 0)
|
$resetTimestamp = $nextResetTime->timestamp;
|
||||||
: $user->plan->reset_traffic_method;
|
|
||||||
|
|
||||||
// 验证重置方式有效性
|
if ($resetTimestamp <= $now) {
|
||||||
if (!in_array($resetMethod, array_keys(Plan::getResetTrafficMethods()), true)) {
|
return 0; // Reset time has passed or is now
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法映射表
|
// Calculate the difference in days (rounded up)
|
||||||
$methodHandlers = [
|
$daysDifference = ceil(($resetTimestamp - $now) / 86400);
|
||||||
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;
|
return (int) $daysDifference;
|
||||||
|
|
||||||
return $handler ? $handler() : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAvailable(User $user)
|
public function isAvailable(User $user)
|
||||||
@@ -165,4 +126,28 @@ class UserService
|
|||||||
StatServerJob::dispatch($server, $chunk->toArray(), $protocol, 'd');
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,4 +202,13 @@ class Helper
|
|||||||
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
|
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
|
||||||
return strtr(rawurlencode($str), $revert);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+16
-15
File diff suppressed because one or more lines are too long
Vendored
+184
-149
File diff suppressed because one or more lines are too long
Vendored
+116
-1
@@ -162,6 +162,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
|
|||||||
"orderManagement": "Order Management",
|
"orderManagement": "Order Management",
|
||||||
"couponManagement": "Coupon Management",
|
"couponManagement": "Coupon Management",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
|
"trafficResetLogs": "Traffic Reset Logs",
|
||||||
"ticketManagement": "Ticket Management"
|
"ticketManagement": "Ticket Management"
|
||||||
},
|
},
|
||||||
"plugin": {
|
"plugin": {
|
||||||
@@ -1216,6 +1217,8 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
|
|||||||
"basicInfo": "Basic Information",
|
"basicInfo": "Basic Information",
|
||||||
"amountInfo": "Amount Information",
|
"amountInfo": "Amount Information",
|
||||||
"timeInfo": "Time Information",
|
"timeInfo": "Time Information",
|
||||||
|
"commissionInfo": "Commission Information",
|
||||||
|
"commissionStatusActive": "Active",
|
||||||
"addOrder": "Add Order",
|
"addOrder": "Add Order",
|
||||||
"assignOrder": "Assign Order",
|
"assignOrder": "Assign Order",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -1229,7 +1232,12 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
|
|||||||
"refundAmount": "Refund Amount",
|
"refundAmount": "Refund Amount",
|
||||||
"deductionAmount": "Deduction Amount",
|
"deductionAmount": "Deduction Amount",
|
||||||
"createdAt": "Created At",
|
"createdAt": "Created At",
|
||||||
"updatedAt": "Updated At"
|
"updatedAt": "Updated At",
|
||||||
|
"commissionStatus": "Commission Status",
|
||||||
|
"commissionAmount": "Commission Amount",
|
||||||
|
"actualCommissionAmount": "Actual Commission",
|
||||||
|
"inviteUser": "Inviter",
|
||||||
|
"inviteUserId": "Inviter ID"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"email": "Please enter user email",
|
"email": "Please enter user email",
|
||||||
@@ -2000,6 +2008,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
|
|||||||
"orders": "Orders",
|
"orders": "Orders",
|
||||||
"invites": "Invites",
|
"invites": "Invites",
|
||||||
"traffic_records": "Traffic Records",
|
"traffic_records": "Traffic Records",
|
||||||
|
"reset_traffic": "Reset Traffic",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_confirm_title": "Confirm Delete User",
|
"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?"
|
"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",
|
"title": "Actions",
|
||||||
"send_email": "Send Email",
|
"send_email": "Send Email",
|
||||||
"export_csv": "Export CSV",
|
"export_csv": "Export CSV",
|
||||||
|
"traffic_reset_stats": "Traffic Reset Stats",
|
||||||
"batch_ban": "Batch Ban",
|
"batch_ban": "Batch Ban",
|
||||||
"confirm_ban": {
|
"confirm_ban": {
|
||||||
"title": "Confirm Batch Ban",
|
"title": "Confirm Batch Ban",
|
||||||
@@ -2163,6 +2173,111 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
|
|||||||
"required_fields": "Please fill in all required fields"
|
"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": {
|
"send_mail": {
|
||||||
"title": "Send Email",
|
"title": "Send Email",
|
||||||
"description": "Send email to selected or filtered users",
|
"description": "Send email to selected or filtered users",
|
||||||
|
|||||||
Vendored
+116
-1
@@ -162,6 +162,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
|||||||
"orderManagement": "주문 관리",
|
"orderManagement": "주문 관리",
|
||||||
"couponManagement": "쿠폰 관리",
|
"couponManagement": "쿠폰 관리",
|
||||||
"userManagement": "사용자 관리",
|
"userManagement": "사용자 관리",
|
||||||
|
"trafficResetLogs": "트래픽 재설정 로그",
|
||||||
"ticketManagement": "티켓 관리"
|
"ticketManagement": "티켓 관리"
|
||||||
},
|
},
|
||||||
"plugin": {
|
"plugin": {
|
||||||
@@ -1232,6 +1233,8 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
|||||||
"basicInfo": "기본 정보",
|
"basicInfo": "기본 정보",
|
||||||
"amountInfo": "금액 정보",
|
"amountInfo": "금액 정보",
|
||||||
"timeInfo": "시간 정보",
|
"timeInfo": "시간 정보",
|
||||||
|
"commissionInfo": "수수료 정보",
|
||||||
|
"commissionStatusActive": "활성",
|
||||||
"addOrder": "주문 추가",
|
"addOrder": "주문 추가",
|
||||||
"assignOrder": "주문 할당",
|
"assignOrder": "주문 할당",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -1245,7 +1248,12 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
|||||||
"refundAmount": "환불 금액",
|
"refundAmount": "환불 금액",
|
||||||
"deductionAmount": "차감 금액",
|
"deductionAmount": "차감 금액",
|
||||||
"createdAt": "생성 시간",
|
"createdAt": "생성 시간",
|
||||||
"updatedAt": "업데이트 시간"
|
"updatedAt": "업데이트 시간",
|
||||||
|
"commissionStatus": "수수료 상태",
|
||||||
|
"commissionAmount": "수수료 금액",
|
||||||
|
"actualCommissionAmount": "실제 수수료",
|
||||||
|
"inviteUser": "초대자",
|
||||||
|
"inviteUserId": "초대자 ID"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"email": "사용자 이메일을 입력해주세요",
|
"email": "사용자 이메일을 입력해주세요",
|
||||||
@@ -1969,6 +1977,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
|||||||
"orders": "주문 내역",
|
"orders": "주문 내역",
|
||||||
"invites": "초대 내역",
|
"invites": "초대 내역",
|
||||||
"traffic_records": "트래픽 기록",
|
"traffic_records": "트래픽 기록",
|
||||||
|
"reset_traffic": "트래픽 재설정",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"delete_confirm_title": "사용자 삭제 확인",
|
"delete_confirm_title": "사용자 삭제 확인",
|
||||||
"delete_confirm_description": "이 작업은 사용자 {{email}}와 관련된 모든 데이터(주문, 쿠폰, 트래픽 기록, 지원 티켓 등)를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다. 계속하시겠습니까?"
|
"delete_confirm_description": "이 작업은 사용자 {{email}}와 관련된 모든 데이터(주문, 쿠폰, 트래픽 기록, 지원 티켓 등)를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다. 계속하시겠습니까?"
|
||||||
@@ -2105,6 +2114,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
|||||||
"title": "작업",
|
"title": "작업",
|
||||||
"send_email": "이메일 보내기",
|
"send_email": "이메일 보내기",
|
||||||
"export_csv": "CSV 내보내기",
|
"export_csv": "CSV 내보내기",
|
||||||
|
"traffic_reset_stats": "트래픽 재설정 통계",
|
||||||
"batch_ban": "일괄 차단",
|
"batch_ban": "일괄 차단",
|
||||||
"confirm_ban": {
|
"confirm_ban": {
|
||||||
"title": "일괄 차단 확인",
|
"title": "일괄 차단 확인",
|
||||||
@@ -2132,6 +2142,111 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
|||||||
"required_fields": "모든 필수 항목을 입력해주세요"
|
"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": {
|
"send_mail": {
|
||||||
"title": "이메일 보내기",
|
"title": "이메일 보내기",
|
||||||
"description": "선택하거나 필터링된 사용자에게 이메일 보내기",
|
"description": "선택하거나 필터링된 사용자에게 이메일 보내기",
|
||||||
|
|||||||
Vendored
+117
-2
@@ -162,7 +162,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
|||||||
"orderManagement": "订单管理",
|
"orderManagement": "订单管理",
|
||||||
"couponManagement": "优惠券管理",
|
"couponManagement": "优惠券管理",
|
||||||
"userManagement": "用户管理",
|
"userManagement": "用户管理",
|
||||||
"ticketManagement": "工单管理"
|
"ticketManagement": "工单管理",
|
||||||
|
"trafficResetLogs": "流量重置日志"
|
||||||
},
|
},
|
||||||
"plugin": {
|
"plugin": {
|
||||||
"title": "插件管理",
|
"title": "插件管理",
|
||||||
@@ -1208,6 +1209,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
|||||||
"basicInfo": "基本信息",
|
"basicInfo": "基本信息",
|
||||||
"amountInfo": "金额信息",
|
"amountInfo": "金额信息",
|
||||||
"timeInfo": "时间信息",
|
"timeInfo": "时间信息",
|
||||||
|
"commissionInfo": "佣金信息",
|
||||||
|
"commissionStatusActive": "有效",
|
||||||
"addOrder": "添加订单",
|
"addOrder": "添加订单",
|
||||||
"assignOrder": "订单分配",
|
"assignOrder": "订单分配",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -1221,7 +1224,12 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
|||||||
"refundAmount": "退回金额",
|
"refundAmount": "退回金额",
|
||||||
"deductionAmount": "折抵金额",
|
"deductionAmount": "折抵金额",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"updatedAt": "更新时间"
|
"updatedAt": "更新时间",
|
||||||
|
"commissionStatus": "佣金状态",
|
||||||
|
"commissionAmount": "佣金金额",
|
||||||
|
"actualCommissionAmount": "实际佣金",
|
||||||
|
"inviteUser": "邀请人",
|
||||||
|
"inviteUserId": "邀请人ID"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"email": "请输入用户邮箱",
|
"email": "请输入用户邮箱",
|
||||||
@@ -1963,6 +1971,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
|||||||
"assign_order": "分配订单",
|
"assign_order": "分配订单",
|
||||||
"copy_url": "复制订阅URL",
|
"copy_url": "复制订阅URL",
|
||||||
"reset_secret": "重置UUID及订阅URL",
|
"reset_secret": "重置UUID及订阅URL",
|
||||||
|
"reset_traffic": "重置流量",
|
||||||
"orders": "TA的订单",
|
"orders": "TA的订单",
|
||||||
"invites": "TA的邀请",
|
"invites": "TA的邀请",
|
||||||
"traffic_records": "TA的流量记录",
|
"traffic_records": "TA的流量记录",
|
||||||
@@ -2102,6 +2111,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
|||||||
"title": "操作",
|
"title": "操作",
|
||||||
"send_email": "发送邮件",
|
"send_email": "发送邮件",
|
||||||
"export_csv": "导出 CSV",
|
"export_csv": "导出 CSV",
|
||||||
|
"traffic_reset_stats": "流量重置统计",
|
||||||
"batch_ban": "批量封禁",
|
"batch_ban": "批量封禁",
|
||||||
"confirm_ban": {
|
"confirm_ban": {
|
||||||
"title": "确认批量封禁",
|
"title": "确认批量封禁",
|
||||||
@@ -2112,6 +2122,111 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
|||||||
"banning": "封禁中..."
|
"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": {
|
"messages": {
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"Currency conversion has timed out, please try again later": "Currency conversion has timed out, please try again later",
|
"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 already exists": "Email already exists",
|
||||||
"Email suffix is not in the Whitelist": "Email suffix is not in the Whitelist",
|
"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": "Email verification code",
|
||||||
"Email verification code cannot be empty": "Email verification code cannot be empty",
|
"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",
|
"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.code_update_failed": "Code update failed: :error",
|
||||||
"update.migration_failed": "Database migration failed: :error",
|
"update.migration_failed": "Database migration failed: :error",
|
||||||
"update.cache_clear_failed": "Cache clearing 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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"Invalid coupon": "优惠券无效",
|
"Invalid coupon": "优惠券无效",
|
||||||
"Invalid code is incorrect": "验证码有误",
|
"Invalid code is incorrect": "验证码有误",
|
||||||
"Email suffix is not in the Whitelist": "邮箱后缀不处于白名单中",
|
"Email suffix is not in the Whitelist": "邮箱后缀不处于白名单中",
|
||||||
|
"Email suffix is not in whitelist": "邮箱后缀不在白名单中",
|
||||||
"Gmail alias is not supported": "不支持 Gmail 别名邮箱",
|
"Gmail alias is not supported": "不支持 Gmail 别名邮箱",
|
||||||
"Registration has closed": "本站已关闭注册",
|
"Registration has closed": "本站已关闭注册",
|
||||||
"You must use the invitation code to register": "必须使用邀请码才可以注册",
|
"You must use the invitation code to register": "必须使用邀请码才可以注册",
|
||||||
@@ -129,5 +130,19 @@
|
|||||||
"update.code_update_failed": "代码更新失败: :error",
|
"update.code_update_failed": "代码更新失败: :error",
|
||||||
"update.migration_failed": "数据库迁移失败: :error",
|
"update.migration_failed": "数据库迁移失败: :error",
|
||||||
"update.cache_clear_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": "该用户当前不能重置流量(用户未激活或无有效套餐)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"Invalid coupon": "優惠券無效",
|
"Invalid coupon": "優惠券無效",
|
||||||
"Invalid code is incorrect": "驗證碼有誤",
|
"Invalid code is incorrect": "驗證碼有誤",
|
||||||
"Email suffix is not in the Whitelist": "郵箱後綴不處於白名單中",
|
"Email suffix is not in the Whitelist": "郵箱後綴不處於白名單中",
|
||||||
|
"Email suffix is not in whitelist": "郵箱後綴不在白名單中",
|
||||||
"Gmail alias is not supported": "不支持 Gmail 別名郵箱",
|
"Gmail alias is not supported": "不支持 Gmail 別名郵箱",
|
||||||
"Registration has closed": "本站已關閉註冊",
|
"Registration has closed": "本站已關閉註冊",
|
||||||
"You must use the invitation code to register": "必須使用邀請碼才可以註冊",
|
"You must use the invitation code to register": "必須使用邀請碼才可以註冊",
|
||||||
@@ -129,5 +130,19 @@
|
|||||||
"update.code_update_failed": "代碼更新失敗: :error",
|
"update.code_update_failed": "代碼更新失敗: :error",
|
||||||
"update.migration_failed": "數據庫遷移失敗: :error",
|
"update.migration_failed": "數據庫遷移失敗: :error",
|
||||||
"update.cache_clear_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": "該用戶當前不能重置流量(用戶未激活或無有效套餐)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user