Jelajahi Sumber

feat: new cron system

M1Screw 2 tahun lalu
induk
melakukan
c3999f9468

+ 2 - 0
app/routes.php

@@ -204,6 +204,8 @@ return static function (Slim\App $app): void {
         $group->post('/setting/billing', App\Controllers\Admin\Setting\BillingController::class . ':saveBilling');
         $group->post('/setting/billing', App\Controllers\Admin\Setting\BillingController::class . ':saveBilling');
         $group->get('/setting/captcha', App\Controllers\Admin\Setting\CaptchaController::class . ':captcha');
         $group->get('/setting/captcha', App\Controllers\Admin\Setting\CaptchaController::class . ':captcha');
         $group->post('/setting/captcha', App\Controllers\Admin\Setting\CaptchaController::class . ':saveCaptcha');
         $group->post('/setting/captcha', App\Controllers\Admin\Setting\CaptchaController::class . ':saveCaptcha');
+        $group->get('/setting/cron', App\Controllers\Admin\Setting\CaptchaController::class . ':cron');
+        $group->post('/setting/cron', App\Controllers\Admin\Setting\CaptchaController::class . ':saveCron');
         $group->get('/setting/email', App\Controllers\Admin\Setting\EmailController::class . ':email');
         $group->get('/setting/email', App\Controllers\Admin\Setting\EmailController::class . ':email');
         $group->post('/setting/email', App\Controllers\Admin\Setting\EmailController::class . ':saveEmail');
         $group->post('/setting/email', App\Controllers\Admin\Setting\EmailController::class . ':saveEmail');
         $group->get('/setting/feature', App\Controllers\Admin\Setting\FeatureController::class . ':feature');
         $group->get('/setting/feature', App\Controllers\Admin\Setting\FeatureController::class . ':feature');

+ 12 - 2
config/settings.json

@@ -1421,13 +1421,23 @@
     },
     },
     {
     {
         "id": null,
         "id": null,
-        "item": "last_user_job_time",
+        "item": "daily_job_hour",
         "value": "0",
         "value": "0",
         "class": "cron",
         "class": "cron",
         "is_public": 0,
         "is_public": 0,
         "type": "int",
         "type": "int",
         "default": "0",
         "default": "0",
-        "mark": "上次执行用户任务的时间"
+        "mark": "每日任务执行时间(小时)"
+    },
+    {
+        "id": null,
+        "item": "daily_job_minute",
+        "value": "0",
+        "class": "cron",
+        "is_public": 0,
+        "type": "int",
+        "default": "0",
+        "mark": "每日任务执行时间(分钟)"
     },
     },
     {
     {
         "id": null,
         "id": null,

+ 1 - 7
phpinsights.php

@@ -31,13 +31,7 @@ return [
         SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff::class,
         SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff::class,
         SlevomatCodingStandard\Sniffs\Variables\UnusedVariableSniff::class,
         SlevomatCodingStandard\Sniffs\Variables\UnusedVariableSniff::class,
     ],
     ],
-    'config' => [
-        PHP_CodeSniffer\Standards\PSR1\Sniffs\Methods\CamelCapsMethodNameSniff::class => [
-            'exclude' => [
-                'src/Command/Job.php',
-            ],
-        ],
-    ],
+    'config' => [],
 
 
     'exclude' => [
     'exclude' => [
         'storage',
         'storage',

+ 87 - 0
resources/views/tabler/admin/setting/cron.tpl

@@ -0,0 +1,87 @@
+{include file='admin/tabler_header.tpl'}
+
+<div class="page-wrapper">
+    <div class="container-xl">
+        <div class="page-header d-print-none text-white">
+            <div class="row align-items-center">
+                <div class="col">
+                    <h2 class="page-title">
+                        <span class="home-title">定时任务设置</span>
+                    </h2>
+                    <div class="page-pretitle my-3">
+                        <span class="home-subtitle">设置站点的定时任务</span>
+                    </div>
+                </div>
+                <div class="col-auto ms-auto d-print-none">
+                    <div class="btn-list">
+                        <a id="save-setting" href="#" class="btn btn-primary">
+                            <i class="icon ti ti-device-floppy"></i>
+                            保存
+                        </a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="page-body">
+        <div class="container-xl">
+            <div class="row row-deck row-cards">
+                <div class="col-md-12">
+                    <div class="card">
+                    <div class="card-header">
+                    <ul class="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
+                        <li class="nav-item">
+                            <a href="#cron" class="nav-link active" data-bs-toggle="tab">任务设置</a>
+                        </li>
+                    </ul>
+                </div>
+                <div class="card-body">
+                    <div class="tab-content">
+                        <div class="tab-pane active show" id="display">
+                            <div class="card-body">
+                                <div class="form-group mb-3 row">
+                                    <label class="form-label col-3 col-form-label">每日任务执行时间(小时)</label>
+                                    <div class="col">
+                                        <input id="daily_job_hour" type="text" class="form-control" value="{$settings['daily_job_hour']}">
+                                    </div>
+                                </div>
+                                <div class="form-group mb-3 row">
+                                    <label class="form-label col-3 col-form-label">每日任务执行时间(分钟)</label>
+                                    <div class="col">
+                                        <input id="daily_job_minute" type="text" class="form-control" value="{$settings['daily_job_minute']}">
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    $("#save-setting").click(function() {
+        $.ajax({
+            url: '/admin/setting/cron',
+            type: 'POST',
+            dataType: "json",
+            data: {
+                {foreach $update_field as $key}
+                {$key}: $('#{$key}').val(),
+                {/foreach}
+            },
+            success: function(data) {
+                if (data.ret === 1) {
+                    $('#success-message').text(data.msg);
+                    $('#success-dialog').modal('show');
+                } else {
+                    $('#fail-message').text(data.msg);
+                    $('#fail-dialog').modal('show');
+                }
+            }
+        })
+    });
+</script>
+
+{include file='admin/tabler_footer.tpl'}

+ 3 - 0
resources/views/tabler/admin/tabler_header.tpl

@@ -122,6 +122,9 @@
                                                     <a href="/admin/setting/feature" class="dropdown-item">
                                                     <a href="/admin/setting/feature" class="dropdown-item">
                                                       功能
                                                       功能
                                                     </a>
                                                     </a>
+                                                    <a href="/admin/setting/cron" class="dropdown-item">
+                                                      定时任务
+                                                    </a>
                                                 </div>
                                                 </div>
                                             </div>
                                             </div>
                                             <a class="dropdown-item" href="/admin/user">
                                             <a class="dropdown-item" href="/admin/user">

+ 62 - 193
src/Command/Cron.php

@@ -4,21 +4,10 @@ declare(strict_types=1);
 
 
 namespace App\Command;
 namespace App\Command;
 
 
-use App\Models\Invoice;
-use App\Models\Node;
-use App\Models\Order;
 use App\Models\Setting;
 use App\Models\Setting;
-use App\Models\User;
-use App\Services\DB;
-use App\Services\Mail;
-use App\Utils\Telegram;
-use App\Utils\Tools;
-use DateTime;
-use Exception;
-use Psr\Http\Client\ClientExceptionInterface;
-use function count;
-use function in_array;
-use function json_decode;
+use App\Services\CronJob;
+use Telegram\Bot\Exceptions\TelegramSDKException;
+use function mktime;
 use function time;
 use function time;
 
 
 final class Cron extends Command
 final class Cron extends Command
@@ -27,194 +16,74 @@ final class Cron extends Command
 ├─=: php xcat Cron - 站点定时任务,每五分钟
 ├─=: php xcat Cron - 站点定时任务,每五分钟
 EOL;
 EOL;
 
 
+    /**
+     * @throws TelegramSDKException
+     */
     public function boot(): void
     public function boot(): void
     {
     {
         ini_set('memory_limit', '-1');
         ini_set('memory_limit', '-1');
-        // 新商店系统相关
-        // 获取等待支付的订单,检查账单支付状态
-        $pending_payment_orders = Order::where('status', 'pending_payment')->get();
 
 
-        foreach ($pending_payment_orders as $order) {
-            // 检查账单支付状态
-            $invoice = Invoice::where('order_id', $order->id)->first();
+        // Log current hour & minute
+        $hour = (int) date('H');
+        $minute = (int) date('i');
 
 
-            if ($invoice === null) {
-                continue;
-            }
-            // 标记订单为等待激活
-            if (in_array($invoice->status, ['paid_gateway', 'paid_balance', 'paid_admin'])) {
-                $order->status = 'pending_activation';
-                $order->update_time = time();
-                $order->save();
-                echo "已标记订单 #{$order->id} 为等待激活。\n";
-                continue;
-            }
-            // 取消超时未支付的订单和关联账单
-            if ($order->create_time + 86400 < time()) {
-                $order->status = 'cancelled';
-                $order->update_time = time();
-                $order->save();
-                echo "已取消超时订单 #{$order->id}。\n";
-                $invoice->status = 'cancelled';
-                $invoice->update_time = time();
-                $invoice->save();
-                echo "已取消超时账单 #{$invoice->id}。\n";
-            }
-        }
-        // 获取使用新商店系统的用户,仅更新这部分用户避免与旧系统冲突
-        $users_new_shop = User::where('use_new_shop', 1)->get();
-
-        foreach ($users_new_shop as $user) {
-            $user_id = $user->id;
-            // 获取用户账户等待激活的订单
-            $pending_activation_orders = Order::where('user_id', $user_id)->where('status', 'pending_activation')->orderBy('id', 'asc')->get();
-            // 获取用户账户已激活的订单,一个用户同时只能有一个已激活的订单
-            $activated_order = Order::where('user_id', $user_id)->where('status', 'activated')->orderBy('id', 'asc')->first();
-            // 如果用户账户中没有已激活的订单,且有等待激活的订单,则激活最早的等待激活订单
-            if ($activated_order === null && count($pending_activation_orders) > 0) {
-                $order = $pending_activation_orders[0];
-                // 获取订单内容准备激活
-                $content = json_decode($order->product_content);
-                // 激活商品
-                $user->u = 0;
-                $user->d = 0;
-                $user->transfer_today = 0;
-                $user->transfer_enable = Tools::toGB($content->bandwidth);
-                $user->class = $content->class;
-                $old_expire_in = new DateTime();
-                $old_class_expire = new DateTime();
-                $user->expire_in = $old_expire_in->modify('+' . $content->time . ' days')->format('Y-m-d H:i:s');
-                $user->class_expire = $old_class_expire->modify('+' . $content->class_time . ' days')->format('Y-m-d H:i:s');
-                $user->node_group = $content->node_group;
-                $user->node_speedlimit = $content->speed_limit;
-                $user->node_iplimit = $content->ip_limit;
-                $user->save();
-                $order->status = 'activated';
-                $order->update_time = time();
-                $order->save();
-                echo "订单 #{$order->id} 已激活。\n";
-                continue;
-            }
-            // 如果用户账户中有已激活的订单,则判断是否过期
-            if ($activated_order !== null) {
-                $content = json_decode($activated_order->product_content);
-                if ($activated_order->update_time + $content->time * 86400 < time()) {
-                    $activated_order->status = 'expired';
-                    $activated_order->update_time = time();
-                    $activated_order->save();
-                    echo "订单 #{$activated_order->id} 已过期。\n";
-                }
-            }
+        $jobs = new CronJob();
+
+        // Run new shop related jobs
+        $jobs->processPendingOrder();
+        $jobs->processOrderActivation();
+
+        // Run user related jobs
+        $jobs->expirePaidUserAccount();
+        $jobs->expireFreeUserAccount();
+        $jobs->sendPaidUserUsageLimitNotification();
+
+        // Run node related jobs
+        $jobs->updateNodeIp();
+
+        if ($_ENV['enable_detect_offline']) {
+            $jobs->detectNodeOffline();
         }
         }
-        //记录当前时间戳
-        $timestamp = time();
-        //邮件队列处理
-        while (true) {
-            if (time() - $timestamp > 299) {
-                echo '邮件队列处理超时,已跳过' . PHP_EOL;
-                break;
-            }
-            DB::beginTransaction();
-            $email_queues_raw = DB::select('SELECT * FROM email_queue LIMIT 1 FOR UPDATE SKIP LOCKED');
-            if (count($email_queues_raw) === 0) {
-                DB::commit();
-                break;
-            }
-            $email_queues = array_map(static function ($value) {
-                return (array) $value;
-            }, $email_queues_raw);
-            $email_queue = $email_queues[0];
-            echo '发送邮件至 ' . $email_queue['to_email'] . PHP_EOL;
-            DB::delete('DELETE FROM email_queue WHERE id = ?', [$email_queue['id']]);
-            if (Tools::isEmail($email_queue['to_email'])) {
-                try {
-                    Mail::send($email_queue['to_email'], $email_queue['subject'], $email_queue['template'], json_decode($email_queue['array']));
-                } catch (Exception|ClientExceptionInterface $e) {
-                    echo $e->getMessage();
-                }
-            } else {
-                echo $email_queue['to_email'] . ' 邮箱格式错误,已跳过' . PHP_EOL;
-            }
-            DB::commit();
+
+        // Run traffic log job
+        if ($minute === 00 && $_ENV['trafficLog']) {
+            $jobs->addTrafficLog();
         }
         }
-        //取出所有节点
-        $nodes = Node::all();
-        //节点掉线检测
-        if ($_ENV['enable_detect_offline']) {
-            echo '节点掉线检测开始' . PHP_EOL;
-            $adminUser = User::where('is_admin', '=', '1')->get();
-
-            foreach ($nodes as $node) {
-                $notice_text = '';
-                if ($node->getNodeOnlineStatus() === -1 && $node->online === 1) {
-                    foreach ($adminUser as $user) {
-                        echo 'Send Email to admin user: ' . $user->id . PHP_EOL;
-                        $user->sendMail(
-                            $_ENV['appName'] . '-系统警告',
-                            'warn.tpl',
-                            [
-                                'text' => '管理员你好,系统发现节点 ' . $node->name . ' 掉线了,请你及时处理。',
-                            ],
-                            [],
-                            false
-                        );
-                        $notice_text = str_replace(
-                            '%node_name%',
-                            $node->name,
-                            Setting::obtain('telegram_node_offline_text')
-                        );
-                    }
-
-                    if (Setting::obtain('telegram_node_offline')) {
-                        try {
-                            Telegram::send($notice_text);
-                        } catch (Exception $e) {
-                            echo $e->getMessage() . PHP_EOL;
-                        }
-                    }
-
-                    $node->online = false;
-                    $node->save();
-                } elseif ($node->getNodeOnlineStatus() === 1 && $node->online === 0) {
-                    foreach ($adminUser as $user) {
-                        echo 'Send Email to admin user: ' . $user->id . PHP_EOL;
-                        $user->sendMail(
-                            $_ENV['appName'] . '-系统提示',
-                            'warn.tpl',
-                            [
-                                'text' => '管理员你好,系统发现节点 ' . $node->name . ' 恢复上线了。',
-                            ],
-                            [],
-                            false
-                        );
-                        $notice_text = str_replace(
-                            '%node_name%',
-                            $node->name,
-                            Setting::obtain('telegram_node_online_text')
-                        );
-                    }
-
-                    if (Setting::obtain('telegram_node_online')) {
-                        try {
-                            Telegram::send($notice_text);
-                        } catch (Exception $e) {
-                            echo $e->getMessage() . PHP_EOL;
-                        }
-                    }
-
-                    $node->online = true;
-                    $node->save();
-                }
+
+        // Run daily job
+        if ($hour === Setting::obtain('daily_job_hour') &&
+            $minute === Setting::obtain('daily_job_minute') &&
+            time() - Setting::obtain('last_daily_job_time') > 86399
+        ) {
+            $jobs->cleanDb();
+            $jobs->cleanUser();
+            $jobs->resetNodeBandwidth();
+            $jobs->resetFreeUserTraffic();
+            $jobs->sendDailyTrafficReport();
+
+            if (Setting::obtain('telegram_diary')) {
+                $jobs->sendTelegramDiary();
             }
             }
-            echo '节点掉线检测结束' . PHP_EOL;
-        }
-        //更新节点 IP
-        foreach ($nodes as $node) {
-            $server = $node->server;
-            if (! Tools::isIPv4($server) && ! Tools::isIPv6($server)) {
-                $node->changeNodeIp($server);
-                $node->save();
+
+            $jobs->resetTodayTraffic();
+
+            if (Setting::obtain('telegram_daily_job')) {
+                $jobs->sendTelegramDailyJob();
             }
             }
+
+            Setting::where('item', '=', 'last_daily_job_time')->update([
+                'value' => mktime(
+                    Setting::obtain('daily_job_hour'),
+                    Setting::obtain('daily_job_minute'),
+                    0,
+                    (int) date('m'),
+                    (int) date('d'),
+                    (int) date('Y')
+                ),
+            ]);
         }
         }
+
+        // Run email queue
+        $jobs->processEmailQueue();
     }
     }
 }
 }

+ 0 - 330
src/Command/Job.php

@@ -1,330 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Command;
-
-use App\Models\Ann;
-use App\Models\DetectLog;
-use App\Models\EmailQueue;
-use App\Models\EmailVerify;
-use App\Models\Node;
-use App\Models\OnlineLog;
-use App\Models\PasswordReset;
-use App\Models\Setting;
-use App\Models\StreamMedia;
-use App\Models\TelegramSession;
-use App\Models\User;
-use App\Models\UserHourlyUsage;
-use App\Models\UserSubscribeLog;
-use App\Services\Analytics;
-use App\Utils\Telegram;
-use App\Utils\Tools;
-use Exception;
-use function count;
-use function max;
-use function str_replace;
-use function strtotime;
-use function time;
-
-final class Job extends Command
-{
-    public string $description = <<<EOL
-├─=: php xcat Job [选项]
-│ ├─ DailyJob                - 每日任务,每天
-│ ├─ UserJob                 - 账户相关任务,每小时
-EOL;
-
-    public function boot(): void
-    {
-        if (count($this->argv) === 2) {
-            echo $this->description;
-        } else {
-            $methodName = $this->argv[2];
-            if (method_exists($this, $methodName)) {
-                $this->$methodName();
-            } else {
-                echo '方法不存在.' . PHP_EOL;
-            }
-        }
-    }
-
-    /**
-     * 每日任务
-     */
-    public function DailyJob(): void
-    {
-        ini_set('memory_limit', '-1');
-
-        // ------- 重置节点流量
-        Node::where('bandwidthlimit_resetday', date('d'))->update(['node_bandwidth' => 0]);
-        // ------- 重置节点流量
-
-        // ------- 清理各表记录
-        UserSubscribeLog::where('request_time', '<', date('Y-m-d H:i:s', time() - 86400 * (int) $_ENV['subscribeLog_keep_days']))->delete();
-        UserHourlyUsage::where('datetime', '<', time() - 86400 * (int) $_ENV['trafficLog_keep_days'])->delete();
-        DetectLog::where('datetime', '<', time() - 86400 * 3)->delete();
-        EmailVerify::where('expire_in', '<', time() - 86400)->delete();
-        EmailQueue::where('time', '<', time() - 86400)->delete();
-        PasswordReset::where('expire_time', '<', time() - 86400)->delete();
-        OnlineLog::where('last_time', '<', time() - 86400)->delete();
-        StreamMedia::where('created_at', '<', time() - 86400)->delete();
-        TelegramSession::where('datetime', '<', time() - 900)->delete();
-        // ------- 清理各表记录
-
-        // ------- 发送系统运行状况通知
-        if (Setting::obtain('telegram_diary')) {
-            $sts = new Analytics();
-            try {
-                Telegram::send(
-                    str_replace(
-                        [
-                            '%getTodayCheckinUser%',
-                            '%lastday_total%',
-                        ],
-                        [
-                            $sts->getTodayCheckinUser(),
-                            $sts->getTodayTrafficUsage(),
-                        ],
-                        Setting::obtain('telegram_diary_text')
-                    )
-                );
-            } catch (Exception $e) {
-                echo $e->getMessage();
-            }
-        }
-        // ------- 发送系统运行状况通知
-
-        // ------- 用户每日流量报告
-        $users = User::all();
-
-        // 判断是否有公告
-        $ann_latest_raw = Ann::orderBy('date', 'desc')->first();
-
-        if ($ann_latest_raw === null) {
-            $ann_latest = '<br><br>';
-        } else {
-            $ann_latest = $ann_latest_raw->content . '<br><br>';
-        }
-
-        foreach ($users as $user) {
-            // ------- 用户每日流量报告
-            $user->sendDailyNotification($ann_latest);
-            // ------- 用户每日流量报告
-
-            // ------- 免费用户流量重置
-            if ($user->class === 0 && date('d') === $user->auto_reset_day) {
-                $user->u = 0;
-                $user->d = 0;
-                $user->transfer_enable = $user->auto_reset_bandwidth * 1024 * 1024 * 1024;
-                $user->save();
-
-                try {
-                    $user->sendMail(
-                        $_ENV['appName'] . '-你的免费流量被重置了',
-                        'warn.tpl',
-                        [
-                            'text' => '你好,你的免费流量已经被重置为' . $user->auto_reset_bandwidth . 'GB',
-                        ],
-                        [],
-                        true
-                    );
-                } catch (Exception $e) {
-                    echo $e->getMessage();
-                }
-            }
-            // ------- 免费用户流量重置
-        }
-
-        // 清空用户的当日使用流量
-        User::query()->update(['transfer_today' => 0]);
-
-        // ------- 发送每日任务运行报告
-        if (Setting::obtain('telegram_daily_job')) {
-            try {
-                Telegram::send(Setting::obtain('telegram_daily_job_text'));
-            } catch (Exception $e) {
-                echo $e->getMessage();
-            }
-        }
-        // ------- 发送每日系统运行报告
-    }
-
-    /**
-     * 账户相关任务,每小时
-     */
-    public function UserJob(): void
-    {
-        $users = User::all();
-        foreach ($users as $user) {
-            //流量记录
-            if ($_ENV['trafficLog']) {
-                $transfer_total = $user->transfer_total;
-                $transfer_total_last = UserHourlyUsage::where('user_id', $user->id)->orderBy('id', 'desc')->first();
-
-                if ($transfer_total_last === null) {
-                    $transfer_total_last = 0;
-                } else {
-                    $transfer_total_last = $transfer_total_last->traffic;
-                }
-
-                $trafficlog = new UserHourlyUsage();
-                $trafficlog->user_id = $user->id;
-                $trafficlog->traffic = $transfer_total;
-                $trafficlog->hourly_usage = $transfer_total - $transfer_total_last;
-                $trafficlog->datetime = time();
-                $trafficlog->save();
-            }
-
-            if (strtotime($user->expire_in) < time() && ! $user->expire_notified) {
-                $user->transfer_enable = 0;
-                $user->u = 0;
-                $user->d = 0;
-                $user->transfer_today = 0;
-
-                $user->sendMail(
-                    $_ENV['appName'] . '-你的账户已经过期了',
-                    'warn.tpl',
-                    [
-                        'text' => '你好,系统发现你的账号已经过期了。',
-                    ],
-                    [],
-                    true
-                );
-
-                $user->expire_notified = true;
-                $user->save();
-            } elseif (strtotime($user->expire_in) > time() && $user->expire_notified) {
-                $user->expire_notified = false;
-                $user->save();
-            }
-            //余量不足检测
-            if ($_ENV['notify_limit_mode'] !== false) {
-                $user_traffic_left = $user->transfer_enable - $user->u - $user->d;
-                $under_limit = false;
-                $unit_text = '';
-
-                if ($user->transfer_enable !== 0 && $user->class !== 0) {
-                    if (
-                        $_ENV['notify_limit_mode'] === 'per' &&
-                        $user_traffic_left / $user->transfer_enable * 100 < $_ENV['notify_limit_value']
-                    ) {
-                        $under_limit = true;
-                        $unit_text = '%';
-                    } elseif (
-                        $_ENV['notify_limit_mode'] === 'mb' &&
-                        Tools::flowToMB($user_traffic_left) < $_ENV['notify_limit_value']
-                    ) {
-                        $under_limit = true;
-                        $unit_text = 'MB';
-                    }
-                }
-
-                if ($under_limit && ! $user->traffic_notified) {
-                    $result = $user->sendMail(
-                        $_ENV['appName'] . '-你的剩余流量过低',
-                        'warn.tpl',
-                        [
-                            'text' => '你好,系统发现你剩余流量已经低于 ' . $_ENV['notify_limit_value'] . $unit_text . ' 。',
-                        ],
-                        [],
-                        true
-                    );
-                    if ($result) {
-                        $user->traffic_notified = true;
-                        $user->save();
-                    }
-                } elseif (! $under_limit && $user->traffic_notified) {
-                    $user->traffic_notified = false;
-                    $user->save();
-                }
-            }
-
-            if (
-                $_ENV['account_expire_delete_days'] >= 0 &&
-                strtotime($user->expire_in) + $_ENV['account_expire_delete_days'] * 86400 < time() &&
-                $user->money <= $_ENV['auto_clean_min_money']
-            ) {
-                $user->sendMail(
-                    $_ENV['appName'] . '-你的账户因为过期被删除了',
-                    'warn.tpl',
-                    [
-                        'text' => '你好,系统发现你的账户已经过期 ' . $_ENV['account_expire_delete_days'] . ' 天了,帐号已经被删除。',
-                    ],
-                    [],
-                    true
-                );
-                $user->killUser();
-                continue;
-            }
-
-            if (
-                $_ENV['auto_clean_uncheck_days'] > 0 &&
-                max(
-                    $user->last_check_in_time,
-                    strtotime($user->reg_date)
-                ) + ($_ENV['auto_clean_uncheck_days'] * 86400) < time() &&
-                $user->class === 0 &&
-                $user->money <= $_ENV['auto_clean_min_money']
-            ) {
-                $user->sendMail(
-                    $_ENV['appName'] . '-你的账户因为未签到被删除了',
-                    'warn.tpl',
-                    [
-                        'text' => '你好,系统发现你的账号已经 ' . $_ENV['auto_clean_uncheck_days'] . ' 天没签到了,帐号已经被删除。',
-                    ],
-                    [],
-                    true
-                );
-                $user->killUser();
-                continue;
-            }
-
-            if (
-                $_ENV['auto_clean_unused_days'] > 0 &&
-                max($user->t, strtotime($user->reg_date)) + ($_ENV['auto_clean_unused_days'] * 86400) < time() &&
-                $user->class === 0 &&
-                $user->money <= $_ENV['auto_clean_min_money']
-            ) {
-                $user->sendMail(
-                    $_ENV['appName'] . '-你的账户因为闲置被删除了',
-                    'warn.tpl',
-                    [
-                        'text' => '你好,系统发现你的账号已经 ' . $_ENV['auto_clean_unused_days'] . ' 天没使用了,帐号已经被删除。',
-                    ],
-                    [],
-                    true
-                );
-                $user->killUser();
-                continue;
-            }
-
-            if (
-                $user->class !== 0 &&
-                strtotime($user->class_expire) < time()
-            ) {
-                $text = '你好,系统发现你的账号等级已经过期了。';
-                $reset_traffic = $_ENV['class_expire_reset_traffic'];
-                if ($reset_traffic >= 0) {
-                    $user->transfer_enable = Tools::toGB($reset_traffic);
-                    $user->u = 0;
-                    $user->d = 0;
-                    $user->transfer_today = 0;
-                    $text .= '流量已经被重置为' . $reset_traffic . 'GB';
-                }
-                $user->sendMail(
-                    $_ENV['appName'] . '-你的账户等级已经过期了',
-                    'warn.tpl',
-                    [
-                        'text' => $text,
-                    ],
-                    [],
-                    true
-                );
-                $user->class = 0;
-            }
-
-            $user->save();
-        }
-    }
-}

+ 77 - 0
src/Controllers/Admin/Setting/CronController.php

@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers\Admin\Setting;
+
+use App\Controllers\BaseController;
+use App\Models\Setting;
+use Exception;
+use function date;
+use function json_encode;
+use function mktime;
+
+final class CronController extends BaseController
+{
+    public static array $update_field = [
+        'daily_job_hour',
+        'daily_job_minute',
+    ];
+
+    /**
+     * @throws Exception
+     */
+    public function cron($request, $response, $args)
+    {
+        $settings = [];
+        $settings_raw = Setting::get(['item', 'value', 'type']);
+
+        foreach ($settings_raw as $setting) {
+            if ($setting->type === 'bool') {
+                $settings[$setting->item] = (bool) $setting->value;
+            } else {
+                $settings[$setting->item] = (string) $setting->value;
+            }
+        }
+
+        return $response->write(
+            $this->view()
+                ->assign('update_field', self::$update_field)
+                ->assign('settings', $settings)
+                ->fetch('admin/setting/cron.tpl')
+        );
+    }
+
+    public function saveCron($request, $response, $args)
+    {
+        $daily_job_hour = (int) $request->getParam('daily_job_hour');
+        $daily_job_minute = (int) $request->getParam('daily_job_minute');
+
+        if ($daily_job_hour < 0 || $daily_job_hour > 23) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '每日任务执行时间的小时数必须在 0-23 之间',
+            ]);
+        }
+
+        if ($daily_job_minute < 0 || $daily_job_minute > 59) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '每日任务执行时间的分钟数必须在 0-59 之间',
+            ]);
+        }
+
+        Setting::where('item', '=', 'daily_job_hour')->update([
+            'value' => $daily_job_hour,
+        ]);
+
+        Setting::where('item', '=', 'daily_job_minute')->update([
+            'value' => ($daily_job_minute - ($daily_job_minute % 5)),
+        ]);
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '保存成功',
+        ]);
+    }
+}

+ 1 - 0
src/Controllers/WebAPI/UserController.php

@@ -79,6 +79,7 @@ final class UserController extends BaseController
             WHERE
             WHERE
                 user.is_banned = 0
                 user.is_banned = 0
                 AND user.expire_in > CURRENT_TIMESTAMP()
                 AND user.expire_in > CURRENT_TIMESTAMP()
+                AND user.class_expire > CURRENT_TIMESTAMP()
                 AND (
                 AND (
                     (
                     (
                         user.class >= ?
                         user.class >= ?

+ 526 - 0
src/Services/CronJob.php

@@ -0,0 +1,526 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\Ann;
+use App\Models\DetectLog;
+use App\Models\EmailQueue;
+use App\Models\EmailVerify;
+use App\Models\Invoice;
+use App\Models\Node;
+use App\Models\OnlineLog;
+use App\Models\Order;
+use App\Models\PasswordReset;
+use App\Models\Setting;
+use App\Models\StreamMedia;
+use App\Models\TelegramSession;
+use App\Models\User;
+use App\Models\UserHourlyUsage;
+use App\Models\UserSubscribeLog;
+use App\Utils\Telegram;
+use App\Utils\Tools;
+use DateTime;
+use Exception;
+use Psr\Http\Client\ClientExceptionInterface;
+use Telegram\Bot\Exceptions\TelegramSDKException;
+use function array_map;
+use function date;
+use function in_array;
+use function json_decode;
+use function max;
+use function str_replace;
+use function strtotime;
+use function time;
+use const PHP_EOL;
+
+final class CronJob
+{
+    public static function addTrafficLog(): void
+    {
+        $users = User::all();
+
+        foreach ($users as $user) {
+            $transfer_total = $user->transfer_total;
+            $transfer_total_last = UserHourlyUsage::where('user_id', $user->id)->orderBy('id', 'desc')->first();
+
+            if ($transfer_total_last === null) {
+                $transfer_total_last = 0;
+            } else {
+                $transfer_total_last = $transfer_total_last->traffic;
+            }
+
+            $trafficlog = new UserHourlyUsage();
+            $trafficlog->user_id = $user->id;
+            $trafficlog->traffic = $transfer_total;
+            $trafficlog->hourly_usage = $transfer_total - $transfer_total_last;
+            $trafficlog->datetime = time();
+            $trafficlog->save();
+        }
+    }
+
+    public static function cleanDb(): void
+    {
+        UserSubscribeLog::where('request_time', '<', date('Y-m-d H:i:s', time() - 86400 * (int) $_ENV['subscribeLog_keep_days']))->delete();
+        UserHourlyUsage::where('datetime', '<', time() - 86400 * (int) $_ENV['trafficLog_keep_days'])->delete();
+        DetectLog::where('datetime', '<', time() - 86400 * 3)->delete();
+        EmailVerify::where('expire_in', '<', time() - 86400)->delete();
+        EmailQueue::where('time', '<', time() - 86400)->delete();
+        PasswordReset::where('expire_time', '<', time() - 86400)->delete();
+        OnlineLog::where('last_time', '<', time() - 86400)->delete();
+        StreamMedia::where('created_at', '<', time() - 86400)->delete();
+        TelegramSession::where('datetime', '<', time() - 900)->delete();
+    }
+
+    public static function cleanUser(): void
+    {
+        $freeUsers = User::where('class', 0)->get();
+
+        foreach ($freeUsers as $user) {
+            if (
+                $_ENV['account_expire_delete_days'] >= 0 &&
+                strtotime($user->expire_in) + $_ENV['account_expire_delete_days'] * 86400 < time() &&
+                $user->money <= $_ENV['auto_clean_min_money']
+            ) {
+                $user->sendMail(
+                    $_ENV['appName'] . '-你的账户因为过期被删除了',
+                    'warn.tpl',
+                    [
+                        'text' => '你好,系统发现你的账户已经过期 ' . $_ENV['account_expire_delete_days'] . ' 天了,帐号已经被删除。',
+                    ],
+                    [],
+                    true
+                );
+                $user->killUser();
+                continue;
+            }
+
+            if (
+                $_ENV['auto_clean_uncheck_days'] > 0 &&
+                max(
+                    $user->last_check_in_time,
+                    strtotime($user->reg_date)
+                ) + ($_ENV['auto_clean_uncheck_days'] * 86400) < time() &&
+                $user->class === 0 &&
+                $user->money <= $_ENV['auto_clean_min_money']
+            ) {
+                $user->sendMail(
+                    $_ENV['appName'] . '-你的账户因为未签到被删除了',
+                    'warn.tpl',
+                    [
+                        'text' => '你好,系统发现你的账号已经 ' . $_ENV['auto_clean_uncheck_days'] . ' 天没签到了,帐号已经被删除。',
+                    ],
+                    [],
+                    true
+                );
+                $user->killUser();
+                continue;
+            }
+
+            if (
+                $_ENV['auto_clean_unused_days'] > 0 &&
+                max($user->t, strtotime($user->reg_date)) + ($_ENV['auto_clean_unused_days'] * 86400) < time() &&
+                $user->class === 0 &&
+                $user->money <= $_ENV['auto_clean_min_money']
+            ) {
+                $user->sendMail(
+                    $_ENV['appName'] . '-你的账户因为闲置被删除了',
+                    'warn.tpl',
+                    [
+                        'text' => '你好,系统发现你的账号已经 ' . $_ENV['auto_clean_unused_days'] . ' 天没使用了,帐号已经被删除。',
+                    ],
+                    [],
+                    true
+                );
+                $user->killUser();
+            }
+        }
+    }
+
+    public static function detectNodeOffline(): void
+    {
+        $nodes = Node::where('type', 1)->get();
+        $adminUsers = User::where('is_admin', '=', '1')->get();
+
+        foreach ($nodes as $node) {
+            $notice_text = '';
+            if ($node->getNodeOnlineStatus() === -1 && $node->online === 1) {
+                foreach ($adminUsers as $user) {
+                    echo 'Send Node Offline Email to admin user: ' . $user->id . PHP_EOL;
+                    $user->sendMail(
+                        $_ENV['appName'] . '-系统警告',
+                        'warn.tpl',
+                        [
+                            'text' => '管理员你好,系统发现节点 ' . $node->name . ' 掉线了,请你及时处理。',
+                        ],
+                        [],
+                        false
+                    );
+                    $notice_text = str_replace(
+                        '%node_name%',
+                        $node->name,
+                        Setting::obtain('telegram_node_offline_text')
+                    );
+                }
+
+                if (Setting::obtain('telegram_node_offline')) {
+                    try {
+                        Telegram::send($notice_text);
+                    } catch (Exception $e) {
+                        echo $e->getMessage() . PHP_EOL;
+                    }
+                }
+
+                $node->online = false;
+                $node->save();
+            } elseif ($node->getNodeOnlineStatus() === 1 && $node->online === 0) {
+                foreach ($adminUsers as $user) {
+                    echo 'Send Node Online Email to admin user: ' . $user->id . PHP_EOL;
+                    $user->sendMail(
+                        $_ENV['appName'] . '-系统提示',
+                        'warn.tpl',
+                        [
+                            'text' => '管理员你好,系统发现节点 ' . $node->name . ' 恢复上线了。',
+                        ],
+                        [],
+                        false
+                    );
+                    $notice_text = str_replace(
+                        '%node_name%',
+                        $node->name,
+                        Setting::obtain('telegram_node_online_text')
+                    );
+                }
+
+                if (Setting::obtain('telegram_node_online')) {
+                    try {
+                        Telegram::send($notice_text);
+                    } catch (Exception $e) {
+                        echo $e->getMessage() . PHP_EOL;
+                    }
+                }
+
+                $node->online = true;
+                $node->save();
+            }
+        }
+        echo '节点掉线检测结束' . PHP_EOL;
+    }
+
+    public static function expirePaidUserAccount(): void
+    {
+        $paidUsers = User::where('class', '>', 0)->get();
+
+        foreach ($paidUsers as $user) {
+            if (strtotime($user->class_expire) < time()) {
+                $text = '你好,系统发现你的账号等级已经过期了。';
+                $reset_traffic = $_ENV['class_expire_reset_traffic'];
+
+                if ($reset_traffic >= 0) {
+                    $user->transfer_enable = Tools::toGB($reset_traffic);
+                    $text .= '流量已经被重置为' . $reset_traffic . 'GB';
+                }
+
+                $user->sendMail(
+                    $_ENV['appName'] . '-你的账户等级已经过期了',
+                    'warn.tpl',
+                    [
+                        'text' => $text,
+                    ],
+                    [],
+                    true
+                );
+
+                $user->u = 0;
+                $user->d = 0;
+                $user->transfer_today = 0;
+                $user->class = 0;
+                $user->save();
+            }
+        }
+    }
+
+    // This shit should be removed but kept for compatibility reason, for now. User account should never expire.
+    public static function expireFreeUserAccount(): void
+    {
+        $freeUsers = User::where('class', 0)->get();
+
+        foreach ($freeUsers as $user) {
+            if (strtotime($user->expire_in) < time() && ! $user->expire_notified) {
+                $user->transfer_enable = 0;
+                $user->u = 0;
+                $user->d = 0;
+                $user->transfer_today = 0;
+
+                $user->sendMail(
+                    $_ENV['appName'] . '-你的账户已经过期了',
+                    'warn.tpl',
+                    [
+                        'text' => '你好,系统发现你的账号已经过期了。',
+                    ],
+                    [],
+                    true
+                );
+
+                $user->expire_notified = true;
+                $user->save();
+            } elseif (strtotime($user->expire_in) > time() && $user->expire_notified) {
+                $user->expire_notified = false;
+                $user->save();
+            }
+        }
+    }
+
+    public static function processEmailQueue(): void
+    {
+        //记录当前时间戳
+        $timestamp = time();
+        //邮件队列处理
+        while (true) {
+            if (time() - $timestamp > 299) {
+                echo '邮件队列处理超时,已跳过' . PHP_EOL;
+                break;
+            }
+            DB::beginTransaction();
+            $email_queues_raw = DB::select('SELECT * FROM email_queue LIMIT 1 FOR UPDATE SKIP LOCKED');
+            if (count($email_queues_raw) === 0) {
+                DB::commit();
+                break;
+            }
+            $email_queues = array_map(static function ($value) {
+                return (array) $value;
+            }, $email_queues_raw);
+            $email_queue = $email_queues[0];
+            echo '发送邮件至 ' . $email_queue['to_email'] . PHP_EOL;
+            DB::delete('DELETE FROM email_queue WHERE id = ?', [$email_queue['id']]);
+            if (Tools::isEmail($email_queue['to_email'])) {
+                try {
+                    Mail::send($email_queue['to_email'], $email_queue['subject'], $email_queue['template'], json_decode($email_queue['array']));
+                } catch (Exception|ClientExceptionInterface $e) {
+                    echo $e->getMessage();
+                }
+            } else {
+                echo $email_queue['to_email'] . ' 邮箱格式错误,已跳过' . PHP_EOL;
+            }
+            DB::commit();
+        }
+    }
+
+    public static function processOrderActivation(): void
+    {
+        $users = User::all();
+
+        foreach ($users as $user) {
+            $user_id = $user->id;
+            // 获取用户账户等待激活的订单
+            $pending_activation_orders = Order::where('user_id', $user_id)->where('status', 'pending_activation')->orderBy('id', 'asc')->get();
+            // 获取用户账户已激活的订单,一个用户同时只能有一个已激活的订单
+            $activated_order = Order::where('user_id', $user_id)->where('status', 'activated')->orderBy('id', 'asc')->first();
+            // 如果用户账户中没有已激活的订单,且有等待激活的订单,则激活最早的等待激活订单
+            if ($activated_order === null && count($pending_activation_orders) > 0) {
+                $order = $pending_activation_orders[0];
+                // 获取订单内容准备激活
+                $content = json_decode($order->product_content);
+                // 激活商品
+                $user->u = 0;
+                $user->d = 0;
+                $user->transfer_today = 0;
+                $user->transfer_enable = Tools::toGB($content->bandwidth);
+                $user->class = $content->class;
+                $old_expire_in = new DateTime();
+                $old_class_expire = new DateTime();
+                $user->expire_in = $old_expire_in->modify('+' . $content->time . ' days')->format('Y-m-d H:i:s');
+                $user->class_expire = $old_class_expire->modify('+' . $content->class_time . ' days')->format('Y-m-d H:i:s');
+                $user->node_group = $content->node_group;
+                $user->node_speedlimit = $content->speed_limit;
+                $user->node_iplimit = $content->ip_limit;
+                $user->save();
+                $order->status = 'activated';
+                $order->update_time = time();
+                $order->save();
+                echo "订单 #{$order->id} 已激活。\n";
+                continue;
+            }
+            // 如果用户账户中有已激活的订单,则判断是否过期
+            if ($activated_order !== null) {
+                $content = json_decode($activated_order->product_content);
+                if ($activated_order->update_time + $content->time * 86400 < time()) {
+                    $activated_order->status = 'expired';
+                    $activated_order->update_time = time();
+                    $activated_order->save();
+                    echo "订单 #{$activated_order->id} 已过期。\n";
+                }
+            }
+        }
+    }
+
+    public static function processPendingOrder(): void
+    {
+        $pending_payment_orders = Order::where('status', 'pending_payment')->get();
+
+        foreach ($pending_payment_orders as $order) {
+            // 检查账单支付状态
+            $invoice = Invoice::where('order_id', $order->id)->first();
+
+            if ($invoice === null) {
+                continue;
+            }
+            // 标记订单为等待激活
+            if (in_array($invoice->status, ['paid_gateway', 'paid_balance', 'paid_admin'])) {
+                $order->status = 'pending_activation';
+                $order->update_time = time();
+                $order->save();
+                echo "已标记订单 #{$order->id} 为等待激活。\n";
+                continue;
+            }
+            // 取消超时未支付的订单和关联账单
+            if ($order->create_time + 86400 < time()) {
+                $order->status = 'cancelled';
+                $order->update_time = time();
+                $order->save();
+                echo "已取消超时订单 #{$order->id}。\n";
+                $invoice->status = 'cancelled';
+                $invoice->update_time = time();
+                $invoice->save();
+                echo "已取消超时账单 #{$invoice->id}。\n";
+            }
+        }
+    }
+
+    public static function resetNodeBandwidth(): void
+    {
+        Node::where('bandwidthlimit_resetday', date('d'))->update(['node_bandwidth' => 0]);
+    }
+
+    public static function resetTodayTraffic(): void
+    {
+        User::query()->update(['transfer_today' => 0]);
+    }
+
+    public static function resetFreeUserTraffic(): void
+    {
+        $freeUsers = User::where('class', 0)->where('auto_reset_day', date('d'))->get();
+
+        foreach ($freeUsers as $user) {
+            $user->u = 0;
+            $user->d = 0;
+            $user->transfer_enable = $user->auto_reset_bandwidth * 1024 * 1024 * 1024;
+            $user->save();
+
+            $user->sendMail(
+                $_ENV['appName'] . '-你的免费流量被重置了',
+                'warn.tpl',
+                [
+                    'text' => '你好,你的免费流量已经被重置为' . $user->auto_reset_bandwidth . 'GB',
+                ],
+                [],
+                true
+            );
+        }
+    }
+
+    public static function sendPaidUserUsageLimitNotification(): void
+    {
+        $paidUsers = User::where('class', '>', 0)->get();
+
+        foreach ($paidUsers as $user) {
+            $user_traffic_left = $user->transfer_enable - $user->u - $user->d;
+            $under_limit = false;
+            $unit_text = '';
+
+            if (
+                $_ENV['notify_limit_mode'] === 'per' &&
+                $user_traffic_left / $user->transfer_enable * 100 < $_ENV['notify_limit_value']
+            ) {
+                $under_limit = true;
+                $unit_text = '%';
+            } elseif (
+                $_ENV['notify_limit_mode'] === 'mb' &&
+                Tools::flowToMB($user_traffic_left) < $_ENV['notify_limit_value']
+            ) {
+                $under_limit = true;
+                $unit_text = 'MB';
+            }
+
+            if ($under_limit && ! $user->traffic_notified) {
+                $result = $user->sendMail(
+                    $_ENV['appName'] . '-你的剩余流量过低',
+                    'warn.tpl',
+                    [
+                        'text' => '你好,系统发现你剩余流量已经低于 ' . $_ENV['notify_limit_value'] . $unit_text . ' 。',
+                    ],
+                    [],
+                    true
+                );
+                if ($result) {
+                    $user->traffic_notified = true;
+                    $user->save();
+                }
+            } elseif (! $under_limit && $user->traffic_notified) {
+                $user->traffic_notified = false;
+                $user->save();
+            }
+        }
+    }
+
+    public static function sendDailyTrafficReport(): void
+    {
+        $users = User::where('sendDailyMail', 1)->get();
+
+        $ann_latest_raw = Ann::orderBy('date', 'desc')->first();
+
+        if ($ann_latest_raw === null) {
+            $ann_latest = '<br><br>';
+        } else {
+            $ann_latest = $ann_latest_raw->content . '<br><br>';
+        }
+
+        foreach ($users as $user) {
+            $user->sendDailyNotification($ann_latest);
+        }
+    }
+
+    /**
+     * @throws TelegramSDKException
+     */
+    public static function sendTelegramDailyJob(): void
+    {
+        Telegram::send(Setting::obtain('telegram_daily_job_text'));
+    }
+
+    /**
+     * @throws TelegramSDKException
+     */
+    public static function sendTelegramDiary(): void
+    {
+        $Analytics = new Analytics();
+
+        Telegram::send(
+            str_replace(
+                [
+                    '%getTodayCheckinUser%',
+                    '%lastday_total%',
+                ],
+                [
+                    $Analytics->getTodayCheckinUser(),
+                    $Analytics->getTodayTrafficUsage(),
+                ],
+                Setting::obtain('telegram_diary_text')
+            )
+        );
+    }
+
+    public static function updateNodeIp(): void
+    {
+        $nodes = Node::where('type', 1)->get();
+
+        foreach ($nodes as $node) {
+            $server = $node->server;
+            if (! Tools::isIPv4($server) && ! Tools::isIPv6($server)) {
+                $node->changeNodeIp($server);
+                $node->save();
+            }
+        }
+    }
+}