From 2c6a38c80dc15102fbeab58e4140a79c40a7c0f7 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 7 Apr 2026 16:54:24 +0800 Subject: [PATCH] first commit --- Xboard/.docker/.data/.gitignore | 3 + Xboard/.docker/.data/redis/.gitignore | 2 + Xboard/.docker/supervisor/supervisord.conf | 81 ++ Xboard/.dockerignore | 26 + Xboard/.editorconfig | 15 + Xboard/.env.example | 41 + Xboard/.gitattributes | 5 + Xboard/.github/ISSUE_TEMPLATE/bug-report.md | 39 + .../.github/ISSUE_TEMPLATE/feature-request.md | 28 + Xboard/.github/workflows/docker-publish.yml | 100 +++ Xboard/.gitignore | 34 + Xboard/.gitmodules | 3 + Xboard/Dockerfile | 47 ++ Xboard/LICENSE | 21 + Xboard/README.md | 100 +++ .../app/Console/Commands/BackupDatabase.php | 100 +++ .../app/Console/Commands/CheckCommission.php | 129 +++ Xboard/app/Console/Commands/CheckOrder.php | 53 ++ Xboard/app/Console/Commands/CheckServer.php | 64 ++ Xboard/app/Console/Commands/CheckTicket.php | 51 ++ .../Console/Commands/CheckTrafficExceeded.php | 63 ++ Xboard/app/Console/Commands/ClearUser.php | 51 ++ Xboard/app/Console/Commands/HookList.php | 42 + .../app/Console/Commands/MigrateFromV2b.php | 186 +++++ .../Console/Commands/NodeWebSocketServer.php | 34 + Xboard/app/Console/Commands/ResetLog.php | 48 ++ Xboard/app/Console/Commands/ResetPassword.php | 55 ++ Xboard/app/Console/Commands/ResetTraffic.php | 289 +++++++ Xboard/app/Console/Commands/ResetUser.php | 58 ++ .../app/Console/Commands/SendRemindMail.php | 103 +++ Xboard/app/Console/Commands/Test.php | 41 + Xboard/app/Console/Commands/XboardInstall.php | 397 +++++++++ .../app/Console/Commands/XboardRollback.php | 45 ++ .../app/Console/Commands/XboardStatistics.php | 75 ++ Xboard/app/Console/Commands/XboardUpdate.php | 65 ++ Xboard/app/Console/Kernel.php | 68 ++ Xboard/app/Contracts/PaymentInterface.php | 10 + Xboard/app/Exceptions/ApiException.php | 23 + Xboard/app/Exceptions/BusinessException.php | 19 + Xboard/app/Exceptions/Handler.php | 101 +++ Xboard/app/Helpers/ApiResponse.php | 79 ++ Xboard/app/Helpers/Functions.php | 82 ++ Xboard/app/Helpers/ResponseEnum.php | 81 ++ Xboard/app/Http/Controllers/Controller.php | 13 + .../app/Http/Controllers/PluginController.php | 26 + .../Controllers/V1/Client/AppController.php | 90 +++ .../V1/Client/ClientController.php | 247 ++++++ .../Controllers/V1/Guest/CommController.php | 39 + .../V1/Guest/PaymentController.php | 52 ++ .../Controllers/V1/Guest/PlanController.php | 25 + .../V1/Guest/TelegramController.php | 126 +++ .../V1/Passport/AuthController.php | 175 ++++ .../V1/Passport/CommController.php | 76 ++ .../Server/ShadowsocksTidalabController.php | 65 ++ .../V1/Server/TrojanTidalabController.php | 108 +++ .../V1/Server/UniProxyController.php | 178 ++++ .../Controllers/V1/User/CommController.php | 39 + .../Controllers/V1/User/CouponController.php | 25 + .../V1/User/GiftCardController.php | 193 +++++ .../Controllers/V1/User/InviteController.php | 79 ++ .../V1/User/KnowledgeController.php | 150 ++++ .../Controllers/V1/User/NoticeController.php | 26 + .../Controllers/V1/User/OrderController.php | 212 +++++ .../Controllers/V1/User/PlanController.php | 38 + .../Controllers/V1/User/ServerController.php | 31 + .../Controllers/V1/User/StatController.php | 26 + .../V1/User/TelegramController.php | 26 + .../Controllers/V1/User/TicketController.php | 154 ++++ .../Controllers/V1/User/UserController.php | 223 ++++++ .../Controllers/V2/Admin/ConfigController.php | 300 +++++++ .../Controllers/V2/Admin/CouponController.php | 186 +++++ .../V2/Admin/GiftCardController.php | 622 ++++++++++++++ .../V2/Admin/KnowledgeController.php | 113 +++ .../Controllers/V2/Admin/NoticeController.php | 101 +++ .../Controllers/V2/Admin/OrderController.php | 252 ++++++ .../V2/Admin/PaymentController.php | 133 +++ .../Controllers/V2/Admin/PlanController.php | 132 +++ .../Controllers/V2/Admin/PluginController.php | 333 ++++++++ .../V2/Admin/Server/GroupController.php | 66 ++ .../V2/Admin/Server/ManageController.php | 219 +++++ .../V2/Admin/Server/RouteController.php | 64 ++ .../Controllers/V2/Admin/StatController.php | 508 ++++++++++++ .../Controllers/V2/Admin/SystemController.php | 144 ++++ .../Controllers/V2/Admin/ThemeController.php | 150 ++++ .../Controllers/V2/Admin/TicketController.php | 156 ++++ .../V2/Admin/TrafficResetController.php | 235 ++++++ .../Controllers/V2/Admin/UpdateController.php | 28 + .../Controllers/V2/Admin/UserController.php | 682 ++++++++++++++++ .../Controllers/V2/Client/AppController.php | 153 ++++ .../V2/Server/ServerController.php | 138 ++++ Xboard/app/Http/Kernel.php | 99 +++ Xboard/app/Http/Middleware/Admin.php | 30 + .../Http/Middleware/ApplyRuntimeSettings.php | 25 + Xboard/app/Http/Middleware/Authenticate.php | 17 + .../Middleware/CheckForMaintenanceMode.php | 18 + Xboard/app/Http/Middleware/Client.php | 33 + Xboard/app/Http/Middleware/EncryptCookies.php | 16 + .../Middleware/EnsureTransactionState.php | 29 + Xboard/app/Http/Middleware/ForceJson.php | 22 + .../app/Http/Middleware/InitializePlugins.php | 37 + Xboard/app/Http/Middleware/Language.php | 17 + .../Middleware/RedirectIfAuthenticated.php | 26 + Xboard/app/Http/Middleware/RequestLog.php | 60 ++ Xboard/app/Http/Middleware/Server.php | 59 ++ Xboard/app/Http/Middleware/Staff.php | 30 + Xboard/app/Http/Middleware/TrimStrings.php | 19 + Xboard/app/Http/Middleware/TrustProxies.php | 47 ++ Xboard/app/Http/Middleware/User.php | 27 + .../app/Http/Middleware/VerifyCsrfToken.php | 22 + Xboard/app/Http/Requests/Admin/ConfigSave.php | 146 ++++ .../Http/Requests/Admin/CouponGenerate.php | 51 ++ .../Requests/Admin/KnowledgeCategorySave.php | 29 + .../Requests/Admin/KnowledgeCategorySort.php | 28 + .../app/Http/Requests/Admin/KnowledgeSave.php | 35 + .../app/Http/Requests/Admin/KnowledgeSort.php | 28 + Xboard/app/Http/Requests/Admin/MailSend.php | 34 + Xboard/app/Http/Requests/Admin/NoticeSave.php | 33 + .../app/Http/Requests/Admin/OrderAssign.php | 34 + Xboard/app/Http/Requests/Admin/OrderFetch.php | 32 + .../app/Http/Requests/Admin/OrderUpdate.php | 29 + Xboard/app/Http/Requests/Admin/PlanSave.php | 157 ++++ Xboard/app/Http/Requests/Admin/PlanSort.php | 28 + Xboard/app/Http/Requests/Admin/PlanUpdate.php | 29 + Xboard/app/Http/Requests/Admin/ServerSave.php | 212 +++++ Xboard/app/Http/Requests/Admin/UserFetch.php | 33 + .../app/Http/Requests/Admin/UserGenerate.php | 33 + .../app/Http/Requests/Admin/UserSendMail.php | 29 + Xboard/app/Http/Requests/Admin/UserUpdate.php | 69 ++ .../app/Http/Requests/Passport/AuthForget.php | 33 + .../app/Http/Requests/Passport/AuthLogin.php | 31 + .../Http/Requests/Passport/AuthRegister.php | 31 + .../Requests/Passport/CommSendEmailVerify.php | 28 + Xboard/app/Http/Requests/Staff/UserUpdate.php | 56 ++ .../Requests/User/GiftCardCheckRequest.php | 28 + .../Requests/User/GiftCardRedeemRequest.php | 44 + Xboard/app/Http/Requests/User/OrderSave.php | 30 + Xboard/app/Http/Requests/User/TicketSave.php | 32 + .../app/Http/Requests/User/TicketWithdraw.php | 29 + .../Http/Requests/User/UserChangePassword.php | 30 + .../app/Http/Requests/User/UserTransfer.php | 29 + Xboard/app/Http/Requests/User/UserUpdate.php | 29 + .../Http/Resources/ComissionLogResource.php | 25 + Xboard/app/Http/Resources/CouponResource.php | 38 + .../app/Http/Resources/InviteCodeResource.php | 28 + .../app/Http/Resources/KnowledgeResource.php | 28 + Xboard/app/Http/Resources/MessageResource.php | 26 + Xboard/app/Http/Resources/NodeResource.php | 29 + Xboard/app/Http/Resources/OrderResource.php | 28 + Xboard/app/Http/Resources/PlanResource.php | 122 +++ Xboard/app/Http/Resources/TicketResource.php | 31 + .../app/Http/Resources/TrafficLogResource.php | 26 + Xboard/app/Http/Routes/V1/ClientRoute.php | 23 + Xboard/app/Http/Routes/V1/GuestRoute.php | 27 + Xboard/app/Http/Routes/V1/PassportRoute.php | 27 + Xboard/app/Http/Routes/V1/ServerRoute.php | 45 ++ Xboard/app/Http/Routes/V1/UserRoute.php | 83 ++ Xboard/app/Http/Routes/V2/AdminRoute.php | 276 +++++++ Xboard/app/Http/Routes/V2/ClientRoute.php | 20 + Xboard/app/Http/Routes/V2/PassportRoute.php | 27 + Xboard/app/Http/Routes/V2/ServerRoute.php | 29 + Xboard/app/Http/Routes/V2/UserRoute.php | 20 + Xboard/app/Jobs/NodeUserSyncJob.php | 45 ++ Xboard/app/Jobs/OrderHandleJob.php | 56 ++ Xboard/app/Jobs/SendEmailJob.php | 42 + Xboard/app/Jobs/SendTelegramJob.php | 43 + Xboard/app/Jobs/StatServerJob.php | 174 ++++ Xboard/app/Jobs/StatUserJob.php | 158 ++++ Xboard/app/Jobs/TrafficFetchJob.php | 51 ++ Xboard/app/Models/AdminAuditLog.php | 21 + Xboard/app/Models/CommissionLog.php | 16 + Xboard/app/Models/Coupon.php | 28 + Xboard/app/Models/GiftCardCode.php | 260 ++++++ Xboard/app/Models/GiftCardTemplate.php | 254 ++++++ Xboard/app/Models/GiftCardUsage.php | 112 +++ Xboard/app/Models/InviteCode.php | 19 + Xboard/app/Models/Knowledge.php | 17 + Xboard/app/Models/MailLog.php | 16 + Xboard/app/Models/Notice.php | 18 + Xboard/app/Models/Order.php | 120 +++ Xboard/app/Models/Payment.php | 18 + Xboard/app/Models/Plan.php | 353 ++++++++ Xboard/app/Models/Plugin.php | 77 ++ Xboard/app/Models/Server.php | 563 +++++++++++++ Xboard/app/Models/ServerGroup.php | 46 ++ Xboard/app/Models/ServerLog.php | 16 + Xboard/app/Models/ServerRoute.php | 17 + Xboard/app/Models/ServerStat.php | 16 + Xboard/app/Models/Setting.php | 68 ++ Xboard/app/Models/Stat.php | 16 + Xboard/app/Models/StatServer.php | 33 + Xboard/app/Models/StatUser.php | 28 + Xboard/app/Models/SubscribeTemplate.php | 46 ++ Xboard/app/Models/Ticket.php | 60 ++ Xboard/app/Models/TicketMessage.php | 56 ++ Xboard/app/Models/TrafficResetLog.php | 149 ++++ Xboard/app/Models/User.php | 215 +++++ Xboard/app/Observers/PlanObserver.php | 35 + Xboard/app/Observers/ServerObserver.php | 37 + Xboard/app/Observers/ServerRouteObserver.php | 31 + Xboard/app/Observers/UserObserver.php | 53 ++ Xboard/app/Protocols/Clash.php | 332 ++++++++ Xboard/app/Protocols/ClashMeta.php | 708 ++++++++++++++++ Xboard/app/Protocols/General.php | 448 +++++++++++ Xboard/app/Protocols/Loon.php | 357 +++++++++ Xboard/app/Protocols/QuantumultX.php | 232 ++++++ Xboard/app/Protocols/Shadowrocket.php | 415 ++++++++++ Xboard/app/Protocols/Shadowsocks.php | 60 ++ Xboard/app/Protocols/SingBox.php | 757 ++++++++++++++++++ Xboard/app/Protocols/Stash.php | 587 ++++++++++++++ Xboard/app/Protocols/Surfboard.php | 229 ++++++ Xboard/app/Protocols/Surge.php | 319 ++++++++ Xboard/app/Providers/AuthServiceProvider.php | 28 + .../Providers/BroadcastServiceProvider.php | 21 + Xboard/app/Providers/EventServiceProvider.php | 40 + .../app/Providers/HorizonServiceProvider.php | 43 + .../app/Providers/OctaneServiceProvider.php | 43 + .../app/Providers/PluginServiceProvider.php | 29 + .../app/Providers/ProtocolServiceProvider.php | 50 ++ Xboard/app/Providers/RouteServiceProvider.php | 87 ++ .../app/Providers/SettingServiceProvider.php | 33 + Xboard/app/Scope/FilterScope.php | 50 ++ Xboard/app/Services/Auth/LoginService.php | 154 ++++ Xboard/app/Services/Auth/MailLinkService.php | 100 +++ Xboard/app/Services/Auth/RegisterService.php | 193 +++++ Xboard/app/Services/AuthService.php | 87 ++ Xboard/app/Services/CaptchaService.php | 112 +++ Xboard/app/Services/CouponService.php | 122 +++ Xboard/app/Services/DeviceStateService.php | 187 +++++ Xboard/app/Services/GiftCardService.php | 334 ++++++++ Xboard/app/Services/MailService.php | 295 +++++++ Xboard/app/Services/NodeRegistry.php | 77 ++ Xboard/app/Services/NodeSyncService.php | 143 ++++ Xboard/app/Services/OrderService.php | 429 ++++++++++ Xboard/app/Services/PaymentService.php | 135 ++++ Xboard/app/Services/PlanService.php | 194 +++++ Xboard/app/Services/Plugin/AbstractPlugin.php | 222 +++++ Xboard/app/Services/Plugin/HookManager.php | 286 +++++++ .../Plugin/InterceptResponseException.php | 22 + .../Services/Plugin/PluginConfigService.php | 111 +++ Xboard/app/Services/Plugin/PluginManager.php | 727 +++++++++++++++++ Xboard/app/Services/ServerService.php | 296 +++++++ Xboard/app/Services/SettingService.php | 18 + Xboard/app/Services/StatisticalService.php | 378 +++++++++ Xboard/app/Services/TelegramService.php | 160 ++++ Xboard/app/Services/ThemeService.php | 424 ++++++++++ Xboard/app/Services/TicketService.php | 125 +++ Xboard/app/Services/TrafficResetService.php | 415 ++++++++++ Xboard/app/Services/UpdateService.php | 458 +++++++++++ Xboard/app/Services/UserService.php | 288 +++++++ Xboard/app/Support/AbstractProtocol.php | 262 ++++++ Xboard/app/Support/ProtocolManager.php | 162 ++++ Xboard/app/Support/Setting.php | 140 ++++ Xboard/app/Traits/HasPluginConfig.php | 144 ++++ Xboard/app/Traits/QueryOperators.php | 68 ++ Xboard/app/Utils/CacheKey.php | 69 ++ Xboard/app/Utils/Dict.php | 23 + Xboard/app/Utils/Helper.php | 243 ++++++ Xboard/app/WebSocket/NodeEventHandlers.php | 144 ++++ Xboard/app/WebSocket/NodeWorker.php | 249 ++++++ Xboard/artisan | 53 ++ Xboard/bootstrap/app.php | 55 ++ Xboard/bootstrap/cache/.gitignore | 2 + Xboard/compose.sample.yaml | 44 + Xboard/composer.json | 98 +++ Xboard/config/app.php | 193 +++++ Xboard/config/auth.php | 103 +++ Xboard/config/broadcasting.php | 59 ++ Xboard/config/cache.php | 107 +++ Xboard/config/cloud_storage.php | 10 + Xboard/config/cors.php | 34 + Xboard/config/database.php | 157 ++++ Xboard/config/debugbar.php | 275 +++++++ Xboard/config/filesystems.php | 69 ++ Xboard/config/hashing.php | 52 ++ Xboard/config/hidden_features.php | 5 + Xboard/config/horizon.php | 228 ++++++ Xboard/config/logging.php | 64 ++ Xboard/config/mail.php | 136 ++++ Xboard/config/octane.php | 221 +++++ Xboard/config/queue.php | 88 ++ Xboard/config/sanctum.php | 83 ++ Xboard/config/scribe.php | 270 +++++++ Xboard/config/services.php | 33 + Xboard/config/session.php | 199 +++++ Xboard/config/theme/.gitignore | 2 + Xboard/config/view.php | 36 + Xboard/database/.gitignore | 2 + Xboard/database/factories/UserFactory.php | 28 + ..._08_19_000000_create_failed_jobs_table.php | 37 + ...01_create_personal_access_tokens_table.php | 34 + .../2023_03_19_000000_create_v2_tables.php | 496 ++++++++++++ ..._08_14_221234_create_v2_settings_table.php | 35 + ...23_add_column_excludes_to_server_table.php | 56 ++ ..._195956_add_column_ips_to_server_table.php | 56 ++ ...d_column_alpn_to_server_hysteria_table.php | 32 + ...d_network_settings_to_v2_server_trojan.php | 33 + ...n_and_is_obfs_to_server_hysteria_table.php | 33 + ...ange_column_value_to_v2_settings_table.php | 28 + ...2_12_212239_add_index_to_v2_user_table.php | 28 + ...modify_icon_column_to_v2_payment_table.php | 28 + ...fy_commission_status_in_v2_order_table.php | 28 + .../2025_01_04_optimize_plan_table.php | 129 +++ ...25_01_05_131425_create_v2_server_table.php | 523 ++++++++++++ ...5_01_10_152139_add_device_limit_column.php | 31 + ..._12_190315_add_sort_to_v2_notice_table.php | 30 + ...fy_commission_status_in_v2_order_table.php | 33 + ..._13_000000_convert_order_period_fields.php | 56 ++ ...15_000002_add_stat_performance_indexes.php | 93 +++ ...add_updated_at_index_to_v2_order_table.php | 28 + ...2025_01_18_140511_create_plugins_table.php | 33 + ...6_21_000001_optimize_v2_settings_table.php | 37 + ...000002_create_traffic_reset_logs_table.php | 43 + ...0003_add_traffic_reset_fields_to_users.php | 39 + ...07_01_081556_add_tags_to_v2_plan_table.php | 28 + ...5_07_01_122908_create_gift_card_tables.php | 98 +++ ...mn_rate_time_ranges_to_v2_server_table.php | 29 + ...07_26_000001_add_type_to_plugins_table.php | 24 + ...01_create_v2_subscribe_templates_table.php | 91 +++ ...01_replace_v2_log_with_admin_audit_log.php | 28 + ...1_000002_add_stat_user_record_at_index.php | 22 + ...tom_config_and_cert_to_v2_server_table.php | 30 + ...8_161536_add_traffic_fields_to_servers.php | 46 ++ Xboard/database/seeders/DatabaseSeeder.php | 17 + .../OriginV2bMigrationsTableSeeder.php | 170 ++++ Xboard/docs/en/development/device-limit.md | 176 ++++ Xboard/docs/en/development/performance.md | 100 +++ .../development/plugin-development-guide.md | 691 ++++++++++++++++ Xboard/docs/en/installation/1panel.md | 210 +++++ Xboard/docs/en/installation/aapanel-docker.md | 151 ++++ Xboard/docs/en/installation/aapanel.md | 210 +++++ Xboard/docs/en/installation/docker-compose.md | 77 ++ Xboard/docs/en/migration/config.md | 54 ++ Xboard/docs/en/migration/v2board-1.7.3.md | 63 ++ Xboard/docs/en/migration/v2board-1.7.4.md | 51 ++ Xboard/docs/en/migration/v2board-dev.md | 61 ++ Xboard/docs/images/admin.png | Bin 0 -> 1423703 bytes Xboard/docs/images/user.png | Bin 0 -> 500933 bytes Xboard/init.sh | 15 + Xboard/package.json | 5 + Xboard/phpstan.neon | 17 + Xboard/plugins/.gitignore | 10 + .../UserOnlineDevicesController.php | 164 ++++ Xboard/plugins/UserOnlineDevices/Plugin.php | 36 + Xboard/plugins/UserOnlineDevices/README.md | 23 + Xboard/plugins/UserOnlineDevices/config.json | 37 + .../resources/views/panel.blade.php | 352 ++++++++ .../plugins/UserOnlineDevices/routes/api.php | 12 + .../plugins/UserOnlineDevices/routes/web.php | 18 + Xboard/public/index.php | 60 ++ Xboard/public/robots.txt | 2 + Xboard/public/theme/.gitignore | 2 + Xboard/public/web.config | 28 + Xboard/resources/js/app.js | 1 + Xboard/resources/js/bootstrap.js | 28 + Xboard/resources/lang/en-US.json | 148 ++++ Xboard/resources/lang/ru-RU.json | 148 ++++ Xboard/resources/lang/zh-CN.json | 148 ++++ Xboard/resources/lang/zh-TW.json | 148 ++++ Xboard/resources/rules/.gitignore | 1 + Xboard/resources/rules/app.clash.yaml | 557 +++++++++++++ Xboard/resources/rules/default.clash.yaml | 286 +++++++ Xboard/resources/rules/default.sing-box.json | 138 ++++ Xboard/resources/rules/default.surfboard.conf | 576 +++++++++++++ Xboard/resources/rules/default.surge.conf | 595 ++++++++++++++ Xboard/resources/sass/app.scss | 1 + Xboard/resources/views/admin.blade.php | 86 ++ .../views/client/subscribe.blade.php | 296 +++++++ Xboard/resources/views/errors/500.blade.php | 5 + .../views/mail/classic/mailLogin.blade.php | 195 +++++ .../views/mail/classic/notify.blade.php | 187 +++++ .../views/mail/classic/remindExpire.blade.php | 187 +++++ .../mail/classic/remindTraffic.blade.php | 187 +++++ .../views/mail/classic/verify.blade.php | 195 +++++ .../views/mail/default/mailLogin.blade.php | 43 + .../views/mail/default/notify.blade.php | 42 + .../views/mail/default/remindExpire.blade.php | 42 + .../mail/default/remindTraffic.blade.php | 42 + .../views/mail/default/verify.blade.php | 42 + Xboard/routes/channels.php | 19 + Xboard/routes/console.php | 19 + Xboard/routes/web.php | 92 +++ Xboard/storage/backup/.gitignore | 2 + Xboard/storage/debugbar/.gitignore | 2 + Xboard/storage/framework/cache/.gitignore | 2 + Xboard/storage/framework/sessions/.gitignore | 2 + Xboard/storage/framework/views/.gitignore | 2 + Xboard/storage/logs/.gitignore | 2 + Xboard/storage/theme/.gitignore | 2 + Xboard/storage/tmp/.gitignore | 2 + Xboard/storage/views/.gitignore | 2 + Xboard/theme/.gitignore | 4 + .../theme/Xboard/assets/images/background.svg | 1 + Xboard/theme/Xboard/assets/umi.js | 2 + Xboard/theme/Xboard/config.json | 33 + Xboard/theme/Xboard/dashboard.blade.php | 264 ++++++ Xboard/theme/Xboard/env.example.js | 19 + Xboard/theme/Xboard/env.js | 18 + Xboard/theme/Xboard/index.html | 1 + Xboard/update.sh | 27 + 399 files changed, 42205 insertions(+) create mode 100644 Xboard/.docker/.data/.gitignore create mode 100644 Xboard/.docker/.data/redis/.gitignore create mode 100644 Xboard/.docker/supervisor/supervisord.conf create mode 100644 Xboard/.dockerignore create mode 100644 Xboard/.editorconfig create mode 100644 Xboard/.env.example create mode 100644 Xboard/.gitattributes create mode 100644 Xboard/.github/ISSUE_TEMPLATE/bug-report.md create mode 100644 Xboard/.github/ISSUE_TEMPLATE/feature-request.md create mode 100644 Xboard/.github/workflows/docker-publish.yml create mode 100644 Xboard/.gitignore create mode 100644 Xboard/.gitmodules create mode 100644 Xboard/Dockerfile create mode 100644 Xboard/LICENSE create mode 100644 Xboard/README.md create mode 100644 Xboard/app/Console/Commands/BackupDatabase.php create mode 100644 Xboard/app/Console/Commands/CheckCommission.php create mode 100644 Xboard/app/Console/Commands/CheckOrder.php create mode 100644 Xboard/app/Console/Commands/CheckServer.php create mode 100644 Xboard/app/Console/Commands/CheckTicket.php create mode 100644 Xboard/app/Console/Commands/CheckTrafficExceeded.php create mode 100644 Xboard/app/Console/Commands/ClearUser.php create mode 100644 Xboard/app/Console/Commands/HookList.php create mode 100644 Xboard/app/Console/Commands/MigrateFromV2b.php create mode 100644 Xboard/app/Console/Commands/NodeWebSocketServer.php create mode 100644 Xboard/app/Console/Commands/ResetLog.php create mode 100644 Xboard/app/Console/Commands/ResetPassword.php create mode 100644 Xboard/app/Console/Commands/ResetTraffic.php create mode 100644 Xboard/app/Console/Commands/ResetUser.php create mode 100644 Xboard/app/Console/Commands/SendRemindMail.php create mode 100644 Xboard/app/Console/Commands/Test.php create mode 100644 Xboard/app/Console/Commands/XboardInstall.php create mode 100644 Xboard/app/Console/Commands/XboardRollback.php create mode 100644 Xboard/app/Console/Commands/XboardStatistics.php create mode 100644 Xboard/app/Console/Commands/XboardUpdate.php create mode 100644 Xboard/app/Console/Kernel.php create mode 100644 Xboard/app/Contracts/PaymentInterface.php create mode 100644 Xboard/app/Exceptions/ApiException.php create mode 100644 Xboard/app/Exceptions/BusinessException.php create mode 100644 Xboard/app/Exceptions/Handler.php create mode 100644 Xboard/app/Helpers/ApiResponse.php create mode 100644 Xboard/app/Helpers/Functions.php create mode 100644 Xboard/app/Helpers/ResponseEnum.php create mode 100644 Xboard/app/Http/Controllers/Controller.php create mode 100644 Xboard/app/Http/Controllers/PluginController.php create mode 100644 Xboard/app/Http/Controllers/V1/Client/AppController.php create mode 100644 Xboard/app/Http/Controllers/V1/Client/ClientController.php create mode 100644 Xboard/app/Http/Controllers/V1/Guest/CommController.php create mode 100644 Xboard/app/Http/Controllers/V1/Guest/PaymentController.php create mode 100644 Xboard/app/Http/Controllers/V1/Guest/PlanController.php create mode 100644 Xboard/app/Http/Controllers/V1/Guest/TelegramController.php create mode 100644 Xboard/app/Http/Controllers/V1/Passport/AuthController.php create mode 100644 Xboard/app/Http/Controllers/V1/Passport/CommController.php create mode 100644 Xboard/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php create mode 100644 Xboard/app/Http/Controllers/V1/Server/TrojanTidalabController.php create mode 100644 Xboard/app/Http/Controllers/V1/Server/UniProxyController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/CommController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/CouponController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/GiftCardController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/InviteController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/KnowledgeController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/NoticeController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/OrderController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/PlanController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/ServerController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/StatController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/TelegramController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/TicketController.php create mode 100644 Xboard/app/Http/Controllers/V1/User/UserController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/ConfigController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/CouponController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/GiftCardController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/KnowledgeController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/NoticeController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/OrderController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/PaymentController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/PlanController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/PluginController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/Server/GroupController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/Server/ManageController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/Server/RouteController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/StatController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/SystemController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/ThemeController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/TicketController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/TrafficResetController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/UpdateController.php create mode 100644 Xboard/app/Http/Controllers/V2/Admin/UserController.php create mode 100644 Xboard/app/Http/Controllers/V2/Client/AppController.php create mode 100644 Xboard/app/Http/Controllers/V2/Server/ServerController.php create mode 100644 Xboard/app/Http/Kernel.php create mode 100644 Xboard/app/Http/Middleware/Admin.php create mode 100644 Xboard/app/Http/Middleware/ApplyRuntimeSettings.php create mode 100644 Xboard/app/Http/Middleware/Authenticate.php create mode 100644 Xboard/app/Http/Middleware/CheckForMaintenanceMode.php create mode 100644 Xboard/app/Http/Middleware/Client.php create mode 100644 Xboard/app/Http/Middleware/EncryptCookies.php create mode 100644 Xboard/app/Http/Middleware/EnsureTransactionState.php create mode 100644 Xboard/app/Http/Middleware/ForceJson.php create mode 100644 Xboard/app/Http/Middleware/InitializePlugins.php create mode 100644 Xboard/app/Http/Middleware/Language.php create mode 100644 Xboard/app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 Xboard/app/Http/Middleware/RequestLog.php create mode 100644 Xboard/app/Http/Middleware/Server.php create mode 100644 Xboard/app/Http/Middleware/Staff.php create mode 100644 Xboard/app/Http/Middleware/TrimStrings.php create mode 100644 Xboard/app/Http/Middleware/TrustProxies.php create mode 100644 Xboard/app/Http/Middleware/User.php create mode 100644 Xboard/app/Http/Middleware/VerifyCsrfToken.php create mode 100644 Xboard/app/Http/Requests/Admin/ConfigSave.php create mode 100644 Xboard/app/Http/Requests/Admin/CouponGenerate.php create mode 100644 Xboard/app/Http/Requests/Admin/KnowledgeCategorySave.php create mode 100644 Xboard/app/Http/Requests/Admin/KnowledgeCategorySort.php create mode 100644 Xboard/app/Http/Requests/Admin/KnowledgeSave.php create mode 100644 Xboard/app/Http/Requests/Admin/KnowledgeSort.php create mode 100644 Xboard/app/Http/Requests/Admin/MailSend.php create mode 100644 Xboard/app/Http/Requests/Admin/NoticeSave.php create mode 100644 Xboard/app/Http/Requests/Admin/OrderAssign.php create mode 100644 Xboard/app/Http/Requests/Admin/OrderFetch.php create mode 100644 Xboard/app/Http/Requests/Admin/OrderUpdate.php create mode 100644 Xboard/app/Http/Requests/Admin/PlanSave.php create mode 100644 Xboard/app/Http/Requests/Admin/PlanSort.php create mode 100644 Xboard/app/Http/Requests/Admin/PlanUpdate.php create mode 100644 Xboard/app/Http/Requests/Admin/ServerSave.php create mode 100644 Xboard/app/Http/Requests/Admin/UserFetch.php create mode 100644 Xboard/app/Http/Requests/Admin/UserGenerate.php create mode 100644 Xboard/app/Http/Requests/Admin/UserSendMail.php create mode 100644 Xboard/app/Http/Requests/Admin/UserUpdate.php create mode 100644 Xboard/app/Http/Requests/Passport/AuthForget.php create mode 100644 Xboard/app/Http/Requests/Passport/AuthLogin.php create mode 100644 Xboard/app/Http/Requests/Passport/AuthRegister.php create mode 100644 Xboard/app/Http/Requests/Passport/CommSendEmailVerify.php create mode 100644 Xboard/app/Http/Requests/Staff/UserUpdate.php create mode 100644 Xboard/app/Http/Requests/User/GiftCardCheckRequest.php create mode 100644 Xboard/app/Http/Requests/User/GiftCardRedeemRequest.php create mode 100644 Xboard/app/Http/Requests/User/OrderSave.php create mode 100644 Xboard/app/Http/Requests/User/TicketSave.php create mode 100644 Xboard/app/Http/Requests/User/TicketWithdraw.php create mode 100644 Xboard/app/Http/Requests/User/UserChangePassword.php create mode 100644 Xboard/app/Http/Requests/User/UserTransfer.php create mode 100644 Xboard/app/Http/Requests/User/UserUpdate.php create mode 100644 Xboard/app/Http/Resources/ComissionLogResource.php create mode 100644 Xboard/app/Http/Resources/CouponResource.php create mode 100644 Xboard/app/Http/Resources/InviteCodeResource.php create mode 100644 Xboard/app/Http/Resources/KnowledgeResource.php create mode 100644 Xboard/app/Http/Resources/MessageResource.php create mode 100644 Xboard/app/Http/Resources/NodeResource.php create mode 100644 Xboard/app/Http/Resources/OrderResource.php create mode 100644 Xboard/app/Http/Resources/PlanResource.php create mode 100644 Xboard/app/Http/Resources/TicketResource.php create mode 100644 Xboard/app/Http/Resources/TrafficLogResource.php create mode 100644 Xboard/app/Http/Routes/V1/ClientRoute.php create mode 100644 Xboard/app/Http/Routes/V1/GuestRoute.php create mode 100644 Xboard/app/Http/Routes/V1/PassportRoute.php create mode 100644 Xboard/app/Http/Routes/V1/ServerRoute.php create mode 100644 Xboard/app/Http/Routes/V1/UserRoute.php create mode 100644 Xboard/app/Http/Routes/V2/AdminRoute.php create mode 100644 Xboard/app/Http/Routes/V2/ClientRoute.php create mode 100644 Xboard/app/Http/Routes/V2/PassportRoute.php create mode 100644 Xboard/app/Http/Routes/V2/ServerRoute.php create mode 100644 Xboard/app/Http/Routes/V2/UserRoute.php create mode 100644 Xboard/app/Jobs/NodeUserSyncJob.php create mode 100644 Xboard/app/Jobs/OrderHandleJob.php create mode 100644 Xboard/app/Jobs/SendEmailJob.php create mode 100644 Xboard/app/Jobs/SendTelegramJob.php create mode 100644 Xboard/app/Jobs/StatServerJob.php create mode 100644 Xboard/app/Jobs/StatUserJob.php create mode 100644 Xboard/app/Jobs/TrafficFetchJob.php create mode 100644 Xboard/app/Models/AdminAuditLog.php create mode 100644 Xboard/app/Models/CommissionLog.php create mode 100644 Xboard/app/Models/Coupon.php create mode 100644 Xboard/app/Models/GiftCardCode.php create mode 100644 Xboard/app/Models/GiftCardTemplate.php create mode 100644 Xboard/app/Models/GiftCardUsage.php create mode 100644 Xboard/app/Models/InviteCode.php create mode 100644 Xboard/app/Models/Knowledge.php create mode 100644 Xboard/app/Models/MailLog.php create mode 100644 Xboard/app/Models/Notice.php create mode 100644 Xboard/app/Models/Order.php create mode 100644 Xboard/app/Models/Payment.php create mode 100644 Xboard/app/Models/Plan.php create mode 100644 Xboard/app/Models/Plugin.php create mode 100644 Xboard/app/Models/Server.php create mode 100644 Xboard/app/Models/ServerGroup.php create mode 100644 Xboard/app/Models/ServerLog.php create mode 100644 Xboard/app/Models/ServerRoute.php create mode 100644 Xboard/app/Models/ServerStat.php create mode 100644 Xboard/app/Models/Setting.php create mode 100644 Xboard/app/Models/Stat.php create mode 100644 Xboard/app/Models/StatServer.php create mode 100644 Xboard/app/Models/StatUser.php create mode 100644 Xboard/app/Models/SubscribeTemplate.php create mode 100644 Xboard/app/Models/Ticket.php create mode 100644 Xboard/app/Models/TicketMessage.php create mode 100644 Xboard/app/Models/TrafficResetLog.php create mode 100644 Xboard/app/Models/User.php create mode 100644 Xboard/app/Observers/PlanObserver.php create mode 100644 Xboard/app/Observers/ServerObserver.php create mode 100644 Xboard/app/Observers/ServerRouteObserver.php create mode 100644 Xboard/app/Observers/UserObserver.php create mode 100644 Xboard/app/Protocols/Clash.php create mode 100644 Xboard/app/Protocols/ClashMeta.php create mode 100644 Xboard/app/Protocols/General.php create mode 100644 Xboard/app/Protocols/Loon.php create mode 100644 Xboard/app/Protocols/QuantumultX.php create mode 100644 Xboard/app/Protocols/Shadowrocket.php create mode 100644 Xboard/app/Protocols/Shadowsocks.php create mode 100644 Xboard/app/Protocols/SingBox.php create mode 100644 Xboard/app/Protocols/Stash.php create mode 100644 Xboard/app/Protocols/Surfboard.php create mode 100644 Xboard/app/Protocols/Surge.php create mode 100644 Xboard/app/Providers/AuthServiceProvider.php create mode 100644 Xboard/app/Providers/BroadcastServiceProvider.php create mode 100644 Xboard/app/Providers/EventServiceProvider.php create mode 100644 Xboard/app/Providers/HorizonServiceProvider.php create mode 100644 Xboard/app/Providers/OctaneServiceProvider.php create mode 100644 Xboard/app/Providers/PluginServiceProvider.php create mode 100644 Xboard/app/Providers/ProtocolServiceProvider.php create mode 100644 Xboard/app/Providers/RouteServiceProvider.php create mode 100644 Xboard/app/Providers/SettingServiceProvider.php create mode 100644 Xboard/app/Scope/FilterScope.php create mode 100644 Xboard/app/Services/Auth/LoginService.php create mode 100644 Xboard/app/Services/Auth/MailLinkService.php create mode 100644 Xboard/app/Services/Auth/RegisterService.php create mode 100644 Xboard/app/Services/AuthService.php create mode 100644 Xboard/app/Services/CaptchaService.php create mode 100644 Xboard/app/Services/CouponService.php create mode 100644 Xboard/app/Services/DeviceStateService.php create mode 100644 Xboard/app/Services/GiftCardService.php create mode 100644 Xboard/app/Services/MailService.php create mode 100644 Xboard/app/Services/NodeRegistry.php create mode 100644 Xboard/app/Services/NodeSyncService.php create mode 100644 Xboard/app/Services/OrderService.php create mode 100644 Xboard/app/Services/PaymentService.php create mode 100644 Xboard/app/Services/PlanService.php create mode 100644 Xboard/app/Services/Plugin/AbstractPlugin.php create mode 100644 Xboard/app/Services/Plugin/HookManager.php create mode 100644 Xboard/app/Services/Plugin/InterceptResponseException.php create mode 100644 Xboard/app/Services/Plugin/PluginConfigService.php create mode 100644 Xboard/app/Services/Plugin/PluginManager.php create mode 100644 Xboard/app/Services/ServerService.php create mode 100644 Xboard/app/Services/SettingService.php create mode 100644 Xboard/app/Services/StatisticalService.php create mode 100644 Xboard/app/Services/TelegramService.php create mode 100644 Xboard/app/Services/ThemeService.php create mode 100644 Xboard/app/Services/TicketService.php create mode 100644 Xboard/app/Services/TrafficResetService.php create mode 100644 Xboard/app/Services/UpdateService.php create mode 100644 Xboard/app/Services/UserService.php create mode 100644 Xboard/app/Support/AbstractProtocol.php create mode 100644 Xboard/app/Support/ProtocolManager.php create mode 100644 Xboard/app/Support/Setting.php create mode 100644 Xboard/app/Traits/HasPluginConfig.php create mode 100644 Xboard/app/Traits/QueryOperators.php create mode 100644 Xboard/app/Utils/CacheKey.php create mode 100644 Xboard/app/Utils/Dict.php create mode 100644 Xboard/app/Utils/Helper.php create mode 100644 Xboard/app/WebSocket/NodeEventHandlers.php create mode 100644 Xboard/app/WebSocket/NodeWorker.php create mode 100644 Xboard/artisan create mode 100644 Xboard/bootstrap/app.php create mode 100644 Xboard/bootstrap/cache/.gitignore create mode 100644 Xboard/compose.sample.yaml create mode 100644 Xboard/composer.json create mode 100644 Xboard/config/app.php create mode 100644 Xboard/config/auth.php create mode 100644 Xboard/config/broadcasting.php create mode 100644 Xboard/config/cache.php create mode 100644 Xboard/config/cloud_storage.php create mode 100644 Xboard/config/cors.php create mode 100644 Xboard/config/database.php create mode 100644 Xboard/config/debugbar.php create mode 100644 Xboard/config/filesystems.php create mode 100644 Xboard/config/hashing.php create mode 100644 Xboard/config/hidden_features.php create mode 100644 Xboard/config/horizon.php create mode 100644 Xboard/config/logging.php create mode 100644 Xboard/config/mail.php create mode 100644 Xboard/config/octane.php create mode 100644 Xboard/config/queue.php create mode 100644 Xboard/config/sanctum.php create mode 100644 Xboard/config/scribe.php create mode 100644 Xboard/config/services.php create mode 100644 Xboard/config/session.php create mode 100644 Xboard/config/theme/.gitignore create mode 100644 Xboard/config/view.php create mode 100644 Xboard/database/.gitignore create mode 100644 Xboard/database/factories/UserFactory.php create mode 100644 Xboard/database/migrations/2019_08_19_000000_create_failed_jobs_table.php create mode 100644 Xboard/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php create mode 100644 Xboard/database/migrations/2023_03_19_000000_create_v2_tables.php create mode 100644 Xboard/database/migrations/2023_08_14_221234_create_v2_settings_table.php create mode 100644 Xboard/database/migrations/2023_09_04_190923_add_column_excludes_to_server_table.php create mode 100644 Xboard/database/migrations/2023_09_06_195956_add_column_ips_to_server_table.php create mode 100644 Xboard/database/migrations/2023_09_14_013244_add_column_alpn_to_server_hysteria_table.php create mode 100644 Xboard/database/migrations/2023_09_24_040317_add_column_network_and_network_settings_to_v2_server_trojan.php create mode 100644 Xboard/database/migrations/2023_09_29_044957_add_column_version_and_is_obfs_to_server_hysteria_table.php create mode 100644 Xboard/database/migrations/2023_11_19_205026_change_column_value_to_v2_settings_table.php create mode 100644 Xboard/database/migrations/2023_12_12_212239_add_index_to_v2_user_table.php create mode 100644 Xboard/database/migrations/2024_03_19_103149_modify_icon_column_to_v2_payment_table.php create mode 100644 Xboard/database/migrations/2025_01_01_130644_modify_commission_status_in_v2_order_table.php create mode 100644 Xboard/database/migrations/2025_01_04_optimize_plan_table.php create mode 100644 Xboard/database/migrations/2025_01_05_131425_create_v2_server_table.php create mode 100644 Xboard/database/migrations/2025_01_10_152139_add_device_limit_column.php create mode 100644 Xboard/database/migrations/2025_01_12_190315_add_sort_to_v2_notice_table.php create mode 100644 Xboard/database/migrations/2025_01_12_200936_modify_commission_status_in_v2_order_table.php create mode 100644 Xboard/database/migrations/2025_01_13_000000_convert_order_period_fields.php create mode 100644 Xboard/database/migrations/2025_01_15_000002_add_stat_performance_indexes.php create mode 100644 Xboard/database/migrations/2025_01_16_142320_add_updated_at_index_to_v2_order_table.php create mode 100644 Xboard/database/migrations/2025_01_18_140511_create_plugins_table.php create mode 100644 Xboard/database/migrations/2025_06_21_000001_optimize_v2_settings_table.php create mode 100644 Xboard/database/migrations/2025_06_21_000002_create_traffic_reset_logs_table.php create mode 100644 Xboard/database/migrations/2025_06_21_000003_add_traffic_reset_fields_to_users.php create mode 100644 Xboard/database/migrations/2025_07_01_081556_add_tags_to_v2_plan_table.php create mode 100644 Xboard/database/migrations/2025_07_01_122908_create_gift_card_tables.php create mode 100644 Xboard/database/migrations/2025_07_13_224539_add_column_rate_time_ranges_to_v2_server_table.php create mode 100644 Xboard/database/migrations/2025_07_26_000001_add_type_to_plugins_table.php create mode 100644 Xboard/database/migrations/2025_07_27_000001_create_v2_subscribe_templates_table.php create mode 100644 Xboard/database/migrations/2026_03_11_000001_replace_v2_log_with_admin_audit_log.php create mode 100644 Xboard/database/migrations/2026_03_11_000002_add_stat_user_record_at_index.php create mode 100644 Xboard/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php create mode 100644 Xboard/database/migrations/2026_03_28_161536_add_traffic_fields_to_servers.php create mode 100644 Xboard/database/seeders/DatabaseSeeder.php create mode 100644 Xboard/database/seeders/OriginV2bMigrationsTableSeeder.php create mode 100644 Xboard/docs/en/development/device-limit.md create mode 100644 Xboard/docs/en/development/performance.md create mode 100644 Xboard/docs/en/development/plugin-development-guide.md create mode 100644 Xboard/docs/en/installation/1panel.md create mode 100644 Xboard/docs/en/installation/aapanel-docker.md create mode 100644 Xboard/docs/en/installation/aapanel.md create mode 100644 Xboard/docs/en/installation/docker-compose.md create mode 100644 Xboard/docs/en/migration/config.md create mode 100644 Xboard/docs/en/migration/v2board-1.7.3.md create mode 100644 Xboard/docs/en/migration/v2board-1.7.4.md create mode 100644 Xboard/docs/en/migration/v2board-dev.md create mode 100644 Xboard/docs/images/admin.png create mode 100644 Xboard/docs/images/user.png create mode 100644 Xboard/init.sh create mode 100644 Xboard/package.json create mode 100644 Xboard/phpstan.neon create mode 100644 Xboard/plugins/.gitignore create mode 100644 Xboard/plugins/UserOnlineDevices/Controllers/UserOnlineDevicesController.php create mode 100644 Xboard/plugins/UserOnlineDevices/Plugin.php create mode 100644 Xboard/plugins/UserOnlineDevices/README.md create mode 100644 Xboard/plugins/UserOnlineDevices/config.json create mode 100644 Xboard/plugins/UserOnlineDevices/resources/views/panel.blade.php create mode 100644 Xboard/plugins/UserOnlineDevices/routes/api.php create mode 100644 Xboard/plugins/UserOnlineDevices/routes/web.php create mode 100644 Xboard/public/index.php create mode 100644 Xboard/public/robots.txt create mode 100644 Xboard/public/theme/.gitignore create mode 100644 Xboard/public/web.config create mode 100644 Xboard/resources/js/app.js create mode 100644 Xboard/resources/js/bootstrap.js create mode 100644 Xboard/resources/lang/en-US.json create mode 100644 Xboard/resources/lang/ru-RU.json create mode 100644 Xboard/resources/lang/zh-CN.json create mode 100644 Xboard/resources/lang/zh-TW.json create mode 100644 Xboard/resources/rules/.gitignore create mode 100644 Xboard/resources/rules/app.clash.yaml create mode 100644 Xboard/resources/rules/default.clash.yaml create mode 100644 Xboard/resources/rules/default.sing-box.json create mode 100644 Xboard/resources/rules/default.surfboard.conf create mode 100644 Xboard/resources/rules/default.surge.conf create mode 100644 Xboard/resources/sass/app.scss create mode 100644 Xboard/resources/views/admin.blade.php create mode 100644 Xboard/resources/views/client/subscribe.blade.php create mode 100644 Xboard/resources/views/errors/500.blade.php create mode 100644 Xboard/resources/views/mail/classic/mailLogin.blade.php create mode 100644 Xboard/resources/views/mail/classic/notify.blade.php create mode 100644 Xboard/resources/views/mail/classic/remindExpire.blade.php create mode 100644 Xboard/resources/views/mail/classic/remindTraffic.blade.php create mode 100644 Xboard/resources/views/mail/classic/verify.blade.php create mode 100644 Xboard/resources/views/mail/default/mailLogin.blade.php create mode 100644 Xboard/resources/views/mail/default/notify.blade.php create mode 100644 Xboard/resources/views/mail/default/remindExpire.blade.php create mode 100644 Xboard/resources/views/mail/default/remindTraffic.blade.php create mode 100644 Xboard/resources/views/mail/default/verify.blade.php create mode 100644 Xboard/routes/channels.php create mode 100644 Xboard/routes/console.php create mode 100644 Xboard/routes/web.php create mode 100644 Xboard/storage/backup/.gitignore create mode 100644 Xboard/storage/debugbar/.gitignore create mode 100644 Xboard/storage/framework/cache/.gitignore create mode 100644 Xboard/storage/framework/sessions/.gitignore create mode 100644 Xboard/storage/framework/views/.gitignore create mode 100644 Xboard/storage/logs/.gitignore create mode 100644 Xboard/storage/theme/.gitignore create mode 100644 Xboard/storage/tmp/.gitignore create mode 100644 Xboard/storage/views/.gitignore create mode 100644 Xboard/theme/.gitignore create mode 100644 Xboard/theme/Xboard/assets/images/background.svg create mode 100644 Xboard/theme/Xboard/assets/umi.js create mode 100644 Xboard/theme/Xboard/config.json create mode 100644 Xboard/theme/Xboard/dashboard.blade.php create mode 100644 Xboard/theme/Xboard/env.example.js create mode 100644 Xboard/theme/Xboard/env.js create mode 100644 Xboard/theme/Xboard/index.html create mode 100644 Xboard/update.sh diff --git a/Xboard/.docker/.data/.gitignore b/Xboard/.docker/.data/.gitignore new file mode 100644 index 0000000..0377233 --- /dev/null +++ b/Xboard/.docker/.data/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!redis \ No newline at end of file diff --git a/Xboard/.docker/.data/redis/.gitignore b/Xboard/.docker/.data/redis/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/Xboard/.docker/.data/redis/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/Xboard/.docker/supervisor/supervisord.conf b/Xboard/.docker/supervisor/supervisord.conf new file mode 100644 index 0000000..c516e4e --- /dev/null +++ b/Xboard/.docker/supervisor/supervisord.conf @@ -0,0 +1,81 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/stdout +logfile_maxbytes=0 +pidfil=/www/storage/logs/supervisor/supervisord.pid +loglevel=info + +[program:octane] +process_name=%(program_name)s_%(process_num)02d +command=php /www/artisan octane:start --host=0.0.0.0 --port=7001 +autostart=%(ENV_ENABLE_WEB)s +autorestart=true +user=www +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stdout_logfile_backups=0 +numprocs=1 +stopwaitsecs=10 +stopsignal=QUIT +stopasgroup=true +killasgroup=true +priority=100 + +[program:horizon] +process_name=%(program_name)s_%(process_num)02d +command=php /www/artisan horizon +autostart=%(ENV_ENABLE_HORIZON)s +autorestart=true +user=www +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stdout_logfile_backups=0 +numprocs=1 +stopwaitsecs=3 +stopsignal=SIGINT +stopasgroup=true +killasgroup=true +priority=200 + +[program:redis] +process_name=%(program_name)s_%(process_num)02d +command=redis-server --dir /data + --dbfilename dump.rdb + --save 900 1 + --save 300 10 + --save 60 10000 + --unixsocket /data/redis.sock + --unixsocketperm 777 +autostart=%(ENV_ENABLE_REDIS)s +autorestart=true +user=redis +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stdout_logfile_backups=0 +numprocs=1 +stopwaitsecs=3 +stopsignal=TERM +stopasgroup=true +killasgroup=true +priority=300 + +[program:ws-server] +process_name=%(program_name)s_%(process_num)02d +command=php /www/artisan ws-server start +autostart=%(ENV_ENABLE_WS_SERVER)s +autorestart=true +user=www +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stdout_logfile_backups=0 +numprocs=1 +stopwaitsecs=5 +stopsignal=SIGINT +stopasgroup=true +killasgroup=true +priority=400 \ No newline at end of file diff --git a/Xboard/.dockerignore b/Xboard/.dockerignore new file mode 100644 index 0000000..3b0ee11 --- /dev/null +++ b/Xboard/.dockerignore @@ -0,0 +1,26 @@ +/node_modules +/config/v2board.php +/public/hot +/public/storage +/public/env.example.js +/storage/*.key +/vendor +.env +.env.backup +.phpunit.result.cache +.idea +.lock +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +composer.phar +composer.lock +yarn.lock +docker-compose.yml +.DS_Store +/docker +storage/laravels.conf +storage/laravels.pid +storage/laravels-timer-process.pid +/frontend diff --git a/Xboard/.editorconfig b/Xboard/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/Xboard/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/Xboard/.env.example b/Xboard/.env.example new file mode 100644 index 0000000..251858b --- /dev/null +++ b/Xboard/.env.example @@ -0,0 +1,41 @@ +APP_NAME=XBoard +APP_ENV=production +APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60= +APP_DEBUG=false +APP_URL=http://localhost + +LOG_CHANNEL=stack + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=xboard +DB_USERNAME=root +DB_PASSWORD= + +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +BROADCAST_DRIVER=log +CACHE_DRIVER=redis +QUEUE_CONNECTION=redis + +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS=null +MAIL_FROM_NAME=null +MAILGUN_DOMAIN= +MAILGUN_SECRET= + +# google cloud storage +ENABLE_AUTO_BACKUP_AND_UPDATE=false +GOOGLE_CLOUD_KEY_FILE=config/googleCloudStorageKey.json +GOOGLE_CLOUD_STORAGE_BUCKET= + +# Prevent reinstallation +INSTALLED=false \ No newline at end of file diff --git a/Xboard/.gitattributes b/Xboard/.gitattributes new file mode 100644 index 0000000..967315d --- /dev/null +++ b/Xboard/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +*.css linguist-vendored +*.scss linguist-vendored +*.js linguist-vendored +CHANGELOG.md export-ignore diff --git a/Xboard/.github/ISSUE_TEMPLATE/bug-report.md b/Xboard/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..271b8df --- /dev/null +++ b/Xboard/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,39 @@ +--- +name: 🐛 问题反馈 | Bug Report +about: 提交使用过程中遇到的问题 | Report an issue +title: "Bug Report:" +labels: '🐛 bug' +assignees: '' +--- + + + + +> ⚠️ 请务必按照模板填写完整信息,没有详细描述的issue可能会被忽略或关闭 +> ⚠️ Please follow the template to provide complete information, issues without detailed description may be ignored or closed + +**基本信息 | Basic Info** +```yaml +XBoard版本 | Version: +部署方式 | Deployment: [Docker/手动部署] +PHP版本 | Version: +数据库 | Database: +``` + +**问题描述 | Description** + + + +**复现步骤 | Steps** + +1. +2. + +**相关截图 | Screenshots** + + +**日志信息 | Logs** + +```log +// 粘贴日志内容到这里 +``` \ No newline at end of file diff --git a/Xboard/.github/ISSUE_TEMPLATE/feature-request.md b/Xboard/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..5428f95 --- /dev/null +++ b/Xboard/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,28 @@ +--- +name: ✨ 功能请求 | Feature Request +about: 提交新功能建议或改进意见 | Suggest an idea +title: "Feature Request:" +labels: '✨ enhancement' +assignees: '' +--- + +> ⚠️ 请务必按照模板详细描述你的需求,没有详细描述的issue可能会被忽略或关闭 +> ⚠️ Please follow the template to describe your request in detail, issues without detailed description may be ignored or closed + +**需求描述 | Description** + + + +**使用场景 | Use Case** + + + +**功能建议 | Suggestion** + +```yaml +功能形式 | Type: [新功能/功能优化/界面改进] +预期效果 | Expected: +``` + +**补充说明 | Additional** + \ No newline at end of file diff --git a/Xboard/.github/workflows/docker-publish.yml b/Xboard/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..9898924 --- /dev/null +++ b/Xboard/.github/workflows/docker-publish.yml @@ -0,0 +1,100 @@ +name: Docker Build and Publish + +on: + push: + branches: ["master", "new-dev"] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: 'arm64,amd64' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64,linux/arm64 + driver-opts: | + image=moby/buildkit:v0.20.0 + network=host + + - name: Free Disk Space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker image prune -af + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,format=short,prefix=,enable=true + type=raw,value=new,enable=${{ github.ref == 'refs/heads/master' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} + type=raw,value=${{ steps.get_version.outputs.version }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + + - name: Get version + id: get_version + run: echo "version=$(git describe --tags --always)" >> $GITHUB_OUTPUT + + - name: Update version in app.php + run: | + VERSION=$(date '+%Y%m%d')-$(git rev-parse --short HEAD) + sed -i "s/'version' => '.*'/'version' => '$VERSION'/g" config/app.php + echo "Updated version to: $VERSION" + + - name: Build and push + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILDKIT_INLINE_CACHE=1 + BUILDKIT_MULTI_PLATFORM=1 + CACHEBUST=${{ github.sha }} + REPO_URL=https://github.com/${{ github.repository }} + BRANCH_NAME=${{ github.ref_name }} + provenance: false + outputs: type=registry,push=true + allow: | + network.host + diff --git a/Xboard/.gitignore b/Xboard/.gitignore new file mode 100644 index 0000000..2715539 --- /dev/null +++ b/Xboard/.gitignore @@ -0,0 +1,34 @@ +/node_modules +/config/v2board.php +/config/googleCloudStorageKey.json +/public/hot +/public/storage +/public/env.example.js +*.user.ini +/storage/*.key +/vendor +.env +.env.backup +.phpunit.result.cache +.idea +.lock +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +composer.phar +composer.lock +yarn.lock +docker-compose.yml +.DS_Store +/docker +storage/laravels.conf +storage/laravels.pid +storage/update_pending +storage/laravels-timer-process.pid +cli-php.ini +frontend +docker-compose.yaml +bun.lockb +compose.yaml +.scribe \ No newline at end of file diff --git a/Xboard/.gitmodules b/Xboard/.gitmodules new file mode 100644 index 0000000..060ba10 --- /dev/null +++ b/Xboard/.gitmodules @@ -0,0 +1,3 @@ +[submodule "public/assets/admin"] + path = public/assets/admin + url = https://github.com/cedar2025/xboard-admin-dist.git diff --git a/Xboard/Dockerfile b/Xboard/Dockerfile new file mode 100644 index 0000000..ba41fe1 --- /dev/null +++ b/Xboard/Dockerfile @@ -0,0 +1,47 @@ +FROM phpswoole/swoole:php8.2-alpine + +COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ + +# Install PHP extensions one by one with lower optimization level for ARM64 compatibility +RUN CFLAGS="-O0" install-php-extensions pcntl && \ + CFLAGS="-O0 -g0" install-php-extensions bcmath && \ + install-php-extensions zip && \ + install-php-extensions redis && \ + apk --no-cache add shadow sqlite mysql-client mysql-dev mariadb-connector-c git patch supervisor redis && \ + addgroup -S -g 1000 www && adduser -S -G www -u 1000 www && \ + (getent group redis || addgroup -S redis) && \ + (getent passwd redis || adduser -S -G redis -H -h /data redis) + +WORKDIR /www + +COPY .docker / + +# Add build arguments +ARG CACHEBUST +ARG REPO_URL +ARG BRANCH_NAME + +RUN echo "Attempting to clone branch: ${BRANCH_NAME} from ${REPO_URL} with CACHEBUST: ${CACHEBUST}" && \ + rm -rf ./* && \ + rm -rf .git && \ + git config --global --add safe.directory /www && \ + git clone --depth 1 --branch ${BRANCH_NAME} ${REPO_URL} . && \ + git submodule update --init --recursive --force + +COPY .docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +RUN composer install --no-cache --no-dev \ + && php artisan storage:link \ + && cp -r plugins/ /opt/default-plugins/ \ + && chown -R www:www /www \ + && chmod -R 775 /www \ + && mkdir -p /data \ + && chown redis:redis /data + +ENV ENABLE_WEB=true \ + ENABLE_HORIZON=true \ + ENABLE_REDIS=false \ + ENABLE_WS_SERVER=false + +EXPOSE 7001 +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/Xboard/LICENSE b/Xboard/LICENSE new file mode 100644 index 0000000..8d23873 --- /dev/null +++ b/Xboard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Tokumeikoi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Xboard/README.md b/Xboard/README.md new file mode 100644 index 0000000..4d6a618 --- /dev/null +++ b/Xboard/README.md @@ -0,0 +1,100 @@ +# Xboard + +
+ +[![Telegram](https://img.shields.io/badge/Telegram-Channel-blue)](https://t.me/XboardOfficial) +![PHP](https://img.shields.io/badge/PHP-8.2+-green.svg) +![MySQL](https://img.shields.io/badge/MySQL-5.7+-blue.svg) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +
+ +## 📖 Introduction + +Xboard is a modern panel system built on Laravel 11, focusing on providing a clean and efficient user experience. + +## ✨ Features + +- 🚀 Built with Laravel 12 + Octane for significant performance gains +- 🎨 Redesigned admin interface (React + Shadcn UI) +- 📱 Modern user frontend (Vue3 + TypeScript) +- 🐳 Ready-to-use Docker deployment solution +- 🎯 Optimized system architecture for better maintainability + +## 🚀 Quick Start + +```bash +git clone -b compose --depth 1 https://github.com/cedar2025/Xboard && \ +cd Xboard && \ +docker compose run -it --rm \ + -e ENABLE_SQLITE=true \ + -e ENABLE_REDIS=true \ + -e ADMIN_ACCOUNT=admin@demo.com \ + web php artisan xboard:install && \ +docker compose up -d +``` + +> After installation, visit: http://SERVER_IP:7001 +> ⚠️ Make sure to save the admin credentials shown during installation + +## 📖 Documentation + +### 🔄 Upgrade Notice +> 🚨 **Important:** This version involves significant changes. Please strictly follow the upgrade documentation and backup your database before upgrading. Note that upgrading and migration are different processes, do not confuse them. + +### Development Guides +- [Plugin Development Guide](./docs/en/development/plugin-development-guide.md) - Complete guide for developing XBoard plugins + +### Deployment Guides +- [Deploy with 1Panel](./docs/en/installation/1panel.md) +- [Deploy with Docker Compose](./docs/en/installation/docker-compose.md) +- [Deploy with aaPanel](./docs/en/installation/aapanel.md) +- [Deploy with aaPanel + Docker](./docs/en/installation/aapanel-docker.md) (Recommended) + +### Migration Guides +- [Migrate from v2board dev](./docs/en/migration/v2board-dev.md) +- [Migrate from v2board 1.7.4](./docs/en/migration/v2board-1.7.4.md) +- [Migrate from v2board 1.7.3](./docs/en/migration/v2board-1.7.3.md) + +## 🛠️ Tech Stack + +- Backend: Laravel 11 + Octane +- Admin Panel: React + Shadcn UI + TailwindCSS +- User Frontend: Vue3 + TypeScript + NaiveUI +- Deployment: Docker + Docker Compose +- Caching: Redis + Octane Cache + +## 📷 Preview +![Admin Preview](./docs/images/admin.png) + +![User Preview](./docs/images/user.png) + +## ⚠️ Disclaimer + +This project is for learning and communication purposes only. Users are responsible for any consequences of using this project. + +## 🌟 Maintenance Notice + +This project is currently under light maintenance. We will: +- Fix critical bugs and security issues +- Review and merge important pull requests +- Provide necessary updates for compatibility + +However, new feature development may be limited. + +## 🔔 Important Notes + +1. Restart required after modifying admin path: +```bash +docker compose restart +``` + +2. For aaPanel installations, restart the Octane daemon process + +## 🤝 Contributing + +Issues and Pull Requests are welcome to help improve the project. + +## 📈 Star History + +[![Stargazers over time](https://starchart.cc/cedar2025/Xboard.svg)](https://starchart.cc/cedar2025/Xboard) diff --git a/Xboard/app/Console/Commands/BackupDatabase.php b/Xboard/app/Console/Commands/BackupDatabase.php new file mode 100644 index 0000000..14c82ee --- /dev/null +++ b/Xboard/app/Console/Commands/BackupDatabase.php @@ -0,0 +1,100 @@ +argument('upload'); + // 如果是上传到云端则判断是否存在必要配置 + if($isUpload){ + $requiredConfigs = ['database.connections.mysql', 'cloud_storage.google_cloud.key_file', 'cloud_storage.google_cloud.storage_bucket']; + foreach ($requiredConfigs as $config) { + if (blank(config($config))) { + $this->error("❌:缺少必要配置项: $config , 取消备份"); + return; + } + } + } + + // 数据库备份逻辑 + try{ + if (config('database.default') === 'mysql'){ + $databaseBackupPath = storage_path('backup/' . now()->format('Y-m-d_H-i-s') . '_' . config('database.connections.mysql.database') . '_database_backup.sql'); + $this->info("1️⃣:开始备份Mysql"); + \Spatie\DbDumper\Databases\MySql::create() + ->setHost(config('database.connections.mysql.host')) + ->setPort(config('database.connections.mysql.port')) + ->setDbName(config('database.connections.mysql.database')) + ->setUserName(config('database.connections.mysql.username')) + ->setPassword(config('database.connections.mysql.password')) + ->dumpToFile($databaseBackupPath); + $this->info("2️⃣:Mysql备份完成"); + }elseif(config('database.default') === 'sqlite'){ + $databaseBackupPath = storage_path('backup/' . now()->format('Y-m-d_H-i-s') . '_sqlite' . '_database_backup.sql'); + $this->info("1️⃣:开始备份Sqlite"); + \Spatie\DbDumper\Databases\Sqlite::create() + ->setDbName(config('database.connections.sqlite.database')) + ->dumpToFile($databaseBackupPath); + $this->info("2️⃣:Sqlite备份完成"); + }else{ + $this->error('备份失败,你的数据库不是sqlite或者mysql'); + return; + } + $this->info('3️⃣:开始压缩备份文件'); + // 使用 gzip 压缩备份文件 + $compressedBackupPath = $databaseBackupPath . '.gz'; + $gzipCommand = new Process(["gzip", "-c", $databaseBackupPath]); + $gzipCommand->run(); + + // 检查压缩是否成功 + if ($gzipCommand->isSuccessful()) { + // 压缩成功,你可以删除原始备份文件 + file_put_contents($compressedBackupPath, $gzipCommand->getOutput()); + $this->info('4️⃣:文件压缩成功'); + unlink($databaseBackupPath); + } else { + // 压缩失败,处理错误 + echo $gzipCommand->getErrorOutput(); + $this->error('😔:文件压缩失败'); + unlink($databaseBackupPath); + return; + } + if (!$isUpload){ + $this->info("🎉:数据库成功备份到:$compressedBackupPath"); + }else{ + // 传到云盘 + $this->info("5️⃣:开始将备份上传到Google Cloud"); + // Google Cloud Storage 配置 + $storage = new StorageClient([ + 'keyFilePath' => config('cloud_storage.google_cloud.key_file'), + ]); + $bucket = $storage->bucket(config('cloud_storage.google_cloud.storage_bucket')); + $objectName = 'backup/' . now()->format('Y-m-d_H-i-s') . '_database_backup.sql.gz'; + // 上传文件 + $bucket->upload(fopen($compressedBackupPath, 'r'), [ + 'name' => $objectName, + ]); + + // 输出文件链接 + Log::channel('backup')->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName"); + $this->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName"); + File::delete($compressedBackupPath); + } + }catch(\Exception $e){ + Log::channel('backup')->error("😔:数据库备份失败 \n" . $e); + $this->error("😔:数据库备份失败\n" . $e); + File::delete($compressedBackupPath); + } + } +} diff --git a/Xboard/app/Console/Commands/CheckCommission.php b/Xboard/app/Console/Commands/CheckCommission.php new file mode 100644 index 0000000..0eabc74 --- /dev/null +++ b/Xboard/app/Console/Commands/CheckCommission.php @@ -0,0 +1,129 @@ +autoCheck(); + $this->autoPayCommission(); + } + + public function autoCheck() + { + if ((int)admin_setting('commission_auto_check_enable', 1)) { + Order::where('commission_status', 0) + ->where('invite_user_id', '!=', NULL) + ->where('status', 3) + ->where('updated_at', '<=', strtotime('-3 day', time())) + ->update([ + 'commission_status' => 1 + ]); + } + } + + public function autoPayCommission() + { + $orders = Order::where('commission_status', 1) + ->where('invite_user_id', '!=', NULL) + ->get(); + foreach ($orders as $order) { + try{ + DB::beginTransaction(); + if (!$this->payHandle($order->invite_user_id, $order)) { + DB::rollBack(); + continue; + } + $order->commission_status = 2; + if (!$order->save()) { + DB::rollBack(); + continue; + } + DB::commit(); + } catch (\Exception $e){ + DB::rollBack(); + throw $e; + } + } + } + + public function payHandle($inviteUserId, Order $order) + { + $level = 3; + if ((int)admin_setting('commission_distribution_enable', 0)) { + $commissionShareLevels = [ + 0 => (int)admin_setting('commission_distribution_l1'), + 1 => (int)admin_setting('commission_distribution_l2'), + 2 => (int)admin_setting('commission_distribution_l3') + ]; + } else { + $commissionShareLevels = [ + 0 => 100 + ]; + } + for ($l = 0; $l < $level; $l++) { + $inviter = User::find($inviteUserId); + if (!$inviter) continue; + if (!isset($commissionShareLevels[$l])) continue; + $commissionBalance = $order->commission_balance * ($commissionShareLevels[$l] / 100); + if (!$commissionBalance) continue; + if ((int)admin_setting('withdraw_close_enable', 0)) { + $inviter->increment('balance', $commissionBalance); + } else { + $inviter->increment('commission_balance', $commissionBalance); + } + if (!$inviter->save()) { + DB::rollBack(); + return false; + } + CommissionLog::create([ + 'invite_user_id' => $inviteUserId, + 'user_id' => $order->user_id, + 'trade_no' => $order->trade_no, + 'order_amount' => $order->total_amount, + 'get_amount' => $commissionBalance + ]); + $inviteUserId = $inviter->invite_user_id; + // update order actual commission balance + $order->actual_commission_balance = $order->actual_commission_balance + $commissionBalance; + } + return true; + } + +} diff --git a/Xboard/app/Console/Commands/CheckOrder.php b/Xboard/app/Console/Commands/CheckOrder.php new file mode 100644 index 0000000..7d03f58 --- /dev/null +++ b/Xboard/app/Console/Commands/CheckOrder.php @@ -0,0 +1,53 @@ +orderBy('created_at', 'ASC') + ->lazyById(200) + ->each(function ($order) { + OrderHandleJob::dispatch($order->trade_no); + }); + } +} diff --git a/Xboard/app/Console/Commands/CheckServer.php b/Xboard/app/Console/Commands/CheckServer.php new file mode 100644 index 0000000..2d3dae9 --- /dev/null +++ b/Xboard/app/Console/Commands/CheckServer.php @@ -0,0 +1,64 @@ +checkOffline(); + } + + private function checkOffline() + { + $servers = ServerService::getAllServers(); + foreach ($servers as $server) { + if ($server['parent_id']) continue; + if ($server['last_check_at'] && (time() - $server['last_check_at']) > 1800) { + $telegramService = new TelegramService(); + $message = sprintf( + "节点掉线通知\r\n----\r\n节点名称:%s\r\n节点地址:%s\r\n", + $server['name'], + $server['host'] + ); + $telegramService->sendMessageWithAdmin($message); + Cache::forget(CacheKey::get(sprintf("SERVER_%s_LAST_CHECK_AT", strtoupper($server['type'])), $server->id)); + } + } + } +} diff --git a/Xboard/app/Console/Commands/CheckTicket.php b/Xboard/app/Console/Commands/CheckTicket.php new file mode 100644 index 0000000..51d4539 --- /dev/null +++ b/Xboard/app/Console/Commands/CheckTicket.php @@ -0,0 +1,51 @@ +where('updated_at', '<=', time() - 24 * 3600) + ->where('reply_status', 0) + ->lazyById(200) + ->each(function ($ticket) { + if ($ticket->user_id === $ticket->last_reply_user_id) return; + $ticket->status = Ticket::STATUS_CLOSED; + $ticket->save(); + }); + } +} diff --git a/Xboard/app/Console/Commands/CheckTrafficExceeded.php b/Xboard/app/Console/Commands/CheckTrafficExceeded.php new file mode 100644 index 0000000..35a1db6 --- /dev/null +++ b/Xboard/app/Console/Commands/CheckTrafficExceeded.php @@ -0,0 +1,63 @@ +whereIn('id', $pendingUserIds) + ->whereRaw('u + d >= transfer_enable') + ->where('transfer_enable', '>', 0) + ->where('banned', 0) + ->select(['id', 'group_id']) + ->get(); + + if ($exceededUsers->isEmpty()) { + return; + } + + $groupedUsers = $exceededUsers->groupBy('group_id'); + $notifiedCount = 0; + + foreach ($groupedUsers as $groupId => $users) { + if (!$groupId) { + continue; + } + + $userIdsInGroup = $users->pluck('id')->toArray(); + $servers = Server::whereJsonContains('group_ids', (string) $groupId)->get(); + + foreach ($servers as $server) { + if (!NodeSyncService::isNodeOnline($server->id)) { + continue; + } + + NodeSyncService::push($server->id, 'sync.user.delta', [ + 'action' => 'remove', + 'users' => array_map(fn($id) => ['id' => $id], $userIdsInGroup), + ]); + $notifiedCount++; + } + } + + $this->info("Checked " . count($pendingUserIds) . " users, notified {$notifiedCount} nodes for " . $exceededUsers->count() . " exceeded users."); + } +} diff --git a/Xboard/app/Console/Commands/ClearUser.php b/Xboard/app/Console/Commands/ClearUser.php new file mode 100644 index 0000000..cd64abe --- /dev/null +++ b/Xboard/app/Console/Commands/ClearUser.php @@ -0,0 +1,51 @@ +where('transfer_enable', 0) + ->where('expired_at', 0) + ->where('last_login_at', NULL); + $count = $builder->count(); + if ($builder->delete()) { + $this->info("已删除{$count}位没有任何数据的用户"); + } + } +} diff --git a/Xboard/app/Console/Commands/HookList.php b/Xboard/app/Console/Commands/HookList.php new file mode 100644 index 0000000..cec2bfd --- /dev/null +++ b/Xboard/app/Console/Commands/HookList.php @@ -0,0 +1,42 @@ +filter(fn($f) => Str::endsWith($f, '.php')); + foreach ($files as $file) { + $content = @file_get_contents($file); + if ($content && preg_match_all($pattern, $content, $matches)) { + foreach ($matches[2] as $hook) { + $hooks->push($hook); + } + } + } + } + $hooks = $hooks->unique()->sort()->values(); + if ($hooks->isEmpty()) { + $this->info('未扫描到任何 hook'); + } else { + $this->info('All Supported Hooks:'); + foreach ($hooks as $hook) { + $this->line(' ' . $hook); + } + } + } +} \ No newline at end of file diff --git a/Xboard/app/Console/Commands/MigrateFromV2b.php b/Xboard/app/Console/Commands/MigrateFromV2b.php new file mode 100644 index 0000000..ace9c85 --- /dev/null +++ b/Xboard/app/Console/Commands/MigrateFromV2b.php @@ -0,0 +1,186 @@ +argument('version'); + if($version === 'config'){ + $this->MigrateV2ConfigToV2Settings(); + return; + } + + // Define your SQL commands based on versions + $sqlCommands = [ + 'dev231027' => [ + // SQL commands for version Dev 2023/10/27 + 'ALTER TABLE v2_order ADD COLUMN surplus_order_ids TEXT NULL;', + 'ALTER TABLE v2_plan DROP COLUMN daily_unit_price, DROP COLUMN transfer_unit_price;', + 'ALTER TABLE v2_server_hysteria DROP COLUMN ignore_client_bandwidth, DROP COLUMN obfs_type;' + ], + '1.7.4' => [ + 'CREATE TABLE `v2_server_vless` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `group_id` TEXT NOT NULL, + `route_id` TEXT NULL, + `name` VARCHAR(255) NOT NULL, + `parent_id` INT NULL, + `host` VARCHAR(255) NOT NULL, + `port` INT NOT NULL, + `server_port` INT NOT NULL, + `tls` BOOLEAN NOT NULL, + `tls_settings` TEXT NULL, + `flow` VARCHAR(64) NULL, + `network` VARCHAR(11) NOT NULL, + `network_settings` TEXT NULL, + `tags` TEXT NULL, + `rate` VARCHAR(11) NOT NULL, + `show` BOOLEAN DEFAULT 0, + `sort` INT NULL, + `created_at` INT NOT NULL, + `updated_at` INT NOT NULL + );' + ], + '1.7.3' => [ + 'ALTER TABLE `v2_stat_order` RENAME TO `v2_stat`;', + "ALTER TABLE `v2_stat` CHANGE COLUMN order_amount paid_total INT COMMENT '订单合计';", + "ALTER TABLE `v2_stat` CHANGE COLUMN order_count paid_count INT COMMENT '邀请佣金';", + "ALTER TABLE `v2_stat` CHANGE COLUMN commission_amount commission_total INT COMMENT '佣金合计';", + "ALTER TABLE `v2_stat` + ADD COLUMN order_count INT NULL, + ADD COLUMN order_total INT NULL, + ADD COLUMN register_count INT NULL, + ADD COLUMN invite_count INT NULL, + ADD COLUMN transfer_used_total VARCHAR(32) NULL; + ", + "CREATE TABLE `v2_log` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `title` TEXT NOT NULL, + `level` VARCHAR(11) NULL, + `host` VARCHAR(255) NULL, + `uri` VARCHAR(255) NOT NULL, + `method` VARCHAR(11) NOT NULL, + `data` TEXT NULL, + `ip` VARCHAR(128) NULL, + `context` TEXT NULL, + `created_at` INT NOT NULL, + `updated_at` INT NOT NULL + );", + 'CREATE TABLE `v2_server_hysteria` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `group_id` VARCHAR(255) NOT NULL, + `route_id` VARCHAR(255) NULL, + `name` VARCHAR(255) NOT NULL, + `parent_id` INT NULL, + `host` VARCHAR(255) NOT NULL, + `port` VARCHAR(11) NOT NULL, + `server_port` INT NOT NULL, + `tags` VARCHAR(255) NULL, + `rate` VARCHAR(11) NOT NULL, + `show` BOOLEAN DEFAULT FALSE, + `sort` INT NULL, + `up_mbps` INT NOT NULL, + `down_mbps` INT NOT NULL, + `server_name` VARCHAR(64) NULL, + `insecure` BOOLEAN DEFAULT FALSE, + `created_at` INT NOT NULL, + `updated_at` INT NOT NULL + );', + "CREATE TABLE `v2_server_vless` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `group_id` TEXT NOT NULL, + `route_id` TEXT NULL, + `name` VARCHAR(255) NOT NULL, + `parent_id` INT NULL, + `host` VARCHAR(255) NOT NULL, + `port` INT NOT NULL, + `server_port` INT NOT NULL, + `tls` BOOLEAN NOT NULL, + `tls_settings` TEXT NULL, + `flow` VARCHAR(64) NULL, + `network` VARCHAR(11) NOT NULL, + `network_settings` TEXT NULL, + `tags` TEXT NULL, + `rate` VARCHAR(11) NOT NULL, + `show` BOOLEAN DEFAULT FALSE, + `sort` INT NULL, + `created_at` INT NOT NULL, + `updated_at` INT NOT NULL + );", + ], + 'wyx2685' => [ + "ALTER TABLE `v2_plan` DROP COLUMN `device_limit`;", + "ALTER TABLE `v2_server_hysteria` DROP COLUMN `version`, DROP COLUMN `obfs`, DROP COLUMN `obfs_password`;", + "ALTER TABLE `v2_server_trojan` DROP COLUMN `network`, DROP COLUMN `network_settings`;", + "ALTER TABLE `v2_user` DROP COLUMN `device_limit`;" + ] + ]; + + if (!$version) { + $version = $this->choice('请选择你迁移前的V2board版本:', array_keys($sqlCommands)); + } + + if (array_key_exists($version, $sqlCommands)) { + + try { + foreach ($sqlCommands[$version] as $sqlCommand) { + // Execute SQL command + DB::statement($sqlCommand); + } + + $this->info('1️⃣、数据库差异矫正成功'); + + // 初始化数据库迁移 + $this->call('db:seed', ['--class' => 'OriginV2bMigrationsTableSeeder']); + $this->info('2️⃣、数据库迁移记录初始化成功'); + + $this->call('xboard:update'); + $this->info('3️⃣、更新成功'); + + $this->info("🎉:成功从 $version 迁移到Xboard"); + } catch (\Exception $e) { + // An error occurred, rollback the transaction + $this->error('迁移失败'. $e->getMessage() ); + } + + + } else { + $this->error("你所输入的版本未找到"); + } + } + + public function MigrateV2ConfigToV2Settings() + { + Artisan::call('config:clear'); + $configValue = config('v2board') ?? []; + + foreach ($configValue as $k => $v) { + // 检查记录是否已存在 + $existingSetting = Setting::where('name', $k)->first(); + + // 如果记录不存在,则插入 + if ($existingSetting) { + $this->warn("配置 {$k} 在数据库已经存在, 忽略"); + continue; + } + Setting::create([ + 'name' => $k, + 'value' => is_array($v)? json_encode($v) : $v, + ]); + $this->info("配置 {$k} 迁移成功"); + } + Artisan::call('config:cache'); + + $this->info('所有配置迁移完成'); + } +} diff --git a/Xboard/app/Console/Commands/NodeWebSocketServer.php b/Xboard/app/Console/Commands/NodeWebSocketServer.php new file mode 100644 index 0000000..cbad533 --- /dev/null +++ b/Xboard/app/Console/Commands/NodeWebSocketServer.php @@ -0,0 +1,34 @@ +argument('action'); + + $argv[1] = $action; + if ($this->option('d')) { + $argv[2] = '-d'; + } + + $host = $this->option('host'); + $port = $this->option('port'); + + $worker = new NodeWorker($host, $port); + $worker->run(); + } +} diff --git a/Xboard/app/Console/Commands/ResetLog.php b/Xboard/app/Console/Commands/ResetLog.php new file mode 100644 index 0000000..89ddfb3 --- /dev/null +++ b/Xboard/app/Console/Commands/ResetLog.php @@ -0,0 +1,48 @@ +delete(); + StatServer::where('record_at', '<', strtotime('-2 month', time()))->delete(); + AdminAuditLog::where('created_at', '<', strtotime('-3 month', time()))->delete(); + } +} diff --git a/Xboard/app/Console/Commands/ResetPassword.php b/Xboard/app/Console/Commands/ResetPassword.php new file mode 100644 index 0000000..3e131f2 --- /dev/null +++ b/Xboard/app/Console/Commands/ResetPassword.php @@ -0,0 +1,55 @@ +argument('password') ; + $user = User::byEmail($this->argument('email'))->first(); + if (!$user) abort(500, '邮箱不存在'); + $password = $password ?? Helper::guid(false); + $user->password = password_hash($password, PASSWORD_DEFAULT); + $user->password_algo = null; + if (!$user->save()) abort(500, '重置失败'); + $this->info("!!!重置成功!!!"); + $this->info("新密码为:{$password},请尽快修改密码。"); + } +} diff --git a/Xboard/app/Console/Commands/ResetTraffic.php b/Xboard/app/Console/Commands/ResetTraffic.php new file mode 100644 index 0000000..5152077 --- /dev/null +++ b/Xboard/app/Console/Commands/ResetTraffic.php @@ -0,0 +1,289 @@ +option('fix-null'); + $force = $this->option('force'); + + $this->info('🚀 开始执行流量重置任务...'); + + if ($fixNull) { + $this->warn('🔧 修正模式 - 将重新计算next_reset_at为null的用户'); + } elseif ($force) { + $this->warn('⚡ 强制模式 - 将重新计算所有用户的重置时间'); + } + + try { + $result = $fixNull ? $this->performFix() : ($force ? $this->performForce() : $this->performReset()); + $this->displayResults($result, $fixNull || $force); + return self::SUCCESS; + + } catch (\Exception $e) { + $this->error("❌ 任务执行失败: {$e->getMessage()}"); + + Log::error('流量重置命令执行失败', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return self::FAILURE; + } + } + + private function displayResults(array $result, bool $isSpecialMode): void + { + $this->info("✅ 任务完成!\n"); + + if ($isSpecialMode) { + $this->displayFixResults($result); + } else { + $this->displayExecutionResults($result); + } + } + + private function displayFixResults(array $result): void + { + $this->info("📊 修正结果统计:"); + $this->info("🔍 发现用户总数: {$result['total_found']}"); + $this->info("✅ 成功修正数量: {$result['total_fixed']}"); + $this->info("⏱️ 总执行时间: {$result['duration']} 秒"); + + if ($result['error_count'] > 0) { + $this->warn("⚠️ 错误数量: {$result['error_count']}"); + $this->warn("详细错误信息请查看日志"); + } else { + $this->info("✨ 无错误发生"); + } + + if ($result['total_found'] > 0) { + $avgTime = round($result['duration'] / $result['total_found'], 4); + $this->info("⚡ 平均处理速度: {$avgTime} 秒/用户"); + } + } + + + + private function displayExecutionResults(array $result): void + { + $this->info("📊 执行结果统计:"); + $this->info("👥 处理用户总数: {$result['total_processed']}"); + $this->info("🔄 重置用户数量: {$result['total_reset']}"); + $this->info("⏱️ 总执行时间: {$result['duration']} 秒"); + + if ($result['error_count'] > 0) { + $this->warn("⚠️ 错误数量: {$result['error_count']}"); + $this->warn("详细错误信息请查看日志"); + } else { + $this->info("✨ 无错误发生"); + } + + if ($result['total_processed'] > 0) { + $avgTime = round($result['duration'] / $result['total_processed'], 4); + $this->info("⚡ 平均处理速度: {$avgTime} 秒/用户"); + } + } + + private function performReset(): array + { + $startTime = microtime(true); + $totalResetCount = 0; + $errors = []; + + $users = $this->getResetQuery()->get(); + + if ($users->isEmpty()) { + $this->info("😴 当前没有需要重置的用户"); + return [ + 'total_processed' => 0, + 'total_reset' => 0, + 'error_count' => 0, + 'duration' => round(microtime(true) - $startTime, 2), + ]; + } + + $this->info("找到 {$users->count()} 个需要重置的用户"); + + foreach ($users as $user) { + try { + $totalResetCount += (int) $this->trafficResetService->checkAndReset($user, TrafficResetLog::SOURCE_CRON); + } catch (\Exception $e) { + $errors[] = [ + 'user_id' => $user->id, + 'email' => $user->email, + 'error' => $e->getMessage(), + ]; + Log::error('用户流量重置失败', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'total_processed' => $users->count(), + 'total_reset' => $totalResetCount, + 'error_count' => count($errors), + 'duration' => round(microtime(true) - $startTime, 2), + ]; + } + + private function performFix(): array + { + $startTime = microtime(true); + $nullUsers = $this->getNullResetTimeUsers(); + + if ($nullUsers->isEmpty()) { + $this->info("✅ 没有发现next_reset_at为null的用户"); + return [ + 'total_found' => 0, + 'total_fixed' => 0, + 'error_count' => 0, + 'duration' => round(microtime(true) - $startTime, 2), + ]; + } + + $this->info("🔧 发现 {$nullUsers->count()} 个next_reset_at为null的用户,开始修正..."); + + $fixedCount = 0; + $errors = []; + + foreach ($nullUsers as $user) { + try { + $nextResetTime = $this->trafficResetService->calculateNextResetTime($user); + if ($nextResetTime) { + $user->next_reset_at = $nextResetTime->timestamp; + $user->save(); + $fixedCount++; + } + } catch (\Exception $e) { + $errors[] = [ + 'user_id' => $user->id, + 'email' => $user->email, + 'error' => $e->getMessage(), + ]; + Log::error('修正用户next_reset_at失败', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'total_found' => $nullUsers->count(), + 'total_fixed' => $fixedCount, + 'error_count' => count($errors), + 'duration' => round(microtime(true) - $startTime, 2), + ]; + } + + private function performForce(): array + { + $startTime = microtime(true); + $allUsers = $this->getAllUsers(); + + if ($allUsers->isEmpty()) { + $this->info("✅ 没有发现需要处理的用户"); + return [ + 'total_found' => 0, + 'total_fixed' => 0, + 'error_count' => 0, + 'duration' => round(microtime(true) - $startTime, 2), + ]; + } + + $this->info("⚡ 发现 {$allUsers->count()} 个用户,开始重新计算重置时间..."); + + $fixedCount = 0; + $errors = []; + + foreach ($allUsers as $user) { + try { + $nextResetTime = $this->trafficResetService->calculateNextResetTime($user); + if ($nextResetTime) { + $user->next_reset_at = $nextResetTime->timestamp; + $user->save(); + $fixedCount++; + } + } catch (\Exception $e) { + $errors[] = [ + 'user_id' => $user->id, + 'email' => $user->email, + 'error' => $e->getMessage(), + ]; + Log::error('强制重新计算用户next_reset_at失败', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'total_found' => $allUsers->count(), + 'total_fixed' => $fixedCount, + 'error_count' => count($errors), + 'duration' => round(microtime(true) - $startTime, 2), + ]; + } + + + + private function getResetQuery() + { + return User::where('next_reset_at', '<=', time()) + ->whereNotNull('next_reset_at') + ->where(function ($query) { + $query->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }) + ->where('banned', 0) + ->whereNotNull('plan_id'); + } + + + + private function getNullResetTimeUsers() + { + return User::whereNull('next_reset_at') + ->whereNotNull('plan_id') + ->where(function ($query) { + $query->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }) + ->where('banned', 0) + ->with('plan:id,name,reset_traffic_method') + ->get(); + } + + private function getAllUsers() + { + return User::whereNotNull('plan_id') + ->where(function ($query) { + $query->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }) + ->where('banned', 0) + ->with('plan:id,name,reset_traffic_method') + ->get(); + } + +} \ No newline at end of file diff --git a/Xboard/app/Console/Commands/ResetUser.php b/Xboard/app/Console/Commands/ResetUser.php new file mode 100644 index 0000000..51197ae --- /dev/null +++ b/Xboard/app/Console/Commands/ResetUser.php @@ -0,0 +1,58 @@ +confirm("确定要重置所有用户安全信息吗?")) { + return; + } + ini_set('memory_limit', -1); + $users = User::all(); + foreach ($users as $user) + { + $user->token = Helper::guid(); + $user->uuid = Helper::guid(true); + $user->save(); + $this->info("已重置用户{$user->email}的安全信息"); + } + } +} diff --git a/Xboard/app/Console/Commands/SendRemindMail.php b/Xboard/app/Console/Commands/SendRemindMail.php new file mode 100644 index 0000000..5fdb2d1 --- /dev/null +++ b/Xboard/app/Console/Commands/SendRemindMail.php @@ -0,0 +1,103 @@ +warn('邮件提醒功能未启用'); + return 0; + } + + $chunkSize = max(100, min(2000, (int) $this->option('chunk-size'))); + $mailService = new MailService(); + + $totalUsers = $mailService->getTotalUsersNeedRemind(); + if ($totalUsers === 0) { + $this->info('没有需要发送提醒邮件的用户'); + return 0; + } + + $this->displayInfo($totalUsers, $chunkSize); + + if (!$this->option('force') && !$this->confirm("确定要发送提醒邮件给 {$totalUsers} 个用户吗?")) { + return 0; + } + + $startTime = microtime(true); + $progressBar = $this->output->createProgressBar((int) ceil($totalUsers / $chunkSize)); + $progressBar->start(); + + $statistics = $mailService->processUsersInChunks($chunkSize, function () use ($progressBar) { + $progressBar->advance(); + }); + + $progressBar->finish(); + $this->newLine(); + + $this->displayResults($statistics, microtime(true) - $startTime); + $this->logResults($statistics); + + return 0; + } + + private function displayInfo(int $totalUsers, int $chunkSize): void + { + $this->table(['项目', '值'], [ + ['需要处理的用户', number_format($totalUsers)], + ['批次大小', $chunkSize], + ['预计批次', ceil($totalUsers / $chunkSize)], + ]); + } + + private function displayResults(array $stats, float $duration): void + { + $this->info('✅ 提醒邮件发送完成!'); + + $this->table(['统计项', '数量'], [ + ['总处理用户', number_format($stats['processed_users'])], + ['过期提醒邮件', number_format($stats['expire_emails'])], + ['流量提醒邮件', number_format($stats['traffic_emails'])], + ['跳过用户', number_format($stats['skipped'])], + ['错误数量', number_format($stats['errors'])], + ['总耗时', round($duration, 2) . ' 秒'], + ['平均速度', round($stats['processed_users'] / max($duration, 0.1), 1) . ' 用户/秒'], + ]); + + if ($stats['errors'] > 0) { + $this->warn("⚠️ 有 {$stats['errors']} 个用户的邮件发送失败,请检查日志"); + } + } + + private function logResults(array $statistics): void + { + Log::info('SendRemindMail命令执行完成', ['statistics' => $statistics]); + } +} diff --git a/Xboard/app/Console/Commands/Test.php b/Xboard/app/Console/Commands/Test.php new file mode 100644 index 0000000..667e616 --- /dev/null +++ b/Xboard/app/Console/Commands/Test.php @@ -0,0 +1,41 @@ +info("__ __ ____ _ "); + $this->info("\ \ / /| __ ) ___ __ _ _ __ __| | "); + $this->info(" \ \/ / | __ \ / _ \ / _` | '__/ _` | "); + $this->info(" / /\ \ | |_) | (_) | (_| | | | (_| | "); + $this->info("/_/ \_\|____/ \___/ \__,_|_| \__,_| "); + if ( + (File::exists(base_path() . '/.env') && $this->getEnvValue('INSTALLED')) + || (getenv('INSTALLED', false) && $isDocker) + ) { + $securePath = admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))); + $this->info("访问 http(s)://你的站点/{$securePath} 进入管理面板,你可以在用户中心修改你的密码。"); + $this->warn("如需重新安装请清空目录下 .env 文件的内容(Docker安装方式不可以删除此文件)"); + $this->warn("快捷清空.env命令:"); + note('rm .env && touch .env'); + return; + } + if (is_dir(base_path() . '/.env')) { + $this->error('😔:安装失败,Docker环境下安装请保留空的 .env 文件'); + return; + } + // 选择数据库类型 + $dbType = $enableSqlite ? 'sqlite' : select( + label: '请选择数据库类型', + options: [ + 'sqlite' => 'SQLite (无需额外安装)', + 'mysql' => 'MySQL', + 'postgresql' => 'PostgreSQL' + ], + default: 'sqlite' + ); + + // 使用 match 表达式配置数据库 + $envConfig = match ($dbType) { + 'sqlite' => $this->configureSqlite(), + 'mysql' => $this->configureMysql(), + 'postgresql' => $this->configurePostgresql(), + default => throw new \InvalidArgumentException("不支持的数据库类型: {$dbType}") + }; + + if (is_null($envConfig)) { + return; // 用户选择退出安装 + } + $envConfig['APP_KEY'] = 'base64:' . base64_encode(Encrypter::generateKey('AES-256-CBC')); + $isReidsValid = false; + while (!$isReidsValid) { + // 判断是否为Docker环境 + if ($isDocker == 'true' && ($enableRedis || confirm(label: '是否启用Docker内置的Redis', default: true, yes: '启用', no: '不启用'))) { + $envConfig['REDIS_HOST'] = '/data/redis.sock'; + $envConfig['REDIS_PORT'] = 0; + $envConfig['REDIS_PASSWORD'] = null; + } else { + $envConfig['REDIS_HOST'] = text(label: '请输入Redis地址', default: '127.0.0.1', required: true); + $envConfig['REDIS_PORT'] = text(label: '请输入Redis端口', default: '6379', required: true); + $envConfig['REDIS_PASSWORD'] = text(label: '请输入redis密码(默认: null)', default: ''); + } + $redisConfig = [ + 'client' => 'phpredis', + 'default' => [ + 'host' => $envConfig['REDIS_HOST'], + 'password' => $envConfig['REDIS_PASSWORD'], + 'port' => $envConfig['REDIS_PORT'], + 'database' => 0, + ], + ]; + try { + $redis = new \Illuminate\Redis\RedisManager(app(), 'phpredis', $redisConfig); + $redis->ping(); + $isReidsValid = true; + } catch (\Exception $e) { + // 连接失败,输出错误消息 + $this->error("redis连接失败:" . $e->getMessage()); + $this->info("请重新输入REDIS配置"); + $enableRedis = false; + sleep(1); + } + } + + if (!copy(base_path() . '/.env.example', base_path() . '/.env')) { + abort(500, '复制环境文件失败,请检查目录权限'); + } + ; + $email = !empty($adminAccount) ? $adminAccount : text( + label: '请输入管理员账号', + default: 'admin@demo.com', + required: true, + validate: fn(string $email): ?string => match (true) { + !filter_var($email, FILTER_VALIDATE_EMAIL) => '请输入有效的邮箱地址.', + default => null, + } + ); + $password = Helper::guid(false); + $this->saveToEnv($envConfig); + + $this->call('config:cache'); + Artisan::call('cache:clear'); + $this->info('正在导入数据库请稍等...'); + Artisan::call("migrate", ['--force' => true]); + $this->info(Artisan::output()); + $this->info('数据库导入完成'); + $this->info('开始注册管理员账号'); + if (!self::registerAdmin($email, $password)) { + abort(500, '管理员账号注册失败,请重试'); + } + self::restoreProtectedPlugins($this); + $this->info('正在安装默认插件...'); + PluginManager::installDefaultPlugins(); + $this->info('默认插件安装完成'); + + $this->info('🎉:一切就绪'); + $this->info("管理员邮箱:{$email}"); + $this->info("管理员密码:{$password}"); + + $defaultSecurePath = hash('crc32b', config('app.key')); + $this->info("访问 http(s)://你的站点/{$defaultSecurePath} 进入管理面板,你可以在用户中心修改你的密码。"); + $envConfig['INSTALLED'] = true; + $this->saveToEnv($envConfig); + } catch (\Exception $e) { + $this->error($e); + } + } + + public static function registerAdmin($email, $password) + { + $user = new User(); + $user->email = $email; + if (strlen($password) < 8) { + abort(500, '管理员密码长度最小为8位字符'); + } + $user->password = password_hash($password, PASSWORD_DEFAULT); + $user->uuid = Helper::guid(true); + $user->token = Helper::guid(); + $user->is_admin = 1; + return $user->save(); + } + + private function set_env_var($key, $value) + { + $value = !strpos($value, ' ') ? $value : '"' . $value . '"'; + $key = strtoupper($key); + + $envPath = app()->environmentFilePath(); + $contents = file_get_contents($envPath); + + if (preg_match("/^{$key}=[^\r\n]*/m", $contents, $matches)) { + $contents = str_replace($matches[0], "{$key}={$value}", $contents); + } else { + $contents .= "\n{$key}={$value}\n"; + } + + return file_put_contents($envPath, $contents) !== false; + } + + private function saveToEnv($data = []) + { + foreach ($data as $key => $value) { + self::set_env_var($key, $value); + } + return true; + } + + function getEnvValue($key, $default = null) + { + $dotenv = \Dotenv\Dotenv::createImmutable(base_path()); + $dotenv->load(); + + return Env::get($key, $default); + } + + /** + * 配置 SQLite 数据库 + * + * @return array|null + */ + private function configureSqlite(): ?array + { + $sqliteFile = '.docker/.data/database.sqlite'; + if (!file_exists(base_path($sqliteFile))) { + // 创建空文件 + if (!touch(base_path($sqliteFile))) { + $this->info("sqlite创建成功: $sqliteFile"); + } + } + + $envConfig = [ + 'DB_CONNECTION' => 'sqlite', + 'DB_DATABASE' => $sqliteFile, + 'DB_HOST' => '', + 'DB_USERNAME' => '', + 'DB_PASSWORD' => '', + ]; + + try { + Config::set("database.default", 'sqlite'); + Config::set("database.connections.sqlite.database", base_path($envConfig['DB_DATABASE'])); + DB::purge('sqlite'); + DB::connection('sqlite')->getPdo(); + + if (!blank(DB::connection('sqlite')->getPdo()->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(\PDO::FETCH_COLUMN))) { + if (confirm(label: '检测到数据库中已经存在数据,是否要清空数据库以便安装新的数据?', default: false, yes: '清空', no: '退出安装')) { + $this->info('正在清空数据库请稍等'); + $this->call('db:wipe', ['--force' => true]); + $this->info('数据库清空完成'); + } else { + return null; + } + } + } catch (\Exception $e) { + $this->error("SQLite数据库连接失败:" . $e->getMessage()); + return null; + } + + return $envConfig; + } + + /** + * 配置 MySQL 数据库 + * + * @return array + */ + private function configureMysql(): array + { + while (true) { + $envConfig = [ + 'DB_CONNECTION' => 'mysql', + 'DB_HOST' => text(label: "请输入MySQL数据库地址", default: '127.0.0.1', required: true), + 'DB_PORT' => text(label: '请输入MySQL数据库端口', default: '3306', required: true), + 'DB_DATABASE' => text(label: '请输入MySQL数据库名', default: 'xboard', required: true), + 'DB_USERNAME' => text(label: '请输入MySQL数据库用户名', default: 'root', required: true), + 'DB_PASSWORD' => text(label: '请输入MySQL数据库密码', required: false), + ]; + + try { + Config::set("database.default", 'mysql'); + Config::set("database.connections.mysql.host", $envConfig['DB_HOST']); + Config::set("database.connections.mysql.port", $envConfig['DB_PORT']); + Config::set("database.connections.mysql.database", $envConfig['DB_DATABASE']); + Config::set("database.connections.mysql.username", $envConfig['DB_USERNAME']); + Config::set("database.connections.mysql.password", $envConfig['DB_PASSWORD']); + DB::purge('mysql'); + DB::connection('mysql')->getPdo(); + + if (!blank(DB::connection('mysql')->select('SHOW TABLES'))) { + if (confirm(label: '检测到数据库中已经存在数据,是否要清空数据库以便安装新的数据?', default: false, yes: '清空', no: '不清空')) { + $this->info('正在清空数据库请稍等'); + $this->call('db:wipe', ['--force' => true]); + $this->info('数据库清空完成'); + return $envConfig; + } else { + continue; // 重新输入配置 + } + } + + return $envConfig; + } catch (\Exception $e) { + $this->error("MySQL数据库连接失败:" . $e->getMessage()); + $this->info("请重新输入MySQL数据库配置"); + } + } + } + + /** + * 配置 PostgreSQL 数据库 + * + * @return array + */ + private function configurePostgresql(): array + { + while (true) { + $envConfig = [ + 'DB_CONNECTION' => 'pgsql', + 'DB_HOST' => text(label: "请输入PostgreSQL数据库地址", default: '127.0.0.1', required: true), + 'DB_PORT' => text(label: '请输入PostgreSQL数据库端口', default: '5432', required: true), + 'DB_DATABASE' => text(label: '请输入PostgreSQL数据库名', default: 'xboard', required: true), + 'DB_USERNAME' => text(label: '请输入PostgreSQL数据库用户名', default: 'postgres', required: true), + 'DB_PASSWORD' => text(label: '请输入PostgreSQL数据库密码', required: false), + ]; + + try { + Config::set("database.default", 'pgsql'); + Config::set("database.connections.pgsql.host", $envConfig['DB_HOST']); + Config::set("database.connections.pgsql.port", $envConfig['DB_PORT']); + Config::set("database.connections.pgsql.database", $envConfig['DB_DATABASE']); + Config::set("database.connections.pgsql.username", $envConfig['DB_USERNAME']); + Config::set("database.connections.pgsql.password", $envConfig['DB_PASSWORD']); + DB::purge('pgsql'); + DB::connection('pgsql')->getPdo(); + + // 检查PostgreSQL数据库是否有表 + $tables = DB::connection('pgsql')->select("SELECT tablename FROM pg_tables WHERE schemaname = 'public'"); + if (!blank($tables)) { + if (confirm(label: '检测到数据库中已经存在数据,是否要清空数据库以便安装新的数据?', default: false, yes: '清空', no: '不清空')) { + $this->info('正在清空数据库请稍等'); + $this->call('db:wipe', ['--force' => true]); + $this->info('数据库清空完成'); + return $envConfig; + } else { + continue; // 重新输入配置 + } + } + + return $envConfig; + } catch (\Exception $e) { + $this->error("PostgreSQL数据库连接失败:" . $e->getMessage()); + $this->info("请重新输入PostgreSQL数据库配置"); + } + } + } + + /** + * 还原内置受保护插件(可在安装和更新时调用) + * Docker 部署时 plugins/ 目录被外部挂载覆盖,需要从镜像备份中还原默认插件 + */ + public static function restoreProtectedPlugins(Command $console = null) + { + $backupBase = '/opt/default-plugins'; + $pluginsBase = base_path('plugins'); + + if (!File::isDirectory($backupBase)) { + $console?->info('非 Docker 环境或备份目录不存在,跳过插件还原。'); + return; + } + + foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) { + $dirName = Str::studly($pluginCode); + $source = "{$backupBase}/{$dirName}"; + $target = "{$pluginsBase}/{$dirName}"; + + if (!File::isDirectory($source)) { + continue; + } + + // 先清除旧文件再复制,避免重命名后残留旧文件 + File::deleteDirectory($target); + File::copyDirectory($source, $target); + $console?->info("已同步默认插件 [{$dirName}]"); + } + } +} diff --git a/Xboard/app/Console/Commands/XboardRollback.php b/Xboard/app/Console/Commands/XboardRollback.php new file mode 100644 index 0000000..f9e5f46 --- /dev/null +++ b/Xboard/app/Console/Commands/XboardRollback.php @@ -0,0 +1,45 @@ +info('正在回滚数据库请稍等...'); + \Artisan::call("migrate:rollback"); + $this->info(\Artisan::output()); + } +} diff --git a/Xboard/app/Console/Commands/XboardStatistics.php b/Xboard/app/Console/Commands/XboardStatistics.php new file mode 100644 index 0000000..0bd4736 --- /dev/null +++ b/Xboard/app/Console/Commands/XboardStatistics.php @@ -0,0 +1,75 @@ +statUser(); + // $this->statServer(); + $this->stat(); + info('统计任务执行完毕。耗时:' . (microtime(true) - $startAt) / 1000); + } + + + private function stat() + { + try { + $endAt = strtotime(date('Y-m-d')); + $startAt = strtotime('-1 day', $endAt); + $statisticalService = new StatisticalService(); + $statisticalService->setStartAt($startAt); + $statisticalService->setEndAt($endAt); + $data = $statisticalService->generateStatData(); + $data['record_at'] = $startAt; + $data['record_type'] = 'd'; + $statistic = Stat::where('record_at', $startAt) + ->where('record_type', 'd') + ->first(); + if ($statistic) { + $statistic->update($data); + return; + } + Stat::create($data); + } catch (\Exception $e) { + Log::error($e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/Xboard/app/Console/Commands/XboardUpdate.php b/Xboard/app/Console/Commands/XboardUpdate.php new file mode 100644 index 0000000..65c95da --- /dev/null +++ b/Xboard/app/Console/Commands/XboardUpdate.php @@ -0,0 +1,65 @@ +info('正在导入数据库请稍等...'); + Artisan::call("migrate", ['--force' => true]); + $this->info(Artisan::output()); + $this->info('正在检查内置插件文件...'); + XboardInstall::restoreProtectedPlugins($this); + $this->info('正在检查并安装默认插件...'); + PluginManager::installDefaultPlugins(); + $this->info('默认插件检查完成'); + // Artisan::call('reset:traffic', ['--fix-null' => true]); + $this->info('正在重新计算所有用户的重置时间...'); + Artisan::call('reset:traffic', ['--force' => true]); + $updateService = new UpdateService(); + $updateService->updateVersionCache(); + $themeService = app(ThemeService::class); + $themeService->refreshCurrentTheme(); + Artisan::call('horizon:terminate'); + $this->info('更新完毕,队列服务已重启,你无需进行任何操作。'); + } +} diff --git a/Xboard/app/Console/Kernel.php b/Xboard/app/Console/Kernel.php new file mode 100644 index 0000000..77b1810 --- /dev/null +++ b/Xboard/app/Console/Kernel.php @@ -0,0 +1,68 @@ +command('xboard:statistics')->dailyAt('0:10')->onOneServer(); + // check + $schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5); + $schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5); + $schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5); + $schedule->command('check:traffic-exceeded')->everyMinute()->onOneServer()->withoutOverlapping(10)->runInBackground(); + // reset + $schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10); + $schedule->command('reset:log')->daily()->onOneServer(); + // send + $schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer(); + // horizon metrics + $schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer(); + // backup Timing + // if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) { + // $schedule->command('backup:database', ['true'])->daily()->onOneServer(); + // } + app(PluginManager::class)->registerPluginSchedules($schedule); + + } + + /** + * Register the commands for the application. + * + * @return void + */ + protected function commands() + { + $this->load(__DIR__ . '/Commands'); + + try { + app(PluginManager::class)->initializeEnabledPlugins(); + } catch (\Exception $e) { + } + require base_path('routes/console.php'); + } +} diff --git a/Xboard/app/Contracts/PaymentInterface.php b/Xboard/app/Contracts/PaymentInterface.php new file mode 100644 index 0000000..2661362 --- /dev/null +++ b/Xboard/app/Contracts/PaymentInterface.php @@ -0,0 +1,10 @@ +message = $message; + $this->code = $code; + $this->errors = $errors; + } + public function errors(){ + return $this->errors; + } + +} diff --git a/Xboard/app/Exceptions/BusinessException.php b/Xboard/app/Exceptions/BusinessException.php new file mode 100644 index 0000000..2079495 --- /dev/null +++ b/Xboard/app/Exceptions/BusinessException.php @@ -0,0 +1,19 @@ +> + */ + protected $dontReport = [ + ApiException::class, + InterceptResponseException::class + ]; + + /** + * A list of the inputs that are never flashed for validation exceptions. + * + * @var array + */ + protected $dontFlash = [ + 'password', + 'password_confirmation', + ]; + + /** + * Report or log an exception. + * + * @param \Throwable $exception + * @return void + * + * @throws \Throwable + */ + public function report(Throwable $exception) + { + parent::report($exception); + } + + /** + * Render an exception into an HTTP response. + * + * @param \Illuminate\Http\Request $request + * @param \Throwable $exception + * @return \Symfony\Component\HttpFoundation\Response + * + * @throws \Throwable + */ + public function render($request, Throwable $exception) + { + if ($exception instanceof ViewException) { + return $this->fail([500, '主题渲染失败。如更新主题,参数可能发生变化请重新配置主题后再试。']); + } + // ApiException主动抛出错误 + if ($exception instanceof ApiException) { + $code = $exception->getCode(); + $message = $exception->getMessage(); + $errors = $exception->errors(); + return $this->fail([$code, $message],null,$errors); + } + return parent::render($request, $exception); + } + + /** + * Register the exception handling callbacks for the application. + */ + public function register(): void + { + $this->reportable(function (Throwable $e) { + // + }); + + $this->renderable(function (InterceptResponseException $e) { + return $e->getResponse(); + }); + } + + protected function convertExceptionToArray(Throwable $e) + { + return config('app.debug') ? [ + 'message' => $e->getMessage(), + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => collect($e->getTrace())->map(function ($trace) { + return Arr::except($trace, ['args']); + })->all(), + ] : [ + 'message' => $this->isHttpException($e) ? $e->getMessage() : __("Uh-oh, we've had some problems, we're working on it."), + ]; + } +} diff --git a/Xboard/app/Helpers/ApiResponse.php b/Xboard/app/Helpers/ApiResponse.php new file mode 100644 index 0000000..4820eb4 --- /dev/null +++ b/Xboard/app/Helpers/ApiResponse.php @@ -0,0 +1,79 @@ +jsonResponse('success', $codeResponse, $data, null); + } + + /** + * 失败 + * @param array $codeResponse + * @param mixed $data + * @param mixed $error + * @return JsonResponse + */ + public function fail($codeResponse = ResponseEnum::HTTP_ERROR, $data = null, $error = null): JsonResponse + { + return $this->jsonResponse('fail', $codeResponse, $data, $error); + } + + /** + * json响应 + * @param $status + * @param $codeResponse + * @param $data + * @param $error + * @return JsonResponse + */ + private function jsonResponse($status, $codeResponse, $data, $error): JsonResponse + { + list($code, $message) = $codeResponse; + return response() + ->json([ + 'status' => $status, + // 'code' => $code, + 'message' => $message, + 'data' => $data ?? null, + 'error' => $error, + ], (int) substr(((string) $code), 0, 3)); + } + + + public function paginate(LengthAwarePaginator $page) + { + return response()->json([ + 'total' => $page->total(), + 'current_page' => $page->currentPage(), + 'per_page' => $page->perPage(), + 'last_page' => $page->lastPage(), + 'data' => $page->items() + ]); + } + + /** + * 业务异常返回 + * @param array $codeResponse + * @param string $info + * @throws BusinessException + */ + public function throwBusinessException(array $codeResponse = ResponseEnum::HTTP_ERROR, string $info = '') + { + throw new BusinessException($codeResponse, $info); + } +} \ No newline at end of file diff --git a/Xboard/app/Helpers/Functions.php b/Xboard/app/Helpers/Functions.php new file mode 100644 index 0000000..282d62e --- /dev/null +++ b/Xboard/app/Helpers/Functions.php @@ -0,0 +1,82 @@ +toArray(); + } + + if (is_array($key)) { + $setting->save($key); + return ''; + } + + $default = config('v2board.' . $key) ?? $default; + return $setting->get($key) ?? $default; + } +} + +if (!function_exists('subscribe_template')) { + /** + * Get subscribe template content by protocol name. + */ + function subscribe_template(string $name): ?string + { + return \App\Models\SubscribeTemplate::getContent($name); + } +} + +if (!function_exists('admin_settings_batch')) { + /** + * 批量获取配置参数,性能优化版本 + * + * @param array $keys 配置键名数组 + * @return array 返回键值对数组 + */ + function admin_settings_batch(array $keys): array + { + return app(Setting::class)->getBatch($keys); + } +} + +if (!function_exists('source_base_url')) { + /** + * 获取来源基础URL,优先Referer,其次Host + * @param string $path + * @return string + */ + function source_base_url(string $path = ''): string + { + $baseUrl = ''; + $referer = request()->header('Referer'); + + if ($referer) { + $parsedUrl = parse_url($referer); + if (isset($parsedUrl['scheme']) && isset($parsedUrl['host'])) { + $baseUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host']; + if (isset($parsedUrl['port'])) { + $baseUrl .= ':' . $parsedUrl['port']; + } + } + } + + if (!$baseUrl) { + $baseUrl = request()->getSchemeAndHttpHost(); + } + + $baseUrl = rtrim($baseUrl, '/'); + $path = ltrim($path, '/'); + return $baseUrl . '/' . $path; + } +} diff --git a/Xboard/app/Helpers/ResponseEnum.php b/Xboard/app/Helpers/ResponseEnum.php new file mode 100644 index 0000000..f749ad3 --- /dev/null +++ b/Xboard/app/Helpers/ResponseEnum.php @@ -0,0 +1,81 @@ +isPluginEnabled()) { + return [400, '插件未启用']; + } + return null; + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Controllers/V1/Client/AppController.php b/Xboard/app/Http/Controllers/V1/Client/AppController.php new file mode 100644 index 0000000..4637b08 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Client/AppController.php @@ -0,0 +1,90 @@ +user(); + $userService = new UserService(); + if ($userService->isAvailable($user)) { + $servers = ServerService::getAvailableServers($user); + } + $defaultConfig = base_path() . '/resources/rules/app.clash.yaml'; + $customConfig = base_path() . '/resources/rules/custom.app.clash.yaml'; + if (File::exists($customConfig)) { + $config = Yaml::parseFile($customConfig); + } else { + $config = Yaml::parseFile($defaultConfig); + } + $proxy = []; + $proxies = []; + + foreach ($servers as $item) { + $protocol_settings = $item['protocol_settings']; + if ($item['type'] === 'shadowsocks' + && in_array(data_get($protocol_settings, 'cipher'), [ + 'aes-128-gcm', + 'aes-192-gcm', + 'aes-256-gcm', + 'chacha20-ietf-poly1305' + ]) + ) { + array_push($proxy, \App\Protocols\Clash::buildShadowsocks($user['uuid'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === 'vmess') { + array_push($proxy, \App\Protocols\Clash::buildVmess($user['uuid'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === 'trojan') { + array_push($proxy, \App\Protocols\Clash::buildTrojan($user['uuid'], $item)); + array_push($proxies, $item['name']); + } + } + + $config['proxies'] = array_merge($config['proxies'] ? $config['proxies'] : [], $proxy); + foreach ($config['proxy-groups'] as $k => $v) { + $config['proxy-groups'][$k]['proxies'] = array_merge($config['proxy-groups'][$k]['proxies'], $proxies); + } + return(Yaml::dump($config)); + } + + public function getVersion(Request $request) + { + if (strpos($request->header('user-agent'), 'tidalab/4.0.0') !== false + || strpos($request->header('user-agent'), 'tunnelab/4.0.0') !== false + ) { + if (strpos($request->header('user-agent'), 'Win64') !== false) { + $data = [ + 'version' => admin_setting('windows_version'), + 'download_url' => admin_setting('windows_download_url') + ]; + } else { + $data = [ + 'version' => admin_setting('macos_version'), + 'download_url' => admin_setting('macos_download_url') + ]; + } + }else{ + $data = [ + 'windows_version' => admin_setting('windows_version'), + 'windows_download_url' => admin_setting('windows_download_url'), + 'macos_version' => admin_setting('macos_version'), + 'macos_download_url' => admin_setting('macos_download_url'), + 'android_version' => admin_setting('android_version'), + 'android_download_url' => admin_setting('android_download_url') + ]; + } + return $this->success($data); + } +} diff --git a/Xboard/app/Http/Controllers/V1/Client/ClientController.php b/Xboard/app/Http/Controllers/V1/Client/ClientController.php new file mode 100644 index 0000000..e4437bc --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Client/ClientController.php @@ -0,0 +1,247 @@ + [ + 1 => '[Hy]', + 2 => '[Hy2]' + ], + 'vless' => '[vless]', + 'shadowsocks' => '[ss]', + 'vmess' => '[vmess]', + 'trojan' => '[trojan]', + 'tuic' => '[tuic]', + 'socks' => '[socks]', + 'anytls' => '[anytls]' + ]; + + + public function subscribe(Request $request) + { + HookManager::call('client.subscribe.before'); + $request->validate([ + 'types' => ['nullable', 'string'], + 'filter' => ['nullable', 'string'], + 'flag' => ['nullable', 'string'], + ]); + + $user = $request->user(); + $userService = new UserService(); + + if (!$userService->isAvailable($user)) { + HookManager::call('client.subscribe.unavailable'); + return response('', 403, ['Content-Type' => 'text/plain']); + } + + return $this->doSubscribe($request, $user); + } + + public function doSubscribe(Request $request, $user, $servers = null) + { + if ($servers === null) { + $servers = ServerService::getAvailableServers($user); + $servers = HookManager::filter('client.subscribe.servers', $servers, $user, $request); + } + + $clientInfo = $this->getClientInfo($request); + + $requestedTypes = $this->parseRequestedTypes($request->input('types')); + $filterKeywords = $this->parseFilterKeywords($request->input('filter')); + + $protocolClassName = app('protocols.manager')->matchProtocolClassName($clientInfo['flag']) + ?? General::class; + + $serversFiltered = $this->filterServers( + servers: $servers, + allowedTypes: $requestedTypes, + filterKeywords: $filterKeywords + ); + + $this->setSubscribeInfoToServers($serversFiltered, $user, count($servers) - count($serversFiltered)); + $serversFiltered = $this->addPrefixToServerName($serversFiltered); + + // Instantiate the protocol class with filtered servers and client info + $protocolInstance = app()->make($protocolClassName, [ + 'user' => $user, + 'servers' => $serversFiltered, + 'clientName' => $clientInfo['name'] ?? null, + 'clientVersion' => $clientInfo['version'] ?? null, + 'userAgent' => $clientInfo['flag'] ?? null + ]); + + return $protocolInstance->handle(); + } + + /** + * Parses the input string for requested server types. + */ + private function parseRequestedTypes(?string $typeInputString): array + { + if (blank($typeInputString) || $typeInputString === 'all') { + return Server::VALID_TYPES; + } + + $requested = collect(preg_split('/[|,|]+/', $typeInputString)) + ->map(fn($type) => trim($type)) + ->filter() // Remove empty strings that might result from multiple delimiters + ->all(); + + return array_values(array_intersect($requested, Server::VALID_TYPES)); + } + + /** + * Parses the input string for filter keywords. + */ + private function parseFilterKeywords(?string $filterInputString): ?array + { + if (blank($filterInputString) || mb_strlen($filterInputString) > 20) { + return null; + } + + return collect(preg_split('/[|,|]+/', $filterInputString)) + ->map(fn($keyword) => trim($keyword)) + ->filter() // Remove empty strings + ->all(); + } + + /** + * Filters servers based on allowed types and keywords. + */ + private function filterServers(array $servers, array $allowedTypes, ?array $filterKeywords): array + { + return collect($servers)->filter(function ($server) use ($allowedTypes, $filterKeywords) { + // Condition 1: Server type must be in the list of allowed types + if ($allowedTypes && !in_array($server['type'], $allowedTypes)) { + return false; // Filter out (don't keep) + } + + // Condition 2: If filterKeywords are provided, at least one keyword must match + if (!empty($filterKeywords)) { // Check if $filterKeywords is not empty + $keywordMatch = collect($filterKeywords)->contains(function ($keyword) use ($server) { + return stripos($server['name'], $keyword) !== false + || in_array($keyword, $server['tags'] ?? []); + }); + if (!$keywordMatch) { + return false; // Filter out if no keywords match + } + } + // Keep the server if its type is allowed AND (no filter keywords OR at least one keyword matched) + return true; + })->values()->all(); + } + + private function getClientInfo(Request $request): array + { + $flag = strtolower($request->input('flag') ?? $request->header('User-Agent', '')); + + $clientName = null; + $clientVersion = null; + + if (preg_match('/([a-zA-Z0-9\-_]+)[\/\s]+(v?[0-9]+(?:\.[0-9]+){0,2})/', $flag, $matches)) { + $potentialName = strtolower($matches[1]); + $clientVersion = preg_replace('/^v/', '', $matches[2]); + + if (in_array($potentialName, app('protocols.flags'))) { + $clientName = $potentialName; + } + } + + if (!$clientName) { + $flags = collect(app('protocols.flags'))->sortByDesc(fn($f) => strlen($f))->values()->all(); + foreach ($flags as $name) { + if (stripos($flag, $name) !== false) { + $clientName = $name; + if (!$clientVersion) { + $pattern = '/' . preg_quote($name, '/') . '[\/\s]+(v?[0-9]+(?:\.[0-9]+){0,2})/i'; + if (preg_match($pattern, $flag, $vMatches)) { + $clientVersion = preg_replace('/^v/', '', $vMatches[1]); + } + } + break; + } + } + } + + if (!$clientVersion) { + if (preg_match('/\/v?(\d+(?:\.\d+){0,2})/', $flag, $matches)) { + $clientVersion = $matches[1]; + } + } + + return [ + 'flag' => $flag, + 'name' => $clientName, + 'version' => $clientVersion + ]; + } + + private function setSubscribeInfoToServers(&$servers, $user, $rejectServerCount = 0) + { + if (!isset($servers[0])) + return; + if ($rejectServerCount > 0) { + array_unshift($servers, array_merge($servers[0], [ + 'name' => "过滤掉{$rejectServerCount}条线路", + ])); + } + if (!(int) admin_setting('show_info_to_server_enable', 0)) + return; + $useTraffic = $user['u'] + $user['d']; + $totalTraffic = $user['transfer_enable']; + $remainingTraffic = Helper::trafficConvert($totalTraffic - $useTraffic); + $expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : __('长期有效'); + $userService = new UserService(); + $resetDay = $userService->getResetDay($user); + array_unshift($servers, array_merge($servers[0], [ + 'name' => "套餐到期:{$expiredDate}", + ])); + if ($resetDay) { + array_unshift($servers, array_merge($servers[0], [ + 'name' => "距离下次重置剩余:{$resetDay} 天", + ])); + } + array_unshift($servers, array_merge($servers[0], [ + 'name' => "剩余流量:{$remainingTraffic}", + ])); + } + + private function addPrefixToServerName(array $servers): array + { + if (!admin_setting('show_protocol_to_server_enable', false)) { + return $servers; + } + return collect($servers) + ->map(function (array $server): array { + $server['name'] = $this->getPrefixedServerName($server); + return $server; + }) + ->all(); + } + + private function getPrefixedServerName(array $server): string + { + $type = $server['type'] ?? ''; + if (!isset(self::PROTOCOL_PREFIXES[$type])) { + return $server['name'] ?? ''; + } + $prefix = is_array(self::PROTOCOL_PREFIXES[$type]) + ? self::PROTOCOL_PREFIXES[$type][$server['protocol_settings']['version'] ?? 1] ?? '' + : self::PROTOCOL_PREFIXES[$type]; + return $prefix . ($server['name'] ?? ''); + } +} diff --git a/Xboard/app/Http/Controllers/V1/Guest/CommController.php b/Xboard/app/Http/Controllers/V1/Guest/CommController.php new file mode 100644 index 0000000..00c05c7 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Guest/CommController.php @@ -0,0 +1,39 @@ + admin_setting('tos_url'), + 'is_email_verify' => (int) admin_setting('email_verify', 0) ? 1 : 0, + 'is_invite_force' => (int) admin_setting('invite_force', 0) ? 1 : 0, + 'email_whitelist_suffix' => (int) admin_setting('email_whitelist_enable', 0) + ? Helper::getEmailSuffix() + : 0, + 'is_captcha' => (int) admin_setting('captcha_enable', 0) ? 1 : 0, + 'captcha_type' => admin_setting('captcha_type', 'recaptcha'), + 'recaptcha_site_key' => admin_setting('recaptcha_site_key'), + 'recaptcha_v3_site_key' => admin_setting('recaptcha_v3_site_key'), + 'recaptcha_v3_score_threshold' => admin_setting('recaptcha_v3_score_threshold', 0.5), + 'turnstile_site_key' => admin_setting('turnstile_site_key'), + 'app_description' => admin_setting('app_description'), + 'app_url' => admin_setting('app_url'), + 'logo' => admin_setting('logo'), + // 保持向后兼容 + 'is_recaptcha' => (int) admin_setting('captcha_enable', 0) ? 1 : 0, + ]; + + $data = HookManager::filter('guest_comm_config', $data); + + return $this->success($data); + } +} diff --git a/Xboard/app/Http/Controllers/V1/Guest/PaymentController.php b/Xboard/app/Http/Controllers/V1/Guest/PaymentController.php new file mode 100644 index 0000000..31f444e --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Guest/PaymentController.php @@ -0,0 +1,52 @@ +notify($request->input()); + if (!$verify) { + HookManager::call('payment.notify.failed', [$method, $uuid, $request]); + return $this->fail([422, 'verify error']); + } + HookManager::call('payment.notify.verified', $verify); + if (!$this->handle($verify['trade_no'], $verify['callback_no'])) { + return $this->fail([400, 'handle error']); + } + return (isset($verify['custom_result']) ? $verify['custom_result'] : 'success'); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, 'fail']); + } + } + + private function handle($tradeNo, $callbackNo) + { + $order = Order::where('trade_no', $tradeNo)->first(); + if (!$order) { + return $this->fail([400202, 'order is not found']); + } + if ($order->status !== Order::STATUS_PENDING) + return true; + $orderService = new OrderService($order); + if (!$orderService->paid($callbackNo)) { + return false; + } + + HookManager::call('payment.notify.success', $order); + return true; + } +} diff --git a/Xboard/app/Http/Controllers/V1/Guest/PlanController.php b/Xboard/app/Http/Controllers/V1/Guest/PlanController.php new file mode 100644 index 0000000..4348a25 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Guest/PlanController.php @@ -0,0 +1,25 @@ +planService = $planService; + } + public function fetch(Request $request) + { + $plan = $this->planService->getAvailablePlans(); + return $this->success(PlanResource::collection($plan)); + } +} diff --git a/Xboard/app/Http/Controllers/V1/Guest/TelegramController.php b/Xboard/app/Http/Controllers/V1/Guest/TelegramController.php new file mode 100644 index 0000000..01369fa --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Guest/TelegramController.php @@ -0,0 +1,126 @@ +telegramService = $telegramService; + $this->userService = $userService; + } + + public function webhook(Request $request): void + { + $expectedToken = md5(admin_setting('telegram_bot_token')); + if ($request->input('access_token') !== $expectedToken) { + throw new ApiException('access_token is error', 401); + } + + $data = $request->json()->all(); + + $this->formatMessage($data); + $this->formatChatJoinRequest($data); + $this->handle(); + } + + private function handle(): void + { + if (!$this->msg) + return; + $msg = $this->msg; + $this->processBotName($msg); + try { + HookManager::call('telegram.message.before', [$msg]); + $handled = HookManager::filter('telegram.message.handle', false, [$msg]); + if (!$handled) { + HookManager::call('telegram.message.unhandled', [$msg]); + } + HookManager::call('telegram.message.after', [$msg]); + } catch (\Exception $e) { + HookManager::call('telegram.message.error', [$msg, $e]); + $this->telegramService->sendMessage($msg->chat_id, $e->getMessage()); + } + } + + private function processBotName(object $msg): void + { + $commandParts = explode('@', $msg->command); + + if (count($commandParts) === 2) { + $botName = $this->getBotName(); + if ($commandParts[1] === $botName) { + $msg->command = $commandParts[0]; + } + } + } + + private function getBotName(): string + { + $response = $this->telegramService->getMe(); + return $response->result->username; + } + + private function formatMessage(array $data): void + { + if (!isset($data['message']['text'])) + return; + + $message = $data['message']; + $text = explode(' ', $message['text']); + + $this->msg = (object) [ + 'command' => $text[0], + 'args' => array_slice($text, 1), + 'chat_id' => $message['chat']['id'], + 'message_id' => $message['message_id'], + 'message_type' => 'message', + 'text' => $message['text'], + 'is_private' => $message['chat']['type'] === 'private', + ]; + + if (isset($message['reply_to_message']['text'])) { + $this->msg->message_type = 'reply_message'; + $this->msg->reply_text = $message['reply_to_message']['text']; + } + } + + private function formatChatJoinRequest(array $data): void + { + $joinRequest = $data['chat_join_request'] ?? null; + if (!$joinRequest) + return; + + $chatId = $joinRequest['chat']['id'] ?? null; + $userId = $joinRequest['from']['id'] ?? null; + + if (!$chatId || !$userId) + return; + + $user = User::where('telegram_id', $userId)->first(); + + if (!$user) { + $this->telegramService->declineChatJoinRequest($chatId, $userId); + return; + } + + if (!$this->userService->isAvailable($user)) { + $this->telegramService->declineChatJoinRequest($chatId, $userId); + return; + } + + $this->telegramService->approveChatJoinRequest($chatId, $userId); + } +} diff --git a/Xboard/app/Http/Controllers/V1/Passport/AuthController.php b/Xboard/app/Http/Controllers/V1/Passport/AuthController.php new file mode 100644 index 0000000..5464426 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Passport/AuthController.php @@ -0,0 +1,175 @@ +mailLinkService = $mailLinkService; + $this->registerService = $registerService; + $this->loginService = $loginService; + } + + /** + * 通过邮件链接登录 + */ + public function loginWithMailLink(Request $request) + { + $params = $request->validate([ + 'email' => 'required|email:strict', + 'redirect' => 'nullable' + ]); + + [$success, $result] = $this->mailLinkService->handleMailLink( + $params['email'], + $request->input('redirect') + ); + + if (!$success) { + return $this->fail($result); + } + + return $this->success($result); + } + + /** + * 用户注册 + */ + public function register(AuthRegister $request) + { + [$success, $result] = $this->registerService->register($request); + + if (!$success) { + return $this->fail($result); + } + + $authService = new AuthService($result); + return $this->success($authService->generateAuthData()); + } + + /** + * 用户登录 + */ + public function login(AuthLogin $request) + { + $email = $request->input('email'); + $password = $request->input('password'); + + [$success, $result] = $this->loginService->login($email, $password); + + if (!$success) { + return $this->fail($result); + } + + $authService = new AuthService($result); + return $this->success($authService->generateAuthData()); + } + + /** + * 通过token登录 + */ + public function token2Login(Request $request) + { + // 处理直接通过token重定向 + if ($token = $request->input('token')) { + $redirect = '/#/login?verify=' . $token . '&redirect=' . ($request->input('redirect', 'dashboard')); + + return redirect()->to( + admin_setting('app_url') + ? admin_setting('app_url') . $redirect + : url($redirect) + ); + } + + // 处理通过验证码登录 + if ($verify = $request->input('verify')) { + $userId = $this->mailLinkService->handleTokenLogin($verify); + + if (!$userId) { + return response()->json([ + 'message' => __('Token error') + ], 400); + } + + $user = \App\Models\User::find($userId); + + if (!$user) { + return response()->json([ + 'message' => __('User not found') + ], 400); + } + + $authService = new AuthService($user); + + return response()->json([ + 'data' => $authService->generateAuthData() + ]); + } + + return response()->json([ + 'message' => __('Invalid request') + ], 400); + } + + /** + * 获取快速登录URL + */ + public function getQuickLoginUrl(Request $request) + { + $authorization = $request->input('auth_data') ?? $request->header('authorization'); + + if (!$authorization) { + return response()->json([ + 'message' => ResponseEnum::CLIENT_HTTP_UNAUTHORIZED + ], 401); + } + + $user = AuthService::findUserByBearerToken($authorization); + + if (!$user) { + return response()->json([ + 'message' => ResponseEnum::CLIENT_HTTP_UNAUTHORIZED_EXPIRED + ], 401); + } + + $url = $this->loginService->generateQuickLoginUrl($user, $request->input('redirect')); + return $this->success($url); + } + + /** + * 忘记密码处理 + */ + public function forget(AuthForget $request) + { + [$success, $result] = $this->loginService->resetPassword( + $request->input('email'), + $request->input('email_code'), + $request->input('password') + ); + + if (!$success) { + return $this->fail($result); + } + + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V1/Passport/CommController.php b/Xboard/app/Http/Controllers/V1/Passport/CommController.php new file mode 100644 index 0000000..663badf --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Passport/CommController.php @@ -0,0 +1,76 @@ +verify($request); + if (!$captchaValid) { + return $this->fail($captchaError); + } + + $email = $request->input('email'); + + // 检查白名单后缀限制 + if ((int) admin_setting('email_whitelist_enable', 0)) { + $isRegisteredEmail = User::byEmail($email)->exists(); + if (!$isRegisteredEmail) { + $allowedSuffixes = Helper::getEmailSuffix(); + $emailSuffix = substr(strrchr($email, '@'), 1); + + if (!in_array($emailSuffix, $allowedSuffixes)) { + return $this->fail([400, __('Email suffix is not in whitelist')]); + } + } + } + + if (Cache::get(CacheKey::get('LAST_SEND_EMAIL_VERIFY_TIMESTAMP', $email))) { + return $this->fail([400, __('Email verification code has been sent, please request again later')]); + } + $code = rand(100000, 999999); + $subject = admin_setting('app_name', 'XBoard') . __('Email verification code'); + + SendEmailJob::dispatch([ + 'email' => $email, + 'subject' => $subject, + 'template_name' => 'verify', + 'template_value' => [ + 'name' => admin_setting('app_name', 'XBoard'), + 'code' => $code, + 'url' => admin_setting('app_url') + ] + ]); + + Cache::put(CacheKey::get('EMAIL_VERIFY_CODE', $email), $code, 300); + Cache::put(CacheKey::get('LAST_SEND_EMAIL_VERIFY_TIMESTAMP', $email), time(), 60); + return $this->success(true); + } + + public function pv(Request $request) + { + $inviteCode = InviteCode::where('code', $request->input('invite_code'))->first(); + if ($inviteCode) { + $inviteCode->pv = $inviteCode->pv + 1; + $inviteCode->save(); + } + + return $this->success(true); + } + +} diff --git a/Xboard/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php b/Xboard/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php new file mode 100644 index 0000000..62e5af9 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php @@ -0,0 +1,65 @@ +attributes->get('node_info'); + Cache::put(CacheKey::get('SERVER_SHADOWSOCKS_LAST_CHECK_AT', $server->id), time(), 3600); + $users = ServerService::getAvailableUsers($server); + $result = []; + foreach ($users as $user) { + array_push($result, [ + 'id' => $user->id, + 'port' => $server->server_port, + 'cipher' => $server->cipher, + 'secret' => $user->uuid + ]); + } + $eTag = sha1(json_encode($result)); + if (strpos($request->header('If-None-Match'), $eTag) !== false ) { + return response(null,304); + } + return response([ + 'data' => $result + ])->header('ETag', "\"{$eTag}\""); + } + + // 后端提交数据 + public function submit(Request $request) + { + $server = $request->attributes->get('node_info'); + $data = json_decode(request()->getContent(), true); + Cache::put(CacheKey::get('SERVER_SHADOWSOCKS_ONLINE_USER', $server->id), count($data), 3600); + Cache::put(CacheKey::get('SERVER_SHADOWSOCKS_LAST_PUSH_AT', $server->id), time(), 3600); + $userService = new UserService(); + $formatData = []; + + foreach ($data as $item) { + $formatData[$item['user_id']] = [$item['u'], $item['d']]; + } + $userService->trafficFetch($server, 'shadowsocks', $formatData); + + return response([ + 'ret' => 1, + 'msg' => 'ok' + ]); + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Controllers/V1/Server/TrojanTidalabController.php b/Xboard/app/Http/Controllers/V1/Server/TrojanTidalabController.php new file mode 100644 index 0000000..ceff48f --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Server/TrojanTidalabController.php @@ -0,0 +1,108 @@ +attributes->get('node_info'); + if ($server->type !== 'trojan') { + return $this->fail([400, '节点不存在']); + } + Cache::put(CacheKey::get('SERVER_TROJAN_LAST_CHECK_AT', $server->id), time(), 3600); + $users = ServerService::getAvailableUsers($server); + $result = []; + foreach ($users as $user) { + $user->trojan_user = [ + "password" => $user->uuid, + ]; + unset($user->uuid); + array_push($result, $user); + } + $eTag = sha1(json_encode($result)); + if (strpos($request->header('If-None-Match'), $eTag) !== false) { + return response(null, 304); + } + return response([ + 'msg' => 'ok', + 'data' => $result, + ])->header('ETag', "\"{$eTag}\""); + } + + // 后端提交数据 + public function submit(Request $request) + { + $server = $request->attributes->get('node_info'); + if ($server->type !== 'trojan') { + return $this->fail([400, '节点不存在']); + } + $data = json_decode(request()->getContent(), true); + Cache::put(CacheKey::get('SERVER_TROJAN_ONLINE_USER', $server->id), count($data), 3600); + Cache::put(CacheKey::get('SERVER_TROJAN_LAST_PUSH_AT', $server->id), time(), 3600); + $userService = new UserService(); + $formatData = []; + foreach ($data as $item) { + $formatData[$item['user_id']] = [$item['u'], $item['d']]; + } + $userService->trafficFetch($server, 'trojan', $formatData); + + return response([ + 'ret' => 1, + 'msg' => 'ok' + ]); + } + + // 后端获取配置 + public function config(Request $request) + { + $server = $request->attributes->get('node_info'); + if ($server->type !== 'trojan') { + return $this->fail([400, '节点不存在']); + } + $request->validate([ + 'node_id' => 'required', + 'local_port' => 'required' + ], [ + 'node_id.required' => '节点ID不能为空', + 'local_port.required' => '本地端口不能为空' + ]); + try { + $json = $this->getTrojanConfig($server, $request->input('local_port')); + } catch (\Exception $e) { + \Log::error($e); + return $this->fail([500, '配置获取失败']); + } + + return (json_encode($json, JSON_UNESCAPED_UNICODE)); + } + + private function getTrojanConfig($server, int $localPort) + { + $protocolSettings = $server->protocol_settings; + $json = json_decode(self::TROJAN_CONFIG); + $json->local_port = $server->server_port; + $json->ssl->sni = data_get($protocolSettings, 'server_name', $server->host); + $json->ssl->cert = "/root/.cert/server.crt"; + $json->ssl->key = "/root/.cert/server.key"; + $json->api->api_port = $localPort; + return $json; + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Controllers/V1/Server/UniProxyController.php b/Xboard/app/Http/Controllers/V1/Server/UniProxyController.php new file mode 100644 index 0000000..5901c4b --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/Server/UniProxyController.php @@ -0,0 +1,178 @@ +attributes->get('node_info'); + } + + // 后端获取用户 + public function user(Request $request) + { + ini_set('memory_limit', -1); + $node = $this->getNodeInfo($request); + $nodeType = $node->type; + $nodeId = $node->id; + Cache::put(CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_CHECK_AT', $nodeId), time(), 3600); + $users = ServerService::getAvailableUsers($node); + + $response['users'] = $users; + + $eTag = sha1(json_encode($response)); + if (strpos($request->header('If-None-Match', ''), $eTag) !== false) { + return response(null, 304); + } + + return response($response)->header('ETag', "\"{$eTag}\""); + } + + // 后端提交数据 + public function push(Request $request) + { + $res = json_decode(request()->getContent(), true); + if (!is_array($res)) { + return $this->fail([422, 'Invalid data format']); + } + $data = array_filter($res, function ($item) { + return is_array($item) + && count($item) === 2 + && is_numeric($item[0]) + && is_numeric($item[1]); + }); + if (empty($data)) { + return $this->success(true); + } + $node = $this->getNodeInfo($request); + $nodeType = $node->type; + $nodeId = $node->id; + + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_ONLINE_USER', $nodeId), + count($data), + 3600 + ); + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_PUSH_AT', $nodeId), + time(), + 3600 + ); + + $userService = new UserService(); + $userService->trafficFetch($node, $nodeType, $data); + return $this->success(true); + } + + // 后端获取配置 + public function config(Request $request) + { + $node = $this->getNodeInfo($request); + $response = ServerService::buildNodeConfig($node); + + $response['base_config'] = [ + 'push_interval' => (int) admin_setting('server_push_interval', 60), + 'pull_interval' => (int) admin_setting('server_pull_interval', 60) + ]; + + $eTag = sha1(json_encode($response)); + if (strpos($request->header('If-None-Match', ''), $eTag) !== false) { + return response(null, 304); + } + return response($response)->header('ETag', "\"{$eTag}\""); + } + + // 获取在线用户数据 + public function alivelist(Request $request): JsonResponse + { + $node = $this->getNodeInfo($request); + $deviceLimitUsers = ServerService::getAvailableUsers($node) + ->where('device_limit', '>', 0); + + $alive = $this->deviceStateService->getAliveList(collect($deviceLimitUsers)); + + return response()->json(['alive' => (object) $alive]); + } + + // 后端提交在线数据 + public function alive(Request $request): JsonResponse + { + $node = $this->getNodeInfo($request); + $data = json_decode(request()->getContent(), true); + if ($data === null) { + return response()->json([ + 'error' => 'Invalid online data' + ], 400); + } + + foreach ($data as $uid => $ips) { + $this->deviceStateService->setDevices((int) $uid, $node->id, $ips); + } + + return response()->json(['data' => true]); + } + + // 提交节点负载状态 + public function status(Request $request): JsonResponse + { + $node = $this->getNodeInfo($request); + + $data = $request->validate([ + 'cpu' => 'required|numeric|min:0|max:100', + 'mem.total' => 'required|integer|min:0', + 'mem.used' => 'required|integer|min:0', + 'swap.total' => 'required|integer|min:0', + 'swap.used' => 'required|integer|min:0', + 'disk.total' => 'required|integer|min:0', + 'disk.used' => 'required|integer|min:0', + ]); + + $nodeType = $node->type; + $nodeId = $node->id; + + $statusData = [ + 'cpu' => (float) $data['cpu'], + 'mem' => [ + 'total' => (int) $data['mem']['total'], + 'used' => (int) $data['mem']['used'], + ], + 'swap' => [ + 'total' => (int) $data['swap']['total'], + 'used' => (int) $data['swap']['used'], + ], + 'disk' => [ + 'total' => (int) $data['disk']['total'], + 'used' => (int) $data['disk']['used'], + ], + 'updated_at' => now()->timestamp, + ]; + + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + cache([ + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LOAD_STATUS', $nodeId) => $statusData, + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_LOAD_AT', $nodeId) => now()->timestamp, + ], $cacheTime); + + return response()->json(['data' => true, "code" => 0, "message" => "success"]); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/CommController.php b/Xboard/app/Http/Controllers/V1/User/CommController.php new file mode 100644 index 0000000..fa0ae27 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/CommController.php @@ -0,0 +1,39 @@ + (int)admin_setting('telegram_bot_enable', 0), + 'telegram_discuss_link' => admin_setting('telegram_discuss_link'), + 'stripe_pk' => admin_setting('stripe_pk_live'), + 'withdraw_methods' => admin_setting('commission_withdraw_method', Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT), + 'withdraw_close' => (int)admin_setting('withdraw_close_enable', 0), + 'currency' => admin_setting('currency', 'CNY'), + 'currency_symbol' => admin_setting('currency_symbol', '¥'), + 'commission_distribution_enable' => (int)admin_setting('commission_distribution_enable', 0), + 'commission_distribution_l1' => admin_setting('commission_distribution_l1'), + 'commission_distribution_l2' => admin_setting('commission_distribution_l2'), + 'commission_distribution_l3' => admin_setting('commission_distribution_l3') + ]; + return $this->success($data); + } + + public function getStripePublicKey(Request $request) + { + $payment = Payment::where('id', $request->input('id')) + ->where('payment', 'StripeCredit') + ->first(); + if (!$payment) throw new ApiException('payment is not found'); + return $this->success($payment->config['stripe_pk_live']); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/CouponController.php b/Xboard/app/Http/Controllers/V1/User/CouponController.php new file mode 100644 index 0000000..b7c091f --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/CouponController.php @@ -0,0 +1,25 @@ +input('code'))) { + return $this->fail([422, __('Coupon cannot be empty')]); + } + $couponService = new CouponService($request->input('code')); + $couponService->setPlanId($request->input('plan_id')); + $couponService->setUserId($request->user()->id); + $couponService->setPeriod($request->input('period')); + $couponService->check(); + return $this->success(CouponResource::make($couponService->getCoupon())); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/GiftCardController.php b/Xboard/app/Http/Controllers/V1/User/GiftCardController.php new file mode 100644 index 0000000..507c164 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/GiftCardController.php @@ -0,0 +1,193 @@ +input('code')); + $giftCardService->setUser($request->user()); + + // 1. 验证礼品卡本身是否有效 (如不存在、已过期、已禁用) + $giftCardService->validateIsActive(); + + // 2. 检查用户是否满足使用条件,但不在此处抛出异常 + $eligibility = $giftCardService->checkUserEligibility(); + + // 3. 获取卡片信息和奖励预览 + $codeInfo = $giftCardService->getCodeInfo(); + $rewardPreview = $giftCardService->previewRewards(); + + return $this->success([ + 'code_info' => $codeInfo, // 这里面已经包含 plan_info + 'reward_preview' => $rewardPreview, + 'can_redeem' => $eligibility['can_redeem'], + 'reason' => $eligibility['reason'], + ]); + + } catch (ApiException $e) { + // 这里只捕获 validateIsActive 抛出的异常 + return $this->fail([400, $e->getMessage()]); + } catch (\Exception $e) { + Log::error('礼品卡查询失败', [ + 'code' => $request->input('code'), + 'user_id' => $request->user()->id, + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '查询失败,请稍后重试']); + } + } + + /** + * 使用兑换码 + */ + public function redeem(GiftCardRedeemRequest $request) + { + try { + $giftCardService = new GiftCardService($request->input('code')); + $giftCardService->setUser($request->user()); + $giftCardService->validate(); + + // 使用礼品卡 + $result = $giftCardService->redeem([ + // 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + Log::info('礼品卡使用成功', [ + 'code' => $request->input('code'), + 'user_id' => $request->user()->id, + 'rewards' => $result['rewards'], + ]); + + return $this->success([ + 'message' => '兑换成功!', + 'rewards' => $result['rewards'], + 'invite_rewards' => $result['invite_rewards'], + 'template_name' => $result['template_name'], + ]); + + } catch (ApiException $e) { + return $this->fail([400, $e->getMessage()]); + } catch (\Exception $e) { + Log::error('礼品卡使用失败', [ + 'code' => $request->input('code'), + 'user_id' => $request->user()->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + return $this->fail([500, '兑换失败,请稍后重试']); + } + } + + /** + * 获取用户兑换记录 + */ + public function history(Request $request) + { + $request->validate([ + 'page' => 'integer|min:1', + 'per_page' => 'integer|min:1|max:100', + ]); + + $perPage = $request->input('per_page', 15); + + $usages = GiftCardUsage::with(['template', 'code']) + ->where('user_id', $request->user()->id) + ->orderBy('created_at', 'desc') + ->paginate($perPage); + + $data = $usages->getCollection()->map(function (GiftCardUsage $usage) { + return [ + 'id' => $usage->id, + 'code' => ($usage->code instanceof \App\Models\GiftCardCode && $usage->code->code) + ? (substr($usage->code->code, 0, 8) . '****') + : '', + 'template_name' => $usage->template->name ?? '', + 'template_type' => $usage->template->type ?? '', + 'template_type_name' => $usage->template->type_name ?? '', + 'rewards_given' => $usage->rewards_given, + 'invite_rewards' => $usage->invite_rewards, + 'multiplier_applied' => $usage->multiplier_applied, + 'created_at' => $usage->created_at, + ]; + })->values(); + return response()->json([ + 'data' => $data, + 'pagination' => [ + 'current_page' => $usages->currentPage(), + 'last_page' => $usages->lastPage(), + 'per_page' => $usages->perPage(), + 'total' => $usages->total(), + ], + ]); + } + + /** + * 获取兑换记录详情 + */ + public function detail(Request $request) + { + $request->validate([ + 'id' => 'required|integer|exists:v2_gift_card_usage,id', + ]); + + $usage = GiftCardUsage::with(['template', 'code', 'inviteUser']) + ->where('user_id', $request->user()->id) + ->where('id', $request->input('id')) + ->first(); + + if (!$usage) { + return $this->fail([404, '记录不存在']); + } + + return $this->success([ + 'id' => $usage->id, + 'code' => $usage->code->code ?? '', + 'template' => [ + 'name' => $usage->template->name ?? '', + 'description' => $usage->template->description ?? '', + 'type' => $usage->template->type ?? '', + 'type_name' => $usage->template->type_name ?? '', + 'icon' => $usage->template->icon ?? '', + 'theme_color' => $usage->template->theme_color ?? '', + ], + 'rewards_given' => $usage->rewards_given, + 'invite_rewards' => $usage->invite_rewards, + 'invite_user' => $usage->inviteUser ? [ + 'id' => $usage->inviteUser->id ?? '', + 'email' => isset($usage->inviteUser->email) ? (substr($usage->inviteUser->email, 0, 3) . '***@***') : '', + ] : null, + 'user_level_at_use' => $usage->user_level_at_use, + 'plan_id_at_use' => $usage->plan_id_at_use, + 'multiplier_applied' => $usage->multiplier_applied, + // 'ip_address' => $usage->ip_address, + 'notes' => $usage->notes, + 'created_at' => $usage->created_at, + ]); + } + + /** + * 获取可用的礼品卡类型 + */ + public function types(Request $request) + { + return $this->success([ + 'types' => \App\Models\GiftCardTemplate::getTypeMap(), + ]); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/InviteController.php b/Xboard/app/Http/Controllers/V1/User/InviteController.php new file mode 100644 index 0000000..cbde315 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/InviteController.php @@ -0,0 +1,79 @@ +user()->id)->where('status', 0)->count() >= admin_setting('invite_gen_limit', 5)) { + return $this->fail([400,__('The maximum number of creations has been reached')]); + } + $inviteCode = new InviteCode(); + $inviteCode->user_id = $request->user()->id; + $inviteCode->code = Helper::randomChar(8); + return $this->success($inviteCode->save()); + } + + public function details(Request $request) + { + $current = $request->input('current') ? $request->input('current') : 1; + $pageSize = $request->input('page_size') >= 10 ? $request->input('page_size') : 10; + $builder = CommissionLog::where('invite_user_id', $request->user()->id) + ->where('get_amount', '>', 0) + ->orderBy('created_at', 'DESC'); + $total = $builder->count(); + $details = $builder->forPage($current, $pageSize) + ->get(); + return response([ + 'data' => ComissionLogResource::collection($details), + 'total' => $total + ]); + } + + public function fetch(Request $request) + { + $commission_rate = admin_setting('invite_commission', 10); + $user = User::find($request->user()->id) + ->load(['codes' => fn($query) => $query->where('status', 0)]); + if ($user->commission_rate) { + $commission_rate = $user->commission_rate; + } + $uncheck_commission_balance = (int)Order::where('status', 3) + ->where('commission_status', 0) + ->where('invite_user_id', $user->id) + ->sum('commission_balance'); + if (admin_setting('commission_distribution_enable', 0)) { + $uncheck_commission_balance = $uncheck_commission_balance * (admin_setting('commission_distribution_l1') / 100); + } + $stat = [ + //已注册用户数 + (int)User::where('invite_user_id', $user->id)->count(), + //有效的佣金 + (int)CommissionLog::where('invite_user_id', $user->id) + ->sum('get_amount'), + //确认中的佣金 + $uncheck_commission_balance, + //佣金比例 + (int)$commission_rate, + //可用佣金 + (int)$user->commission_balance + ]; + $data = [ + 'codes' => InviteCodeResource::collection($user->codes), + 'stat' => $stat + ]; + return $this->success($data); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/KnowledgeController.php b/Xboard/app/Http/Controllers/V1/User/KnowledgeController.php new file mode 100644 index 0000000..e3646c9 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/KnowledgeController.php @@ -0,0 +1,150 @@ +userService = $userService; + } + + public function fetch(Request $request) + { + $request->validate([ + 'id' => 'nullable|sometimes|integer|min:1', + 'language' => 'nullable|sometimes|string|max:10', + 'keyword' => 'nullable|sometimes|string|max:255', + ]); + + return $request->input('id') + ? $this->fetchSingle($request) + : $this->fetchList($request); + } + + private function fetchSingle(Request $request) + { + $knowledge = $this->buildKnowledgeQuery() + ->where('id', $request->input('id')) + ->first(); + + if (!$knowledge) { + return $this->fail([500, __('Article does not exist')]); + } + + $knowledge = $knowledge->toArray(); + $knowledge = $this->processKnowledgeContent($knowledge, $request->user()); + + return $this->success(KnowledgeResource::make($knowledge)); + } + + private function fetchList(Request $request) + { + $builder = $this->buildKnowledgeQuery(['id', 'category', 'title', 'updated_at', 'body']) + ->where('language', $request->input('language')) + ->orderBy('sort', 'ASC'); + + $keyword = $request->input('keyword'); + if ($keyword) { + $builder = $builder->where(function ($query) use ($keyword) { + $query->where('title', 'LIKE', "%{$keyword}%") + ->orWhere('body', 'LIKE', "%{$keyword}%"); + }); + } + + $knowledges = $builder->get() + ->map(function ($knowledge) use ($request) { + $knowledge = $knowledge->toArray(); + $knowledge = $this->processKnowledgeContent($knowledge, $request->user()); + return KnowledgeResource::make($knowledge); + }) + ->groupBy('category'); + + return $this->success($knowledges); + } + + private function buildKnowledgeQuery(array $select = ['*']) + { + return Knowledge::select($select)->where('show', 1); + } + + private function processKnowledgeContent(array $knowledge, User $user): array + { + if (!isset($knowledge['body'])) { + return $knowledge; + } + + if (!$this->userService->isAvailable($user)) { + $this->formatAccessData($knowledge['body']); + } + $subscribeUrl = Helper::getSubscribeUrl($user['token']); + $knowledge['body'] = $this->replacePlaceholders($knowledge['body'], $subscribeUrl); + + return $knowledge; + } + + private function formatAccessData(&$body): void + { + $rules = [ + [ + 'type' => 'regex', + 'pattern' => '/(.*?)/s', + 'replacement' => '
' . __('You must have a valid subscription to view content in this area') . '
' + ] + ]; + + $this->applyReplacementRules($body, $rules); + } + + private function replacePlaceholders(string $body, string $subscribeUrl): string + { + $rules = [ + [ + 'type' => 'string', + 'search' => '{{siteName}}', + 'replacement' => admin_setting('app_name', 'XBoard') + ], + [ + 'type' => 'string', + 'search' => '{{subscribeUrl}}', + 'replacement' => $subscribeUrl + ], + [ + 'type' => 'string', + 'search' => '{{urlEncodeSubscribeUrl}}', + 'replacement' => urlencode($subscribeUrl) + ], + [ + 'type' => 'string', + 'search' => '{{safeBase64SubscribeUrl}}', + 'replacement' => str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($subscribeUrl)) + ] + ]; + + $this->applyReplacementRules($body, $rules); + return $body; + } + + private function applyReplacementRules(string &$body, array $rules): void + { + foreach ($rules as $rule) { + if ($rule['type'] === 'regex') { + $body = preg_replace($rule['pattern'], $rule['replacement'], $body); + } else { + $body = str_replace($rule['search'], $rule['replacement'], $body); + } + } + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/NoticeController.php b/Xboard/app/Http/Controllers/V1/User/NoticeController.php new file mode 100644 index 0000000..9382e06 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/NoticeController.php @@ -0,0 +1,26 @@ +input('current') ? $request->input('current') : 1; + $pageSize = 5; + $model = Notice::orderBy('sort', 'ASC') + ->orderBy('id', 'DESC') + ->where('show', true); + $total = $model->count(); + $res = $model->forPage($current, $pageSize) + ->get(); + return response([ + 'data' => $res, + 'total' => $total + ]); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/OrderController.php b/Xboard/app/Http/Controllers/V1/User/OrderController.php new file mode 100644 index 0000000..7b28128 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/OrderController.php @@ -0,0 +1,212 @@ +validate([ + 'status' => 'nullable|integer|in:0,1,2,3', + ]); + $orders = Order::with('plan') + ->where('user_id', $request->user()->id) + ->when($request->input('status') !== null, function ($query) use ($request) { + $query->where('status', $request->input('status')); + }) + ->orderBy('created_at', 'DESC') + ->get(); + + return $this->success(OrderResource::collection($orders)); + } + + public function detail(Request $request) + { + $request->validate([ + 'trade_no' => 'required|string', + ]); + $order = Order::with(['payment', 'plan']) + ->where('user_id', $request->user()->id) + ->where('trade_no', $request->input('trade_no')) + ->first(); + if (!$order) { + return $this->fail([400, __('Order does not exist or has been paid')]); + } + $order['try_out_plan_id'] = (int) admin_setting('try_out_plan_id'); + if (!$order->plan) { + return $this->fail([400, __('Subscription plan does not exist')]); + } + if ($order->surplus_order_ids) { + $order['surplus_orders'] = Order::whereIn('id', $order->surplus_order_ids)->get(); + } + return $this->success(OrderResource::make($order)); + } + + public function save(OrderSave $request) + { + $request->validate([ + 'plan_id' => 'required|exists:App\Models\Plan,id', + 'period' => 'required|string' + ]); + + $user = User::findOrFail($request->user()->id); + $userService = app(UserService::class); + + if ($userService->isNotCompleteOrderByUserId($user->id)) { + throw new ApiException(__('You have an unpaid or pending order, please try again later or cancel it')); + } + + $plan = Plan::findOrFail($request->input('plan_id')); + $planService = new PlanService($plan); + + $planService->validatePurchase($user, $request->input('period')); + + $order = OrderService::createFromRequest( + $user, + $plan, + $request->input('period'), + $request->input('coupon_code') + ); + + return $this->success($order->trade_no); + } + + protected function applyCoupon(Order $order, string $couponCode): void + { + $couponService = new CouponService($couponCode); + if (!$couponService->use($order)) { + throw new ApiException(__('Coupon failed')); + } + $order->coupon_id = $couponService->getId(); + } + + protected function handleUserBalance(Order $order, User $user, UserService $userService): void + { + $remainingBalance = $user->balance - $order->total_amount; + + if ($remainingBalance > 0) { + if (!$userService->addBalance($order->user_id, -$order->total_amount)) { + throw new ApiException(__('Insufficient balance')); + } + $order->balance_amount = $order->total_amount; + $order->total_amount = 0; + } else { + if (!$userService->addBalance($order->user_id, -$user->balance)) { + throw new ApiException(__('Insufficient balance')); + } + $order->balance_amount = $user->balance; + $order->total_amount = $order->total_amount - $user->balance; + } + } + + public function checkout(Request $request) + { + $tradeNo = $request->input('trade_no'); + $method = $request->input('method'); + $order = Order::where('trade_no', $tradeNo) + ->where('user_id', $request->user()->id) + ->where('status', 0) + ->first(); + if (!$order) { + return $this->fail([400, __('Order does not exist or has been paid')]); + } + // free process + if ($order->total_amount <= 0) { + $orderService = new OrderService($order); + if (!$orderService->paid($order->trade_no)) + return $this->fail([400, '支付失败']); + return response([ + 'type' => -1, + 'data' => true + ]); + } + $payment = Payment::find($method); + if (!$payment || !$payment->enable) { + return $this->fail([400, __('Payment method is not available')]); + } + $paymentService = new PaymentService($payment->payment, $payment->id); + $order->handling_amount = NULL; + if ($payment->handling_fee_fixed || $payment->handling_fee_percent) { + $order->handling_amount = (int) round(($order->total_amount * ($payment->handling_fee_percent / 100)) + $payment->handling_fee_fixed); + } + $order->payment_id = $method; + if (!$order->save()) + return $this->fail([400, __('Request failed, please try again later')]); + $result = $paymentService->pay([ + 'trade_no' => $tradeNo, + 'total_amount' => isset($order->handling_amount) ? ($order->total_amount + $order->handling_amount) : $order->total_amount, + 'user_id' => $order->user_id, + 'stripe_token' => $request->input('token') + ]); + return response([ + 'type' => $result['type'], + 'data' => $result['data'] + ]); + } + + public function check(Request $request) + { + $tradeNo = $request->input('trade_no'); + $order = Order::where('trade_no', $tradeNo) + ->where('user_id', $request->user()->id) + ->first(); + if (!$order) { + return $this->fail([400, __('Order does not exist')]); + } + return $this->success($order->status); + } + + public function getPaymentMethod() + { + $methods = Payment::select([ + 'id', + 'name', + 'payment', + 'icon', + 'handling_fee_fixed', + 'handling_fee_percent' + ]) + ->where('enable', 1) + ->orderBy('sort', 'ASC') + ->get(); + + return $this->success($methods); + } + + public function cancel(Request $request) + { + if (empty($request->input('trade_no'))) { + return $this->fail([422, __('Invalid parameter')]); + } + $order = Order::where('trade_no', $request->input('trade_no')) + ->where('user_id', $request->user()->id) + ->first(); + if (!$order) { + return $this->fail([400, __('Order does not exist')]); + } + if ($order->status !== 0) { + return $this->fail([400, __('You can only cancel pending orders')]); + } + $orderService = new OrderService($order); + if (!$orderService->cancel()) { + return $this->fail([400, __('Cancel failed')]); + } + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/PlanController.php b/Xboard/app/Http/Controllers/V1/User/PlanController.php new file mode 100644 index 0000000..7db7aad --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/PlanController.php @@ -0,0 +1,38 @@ +planService = $planService; + } + public function fetch(Request $request) + { + $user = User::find($request->user()->id); + if ($request->input('id')) { + $plan = Plan::where('id', $request->input('id'))->first(); + if (!$plan) { + return $this->fail([400, __('Subscription plan does not exist')]); + } + if (!$this->planService->isPlanAvailableForUser($plan, $user)) { + return $this->fail([400, __('Subscription plan does not exist')]); + } + return $this->success(PlanResource::make($plan)); + } + + $plans = $this->planService->getAvailablePlans(); + return $this->success(PlanResource::collection($plans)); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/ServerController.php b/Xboard/app/Http/Controllers/V1/User/ServerController.php new file mode 100644 index 0000000..f12b005 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/ServerController.php @@ -0,0 +1,31 @@ +user()->id); + $servers = []; + $userService = new UserService(); + if ($userService->isAvailable($user)) { + $servers = ServerService::getAvailableServers($user); + } + $eTag = sha1(json_encode(array_column($servers, 'cache_key'))); + if (strpos($request->header('If-None-Match', ''), $eTag) !== false ) { + return response(null,304); + } + $data = NodeResource::collection($servers); + return response([ + 'data' => $data + ])->header('ETag', "\"{$eTag}\""); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/StatController.php b/Xboard/app/Http/Controllers/V1/User/StatController.php new file mode 100644 index 0000000..11bb9c0 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/StatController.php @@ -0,0 +1,26 @@ +startOfMonth()->timestamp; + $records = StatUser::query() + ->where('user_id', $request->user()->id) + ->where('record_at', '>=', $startDate) + ->orderBy('record_at', 'DESC') + ->get(); + + $data = TrafficLogResource::collection(collect($records)); + return $this->success($data); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/TelegramController.php b/Xboard/app/Http/Controllers/V1/User/TelegramController.php new file mode 100644 index 0000000..2cf65c7 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/TelegramController.php @@ -0,0 +1,26 @@ +getMe(); + $data = [ + 'username' => $response->result->username + ]; + return $this->success($data); + } + + public function unbind(Request $request) + { + $user = User::where('user_id', $request->user()->id)->first(); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/TicketController.php b/Xboard/app/Http/Controllers/V1/User/TicketController.php new file mode 100644 index 0000000..05ca915 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/TicketController.php @@ -0,0 +1,154 @@ +input('id')) { + $ticket = Ticket::where('id', $request->input('id')) + ->where('user_id', $request->user()->id) + ->first() + ->load('message'); + if (!$ticket) { + return $this->fail([400, __('Ticket does not exist')]); + } + $ticket['message'] = TicketMessage::where('ticket_id', $ticket->id)->get(); + $ticket['message']->each(function ($message) use ($ticket) { + $message['is_me'] = ($message['user_id'] == $ticket->user_id); + }); + return $this->success(TicketResource::make($ticket)->additional(['message' => true])); + } + $ticket = Ticket::where('user_id', $request->user()->id) + ->orderBy('created_at', 'DESC') + ->get(); + return $this->success(TicketResource::collection($ticket)); + } + + public function save(TicketSave $request) + { + $ticketService = new TicketService(); + $ticket = $ticketService->createTicket( + $request->user()->id, + $request->input('subject'), + $request->input('level'), + $request->input('message') + ); + HookManager::call('ticket.create.after', $ticket); + return $this->success(true); + + } + + public function reply(Request $request) + { + if (empty($request->input('id'))) { + return $this->fail([400, __('Invalid parameter')]); + } + if (empty($request->input('message'))) { + return $this->fail([400, __('Message cannot be empty')]); + } + $ticket = Ticket::where('id', $request->input('id')) + ->where('user_id', $request->user()->id) + ->first(); + if (!$ticket) { + return $this->fail([400, __('Ticket does not exist')]); + } + if ($ticket->status) { + return $this->fail([400, __('The ticket is closed and cannot be replied')]); + } + if ((int) admin_setting('ticket_must_wait_reply', 0) && $request->user()->id == $this->getLastMessage($ticket->id)->user_id) { + return $this->fail(codeResponse: [400, __('Please wait for the technical enginneer to reply')]); + } + $ticketService = new TicketService(); + if ( + !$ticketService->reply( + $ticket, + $request->input('message'), + $request->user()->id + ) + ) { + return $this->fail([400, __('Ticket reply failed')]); + } + HookManager::call('ticket.reply.user.after', $ticket); + return $this->success(true); + } + + + public function close(Request $request) + { + if (empty($request->input('id'))) { + return $this->fail([422, __('Invalid parameter')]); + } + $ticket = Ticket::where('id', $request->input('id')) + ->where('user_id', $request->user()->id) + ->first(); + if (!$ticket) { + return $this->fail([400, __('Ticket does not exist')]); + } + $ticket->status = Ticket::STATUS_CLOSED; + if (!$ticket->save()) { + return $this->fail([500, __('Close failed')]); + } + return $this->success(true); + } + + private function getLastMessage($ticketId) + { + return TicketMessage::where('ticket_id', $ticketId) + ->orderBy('id', 'DESC') + ->first(); + } + + public function withdraw(TicketWithdraw $request) + { + if ((int) admin_setting('withdraw_close_enable', 0)) { + return $this->fail([400, 'Unsupported withdraw']); + } + if ( + !in_array( + $request->input('withdraw_method'), + admin_setting('commission_withdraw_method', Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT) + ) + ) { + return $this->fail([422, __('Unsupported withdrawal method')]); + } + $user = User::find($request->user()->id); + $limit = admin_setting('commission_withdraw_limit', 100); + if ($limit > ($user->commission_balance / 100)) { + return $this->fail([422, __('The current required minimum withdrawal commission is :limit', ['limit' => $limit])]); + } + try { + $ticketService = new TicketService(); + $subject = __('[Commission Withdrawal Request] This ticket is opened by the system'); + $message = sprintf( + "%s\r\n%s", + __('Withdrawal method') . ":" . $request->input('withdraw_method'), + __('Withdrawal account') . ":" . $request->input('withdraw_account') + ); + $ticket = $ticketService->createTicket( + $request->user()->id, + $subject, + 2, + $message + ); + } catch (\Exception $e) { + throw $e; + } + HookManager::call('ticket.create.after', $ticket); + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V1/User/UserController.php b/Xboard/app/Http/Controllers/V1/User/UserController.php new file mode 100644 index 0000000..c561988 --- /dev/null +++ b/Xboard/app/Http/Controllers/V1/User/UserController.php @@ -0,0 +1,223 @@ +loginService = $loginService; + } + + public function getActiveSession(Request $request) + { + $user = $request->user(); + $authService = new AuthService($user); + return $this->success($authService->getSessions()); + } + + public function removeActiveSession(Request $request) + { + $user = $request->user(); + $authService = new AuthService($user); + return $this->success($authService->removeSession($request->input('session_id'))); + } + + public function checkLogin(Request $request) + { + $data = [ + 'is_login' => $request->user()?->id ? true : false + ]; + if ($request->user()?->is_admin) { + $data['is_admin'] = true; + } + return $this->success($data); + } + + public function changePassword(UserChangePassword $request) + { + $user = $request->user(); + if ( + !Helper::multiPasswordVerify( + $user->password_algo, + $user->password_salt, + $request->input('old_password'), + $user->password + ) + ) { + return $this->fail([400, __('The old password is wrong')]); + } + $user->password = password_hash($request->input('new_password'), PASSWORD_DEFAULT); + $user->password_algo = NULL; + $user->password_salt = NULL; + if (!$user->save()) { + return $this->fail([400, __('Save failed')]); + } + + $currentToken = $user->currentAccessToken(); + if ($currentToken) { + $user->tokens()->where('id', '!=', $currentToken->id)->delete(); + } else { + $user->tokens()->delete(); + } + + return $this->success(true); + } + + public function info(Request $request) + { + $user = User::where('id', $request->user()->id) + ->select([ + 'email', + 'transfer_enable', + 'last_login_at', + 'created_at', + 'banned', + 'remind_expire', + 'remind_traffic', + 'expired_at', + 'balance', + 'commission_balance', + 'plan_id', + 'discount', + 'commission_rate', + 'telegram_id', + 'uuid' + ]) + ->first(); + if (!$user) { + return $this->fail([400, __('The user does not exist')]); + } + $user['avatar_url'] = 'https://cdn.v2ex.com/gravatar/' . md5($user->email) . '?s=64&d=identicon'; + return $this->success($user); + } + + public function getStat(Request $request) + { + $stat = [ + Order::where('status', 0) + ->where('user_id', $request->user()->id) + ->count(), + Ticket::where('status', 0) + ->where('user_id', $request->user()->id) + ->count(), + User::where('invite_user_id', $request->user()->id) + ->count() + ]; + return $this->success($stat); + } + + public function getSubscribe(Request $request) + { + $user = User::where('id', $request->user()->id) + ->select([ + 'plan_id', + 'token', + 'expired_at', + 'u', + 'd', + 'transfer_enable', + 'email', + 'uuid', + 'device_limit', + 'speed_limit', + 'next_reset_at' + ]) + ->first(); + if (!$user) { + return $this->fail([400, __('The user does not exist')]); + } + if ($user->plan_id) { + $user['plan'] = Plan::find($user->plan_id); + if (!$user['plan']) { + return $this->fail([400, __('Subscription plan does not exist')]); + } + } + $user['subscribe_url'] = Helper::getSubscribeUrl($user['token']); + $userService = new UserService(); + $user['reset_day'] = $userService->getResetDay($user); + $user = HookManager::filter('user.subscribe.response', $user); + return $this->success($user); + } + + public function resetSecurity(Request $request) + { + $user = $request->user(); + $user->uuid = Helper::guid(true); + $user->token = Helper::guid(); + if (!$user->save()) { + return $this->fail([400, __('Reset failed')]); + } + return $this->success(Helper::getSubscribeUrl($user->token)); + } + + public function update(UserUpdate $request) + { + $updateData = $request->only([ + 'remind_expire', + 'remind_traffic' + ]); + + $user = $request->user(); + try { + $user->update($updateData); + } catch (\Exception $e) { + return $this->fail([400, __('Save failed')]); + } + + return $this->success(true); + } + + public function transfer(UserTransfer $request) + { + $amount = $request->input('transfer_amount'); + try { + DB::transaction(function () use ($request, $amount) { + $user = User::lockForUpdate()->find($request->user()->id); + if (!$user) { + throw new \Exception(__('The user does not exist')); + } + if ($amount > $user->commission_balance) { + throw new \Exception(__('Insufficient commission balance')); + } + $user->commission_balance -= $amount; + $user->balance += $amount; + if (!$user->save()) { + throw new \Exception(__('Transfer failed')); + } + }); + } catch (\Exception $e) { + return $this->fail([400, $e->getMessage()]); + } + return $this->success(true); + } + + public function getQuickLoginUrl(Request $request) + { + $user = $request->user(); + + $url = $this->loginService->generateQuickLoginUrl($user, $request->input('redirect')); + return $this->success($url); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/ConfigController.php b/Xboard/app/Http/Controllers/V2/Admin/ConfigController.php new file mode 100644 index 0000000..81c5570 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/ConfigController.php @@ -0,0 +1,300 @@ +success($files); + } + + public function getThemeTemplate() + { + $path = public_path('theme/'); + $files = array_map(function ($item) use ($path) { + return str_replace($path, '', $item); + }, glob($path . '*')); + return $this->success($files); + } + + public function testSendMail(Request $request) + { + $mailLog = MailService::sendEmail([ + 'email' => $request->user()->email, + 'subject' => 'This is xboard test email', + 'template_name' => 'notify', + 'template_value' => [ + 'name' => admin_setting('app_name', 'XBoard'), + 'content' => 'This is xboard test email', + 'url' => admin_setting('app_url') + ] + ]); + return response([ + 'data' => $mailLog, + ]); + } + public function setTelegramWebhook(Request $request) + { + $hookUrl = $this->resolveTelegramWebhookUrl(); + if (blank($hookUrl)) { + return $this->fail([422, 'Telegram Webhook地址未配置']); + } + $hookUrl .= '?' . http_build_query([ + 'access_token' => md5(admin_setting('telegram_bot_token', $request->input('telegram_bot_token'))) + ]); + $telegramService = new TelegramService($request->input('telegram_bot_token')); + $telegramService->getMe(); + $telegramService->setWebhook(url: $hookUrl); + $telegramService->registerBotCommands(); + return $this->success([ + 'success' => true, + 'webhook_url' => $hookUrl, + 'webhook_base_url' => $this->getTelegramWebhookBaseUrl(), + ]); + } + + public function fetch(Request $request) + { + $key = $request->input('key'); + $configMappings = $this->getConfigMappings(); + if ($key && isset($configMappings[$key])) { + return $this->success([$key => $configMappings[$key]]); + } + + return $this->success($configMappings); + } + + /** + * 获取配置映射数据 + * + * @return array 配置映射数组 + */ + private function getConfigMappings(): array + { + return [ + 'invite' => [ + 'invite_force' => (bool) admin_setting('invite_force', 0), + 'invite_commission' => admin_setting('invite_commission', 10), + 'invite_gen_limit' => admin_setting('invite_gen_limit', 5), + 'invite_never_expire' => (bool) admin_setting('invite_never_expire', 0), + 'commission_first_time_enable' => (bool) admin_setting('commission_first_time_enable', 1), + 'commission_auto_check_enable' => (bool) admin_setting('commission_auto_check_enable', 1), + 'commission_withdraw_limit' => admin_setting('commission_withdraw_limit', 100), + 'commission_withdraw_method' => admin_setting('commission_withdraw_method', Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT), + 'withdraw_close_enable' => (bool) admin_setting('withdraw_close_enable', 0), + 'commission_distribution_enable' => (bool) admin_setting('commission_distribution_enable', 0), + 'commission_distribution_l1' => admin_setting('commission_distribution_l1'), + 'commission_distribution_l2' => admin_setting('commission_distribution_l2'), + 'commission_distribution_l3' => admin_setting('commission_distribution_l3') + ], + 'site' => [ + 'logo' => admin_setting('logo'), + 'force_https' => (int) admin_setting('force_https', 0), + 'stop_register' => (int) admin_setting('stop_register', 0), + 'app_name' => admin_setting('app_name', 'XBoard'), + 'app_description' => admin_setting('app_description', 'XBoard is best!'), + 'app_url' => admin_setting('app_url'), + 'subscribe_url' => admin_setting('subscribe_url'), + 'try_out_plan_id' => (int) admin_setting('try_out_plan_id', 0), + 'try_out_hour' => (int) admin_setting('try_out_hour', 1), + 'tos_url' => admin_setting('tos_url'), + 'currency' => admin_setting('currency', 'CNY'), + 'currency_symbol' => admin_setting('currency_symbol', '¥'), + 'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0), + ], + 'subscribe' => [ + 'plan_change_enable' => (bool) admin_setting('plan_change_enable', 1), + 'reset_traffic_method' => (int) admin_setting('reset_traffic_method', 0), + 'surplus_enable' => (bool) admin_setting('surplus_enable', 1), + 'new_order_event_id' => (int) admin_setting('new_order_event_id', 0), + 'renew_order_event_id' => (int) admin_setting('renew_order_event_id', 0), + 'change_order_event_id' => (int) admin_setting('change_order_event_id', 0), + 'show_info_to_server_enable' => (bool) admin_setting('show_info_to_server_enable', 0), + 'show_protocol_to_server_enable' => (bool) admin_setting('show_protocol_to_server_enable', 0), + 'default_remind_expire' => (bool) admin_setting('default_remind_expire', 1), + 'default_remind_traffic' => (bool) admin_setting('default_remind_traffic', 1), + 'subscribe_path' => admin_setting('subscribe_path', 's'), + ], + 'frontend' => [ + 'frontend_theme' => admin_setting('frontend_theme', 'Xboard'), + 'frontend_theme_sidebar' => admin_setting('frontend_theme_sidebar', 'light'), + 'frontend_theme_header' => admin_setting('frontend_theme_header', 'dark'), + 'frontend_theme_color' => admin_setting('frontend_theme_color', 'default'), + 'frontend_background_url' => admin_setting('frontend_background_url'), + ], + 'server' => [ + 'server_token' => admin_setting('server_token'), + 'server_pull_interval' => admin_setting('server_pull_interval', 60), + 'server_push_interval' => admin_setting('server_push_interval', 60), + 'device_limit_mode' => (int) admin_setting('device_limit_mode', 0), + 'server_ws_enable' => (bool) admin_setting('server_ws_enable', 1), + 'server_ws_url' => admin_setting('server_ws_url', ''), + ], + 'email' => [ + 'email_template' => admin_setting('email_template', 'default'), + 'email_host' => admin_setting('email_host'), + 'email_port' => admin_setting('email_port'), + 'email_username' => admin_setting('email_username'), + 'email_password' => admin_setting('email_password'), + 'email_encryption' => admin_setting('email_encryption'), + 'email_from_address' => admin_setting('email_from_address'), + 'remind_mail_enable' => (bool) admin_setting('remind_mail_enable', false), + ], + 'telegram' => [ + 'telegram_bot_enable' => (bool) admin_setting('telegram_bot_enable', 0), + 'telegram_bot_token' => admin_setting('telegram_bot_token'), + 'telegram_webhook_url' => admin_setting('telegram_webhook_url'), + 'telegram_discuss_link' => admin_setting('telegram_discuss_link') + ], + 'app' => [ + 'windows_version' => admin_setting('windows_version', ''), + 'windows_download_url' => admin_setting('windows_download_url', ''), + 'macos_version' => admin_setting('macos_version', ''), + 'macos_download_url' => admin_setting('macos_download_url', ''), + 'android_version' => admin_setting('android_version', ''), + 'android_download_url' => admin_setting('android_download_url', '') + ], + 'safe' => [ + 'email_verify' => (bool) admin_setting('email_verify', 0), + 'safe_mode_enable' => (bool) admin_setting('safe_mode_enable', 0), + 'secure_path' => admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))), + 'email_whitelist_enable' => (bool) admin_setting('email_whitelist_enable', 0), + 'email_whitelist_suffix' => admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT), + 'email_gmail_limit_enable' => (bool) admin_setting('email_gmail_limit_enable', 0), + 'captcha_enable' => (bool) admin_setting('captcha_enable', 0), + 'captcha_type' => admin_setting('captcha_type', 'recaptcha'), + 'recaptcha_key' => admin_setting('recaptcha_key', ''), + 'recaptcha_site_key' => admin_setting('recaptcha_site_key', ''), + 'recaptcha_v3_secret_key' => admin_setting('recaptcha_v3_secret_key', ''), + 'recaptcha_v3_site_key' => admin_setting('recaptcha_v3_site_key', ''), + 'recaptcha_v3_score_threshold' => admin_setting('recaptcha_v3_score_threshold', 0.5), + 'turnstile_secret_key' => admin_setting('turnstile_secret_key', ''), + 'turnstile_site_key' => admin_setting('turnstile_site_key', ''), + 'register_limit_by_ip_enable' => (bool) admin_setting('register_limit_by_ip_enable', 0), + 'register_limit_count' => admin_setting('register_limit_count', 3), + 'register_limit_expire' => admin_setting('register_limit_expire', 60), + 'password_limit_enable' => (bool) admin_setting('password_limit_enable', 1), + 'password_limit_count' => admin_setting('password_limit_count', 5), + 'password_limit_expire' => admin_setting('password_limit_expire', 60), + // 保持向后兼容 + 'recaptcha_enable' => (bool) admin_setting('captcha_enable', 0) + ], + 'subscribe_template' => [ + 'subscribe_template_singbox' => $this->formatTemplateContent( + subscribe_template('singbox') ?? '', + 'json' + ), + 'subscribe_template_clash' => subscribe_template('clash') ?? '', + 'subscribe_template_clashmeta' => subscribe_template('clashmeta') ?? '', + 'subscribe_template_stash' => subscribe_template('stash') ?? '', + 'subscribe_template_surge' => subscribe_template('surge') ?? '', + 'subscribe_template_surfboard' => subscribe_template('surfboard') ?? '' + ] + ]; + } + + public function save(ConfigSave $request) + { + $data = $request->validated(); + + $templateKeys = [ + 'subscribe_template_singbox' => 'singbox', + 'subscribe_template_clash' => 'clash', + 'subscribe_template_clashmeta' => 'clashmeta', + 'subscribe_template_stash' => 'stash', + 'subscribe_template_surge' => 'surge', + 'subscribe_template_surfboard' => 'surfboard', + ]; + + foreach ($data as $k => $v) { + if (isset($templateKeys[$k])) { + SubscribeTemplate::setContent($templateKeys[$k], $v); + continue; + } + if ($k == 'frontend_theme') { + $themeService = app(ThemeService::class); + $themeService->switch($v); + } + admin_setting([$k => $v]); + } + + return $this->success(true); + } + + /** + * 格式化模板内容 + * + * @param mixed $content 模板内容 + * @param string $format 输出格式 (json|string) + * @return string 格式化后的内容 + */ + private function formatTemplateContent(mixed $content, string $format = 'string'): string + { + return match ($format) { + 'json' => match (true) { + is_array($content) => json_encode( + value: $content, + flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ), + + is_string($content) && str($content)->isJson() => rescue( + callback: fn() => json_encode( + value: json_decode($content, associative: true, flags: JSON_THROW_ON_ERROR), + flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ), + rescue: $content, + report: false + ), + + default => str($content)->toString() + }, + + default => str($content)->toString() + }; + } + + private function getTelegramWebhookBaseUrl(): ?string + { + $customUrl = trim((string) admin_setting('telegram_webhook_url', '')); + if ($customUrl !== '') { + return rtrim($customUrl, '/'); + } + + $appUrl = trim((string) admin_setting('app_url', '')); + if ($appUrl !== '') { + return rtrim($appUrl, '/'); + } + + return null; + } + + private function resolveTelegramWebhookUrl(): ?string + { + $baseUrl = $this->getTelegramWebhookBaseUrl(); + if (!$baseUrl) { + return null; + } + + if (str_contains($baseUrl, '/api/v1/guest/telegram/webhook')) { + return $baseUrl; + } + + return $baseUrl . '/api/v1/guest/telegram/webhook'; + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/CouponController.php b/Xboard/app/Http/Controllers/V2/Admin/CouponController.php new file mode 100644 index 0000000..364dff4 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/CouponController.php @@ -0,0 +1,186 @@ +has('filter')) { + collect($request->input('filter'))->each(function ($filter) use ($builder) { + $key = $filter['id']; + $value = $filter['value']; + $builder->where(function ($query) use ($key, $value) { + if (is_array($value)) { + $query->whereIn($key, $value); + } else { + $query->where($key, 'like', "%{$value}%"); + } + }); + }); + } + + if ($request->has('sort')) { + collect($request->input('sort'))->each(function ($sort) use ($builder) { + $key = $sort['id']; + $value = $sort['desc'] ? 'DESC' : 'ASC'; + $builder->orderBy($key, $value); + }); + } + } + public function fetch(Request $request) + { + $current = $request->input('current', 1); + $pageSize = $request->input('pageSize', 10); + $builder = Coupon::query(); + $this->applyFiltersAndSorts($request, $builder); + $coupons = $builder + ->orderBy('created_at', 'desc') + ->paginate($pageSize, ["*"], 'page', $current); + return $this->paginate($coupons); + } + + public function update(Request $request) + { + $params = $request->validate([ + 'id' => 'required|numeric', + 'show' => 'nullable|boolean' + ], [ + 'id.required' => '优惠券ID不能为空', + 'id.numeric' => '优惠券ID必须为数字' + ]); + try { + DB::beginTransaction(); + $coupon = Coupon::find($request->input('id')); + if (!$coupon) { + throw new ApiException(400201, '优惠券不存在'); + } + $coupon->update($params); + DB::commit(); + } catch (\Exception $e) { + \Log::error($e); + return $this->fail([500, '保存失败']); + } + } + + public function show(Request $request) + { + $request->validate([ + 'id' => 'required|numeric' + ], [ + 'id.required' => '优惠券ID不能为空', + 'id.numeric' => '优惠券ID必须为数字' + ]); + $coupon = Coupon::find($request->input('id')); + if (!$coupon) { + return $this->fail([400202, '优惠券不存在']); + } + $coupon->show = !$coupon->show; + if (!$coupon->save()) { + return $this->fail([500, '保存失败']); + } + return $this->success(true); + } + + public function generate(CouponGenerate $request) + { + if ($request->input('generate_count')) { + $this->multiGenerate($request); + return; + } + + $params = $request->validated(); + if (!$request->input('id')) { + if (!isset($params['code'])) { + $params['code'] = Helper::randomChar(8); + } + if (!Coupon::create($params)) { + return $this->fail([500, '创建失败']); + } + } else { + try { + Coupon::find($request->input('id'))->update($params); + } catch (\Exception $e) { + \Log::error($e); + return $this->fail([500, '保存失败']); + } + } + + return $this->success(true); + } + + private function multiGenerate(CouponGenerate $request) + { + $coupons = []; + $coupon = $request->validated(); + $coupon['created_at'] = $coupon['updated_at'] = time(); + $coupon['show'] = 1; + unset($coupon['generate_count']); + for ($i = 0; $i < $request->input('generate_count'); $i++) { + $coupon['code'] = Helper::randomChar(8); + array_push($coupons, $coupon); + } + try { + DB::beginTransaction(); + if ( + !Coupon::insert(array_map(function ($item) use ($coupon) { + // format data + if (isset($item['limit_plan_ids']) && is_array($item['limit_plan_ids'])) { + $item['limit_plan_ids'] = json_encode($coupon['limit_plan_ids']); + } + if (isset($item['limit_period']) && is_array($item['limit_period'])) { + $item['limit_period'] = json_encode($coupon['limit_period']); + } + return $item; + }, $coupons)) + ) { + throw new \Exception(); + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + return $this->fail([500, '生成失败']); + } + + $data = "名称,类型,金额或比例,开始时间,结束时间,可用次数,可用于订阅,券码,生成时间\r\n"; + foreach ($coupons as $coupon) { + $type = ['', '金额', '比例'][$coupon['type']]; + $value = ['', ($coupon['value'] / 100), $coupon['value']][$coupon['type']]; + $startTime = date('Y-m-d H:i:s', $coupon['started_at']); + $endTime = date('Y-m-d H:i:s', $coupon['ended_at']); + $limitUse = $coupon['limit_use'] ?? '不限制'; + $createTime = date('Y-m-d H:i:s', $coupon['created_at']); + $limitPlanIds = isset($coupon['limit_plan_ids']) ? implode("/", $coupon['limit_plan_ids']) : '不限制'; + $data .= "{$coupon['name']},{$type},{$value},{$startTime},{$endTime},{$limitUse},{$limitPlanIds},{$coupon['code']},{$createTime}\r\n"; + } + echo $data; + } + + public function drop(Request $request) + { + $request->validate([ + 'id' => 'required|numeric' + ], [ + 'id.required' => '优惠券ID不能为空', + 'id.numeric' => '优惠券ID必须为数字' + ]); + $coupon = Coupon::find($request->input('id')); + if (!$coupon) { + return $this->fail([400202, '优惠券不存在']); + } + if (!$coupon->delete()) { + return $this->fail([500, '删除失败']); + } + + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/GiftCardController.php b/Xboard/app/Http/Controllers/V2/Admin/GiftCardController.php new file mode 100644 index 0000000..2c8ecf5 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/GiftCardController.php @@ -0,0 +1,622 @@ +validate([ + 'type' => 'integer|min:1|max:10', + 'status' => 'integer|in:0,1', + 'page' => 'integer|min:1', + 'per_page' => 'integer|min:1|max:1000', + ]); + + $query = GiftCardTemplate::query(); + + if ($request->has('type')) { + $query->where('type', $request->input('type')); + } + + if ($request->has('status')) { + $query->where('status', $request->input('status')); + } + + $perPage = $request->input('per_page', 15); + $templates = $query->orderBy('sort', 'asc') + ->orderBy('created_at', 'desc') + ->paginate($perPage); + + $data = $templates->getCollection()->map(function ($template) { + return [ + 'id' => $template->id, + 'name' => $template->name, + 'description' => $template->description, + 'type' => $template->type, + 'type_name' => $template->type_name, + 'status' => $template->status, + 'conditions' => $template->conditions, + 'rewards' => $template->rewards, + 'limits' => $template->limits, + 'special_config' => $template->special_config, + 'icon' => $template->icon, + 'background_image' => $template->background_image, + 'theme_color' => $template->theme_color, + 'sort' => $template->sort, + 'admin_id' => $template->admin_id, + 'created_at' => $template->created_at, + 'updated_at' => $template->updated_at, + // 统计信息 + 'codes_count' => $template->codes()->count(), + 'used_count' => $template->usages()->count(), + ]; + })->values(); + + return $this->paginate( $templates); + } + + /** + * 创建礼品卡模板 + */ + public function createTemplate(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'type' => [ + 'required', + 'integer', + Rule::in(array_keys(GiftCardTemplate::getTypeMap())) + ], + 'status' => 'boolean', + 'conditions' => 'nullable|array', + 'rewards' => 'required|array', + 'limits' => 'nullable|array', + 'special_config' => 'nullable|array', + 'icon' => 'nullable|string|max:255', + 'background_image' => 'nullable|string|url|max:255', + 'theme_color' => 'nullable|string|regex:/^#[0-9A-Fa-f]{6}$/', + 'sort' => 'integer|min:0', + ], [ + 'name.required' => '礼品卡名称不能为空', + 'type.required' => '礼品卡类型不能为空', + 'type.in' => '无效的礼品卡类型', + 'rewards.required' => '奖励配置不能为空', + 'theme_color.regex' => '主题色格式不正确', + 'background_image.url' => '背景图片必须是有效的URL', + ]); + + try { + $template = GiftCardTemplate::create([ + 'name' => $request->input('name'), + 'description' => $request->input('description'), + 'type' => $request->input('type'), + 'status' => $request->input('status', true), + 'conditions' => $request->input('conditions'), + 'rewards' => $request->input('rewards'), + 'limits' => $request->input('limits'), + 'special_config' => $request->input('special_config'), + 'icon' => $request->input('icon'), + 'background_image' => $request->input('background_image'), + 'theme_color' => $request->input('theme_color', '#1890ff'), + 'sort' => $request->input('sort', 0), + 'admin_id' => $request->user()->id, + 'created_at' => time(), + 'updated_at' => time(), + ]); + + return $this->success($template); + } catch (\Exception $e) { + Log::error('创建礼品卡模板失败', [ + 'admin_id' => $request->user()->id, + 'data' => $request->all(), + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '创建失败']); + } + } + + /** + * 更新礼品卡模板 + */ + public function updateTemplate(Request $request) + { + $validatedData = $request->validate([ + 'id' => 'required|integer|exists:v2_gift_card_template,id', + 'name' => 'sometimes|required|string|max:255', + 'description' => 'sometimes|nullable|string', + 'type' => [ + 'sometimes', + 'required', + 'integer', + Rule::in(array_keys(GiftCardTemplate::getTypeMap())) + ], + 'status' => 'sometimes|boolean', + 'conditions' => 'sometimes|nullable|array', + 'rewards' => 'sometimes|required|array', + 'limits' => 'sometimes|nullable|array', + 'special_config' => 'sometimes|nullable|array', + 'icon' => 'sometimes|nullable|string|max:255', + 'background_image' => 'sometimes|nullable|string|url|max:255', + 'theme_color' => 'sometimes|nullable|string|regex:/^#[0-9A-Fa-f]{6}$/', + 'sort' => 'sometimes|integer|min:0', + ]); + + $template = GiftCardTemplate::find($validatedData['id']); + if (!$template) { + return $this->fail([404, '模板不存在']); + } + + try { + $updateData = collect($validatedData)->except('id')->all(); + + if (empty($updateData)) { + return $this->success($template); + } + + $updateData['updated_at'] = time(); + + $template->update($updateData); + + return $this->success($template->fresh()); + } catch (\Exception $e) { + Log::error('更新礼品卡模板失败', [ + 'admin_id' => $request->user()->id, + 'template_id' => $template->id, + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '更新失败']); + } + } + + /** + * 删除礼品卡模板 + */ + public function deleteTemplate(Request $request) + { + $request->validate([ + 'id' => 'required|integer|exists:v2_gift_card_template,id', + ]); + + $template = GiftCardTemplate::find($request->input('id')); + if (!$template) { + return $this->fail([404, '模板不存在']); + } + + // 检查是否有关联的兑换码 + if ($template->codes()->exists()) { + return $this->fail([400, '该模板下存在兑换码,无法删除']); + } + + try { + $template->delete(); + return $this->success(true); + } catch (\Exception $e) { + Log::error('删除礼品卡模板失败', [ + 'admin_id' => $request->user()->id, + 'template_id' => $template->id, + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '删除失败']); + } + } + + /** + * 生成兑换码 + */ + public function generateCodes(Request $request) + { + $request->validate([ + 'template_id' => 'required|integer|exists:v2_gift_card_template,id', + 'count' => 'required|integer|min:1|max:10000', + 'prefix' => 'nullable|string|max:10|regex:/^[A-Z0-9]*$/', + 'expires_hours' => 'nullable|integer|min:1', + 'max_usage' => 'integer|min:1|max:1000', + ], [ + 'template_id.required' => '请选择礼品卡模板', + 'count.required' => '请指定生成数量', + 'count.max' => '单次最多生成10000个兑换码', + 'prefix.regex' => '前缀只能包含大写字母和数字', + ]); + + $template = GiftCardTemplate::find($request->input('template_id')); + if (!$template->isAvailable()) { + return $this->fail([400, '模板已被禁用']); + } + + try { + $options = [ + 'prefix' => $request->input('prefix', 'GC'), + 'max_usage' => $request->input('max_usage', 1), + ]; + + if ($request->has('expires_hours')) { + $options['expires_at'] = time() + ($request->input('expires_hours') * 3600); + } + + $batchId = GiftCardCode::batchGenerate( + $request->input('template_id'), + $request->input('count'), + $options + ); + + // 查询本次生成的所有兑换码 + $codes = GiftCardCode::where('batch_id', $batchId)->get(); + + // 判断是否导出 CSV + if ($request->input('download_csv')) { + $headers = [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="gift_codes.csv"', + ]; + $callback = function () use ($codes, $template) { + $handle = fopen('php://output', 'w'); + // 表头 + fputcsv($handle, [ + '兑换码', + '前缀', + '有效期', + '最大使用次数', + '批次号', + '创建时间', + '模板名称', + '模板类型', + '模板奖励', + '状态', + '使用者', + '使用时间', + '备注' + ]); + foreach ($codes as $code) { + $expireDate = $code->expires_at ? date('Y-m-d H:i:s', $code->expires_at) : '长期有效'; + $createDate = date('Y-m-d H:i:s', $code->created_at); + $templateName = $template->name ?? ''; + $templateType = $template->type ?? ''; + $templateRewards = $template->rewards ? json_encode($template->rewards, JSON_UNESCAPED_UNICODE) : ''; + // 状态判断 + $status = $code->status_name; + $usedBy = $code->user_id ?? ''; + $usedAt = $code->used_at ? date('Y-m-d H:i:s', $code->used_at) : ''; + $remark = $code->remark ?? ''; + fputcsv($handle, [ + $code->code, + $code->prefix ?? '', + $expireDate, + $code->max_usage, + $code->batch_id, + $createDate, + $templateName, + $templateType, + $templateRewards, + $status, + $usedBy, + $usedAt, + $remark, + ]); + } + fclose($handle); + }; + return response()->streamDownload($callback, 'gift_codes.csv', $headers); + } + + Log::info('批量生成兑换码', [ + 'admin_id' => $request->user()->id, + 'template_id' => $request->input('template_id'), + 'count' => $request->input('count'), + 'batch_id' => $batchId, + ]); + + return $this->success([ + 'batch_id' => $batchId, + 'count' => $request->input('count'), + 'message' => '生成成功', + ]); + } catch (\Exception $e) { + Log::error('生成兑换码失败', [ + 'admin_id' => $request->user()->id, + 'data' => $request->all(), + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '生成失败']); + } + } + + /** + * 获取兑换码列表 + */ + public function codes(Request $request) + { + $request->validate([ + 'template_id' => 'integer|exists:v2_gift_card_template,id', + 'batch_id' => 'string', + 'status' => 'integer|in:0,1,2,3', + 'page' => 'integer|min:1', + 'per_page' => 'integer|min:1|max:500', + ]); + + $query = GiftCardCode::with(['template', 'user']); + + if ($request->has('template_id')) { + $query->where('template_id', $request->input('template_id')); + } + + if ($request->has('batch_id')) { + $query->where('batch_id', $request->input('batch_id')); + } + + if ($request->has('status')) { + $query->where('status', $request->input('status')); + } + + $perPage = $request->input('per_page', 15); + $codes = $query->orderBy('created_at', 'desc')->paginate($perPage); + + $data = $codes->getCollection()->map(function ($code) { + return [ + 'id' => $code->id, + 'template_id' => $code->template_id, + 'template_name' => $code->template->name ?? '', + 'code' => $code->code, + 'batch_id' => $code->batch_id, + 'status' => $code->status, + 'status_name' => $code->status_name, + 'user_id' => $code->user_id, + 'user_email' => $code->user ? (substr($code->user->email ?? '', 0, 3) . '***@***') : null, + 'used_at' => $code->used_at, + 'expires_at' => $code->expires_at, + 'usage_count' => $code->usage_count, + 'max_usage' => $code->max_usage, + 'created_at' => $code->created_at, + ]; + })->values(); + + return $this->paginate($codes); + } + + /** + * 禁用/启用兑换码 + */ + public function toggleCode(Request $request) + { + $request->validate([ + 'id' => 'required|integer|exists:v2_gift_card_code,id', + 'action' => 'required|string|in:disable,enable', + ]); + + $code = GiftCardCode::find($request->input('id')); + if (!$code) { + return $this->fail([404, '兑换码不存在']); + } + + try { + if ($request->input('action') === 'disable') { + $code->markAsDisabled(); + } else { + if ($code->status === GiftCardCode::STATUS_DISABLED) { + $code->status = GiftCardCode::STATUS_UNUSED; + $code->save(); + } + } + + return $this->success([ + 'message' => $request->input('action') === 'disable' ? '已禁用' : '已启用', + ]); + } catch (\Exception $e) { + return $this->fail([500, '操作失败']); + } + } + + /** + * 导出兑换码 + */ + public function exportCodes(Request $request) + { + $request->validate([ + 'batch_id' => 'required|string|exists:v2_gift_card_code,batch_id', + ]); + + $codes = GiftCardCode::where('batch_id', $request->input('batch_id')) + ->orderBy('created_at', 'asc') + ->get(['code']); + + $content = $codes->pluck('code')->implode("\n"); + + return response($content) + ->header('Content-Type', 'text/plain') + ->header('Content-Disposition', 'attachment; filename="gift_cards_' . $request->input('batch_id') . '.txt"'); + } + + /** + * 获取使用记录 + */ + public function usages(Request $request) + { + $request->validate([ + 'template_id' => 'integer|exists:v2_gift_card_template,id', + 'user_id' => 'integer|exists:v2_user,id', + 'page' => 'integer|min:1', + 'per_page' => 'integer|min:1|max:500', + ]); + + $query = GiftCardUsage::with(['template', 'code', 'user', 'inviteUser']); + + if ($request->has('template_id')) { + $query->where('template_id', $request->input('template_id')); + } + + if ($request->has('user_id')) { + $query->where('user_id', $request->input('user_id')); + } + + $perPage = $request->input('per_page', 15); + $usages = $query->orderBy('created_at', 'desc')->paginate($perPage); + + $usages->transform(function ($usage) { + return [ + 'id' => $usage->id, + 'code' => $usage->code->code ?? '', + 'template_name' => $usage->template->name ?? '', + 'user_email' => $usage->user->email ?? '', + 'invite_user_email' => $usage->inviteUser ? (substr($usage->inviteUser->email ?? '', 0, 3) . '***@***') : null, + 'rewards_given' => $usage->rewards_given, + 'invite_rewards' => $usage->invite_rewards, + 'multiplier_applied' => $usage->multiplier_applied, + 'created_at' => $usage->created_at, + ]; + })->values(); + return $this->paginate($usages); + } + + /** + * 获取统计数据 + */ + public function statistics(Request $request) + { + $request->validate([ + 'start_date' => 'date_format:Y-m-d', + 'end_date' => 'date_format:Y-m-d', + ]); + + $startDate = $request->input('start_date', date('Y-m-d', strtotime('-30 days'))); + $endDate = $request->input('end_date', date('Y-m-d')); + + // 总体统计 + $totalStats = [ + 'templates_count' => GiftCardTemplate::count(), + 'active_templates_count' => GiftCardTemplate::where('status', 1)->count(), + 'codes_count' => GiftCardCode::count(), + 'used_codes_count' => GiftCardCode::where('status', GiftCardCode::STATUS_USED)->count(), + 'usages_count' => GiftCardUsage::count(), + ]; + + // 每日使用统计 + $driver = DB::connection()->getDriverName(); + $dateExpression = "date(created_at, 'unixepoch')"; // Default for SQLite + if ($driver === 'mysql') { + $dateExpression = 'DATE(FROM_UNIXTIME(created_at))'; + } elseif ($driver === 'pgsql') { + $dateExpression = 'date(to_timestamp(created_at))'; + } + + $dailyUsages = GiftCardUsage::selectRaw("{$dateExpression} as date, COUNT(*) as count") + ->whereRaw("{$dateExpression} BETWEEN ? AND ?", [$startDate, $endDate]) + ->groupBy('date') + ->orderBy('date') + ->get(); + + // 类型统计 + $typeStats = GiftCardUsage::with('template') + ->selectRaw('template_id, COUNT(*) as count') + ->groupBy('template_id') + ->get() + ->map(function ($item) { + return [ + 'template_name' => $item->template->name ?? '', + 'type_name' => $item->template->type_name ?? '', + 'count' => $item->count ?? 0, + ]; + }); + + return $this->success([ + 'total_stats' => $totalStats, + 'daily_usages' => $dailyUsages, + 'type_stats' => $typeStats, + ]); + } + + /** + * 获取所有可用的礼品卡类型 + */ + public function types() + { + return $this->success(GiftCardTemplate::getTypeMap()); + } + + /** + * 更新单个兑换码 + */ + public function updateCode(Request $request) + { + $validatedData = $request->validate([ + 'id' => 'required|integer|exists:v2_gift_card_code,id', + 'expires_at' => 'sometimes|nullable|integer', + 'max_usage' => 'sometimes|integer|min:1|max:1000', + 'status' => 'sometimes|integer|in:0,1,2,3', + ]); + + $code = GiftCardCode::find($validatedData['id']); + if (!$code) { + return $this->fail([404, '礼品卡不存在']); + } + + try { + $updateData = collect($validatedData)->except('id')->all(); + + if (empty($updateData)) { + return $this->success($code); + } + + $updateData['updated_at'] = time(); + $code->update($updateData); + + return $this->success($code->fresh()); + } catch (\Exception $e) { + Log::error('更新礼品卡信息失败', [ + 'admin_id' => $request->user()->id, + 'code_id' => $code->id, + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '更新失败']); + } + } + + /** + * 删除礼品卡 + */ + public function deleteCode(Request $request) + { + $request->validate([ + 'id' => 'required|integer|exists:v2_gift_card_code,id', + ]); + + $code = GiftCardCode::find($request->input('id')); + if (!$code) { + return $this->fail([404, '礼品卡不存在']); + } + + // 检查是否已被使用 + if ($code->status === GiftCardCode::STATUS_USED) { + return $this->fail([400, '该礼品卡已被使用,无法删除']); + } + + try { + // 检查是否有关联的使用记录 + if ($code->usages()->exists()) { + return $this->fail([400, '该礼品卡存在使用记录,无法删除']); + } + + $code->delete(); + return $this->success(['message' => '删除成功']); + } catch (\Exception $e) { + Log::error('删除礼品卡失败', [ + 'admin_id' => $request->user()->id, + 'code_id' => $code->id, + 'error' => $e->getMessage(), + ]); + return $this->fail([500, '删除失败']); + } + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/KnowledgeController.php b/Xboard/app/Http/Controllers/V2/Admin/KnowledgeController.php new file mode 100644 index 0000000..d773a8d --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/KnowledgeController.php @@ -0,0 +1,113 @@ +input('id')) { + $knowledge = Knowledge::find($request->input('id'))->toArray(); + if (!$knowledge) + return $this->fail([400202, '知识不存在']); + return $this->success($knowledge); + } + $data = Knowledge::select(['title', 'id', 'updated_at', 'category', 'show']) + ->orderBy('sort', 'ASC') + ->get(); + return $this->success($data); + } + + public function getCategory(Request $request) + { + return $this->success(array_keys(Knowledge::get()->groupBy('category')->toArray())); + } + + public function save(KnowledgeSave $request) + { + $params = $request->validated(); + + if (!$request->input('id')) { + if (!Knowledge::create($params)) { + return $this->fail([500, '创建失败']); + } + } else { + try { + Knowledge::find($request->input('id'))->update($params); + } catch (\Exception $e) { + \Log::error($e); + return $this->fail([500, '创建失败']); + } + } + + return $this->success(true); + } + + public function show(Request $request) + { + $request->validate([ + 'id' => 'required|numeric' + ], [ + 'id.required' => '知识库ID不能为空' + ]); + $knowledge = Knowledge::find($request->input('id')); + if (!$knowledge) { + throw new ApiException('知识不存在'); + } + $knowledge->show = !$knowledge->show; + if (!$knowledge->save()) { + throw new ApiException('保存失败'); + } + + return $this->success(true); + } + + public function sort(Request $request) + { + $request->validate([ + 'ids' => 'required|array' + ], [ + 'ids.required' => '参数有误', + 'ids.array' => '参数有误' + ]); + try { + DB::beginTransaction(); + foreach ($request->input('ids') as $k => $v) { + $knowledge = Knowledge::find($v); + $knowledge->timestamps = false; + $knowledge->update(['sort' => $k + 1]); + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + throw new ApiException('保存失败'); + } + return $this->success(true); + } + + public function drop(Request $request) + { + $request->validate([ + 'id' => 'required|numeric' + ], [ + 'id.required' => '知识库ID不能为空' + ]); + $knowledge = Knowledge::find($request->input('id')); + if (!$knowledge) { + return $this->fail([400202, '知识不存在']); + } + if (!$knowledge->delete()) { + return $this->fail([500, '删除失败']); + } + + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/NoticeController.php b/Xboard/app/Http/Controllers/V2/Admin/NoticeController.php new file mode 100644 index 0000000..854b313 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/NoticeController.php @@ -0,0 +1,101 @@ +success( + Notice::orderBy('sort', 'ASC') + ->orderBy('id', 'DESC') + ->get() + ); + } + + public function save(NoticeSave $request) + { + $data = $request->only([ + 'title', + 'content', + 'img_url', + 'tags', + 'show', + 'popup' + ]); + if (!$request->input('id')) { + if (!Notice::create($data)) { + return $this->fail([500, '保存失败']); + } + } else { + try { + Notice::find($request->input('id'))->update($data); + } catch (\Exception $e) { + return $this->fail([500, '保存失败']); + } + } + return $this->success(true); + } + + + + public function show(Request $request) + { + if (empty($request->input('id'))) { + return $this->fail([500, '公告ID不能为空']); + } + $notice = Notice::find($request->input('id')); + if (!$notice) { + return $this->fail([400202, '公告不存在']); + } + $notice->show = $notice->show ? 0 : 1; + if (!$notice->save()) { + return $this->fail([500, '保存失败']); + } + + return $this->success(true); + } + + public function drop(Request $request) + { + if (empty($request->input('id'))) { + return $this->fail([422, '公告ID不能为空']); + } + $notice = Notice::find($request->input('id')); + if (!$notice) { + return $this->fail([400202, '公告不存在']); + } + if (!$notice->delete()) { + return $this->fail([500, '删除失败']); + } + return $this->success(true); + } + + public function sort(Request $request) + { + $params = $request->validate([ + 'ids' => 'required|array' + ]); + + try { + DB::beginTransaction(); + foreach ($params['ids'] as $k => $v) { + $notice = Notice::findOrFail($v); + $notice->update(['sort' => $k + 1]); + } + DB::commit(); + return $this->success(true); + } catch (\Exception $e) { + DB::rollBack(); + \Log::error($e); + return $this->fail([500, '排序保存失败']); + } + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/OrderController.php b/Xboard/app/Http/Controllers/V2/Admin/OrderController.php new file mode 100644 index 0000000..c06c7c8 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/OrderController.php @@ -0,0 +1,252 @@ +find($request->input('id')); + if (!$order) + return $this->fail([400202, '订单不存在']); + if ($order->surplus_order_ids) { + $order['surplus_orders'] = Order::whereIn('id', $order->surplus_order_ids)->get(); + } + $order['period'] = PlanService::getLegacyPeriod((string) $order->period); + return $this->success($order); + } + + public function fetch(Request $request) + { + $current = $request->input('current', 1); + $pageSize = $request->input('pageSize', 10); + $orderModel = Order::with('plan:id,name'); + + if ($request->boolean('is_commission')) { + $orderModel->whereNotNull('invite_user_id') + ->whereNotIn('status', [0, 2]) + ->where('commission_balance', '>', 0); + } + + $this->applyFiltersAndSorts($request, $orderModel); + + /** @var \Illuminate\Pagination\LengthAwarePaginator $paginatedResults */ + $paginatedResults = $orderModel + ->latest('created_at') + ->paginate( + perPage: $pageSize, + page: $current + ); + + $paginatedResults->getCollection()->transform(function ($order) { + $orderArray = $order->toArray(); + $orderArray['period'] = PlanService::getLegacyPeriod((string) $order->period); + return $orderArray; + }); + + return $this->paginate($paginatedResults); + } + + private function applyFiltersAndSorts(Request $request, Builder $builder): void + { + $this->applyFilters($request, $builder); + $this->applySorting($request, $builder); + } + + private function applyFilters(Request $request, Builder $builder): void + { + if (!$request->has('filter')) { + return; + } + + collect($request->input('filter'))->each(function ($filter) use ($builder) { + $field = $filter['id']; + $value = $filter['value']; + + $builder->where(function ($query) use ($field, $value) { + $this->buildFilterQuery($query, $field, $value); + }); + }); + } + + private function buildFilterQuery(Builder $query, string $field, mixed $value): void + { + // Handle array values for 'in' operations + if (is_array($value)) { + $query->whereIn($field, $value); + return; + } + + // Handle operator-based filtering + if (!is_string($value) || !str_contains($value, ':')) { + $query->where($field, 'like', "%{$value}%"); + return; + } + + [$operator, $filterValue] = explode(':', $value, 2); + + // Convert numeric strings to appropriate type + if (is_numeric($filterValue)) { + $filterValue = strpos($filterValue, '.') !== false + ? (float) $filterValue + : (int) $filterValue; + } + + // Apply operator + $query->where($field, match (strtolower($operator)) { + 'eq' => '=', + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + 'like' => 'like', + 'notlike' => 'not like', + 'null' => static fn($q) => $q->whereNull($field), + 'notnull' => static fn($q) => $q->whereNotNull($field), + default => 'like' + }, match (strtolower($operator)) { + 'like', 'notlike' => "%{$filterValue}%", + 'null', 'notnull' => null, + default => $filterValue + }); + } + + private function applySorting(Request $request, Builder $builder): void + { + if (!$request->has('sort')) { + return; + } + + collect($request->input('sort'))->each(function ($sort) use ($builder) { + $field = $sort['id']; + $direction = $sort['desc'] ? 'DESC' : 'ASC'; + $builder->orderBy($field, $direction); + }); + } + + public function paid(Request $request) + { + $order = Order::where('trade_no', $request->input('trade_no')) + ->first(); + if (!$order) { + return $this->fail([400202, '订单不存在']); + } + if ($order->status !== 0) + return $this->fail([400, '只能对待支付的订单进行操作']); + + $orderService = new OrderService($order); + if (!$orderService->paid('manual_operation')) { + return $this->fail([500, '更新失败']); + } + return $this->success(true); + } + + public function cancel(Request $request) + { + $order = Order::where('trade_no', $request->input('trade_no')) + ->first(); + if (!$order) { + return $this->fail([400202, '订单不存在']); + } + if ($order->status !== 0) + return $this->fail([400, '只能对待支付的订单进行操作']); + + $orderService = new OrderService($order); + if (!$orderService->cancel()) { + return $this->fail([400, '更新失败']); + } + return $this->success(true); + } + + public function update(OrderUpdate $request) + { + $params = $request->only([ + 'commission_status' + ]); + + $order = Order::where('trade_no', $request->input('trade_no')) + ->first(); + if (!$order) { + return $this->fail([400202, '订单不存在']); + } + + try { + $order->update($params); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '更新失败']); + } + + return $this->success(true); + } + + public function assign(OrderAssign $request) + { + $plan = Plan::find($request->input('plan_id')); + $user = User::byEmail($request->input('email'))->first(); + + if (!$user) { + return $this->fail([400202, '该用户不存在']); + } + + if (!$plan) { + return $this->fail([400202, '该订阅不存在']); + } + + $userService = new UserService(); + if ($userService->isNotCompleteOrderByUserId($user->id)) { + return $this->fail([400, '该用户还有待支付的订单,无法分配']); + } + + try { + DB::beginTransaction(); + $order = new Order(); + $orderService = new OrderService($order); + $order->user_id = $user->id; + $order->plan_id = $plan->id; + $period = $request->input('period'); + $order->period = PlanService::getPeriodKey((string) $period); + $order->trade_no = Helper::guid(); + $order->total_amount = $request->input('total_amount'); + + if (PlanService::getPeriodKey((string) $order->period) === Plan::PERIOD_RESET_TRAFFIC) { + $order->type = Order::TYPE_RESET_TRAFFIC; + } else if ($user->plan_id !== NULL && $order->plan_id !== $user->plan_id) { + $order->type = Order::TYPE_UPGRADE; + } else if ($user->expired_at > time() && $order->plan_id == $user->plan_id) { + $order->type = Order::TYPE_RENEWAL; + } else { + $order->type = Order::TYPE_NEW_PURCHASE; + } + + $orderService->setInvite($user); + + if (!$order->save()) { + DB::rollBack(); + return $this->fail([500, '订单创建失败']); + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + + return $this->success($order->trade_no); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/PaymentController.php b/Xboard/app/Http/Controllers/V2/Admin/PaymentController.php new file mode 100644 index 0000000..6649aaa --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/PaymentController.php @@ -0,0 +1,133 @@ +success(array_unique($methods)); + } + + public function fetch() + { + $payments = Payment::orderBy('sort', 'ASC')->get(); + foreach ($payments as $k => $v) { + $notifyUrl = url("/api/v1/guest/payment/notify/{$v->payment}/{$v->uuid}"); + if ($v->notify_domain) { + $parseUrl = parse_url($notifyUrl); + $notifyUrl = $v->notify_domain . $parseUrl['path']; + } + $payments[$k]['notify_url'] = $notifyUrl; + } + return $this->success($payments); + } + + public function getPaymentForm(Request $request) + { + try { + $paymentService = new PaymentService($request->input('payment'), $request->input('id')); + return $this->success(collect($paymentService->form())); + } catch (\Exception $e) { + return $this->fail([400, '支付方式不存在或未启用']); + } + } + + public function show(Request $request) + { + $payment = Payment::find($request->input('id')); + if (!$payment) + return $this->fail([400202, '支付方式不存在']); + $payment->enable = !$payment->enable; + if (!$payment->save()) + return $this->fail([500, '保存失败']); + return $this->success(true); + } + + public function save(Request $request) + { + if (!admin_setting('app_url')) { + return $this->fail([400, '请在站点配置中配置站点地址']); + } + $params = $request->validate([ + 'name' => 'required', + 'icon' => 'nullable', + 'payment' => 'required', + 'config' => 'required', + 'notify_domain' => 'nullable|url', + 'handling_fee_fixed' => 'nullable|integer', + 'handling_fee_percent' => 'nullable|numeric|between:0,100' + ], [ + 'name.required' => '显示名称不能为空', + 'payment.required' => '网关参数不能为空', + 'config.required' => '配置参数不能为空', + 'notify_domain.url' => '自定义通知域名格式有误', + 'handling_fee_fixed.integer' => '固定手续费格式有误', + 'handling_fee_percent.between' => '百分比手续费范围须在0-100之间' + ]); + if ($request->input('id')) { + $payment = Payment::find($request->input('id')); + if (!$payment) + return $this->fail([400202, '支付方式不存在']); + try { + $payment->update($params); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '保存失败']); + } + return $this->success(true); + } + $params['uuid'] = Helper::randomChar(8); + if (!Payment::create($params)) { + return $this->fail([500, '保存失败']); + } + return $this->success(true); + } + + public function drop(Request $request) + { + $payment = Payment::find($request->input('id')); + if (!$payment) + return $this->fail([400202, '支付方式不存在']); + return $this->success($payment->delete()); + } + + + public function sort(Request $request) + { + $request->validate([ + 'ids' => 'required|array' + ], [ + 'ids.required' => '参数有误', + 'ids.array' => '参数有误' + ]); + try { + DB::beginTransaction(); + foreach ($request->input('ids') as $k => $v) { + if (!Payment::find($v)->update(['sort' => $k + 1])) { + throw new \Exception(); + } + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + return $this->fail([500, '保存失败']); + } + + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/PlanController.php b/Xboard/app/Http/Controllers/V2/Admin/PlanController.php new file mode 100644 index 0000000..6a39f9f --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/PlanController.php @@ -0,0 +1,132 @@ +with([ + 'group:id,name' + ]) + ->withCount([ + 'users', + 'users as active_users_count' => function ($query) { + $query->where(function ($q) { + $q->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }); + } + ]) + ->get(); + + return $this->success($plans); + } + + public function save(PlanSave $request) + { + $params = $request->validated(); + + if ($request->input('id')) { + $plan = Plan::find($request->input('id')); + if (!$plan) { + return $this->fail([400202, '该订阅不存在']); + } + + DB::beginTransaction(); + try { + if ($request->input('force_update')) { + User::where('plan_id', $plan->id)->update([ + 'group_id' => $params['group_id'], + 'transfer_enable' => $params['transfer_enable'] * 1073741824, + 'speed_limit' => $params['speed_limit'], + 'device_limit' => $params['device_limit'], + ]); + } + $plan->update($params); + DB::commit(); + return $this->success(true); + } catch (\Exception $e) { + DB::rollBack(); + Log::error($e); + return $this->fail([500, '保存失败']); + } + } + if (!Plan::create($params)) { + return $this->fail([500, '创建失败']); + } + return $this->success(true); + } + + public function drop(Request $request) + { + if (Order::where('plan_id', $request->input('id'))->first()) { + return $this->fail([400201, '该订阅下存在订单无法删除']); + } + if (User::where('plan_id', $request->input('id'))->first()) { + return $this->fail([400201, '该订阅下存在用户无法删除']); + } + + $plan = Plan::find($request->input('id')); + if (!$plan) { + return $this->fail([400202, '该订阅不存在']); + } + + return $this->success($plan->delete()); + } + + public function update(Request $request) + { + $updateData = $request->only([ + 'show', + 'renew', + 'sell' + ]); + + $plan = Plan::find($request->input('id')); + if (!$plan) { + return $this->fail([400202, '该订阅不存在']); + } + + try { + $plan->update($updateData); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '保存失败']); + } + + return $this->success(true); + } + + public function sort(Request $request) + { + $params = $request->validate([ + 'ids' => 'required|array' + ]); + + try { + DB::beginTransaction(); + foreach ($params['ids'] as $k => $v) { + if (!Plan::find($v)->update(['sort' => $k + 1])) { + throw new \Exception(); + } + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + Log::error($e); + return $this->fail([500, '保存失败']); + } + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/PluginController.php b/Xboard/app/Http/Controllers/V2/Admin/PluginController.php new file mode 100644 index 0000000..da41477 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/PluginController.php @@ -0,0 +1,333 @@ +pluginManager = $pluginManager; + $this->configService = $configService; + } + + /** + * 获取所有插件类型 + */ + public function types() + { + return response()->json([ + 'data' => [ + [ + 'value' => Plugin::TYPE_FEATURE, + 'label' => '功能', + 'description' => '提供功能扩展的插件,如Telegram登录、邮件通知等', + 'icon' => '🔧' + ], + [ + 'value' => Plugin::TYPE_PAYMENT, + 'label' => '支付方式', + 'description' => '提供支付接口的插件,如支付宝、微信支付等', + 'icon' => '💳' + ] + ] + ]); + } + + /** + * 获取插件列表 + */ + public function index(Request $request) + { + $type = $request->query('type'); + + $installedPlugins = Plugin::when($type, function ($query) use ($type) { + return $query->byType($type); + }) + ->get() + ->keyBy('code') + ->toArray(); + + $pluginPath = base_path('plugins'); + $plugins = []; + + if (File::exists($pluginPath)) { + $directories = File::directories($pluginPath); + foreach ($directories as $directory) { + $pluginName = basename($directory); + $configFile = $directory . '/config.json'; + if (File::exists($configFile)) { + $config = json_decode(File::get($configFile), true); + $code = $config['code']; + $pluginType = $config['type'] ?? Plugin::TYPE_FEATURE; + + // 如果指定了类型,过滤插件 + if ($type && $pluginType !== $type) { + continue; + } + + $installed = isset($installedPlugins[$code]); + $pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []); + $readmeFile = collect(['README.md', 'readme.md']) + ->map(fn($f) => $directory . '/' . $f) + ->first(fn($path) => File::exists($path)); + $readmeContent = $readmeFile ? File::get($readmeFile) : ''; + $needUpgrade = false; + if ($installed) { + $installedVersion = $installedPlugins[$code]['version'] ?? null; + $localVersion = $config['version'] ?? null; + if ($installedVersion && $localVersion && version_compare($localVersion, $installedVersion, '>')) { + $needUpgrade = true; + } + } + $plugins[] = [ + 'code' => $config['code'], + 'name' => $config['name'], + 'version' => $config['version'], + 'description' => $config['description'], + 'author' => $config['author'], + 'type' => $pluginType, + 'is_installed' => $installed, + 'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false, + 'is_protected' => in_array($code, Plugin::PROTECTED_PLUGINS), + 'can_be_deleted' => !in_array($code, Plugin::PROTECTED_PLUGINS), + 'config' => $pluginConfig, + 'readme' => $readmeContent, + 'need_upgrade' => $needUpgrade, + ]; + } + } + } + + return response()->json([ + 'data' => $plugins + ]); + } + + /** + * 安装插件 + */ + public function install(Request $request) + { + $request->validate([ + 'code' => 'required|string' + ]); + + try { + $this->pluginManager->install($request->input('code')); + return response()->json([ + 'message' => '插件安装成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '插件安装失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 卸载插件 + */ + public function uninstall(Request $request) + { + $request->validate([ + 'code' => 'required|string' + ]); + + $code = $request->input('code'); + $plugin = Plugin::where('code', $code)->first(); + if ($plugin && $plugin->is_enabled) { + return response()->json([ + 'message' => '请先禁用插件后再卸载' + ], 400); + } + + try { + $this->pluginManager->uninstall($code); + return response()->json([ + 'message' => '插件卸载成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '插件卸载失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 升级插件 + */ + public function upgrade(Request $request) + { + $request->validate([ + 'code' => 'required|string', + ]); + try { + $this->pluginManager->update($request->input('code')); + return response()->json([ + 'message' => '插件升级成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '插件升级失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 启用插件 + */ + public function enable(Request $request) + { + $request->validate([ + 'code' => 'required|string' + ]); + + try { + $this->pluginManager->enable($request->input('code')); + return response()->json([ + 'message' => '插件启用成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '插件启用失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 禁用插件 + */ + public function disable(Request $request) + { + $request->validate([ + 'code' => 'required|string' + ]); + + $this->pluginManager->disable($request->input('code')); + return response()->json([ + 'message' => '插件禁用成功' + ]); + + } + + /** + * 获取插件配置 + */ + public function getConfig(Request $request) + { + $request->validate([ + 'code' => 'required|string' + ]); + + try { + $config = $this->configService->getConfig($request->input('code')); + return response()->json([ + 'data' => $config + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '获取配置失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 更新插件配置 + */ + public function updateConfig(Request $request) + { + $request->validate([ + 'code' => 'required|string', + 'config' => 'required|array' + ]); + + try { + $this->configService->updateConfig( + $request->input('code'), + $request->input('config') + ); + + return response()->json([ + 'message' => '配置更新成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '配置更新失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 上传插件 + */ + public function upload(Request $request) + { + $request->validate([ + 'file' => [ + 'required', + 'file', + 'mimes:zip', + 'max:10240', // 最大10MB + ] + ], [ + 'file.required' => '请选择插件包文件', + 'file.file' => '无效的文件类型', + 'file.mimes' => '插件包必须是zip格式', + 'file.max' => '插件包大小不能超过10MB' + ]); + + try { + $this->pluginManager->upload($request->file('file')); + return response()->json([ + 'message' => '插件上传成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '插件上传失败:' . $e->getMessage() + ], 400); + } + } + + /** + * 删除插件 + */ + public function delete(Request $request) + { + $request->validate([ + 'code' => 'required|string' + ]); + + $code = $request->input('code'); + + // 检查是否为受保护的插件 + if (in_array($code, Plugin::PROTECTED_PLUGINS)) { + return response()->json([ + 'message' => '该插件为系统默认插件,不允许删除' + ], 403); + } + + try { + $this->pluginManager->delete($code); + return response()->json([ + 'message' => '插件删除成功' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => '插件删除失败:' . $e->getMessage() + ], 400); + } + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Controllers/V2/Admin/Server/GroupController.php b/Xboard/app/Http/Controllers/V2/Admin/Server/GroupController.php new file mode 100644 index 0000000..83a53ac --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/Server/GroupController.php @@ -0,0 +1,66 @@ +orderByDesc('id') + ->withCount('users') + ->get(); + + // 只在需要时手动加载server_count + $serverGroups->each(function ($group) { + $group->setAttribute('server_count', $group->server_count); + }); + + return $this->success($serverGroups); + } + + public function save(Request $request) + { + if (empty($request->input('name'))) { + return $this->fail([422, '组名不能为空']); + } + + if ($request->input('id')) { + $serverGroup = ServerGroup::find($request->input('id')); + } else { + $serverGroup = new ServerGroup(); + } + + $serverGroup->name = $request->input('name'); + return $this->success($serverGroup->save()); + } + + public function drop(Request $request) + { + $groupId = $request->input('id'); + + $serverGroup = ServerGroup::find($groupId); + if (!$serverGroup) { + return $this->fail([400202, '组不存在']); + } + if (Server::whereJsonContains('group_ids', $groupId)->exists()) { + return $this->fail([400, '该组已被节点所使用,无法删除']); + } + + if (Plan::where('group_id', $groupId)->exists()) { + return $this->fail([400, '该组已被订阅所使用,无法删除']); + } + if (User::where('group_id', $groupId)->exists()) { + return $this->fail([400, '该组已被用户所使用,无法删除']); + } + return $this->success($serverGroup->delete()); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/Server/ManageController.php b/Xboard/app/Http/Controllers/V2/Admin/Server/ManageController.php new file mode 100644 index 0000000..41a4bac --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/Server/ManageController.php @@ -0,0 +1,219 @@ +map(function ($item) { + $item['groups'] = ServerGroup::whereIn('id', $item['group_ids'])->get(['name', 'id']); + $item['parent'] = $item->parent; + return $item; + }); + return $this->success($servers); + } + + public function sort(Request $request) + { + ini_set('post_max_size', '1m'); + $params = $request->validate([ + '*.id' => 'numeric', + '*.order' => 'numeric' + ]); + + try { + DB::beginTransaction(); + collect($params)->each(function ($item) { + if (isset($item['id']) && isset($item['order'])) { + Server::where('id', $item['id'])->update(['sort' => $item['order']]); + } + }); + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + Log::error($e); + return $this->fail([500, '保存失败']); + + } + return $this->success(true); + } + + public function save(ServerSave $request) + { + $params = $request->validated(); + if ($request->input('id')) { + $server = Server::find($request->input('id')); + if (!$server) { + return $this->fail([400202, '服务器不存在']); + } + try { + $server->update($params); + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '保存失败']); + } + } + + try { + Server::create($params); + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '创建失败']); + } + + + } + + public function update(Request $request) + { + $request->validate([ + 'id' => 'required|integer', + 'show' => 'integer', + ]); + + $server = Server::find($request->id); + if (!$server) { + return $this->fail([400202, '服务器不存在']); + } + $server->show = (int) $request->show; + if (!$server->save()) { + return $this->fail([500, '保存失败']); + } + return $this->success(true); + } + + /** + * 删除 + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function drop(Request $request) + { + $request->validate([ + 'id' => 'required|integer', + ]); + if (Server::where('id', $request->id)->delete() === false) { + return $this->fail([500, '删除失败']); + } + return $this->success(true); + } + + /** + * 批量删除节点 + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function batchDelete(Request $request) + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer', + ]); + + $ids = $request->input('ids'); + if (empty($ids)) { + return $this->fail([400, '请选择要删除的节点']); + } + + try { + $deleted = Server::whereIn('id', $ids)->delete(); + if ($deleted === false) { + return $this->fail([500, '批量删除失败']); + } + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '批量删除失败']); + } + } + + /** + * 重置节点流量 + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function resetTraffic(Request $request) + { + $request->validate([ + 'id' => 'required|integer', + ]); + + $server = Server::find($request->id); + if (!$server) { + return $this->fail([400202, '服务器不存在']); + } + + try { + $server->u = 0; + $server->d = 0; + $server->save(); + + Log::info("Server {$server->id} ({$server->name}) traffic reset by admin"); + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '重置失败']); + } + } + + /** + * 批量重置节点流量 + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function batchResetTraffic(Request $request) + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer', + ]); + + $ids = $request->input('ids'); + if (empty($ids)) { + return $this->fail([400, '请选择要重置的节点']); + } + + try { + Server::whereIn('id', $ids)->update([ + 'u' => 0, + 'd' => 0, + ]); + + Log::info("Servers " . implode(',', $ids) . " traffic reset by admin"); + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '批量重置失败']); + } + } + + /** + * 复制节点 + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function copy(Request $request) + { + $server = Server::find($request->input('id')); + if (!$server) { + return $this->fail([400202, '服务器不存在']); + } + $server->show = 0; + $server->code = null; + Server::create($server->toArray()); + return $this->success(true); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/Server/RouteController.php b/Xboard/app/Http/Controllers/V2/Admin/Server/RouteController.php new file mode 100644 index 0000000..7f155ec --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/Server/RouteController.php @@ -0,0 +1,64 @@ + $routes + ]; + } + + public function save(Request $request) + { + $params = $request->validate([ + 'remarks' => 'required', + 'match' => 'required|array', + 'action' => 'required|in:block,direct,dns,proxy', + 'action_value' => 'nullable' + ], [ + 'remarks.required' => '备注不能为空', + 'match.required' => '匹配值不能为空', + 'action.required' => '动作类型不能为空', + 'action.in' => '动作类型参数有误' + ]); + $params['match'] = array_filter($params['match']); + // TODO: remove on 1.8.0 + if ($request->input('id')) { + try { + $route = ServerRoute::find($request->input('id')); + $route->update($params); + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500,'保存失败']); + } + } + try{ + ServerRoute::create($params); + return $this->success(true); + }catch(\Exception $e){ + Log::error($e); + return $this->fail([500,'创建失败']); + } + } + + public function drop(Request $request) + { + $route = ServerRoute::find($request->input('id')); + if (!$route) throw new ApiException('路由不存在'); + if (!$route->delete()) throw new ApiException('删除失败'); + return [ + 'data' => true + ]; + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/StatController.php b/Xboard/app/Http/Controllers/V2/Admin/StatController.php new file mode 100644 index 0000000..805fa94 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/StatController.php @@ -0,0 +1,508 @@ +service = $service; + } + public function getOverride(Request $request) + { + // 获取在线节点数 + $onlineNodes = Server::all()->filter(function ($server) { + return !!$server->is_online; + })->count(); + // 获取在线设备数和在线用户数 + $onlineDevices = User::where('t', '>=', time() - 600) + ->sum('online_count'); + $onlineUsers = User::where('t', '>=', time() - 600) + ->count(); + + // 获取今日流量统计 + $todayStart = strtotime('today'); + $todayTraffic = StatServer::where('record_at', '>=', $todayStart) + ->where('record_at', '<', time()) + ->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total') + ->first(); + + // 获取本月流量统计 + $monthStart = strtotime(date('Y-m-1')); + $monthTraffic = StatServer::where('record_at', '>=', $monthStart) + ->where('record_at', '<', time()) + ->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total') + ->first(); + + // 获取总流量统计 + $totalTraffic = StatServer::selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total') + ->first(); + + return [ + 'data' => [ + 'month_income' => Order::where('created_at', '>=', strtotime(date('Y-m-1'))) + ->where('created_at', '<', time()) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'), + 'month_register_total' => User::where('created_at', '>=', strtotime(date('Y-m-1'))) + ->where('created_at', '<', time()) + ->count(), + 'ticket_pending_total' => Ticket::where('status', 0) + ->count(), + 'commission_pending_total' => Order::where('commission_status', 0) + ->where('invite_user_id', '!=', NULL) + ->whereNotIn('status', [0, 2]) + ->where('commission_balance', '>', 0) + ->count(), + 'day_income' => Order::where('created_at', '>=', strtotime(date('Y-m-d'))) + ->where('created_at', '<', time()) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'), + 'last_month_income' => Order::where('created_at', '>=', strtotime('-1 month', strtotime(date('Y-m-1')))) + ->where('created_at', '<', strtotime(date('Y-m-1'))) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'), + 'commission_month_payout' => CommissionLog::where('created_at', '>=', strtotime(date('Y-m-1'))) + ->where('created_at', '<', time()) + ->sum('get_amount'), + 'commission_last_month_payout' => CommissionLog::where('created_at', '>=', strtotime('-1 month', strtotime(date('Y-m-1')))) + ->where('created_at', '<', strtotime(date('Y-m-1'))) + ->sum('get_amount'), + // 新增统计数据 + 'online_nodes' => $onlineNodes, + 'online_devices' => $onlineDevices, + 'online_users' => $onlineUsers, + 'today_traffic' => [ + 'upload' => $todayTraffic->upload ?? 0, + 'download' => $todayTraffic->download ?? 0, + 'total' => $todayTraffic->total ?? 0 + ], + 'month_traffic' => [ + 'upload' => $monthTraffic->upload ?? 0, + 'download' => $monthTraffic->download ?? 0, + 'total' => $monthTraffic->total ?? 0 + ], + 'total_traffic' => [ + 'upload' => $totalTraffic->upload ?? 0, + 'download' => $totalTraffic->download ?? 0, + 'total' => $totalTraffic->total ?? 0 + ] + ] + ]; + } + + /** + * Get order statistics with filtering and pagination + * + * @param Request $request + * @return array + */ + public function getOrder(Request $request) + { + $request->validate([ + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'type' => 'nullable|in:paid_total,paid_count,commission_total,commission_count', + ]); + + $query = Stat::where('record_type', 'd'); + + // Apply date filters + if ($request->input('start_date')) { + $query->where('record_at', '>=', strtotime($request->input('start_date'))); + } + if ($request->input('end_date')) { + $query->where('record_at', '<=', strtotime($request->input('end_date') . ' 23:59:59')); + } + + $statistics = $query->orderBy('record_at', 'DESC') + ->get(); + + $summary = [ + 'paid_total' => 0, + 'paid_count' => 0, + 'commission_total' => 0, + 'commission_count' => 0, + 'start_date' => $request->input('start_date', date('Y-m-d', $statistics->last()?->record_at)), + 'end_date' => $request->input('end_date', date('Y-m-d', $statistics->first()?->record_at)), + 'avg_paid_amount' => 0, + 'avg_commission_amount' => 0 + ]; + + $dailyStats = []; + foreach ($statistics as $statistic) { + $date = date('Y-m-d', $statistic['record_at']); + + // Update summary + $summary['paid_total'] += $statistic['paid_total']; + $summary['paid_count'] += $statistic['paid_count']; + $summary['commission_total'] += $statistic['commission_total']; + $summary['commission_count'] += $statistic['commission_count']; + + // Calculate daily stats + $dailyData = [ + 'date' => $date, + 'paid_total' => $statistic['paid_total'], + 'paid_count' => $statistic['paid_count'], + 'commission_total' => $statistic['commission_total'], + 'commission_count' => $statistic['commission_count'], + 'avg_order_amount' => $statistic['paid_count'] > 0 ? round($statistic['paid_total'] / $statistic['paid_count'], 2) : 0, + 'avg_commission_amount' => $statistic['commission_count'] > 0 ? round($statistic['commission_total'] / $statistic['commission_count'], 2) : 0 + ]; + + if ($request->input('type')) { + $dailyStats[] = [ + 'date' => $date, + 'value' => $statistic[$request->input('type')], + 'type' => $this->getTypeLabel($request->input('type')) + ]; + } else { + $dailyStats[] = $dailyData; + } + } + + // Calculate averages for summary + if ($summary['paid_count'] > 0) { + $summary['avg_paid_amount'] = round($summary['paid_total'] / $summary['paid_count'], 2); + } + if ($summary['commission_count'] > 0) { + $summary['avg_commission_amount'] = round($summary['commission_total'] / $summary['commission_count'], 2); + } + + // Add percentage calculations to summary + $summary['commission_rate'] = $summary['paid_total'] > 0 + ? round(($summary['commission_total'] / $summary['paid_total']) * 100, 2) + : 0; + + return [ + 'code' => 0, + 'message' => 'success', + 'data' => [ + 'list' => array_reverse($dailyStats), + 'summary' => $summary, + ] + ]; + } + + /** + * Get human readable label for statistic type + * + * @param string $type + * @return string + */ + private function getTypeLabel(string $type): string + { + return match ($type) { + 'paid_total' => '收款金额', + 'paid_count' => '收款笔数', + 'commission_total' => '佣金金额(已发放)', + 'commission_count' => '佣金笔数(已发放)', + default => $type + }; + } + + // 获取当日实时流量排行 + public function getServerLastRank() + { + $data = $this->service->getServerRank(); + return $this->success(data: $data); + } + // 获取昨日节点流量排行 + public function getServerYesterdayRank() + { + $data = $this->service->getServerRank('yesterday'); + return $this->success($data); + } + + public function getStatUser(Request $request) + { + $request->validate([ + 'user_id' => 'required|integer' + ]); + + $pageSize = $request->input('pageSize', 10); + $records = StatUser::orderBy('record_at', 'DESC') + ->where('user_id', $request->input('user_id')) + ->paginate($pageSize); + + $data = $records->items(); + return [ + 'data' => $data, + 'total' => $records->total(), + ]; + } + + public function getStatRecord(Request $request) + { + return [ + 'data' => $this->service->getStatRecord($request->input('type')) + ]; + } + + /** + * Get comprehensive statistics data including income, users, and growth rates + */ + public function getStats() + { + $currentMonthStart = strtotime(date('Y-m-01')); + $lastMonthStart = strtotime('-1 month', $currentMonthStart); + $twoMonthsAgoStart = strtotime('-2 month', $currentMonthStart); + + // Today's start timestamp + $todayStart = strtotime('today'); + $yesterdayStart = strtotime('-1 day', $todayStart); + + // 获取在线节点数 + $onlineNodes = Server::all()->filter(function ($server) { + return !!$server->is_online; + })->count(); + + // 获取在线设备数和在线用户数 + $onlineDevices = User::where('t', '>=', time() - 600) + ->sum('online_count'); + $onlineUsers = User::where('t', '>=', time() - 600) + ->count(); + + // 获取今日流量统计 + $todayTraffic = StatServer::where('record_at', '>=', $todayStart) + ->where('record_at', '<', time()) + ->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total') + ->first(); + + // 获取本月流量统计 + $monthTraffic = StatServer::where('record_at', '>=', $currentMonthStart) + ->where('record_at', '<', time()) + ->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total') + ->first(); + + // 获取总流量统计 + $totalTraffic = StatServer::selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total') + ->first(); + + // Today's income + $todayIncome = Order::where('created_at', '>=', $todayStart) + ->where('created_at', '<', time()) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'); + + // Yesterday's income for day growth calculation + $yesterdayIncome = Order::where('created_at', '>=', $yesterdayStart) + ->where('created_at', '<', $todayStart) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'); + + // Current month income + $currentMonthIncome = Order::where('created_at', '>=', $currentMonthStart) + ->where('created_at', '<', time()) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'); + + // Last month income + $lastMonthIncome = Order::where('created_at', '>=', $lastMonthStart) + ->where('created_at', '<', $currentMonthStart) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'); + + // Last month commission payout + $lastMonthCommissionPayout = CommissionLog::where('created_at', '>=', $lastMonthStart) + ->where('created_at', '<', $currentMonthStart) + ->sum('get_amount'); + + // Current month commission payout + $currentMonthCommissionPayout = CommissionLog::where('created_at', '>=', $currentMonthStart) + ->where('created_at', '<', time()) + ->sum('get_amount'); + + // Current month new users + $currentMonthNewUsers = User::where('created_at', '>=', $currentMonthStart) + ->where('created_at', '<', time()) + ->count(); + + // Total users + $totalUsers = User::count(); + + // Active users (users with valid subscription) + $activeUsers = User::where(function ($query) { + $query->where('expired_at', '>=', time()) + ->orWhere('expired_at', NULL); + })->count(); + + // Previous month income for growth calculation + $twoMonthsAgoIncome = Order::where('created_at', '>=', $twoMonthsAgoStart) + ->where('created_at', '<', $lastMonthStart) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'); + + // Previous month commission for growth calculation + $twoMonthsAgoCommission = CommissionLog::where('created_at', '>=', $twoMonthsAgoStart) + ->where('created_at', '<', $lastMonthStart) + ->sum('get_amount'); + + // Previous month users for growth calculation + $lastMonthNewUsers = User::where('created_at', '>=', $lastMonthStart) + ->where('created_at', '<', $currentMonthStart) + ->count(); + + // Calculate growth rates + $monthIncomeGrowth = $lastMonthIncome > 0 ? round(($currentMonthIncome - $lastMonthIncome) / $lastMonthIncome * 100, 1) : 0; + $lastMonthIncomeGrowth = $twoMonthsAgoIncome > 0 ? round(($lastMonthIncome - $twoMonthsAgoIncome) / $twoMonthsAgoIncome * 100, 1) : 0; + $commissionGrowth = $twoMonthsAgoCommission > 0 ? round(($lastMonthCommissionPayout - $twoMonthsAgoCommission) / $twoMonthsAgoCommission * 100, 1) : 0; + $userGrowth = $lastMonthNewUsers > 0 ? round(($currentMonthNewUsers - $lastMonthNewUsers) / $lastMonthNewUsers * 100, 1) : 0; + $dayIncomeGrowth = $yesterdayIncome > 0 ? round(($todayIncome - $yesterdayIncome) / $yesterdayIncome * 100, 1) : 0; + + // 获取待处理工单和佣金数据 + $ticketPendingTotal = Ticket::where('status', 0)->count(); + $commissionPendingTotal = Order::where('commission_status', 0) + ->where('invite_user_id', '!=', NULL) + ->whereIn('status', [Order::STATUS_COMPLETED]) + ->where('commission_balance', '>', 0) + ->count(); + + return [ + 'data' => [ + // 收入相关 + 'todayIncome' => $todayIncome, + 'dayIncomeGrowth' => $dayIncomeGrowth, + 'currentMonthIncome' => $currentMonthIncome, + 'lastMonthIncome' => $lastMonthIncome, + 'monthIncomeGrowth' => $monthIncomeGrowth, + 'lastMonthIncomeGrowth' => $lastMonthIncomeGrowth, + + // 佣金相关 + 'currentMonthCommissionPayout' => $currentMonthCommissionPayout, + 'lastMonthCommissionPayout' => $lastMonthCommissionPayout, + 'commissionGrowth' => $commissionGrowth, + 'commissionPendingTotal' => $commissionPendingTotal, + + // 用户相关 + 'currentMonthNewUsers' => $currentMonthNewUsers, + 'totalUsers' => $totalUsers, + 'activeUsers' => $activeUsers, + 'userGrowth' => $userGrowth, + 'onlineUsers' => $onlineUsers, + 'onlineDevices' => $onlineDevices, + + // 工单相关 + 'ticketPendingTotal' => $ticketPendingTotal, + + // 节点相关 + 'onlineNodes' => $onlineNodes, + + // 流量统计 + 'todayTraffic' => [ + 'upload' => $todayTraffic->upload ?? 0, + 'download' => $todayTraffic->download ?? 0, + 'total' => $todayTraffic->total ?? 0 + ], + 'monthTraffic' => [ + 'upload' => $monthTraffic->upload ?? 0, + 'download' => $monthTraffic->download ?? 0, + 'total' => $monthTraffic->total ?? 0 + ], + 'totalTraffic' => [ + 'upload' => $totalTraffic->upload ?? 0, + 'download' => $totalTraffic->download ?? 0, + 'total' => $totalTraffic->total ?? 0 + ] + ] + ]; + } + + /** + * Get traffic ranking data for nodes or users + * + * @param Request $request + * @return array + */ + public function getTrafficRank(Request $request) + { + $request->validate([ + 'type' => 'required|in:node,user', + 'start_time' => 'nullable|integer|min:1000000000|max:9999999999', + 'end_time' => 'nullable|integer|min:1000000000|max:9999999999' + ]); + + $type = $request->input('type'); + $startDate = $request->input('start_time', strtotime('-7 days')); + $endDate = $request->input('end_time', time()); + $previousStartDate = $startDate - ($endDate - $startDate); + $previousEndDate = $startDate; + + if ($type === 'node') { + // Get node traffic data + $currentData = StatServer::selectRaw('server_id as id, SUM(u + d) as value') + ->where('record_at', '>=', $startDate) + ->where('record_at', '<=', $endDate) + ->groupBy('server_id') + ->orderBy('value', 'DESC') + ->limit(10) + ->get(); + + // Get previous period data for comparison + $previousData = StatServer::selectRaw('server_id as id, SUM(u + d) as value') + ->where('record_at', '>=', $previousStartDate) + ->where('record_at', '<', $previousEndDate) + ->whereIn('server_id', $currentData->pluck('id')) + ->groupBy('server_id') + ->get() + ->keyBy('id'); + + } else { + // Get user traffic data + $currentData = StatUser::selectRaw('user_id as id, SUM(u + d) as value') + ->where('record_at', '>=', $startDate) + ->where('record_at', '<=', $endDate) + ->groupBy('user_id') + ->orderBy('value', 'DESC') + ->limit(10) + ->get(); + + // Get previous period data for comparison + $previousData = StatUser::selectRaw('user_id as id, SUM(u + d) as value') + ->where('record_at', '>=', $previousStartDate) + ->where('record_at', '<', $previousEndDate) + ->whereIn('user_id', $currentData->pluck('id')) + ->groupBy('user_id') + ->get() + ->keyBy('id'); + } + + $result = []; + $ids = $currentData->pluck('id'); + $names = $type === 'node' + ? Server::whereIn('id', $ids)->pluck('name', 'id') + : User::whereIn('id', $ids)->pluck('email', 'id'); + + foreach ($currentData as $data) { + $previousValue = isset($previousData[$data->id]) ? $previousData[$data->id]->value : 0; + $change = $previousValue > 0 ? round(($data->value - $previousValue) / $previousValue * 100, 1) : 0; + + $result[] = [ + 'id' => (string) $data->id, + 'name' => $names[$data->id] ?? ($type === 'node' ? "Node {$data->id}" : "User {$data->id}"), + 'value' => $data->value, + 'previousValue' => $previousValue, + 'change' => $change, + 'timestamp' => date('c', $endDate) + ]; + } + + return [ + 'timestamp' => date('c'), + 'data' => $result + ]; + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/SystemController.php b/Xboard/app/Http/Controllers/V2/Admin/SystemController.php new file mode 100644 index 0000000..2ba35b5 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/SystemController.php @@ -0,0 +1,144 @@ + $this->getScheduleStatus(), + 'horizon' => $this->getHorizonStatus(), + 'schedule_last_runtime' => Cache::get(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null)), + ]; + return $this->success($data); + } + + public function getQueueWorkload(WorkloadRepository $workload) + { + return $this->success(collect($workload->get())->sortBy('name')->values()->toArray()); + } + + protected function getScheduleStatus(): bool + { + return (time() - 120) < Cache::get(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null)); + } + + protected function getHorizonStatus(): bool + { + if (!$masters = app(MasterSupervisorRepository::class)->all()) { + return false; + } + + return collect($masters)->contains(function ($master) { + return $master->status === 'paused'; + }) ? false : true; + } + + public function getQueueStats() + { + $data = [ + 'failedJobs' => app(JobRepository::class)->countRecentlyFailed(), + 'jobsPerMinute' => app(MetricsRepository::class)->jobsProcessedPerMinute(), + 'pausedMasters' => $this->totalPausedMasters(), + 'periods' => [ + 'failedJobs' => config('horizon.trim.recent_failed', config('horizon.trim.failed')), + 'recentJobs' => config('horizon.trim.recent'), + ], + 'processes' => $this->totalProcessCount(), + 'queueWithMaxRuntime' => app(MetricsRepository::class)->queueWithMaximumRuntime(), + 'queueWithMaxThroughput' => app(MetricsRepository::class)->queueWithMaximumThroughput(), + 'recentJobs' => app(JobRepository::class)->countRecent(), + 'status' => $this->getHorizonStatus(), + 'wait' => collect(app(WaitTimeCalculator::class)->calculate())->take(1), + ]; + return $this->success($data); + } + + /** + * Get the total process count across all supervisors. + * + * @return int + */ + protected function totalProcessCount() + { + $supervisors = app(SupervisorRepository::class)->all(); + + return collect($supervisors)->reduce(function ($carry, $supervisor) { + return $carry + collect($supervisor->processes)->sum(); + }, 0); + } + + /** + * Get the number of master supervisors that are currently paused. + * + * @return int + */ + protected function totalPausedMasters() + { + if (!$masters = app(MasterSupervisorRepository::class)->all()) { + return 0; + } + + return collect($masters)->filter(function ($master) { + return $master->status === 'paused'; + })->count(); + } + + public function getAuditLog(Request $request) + { + $current = max(1, (int) $request->input('current', 1)); + $pageSize = max(10, (int) $request->input('page_size', 10)); + + $builder = AdminAuditLog::with('admin:id,email') + ->orderBy('id', 'DESC') + ->when($request->input('action'), fn($q, $v) => $q->where('action', $v)) + ->when($request->input('admin_id'), fn($q, $v) => $q->where('admin_id', $v)) + ->when($request->input('keyword'), function ($q, $keyword) { + $q->where(function ($q) use ($keyword) { + $q->where('uri', 'like', '%' . $keyword . '%') + ->orWhere('request_data', 'like', '%' . $keyword . '%'); + }); + }); + + $total = $builder->count(); + $res = $builder->forPage($current, $pageSize)->get(); + + return response(['data' => $res, 'total' => $total]); + } + + public function getHorizonFailedJobs(Request $request, JobRepository $jobRepository) + { + $current = max(1, (int) $request->input('current', 1)); + $pageSize = max(10, (int) $request->input('page_size', 20)); + $offset = ($current - 1) * $pageSize; + + $failedJobs = collect($jobRepository->getFailed()) + ->sortByDesc('failed_at') + ->slice($offset, $pageSize) + ->values(); + + $total = $jobRepository->countFailed(); + + return response()->json([ + 'data' => $failedJobs, + 'total' => $total, + 'current' => $current, + 'page_size' => $pageSize, + ]); + } + +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/ThemeController.php b/Xboard/app/Http/Controllers/V2/Admin/ThemeController.php new file mode 100644 index 0000000..727cd27 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/ThemeController.php @@ -0,0 +1,150 @@ +themeService = $themeService; + } + + /** + * 上传新主题 + * + * @throws ApiException + */ + public function upload(Request $request) + { + $request->validate([ + 'file' => [ + 'required', + 'file', + 'mimes:zip', + 'max:10240', // 最大10MB + ] + ], [ + 'file.required' => '请选择主题包文件', + 'file.file' => '无效的文件类型', + 'file.mimes' => '主题包必须是zip格式', + 'file.max' => '主题包大小不能超过10MB' + ]); + + try { + // 检查上传目录权限 + $uploadPath = storage_path('tmp'); + if (!File::exists($uploadPath)) { + File::makeDirectory($uploadPath, 0755, true); + } + + if (!is_writable($uploadPath)) { + throw new ApiException('上传目录无写入权限'); + } + + // 检查主题目录权限 + $themePath = base_path('theme'); + if (!is_writable($themePath)) { + throw new ApiException('主题目录无写入权限'); + } + + $file = $request->file('file'); + + // 检查文件MIME类型 + $mimeType = $file->getMimeType(); + if (!in_array($mimeType, ['application/zip', 'application/x-zip-compressed'])) { + throw new ApiException('无效的文件类型,仅支持ZIP格式'); + } + + // 检查文件名安全性 + $originalName = $file->getClientOriginalName(); + if (!preg_match('/^[a-zA-Z0-9\-\_\.]+\.zip$/', $originalName)) { + throw new ApiException('主题包文件名只能包含字母、数字、下划线、中划线和点'); + } + + $this->themeService->upload($file); + return $this->success(true); + + } catch (ApiException $e) { + throw $e; + } catch (\Exception $e) { + Log::error('Theme upload failed', [ + 'error' => $e->getMessage(), + 'file' => $request->file('file')?->getClientOriginalName() + ]); + throw new ApiException('主题上传失败:' . $e->getMessage()); + } + } + + /** + * 删除主题 + */ + public function delete(Request $request) + { + $payload = $request->validate([ + 'name' => 'required' + ]); + $this->themeService->delete($payload['name']); + return $this->success(true); + } + + /** + * 获取所有主题和其配置列 + * + * @return \Illuminate\Http\JsonResponse + */ + public function getThemes() + { + $data = [ + 'themes' => $this->themeService->getList(), + 'active' => admin_setting('frontend_theme', 'Xboard') + ]; + return $this->success($data); + } + + /** + * 切换主题 + */ + public function switchTheme(Request $request) + { + $payload = $request->validate([ + 'name' => 'required' + ]); + $this->themeService->switch($payload['name']); + return $this->success(true); + } + + /** + * 获取主题配置 + */ + public function getThemeConfig(Request $request) + { + $payload = $request->validate([ + 'name' => 'required' + ]); + $data = $this->themeService->getConfig($payload['name']); + return $this->success($data); + } + + /** + * 保存主题配置 + */ + public function saveThemeConfig(Request $request) + { + $payload = $request->validate([ + 'name' => 'required', + 'config' => 'required' + ]); + $this->themeService->updateConfig($payload['name'], $payload['config']); + $config = $this->themeService->getConfig($payload['name']); + return $this->success($config); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/TicketController.php b/Xboard/app/Http/Controllers/V2/Admin/TicketController.php new file mode 100644 index 0000000..ca6d8c4 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/TicketController.php @@ -0,0 +1,156 @@ +has('filter')) { + collect($request->input('filter'))->each(function ($filter) use ($builder) { + $key = $filter['id']; + $value = $filter['value']; + $builder->where(function ($query) use ($key, $value) { + if (is_array($value)) { + $query->whereIn($key, $value); + } else { + $query->where($key, 'like', "%{$value}%"); + } + }); + }); + } + + if ($request->has('sort')) { + collect($request->input('sort'))->each(function ($sort) use ($builder) { + $key = $sort['id']; + $value = $sort['desc'] ? 'DESC' : 'ASC'; + $builder->orderBy($key, $value); + }); + } + } + public function fetch(Request $request) + { + if ($request->input('id')) { + return $this->fetchTicketById($request); + } else { + return $this->fetchTickets($request); + } + } + + /** + * Summary of fetchTicketById + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + private function fetchTicketById(Request $request) + { + $ticket = Ticket::with('messages', 'user')->find($request->input('id')); + + if (!$ticket) { + return $this->fail([400202, '工单不存在']); + } + $result = $ticket->toArray(); + $result['user'] = UserController::transformUserData($ticket->user); + + return $this->success($result); + } + + /** + * Summary of fetchTickets + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response + */ + private function fetchTickets(Request $request) + { + $ticketModel = Ticket::with('user') + ->when($request->has('status'), function ($query) use ($request) { + $query->where('status', $request->input('status')); + }) + ->when($request->has('reply_status'), function ($query) use ($request) { + $query->whereIn('reply_status', $request->input('reply_status')); + }) + ->when($request->has('email'), function ($query) use ($request) { + $query->whereHas('user', function ($q) use ($request) { + $q->where('email', $request->input('email')); + }); + }); + + $this->applyFiltersAndSorts($request, $ticketModel); + $tickets = $ticketModel + ->latest('updated_at') + ->paginate( + perPage: $request->integer('pageSize', 10), + page: $request->integer('current', 1) + ); + + // 获取items然后映射转换 + $items = collect($tickets->items())->map(function ($ticket) { + $ticketData = $ticket->toArray(); + $ticketData['user'] = UserController::transformUserData($ticket->user); + return $ticketData; + })->all(); + + return response([ + 'data' => $items, + 'total' => $tickets->total() + ]); + } + + public function reply(Request $request) + { + $request->validate([ + 'id' => 'required|numeric', + 'message' => 'required|string' + ], [ + 'id.required' => '工单ID不能为空', + 'message.required' => '消息不能为空' + ]); + $ticketService = new TicketService(); + $ticketService->replyByAdmin( + $request->input('id'), + $request->input('message'), + $request->user()->id + ); + return $this->success(true); + } + + public function close(Request $request) + { + $request->validate([ + 'id' => 'required|numeric' + ], [ + 'id.required' => '工单ID不能为空' + ]); + try { + $ticket = Ticket::findOrFail($request->input('id')); + $ticket->status = Ticket::STATUS_CLOSED; + $ticket->save(); + return $this->success(true); + } catch (ModelNotFoundException $e) { + return $this->fail([400202, '工单不存在']); + } catch (\Exception $e) { + return $this->fail([500101, '关闭失败']); + } + } + + public function show($ticketId) + { + $ticket = Ticket::with([ + 'user', + 'messages' => function ($query) { + $query->with(['user']); // 如果需要用户信息 + } + ])->findOrFail($ticketId); + + // 自动包含 is_me 属性 + return response()->json([ + 'data' => $ticket + ]); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Admin/TrafficResetController.php b/Xboard/app/Http/Controllers/V2/Admin/TrafficResetController.php new file mode 100644 index 0000000..53e4f54 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/TrafficResetController.php @@ -0,0 +1,235 @@ +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); + + // 格式化数据 + $formattedLogs = $logs->getCollection()->map(function (TrafficResetLog $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' => $formattedLogs->toArray(), + '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); + + /** @var \Illuminate\Database\Eloquent\Collection $history */ + $data = $history->map(function (TrafficResetLog $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, + ] + ]); + } + + +} \ No newline at end of file diff --git a/Xboard/app/Http/Controllers/V2/Admin/UpdateController.php b/Xboard/app/Http/Controllers/V2/Admin/UpdateController.php new file mode 100644 index 0000000..d846466 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/UpdateController.php @@ -0,0 +1,28 @@ +updateService = $updateService; + } + + public function checkUpdate() + { + return $this->success($this->updateService->checkForUpdates()); + } + + public function executeUpdate() + { + $result = $this->updateService->executeUpdate(); + return $result['success'] ? $this->success($result) : $this->fail([500, $result['message']]); + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Controllers/V2/Admin/UserController.php b/Xboard/app/Http/Controllers/V2/Admin/UserController.php new file mode 100644 index 0000000..b135270 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Admin/UserController.php @@ -0,0 +1,682 @@ +input('id')); + if (!$user) + return $this->fail([400202, '用户不存在']); + $user->token = Helper::guid(); + $user->uuid = Helper::guid(true); + return $this->success($user->save()); + } + + // Apply filters and sorts to the query builder. + private function applyFiltersAndSorts(Request $request, Builder|QueryBuilder $builder): void + { + $this->applyFilters($request, $builder); + $this->applySorting($request, $builder); + } + + // Apply filters to the query builder. + private function applyFilters(Request $request, Builder|QueryBuilder $builder): void + { + if (!$request->has('filter')) { + return; + } + + collect($request->input('filter'))->each(function ($filter) use ($builder) { + $field = $filter['id']; + $value = $filter['value']; + $logic = strtolower($filter['logic'] ?? 'and'); + + if ($logic === 'or') { + $builder->orWhere(function ($query) use ($field, $value) { + $this->buildFilterQuery($query, $field, $value); + }); + } else { + $builder->where(function ($query) use ($field, $value) { + $this->buildFilterQuery($query, $field, $value); + }); + } + }); + } + + // Build one filter query condition. + private function buildFilterQuery(Builder|QueryBuilder $query, string $field, mixed $value): void + { + // 处理关联查询 + if (str_contains($field, '.')) { + if (!method_exists($query, 'whereHas')) { + return; + } + [$relation, $relationField] = explode('.', $field); + $query->whereHas($relation, function ($q) use ($relationField, $value) { + if (is_array($value)) { + $q->whereIn($relationField, $value); + } else if (is_string($value) && str_contains($value, ':')) { + [$operator, $filterValue] = explode(':', $value, 2); + $this->applyQueryCondition($q, $relationField, $operator, $filterValue); + } else { + $q->where($relationField, 'like', "%{$value}%"); + } + }); + return; + } + + // 处理数组值的 'in' 操作 + if (is_array($value)) { + $query->whereIn($field === 'group_ids' ? 'group_id' : $field, $value); + return; + } + + // 处理基于运算符的过滤 + if (!is_string($value) || !str_contains($value, ':')) { + $query->where($field, 'like', "%{$value}%"); + return; + } + + [$operator, $filterValue] = explode(':', $value, 2); + + // 转换数字字符串为适当的类型 + if (is_numeric($filterValue)) { + $filterValue = strpos($filterValue, '.') !== false + ? (float) $filterValue + : (int) $filterValue; + } + + // 处理计算字段 + $queryField = match ($field) { + 'total_used' => DB::raw('(u + d)'), + default => $field + }; + + $this->applyQueryCondition($query, $queryField, $operator, $filterValue); + } + + // Apply sorting rules to the query builder. + private function applySorting(Request $request, Builder|QueryBuilder $builder): void + { + if (!$request->has('sort')) { + return; + } + + collect($request->input('sort'))->each(function ($sort) use ($builder) { + $field = $sort['id']; + $direction = $sort['desc'] ? 'DESC' : 'ASC'; + $builder->orderBy($field, $direction); + }); + } + + // Resolve bulk operation scope and normalize user_ids. + private function resolveScope(Request $request): array + { + $scope = $request->input('scope'); + $userIds = $request->input('user_ids'); + + $hasSelection = is_array($userIds) && count(array_filter($userIds, static fn($v) => is_numeric($v))) > 0; + $hasFilter = $request->has('filter') && !empty($request->input('filter')); + + if (!in_array($scope, ['selected', 'filtered', 'all'], true)) { + if ($hasSelection) { + $scope = 'selected'; + } elseif ($hasFilter) { + $scope = 'filtered'; + } else { + $scope = 'all'; + } + } + + $normalizedIds = []; + if ($scope === 'selected') { + $normalizedIds = is_array($userIds) ? $userIds : []; + $normalizedIds = array_values(array_unique(array_map(static function ($v) { + return is_numeric($v) ? (int) $v : null; + }, $normalizedIds))); + $normalizedIds = array_values(array_filter($normalizedIds, static fn($v) => is_int($v))); + } + + return [ + 'scope' => $scope, + 'user_ids' => $normalizedIds, + ]; + } + + // Fetch paginated user list (filters + sorting). + public function fetch(Request $request) + { + $current = $request->input('current', 1); + $pageSize = $request->input('pageSize', 10); + + $userModel = User::query() + ->with(['plan:id,name', 'invite_user:id,email', 'group:id,name']) + ->select((new User())->getTable() . '.*') + ->selectRaw('(u + d) as total_used'); + + $this->applyFiltersAndSorts($request, $userModel); + + $users = $userModel->orderBy('id', 'desc') + ->paginate($pageSize, ['*'], 'page', $current); + + $users->getCollection()->transform(function ($user): array { + return self::transformUserData($user); + }); + + return $this->paginate($users); + } + + // Transform user fields for API response. + public static function transformUserData(User $user): array + { + $user = $user->toArray(); + $user['balance'] = $user['balance'] / 100; + $user['commission_balance'] = $user['commission_balance'] / 100; + $user['subscribe_url'] = Helper::getSubscribeUrl($user['token']); + return $user; + } + + public function getUserInfoById(Request $request) + { + $request->validate([ + 'id' => 'required|numeric' + ], [ + 'id.required' => '用户ID不能为空' + ]); + $user = User::find($request->input('id'))->load('invite_user'); + return $this->success($user); + } + + public function update(UserUpdate $request) + { + $params = $request->validated(); + + $user = User::find($request->input('id')); + if (!$user) { + return $this->fail([400202, '用户不存在']); + } + if (isset($params['email'])) { + if (User::byEmail($params['email'])->first() && $user->email !== $params['email']) { + return $this->fail([400201, '邮箱已被使用']); + } + } + // 处理密码 + if (isset($params['password'])) { + $params['password'] = password_hash($params['password'], PASSWORD_DEFAULT); + $params['password_algo'] = NULL; + } else { + unset($params['password']); + } + // 处理订阅计划 + if (isset($params['plan_id'])) { + $plan = Plan::find($params['plan_id']); + if (!$plan) { + return $this->fail([400202, '订阅计划不存在']); + } + $params['group_id'] = $plan->group_id; + } + // 处理邀请用户 + if ($request->input('invite_user_email') && $inviteUser = User::byEmail($request->input('invite_user_email'))->first()) { + $params['invite_user_id'] = $inviteUser->id; + } else { + $params['invite_user_id'] = null; + } + + if (isset($params['banned']) && (int) $params['banned'] === 1) { + $authService = new AuthService($user); + $authService->removeAllSessions(); + } + if (isset($params['balance'])) { + $params['balance'] = $params['balance'] * 100; + } + if (isset($params['commission_balance'])) { + $params['commission_balance'] = $params['commission_balance'] * 100; + } + + try { + $user->update($params); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '保存失败']); + } + return $this->success(true); + } + + // Export users to CSV. + public function dumpCSV(Request $request) + { + ini_set('memory_limit', '-1'); + gc_enable(); // 启用垃圾回收 + + $scopeInfo = $this->resolveScope($request); + $scope = $scopeInfo['scope']; + $userIds = $scopeInfo['user_ids']; + + if ($scope === 'selected') { + if (empty($userIds)) { + return $this->fail([422, 'user_ids不能为空']); + } + } + + // 优化查询:使用with预加载plan关系,避免N+1问题 + $query = User::query() + ->with('plan:id,name') + ->orderBy('id', 'asc') + ->select([ + 'email', + 'balance', + 'commission_balance', + 'transfer_enable', + 'u', + 'd', + 'expired_at', + 'token', + 'plan_id' + ]); + + if ($scope === 'selected') { + $query->whereIn('id', $userIds); + } elseif ($scope === 'filtered') { + $this->applyFiltersAndSorts($request, $query); + } // all: ignore filter/sort + + $filename = 'users_' . date('Y-m-d_His') . '.csv'; + + return response()->streamDownload(function () use ($query) { + // 打开输出流 + $output = fopen('php://output', 'w'); + + // 添加BOM标记,确保Excel正确显示中文 + fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); + + // 写入CSV头部 + fputcsv($output, [ + '邮箱', + '余额', + '推广佣金', + '总流量', + '剩余流量', + '套餐到期时间', + '订阅计划', + '订阅地址' + ]); + + // 分批处理数据以减少内存使用 + $query->chunk(500, function ($users) use ($output) { + foreach ($users as $user) { + try { + $row = [ + $user->email, + number_format($user->balance / 100, 2), + number_format($user->commission_balance / 100, 2), + Helper::trafficConvert($user->transfer_enable), + Helper::trafficConvert($user->transfer_enable - ($user->u + $user->d)), + $user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '长期有效', + $user->plan ? $user->plan->name : '无订阅', + Helper::getSubscribeUrl($user->token) + ]; + fputcsv($output, $row); + } catch (\Exception $e) { + Log::error('CSV导出错误: ' . $e->getMessage(), [ + 'user_id' => $user->id, + 'email' => $user->email + ]); + continue; // 继续处理下一条记录 + } + } + + // 清理内存 + gc_collect_cycles(); + }); + + fclose($output); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"' + ]); + } + + public function generate(UserGenerate $request) + { + if ($request->input('email_prefix')) { + // If generate_count is specified with email_prefix, generate multiple users with incremented emails + if ($request->input('generate_count')) { + return $this->multiGenerateWithPrefix($request); + } + + // Single user generation with email_prefix + $email = $request->input('email_prefix') . '@' . $request->input('email_suffix'); + + if (User::byEmail($email)->exists()) { + return $this->fail([400201, '邮箱已存在于系统中']); + } + + $userService = app(UserService::class); + $user = $userService->createUser([ + 'email' => $email, + 'password' => $request->input('password') ?? $email, + 'plan_id' => $request->input('plan_id'), + 'expired_at' => $request->input('expired_at'), + ]); + + if (!$user->save()) { + return $this->fail([500, '生成失败']); + } + return $this->success(true); + } + + if ($request->input('generate_count')) { + return $this->multiGenerate($request); + } + } + + private function multiGenerate(Request $request) + { + $userService = app(UserService::class); + $usersData = []; + + for ($i = 0; $i < $request->input('generate_count'); $i++) { + $email = Helper::randomChar(6) . '@' . $request->input('email_suffix'); + $usersData[] = [ + 'email' => $email, + 'password' => $request->input('password') ?? $email, + 'plan_id' => $request->input('plan_id'), + 'expired_at' => $request->input('expired_at'), + ]; + } + + + + try { + DB::beginTransaction(); + $users = []; + foreach ($usersData as $userData) { + $user = $userService->createUser($userData); + $user->save(); + $users[] = $user; + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + return $this->fail([500, '生成失败']); + } + + // 判断是否导出 CSV + if ($request->input('download_csv')) { + $headers = [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="users.csv"', + ]; + $callback = function () use ($users, $request) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, ['账号', '密码', '过期时间', 'UUID', '创建时间', '订阅地址']); + foreach ($users as $user) { + $user = $user->refresh(); + $expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']); + $createDate = date('Y-m-d H:i:s', $user['created_at']); + $password = $request->input('password') ?? $user['email']; + $subscribeUrl = Helper::getSubscribeUrl($user['token']); + fputcsv($handle, [$user['email'], $password, $expireDate, $user['uuid'], $createDate, $subscribeUrl]); + } + fclose($handle); + }; + return response()->streamDownload($callback, 'users.csv', $headers); + } + + // 默认返回 JSON + $data = collect($users)->map(function ($user) use ($request) { + return [ + 'email' => $user['email'], + 'password' => $request->input('password') ?? $user['email'], + 'expired_at' => $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']), + 'uuid' => $user['uuid'], + 'created_at' => date('Y-m-d H:i:s', $user['created_at']), + 'subscribe_url' => Helper::getSubscribeUrl($user['token']), + ]; + }); + return response()->json([ + 'code' => 0, + 'message' => '批量生成成功', + 'data' => $data, + ]); + } + + private function multiGenerateWithPrefix(Request $request) + { + $userService = app(UserService::class); + $usersData = []; + $emailPrefix = $request->input('email_prefix'); + $emailSuffix = $request->input('email_suffix'); + $generateCount = $request->input('generate_count'); + + // Check if any of the emails with prefix already exist + for ($i = 1; $i <= $generateCount; $i++) { + $email = $emailPrefix . '_' . $i . '@' . $emailSuffix; + if (User::where('email', $email)->exists()) { + return $this->fail([400201, '邮箱 ' . $email . ' 已存在于系统中']); + } + } + + // Generate user data for batch creation + for ($i = 1; $i <= $generateCount; $i++) { + $email = $emailPrefix . '_' . $i . '@' . $emailSuffix; + $usersData[] = [ + 'email' => $email, + 'password' => $request->input('password') ?? $email, + 'plan_id' => $request->input('plan_id'), + 'expired_at' => $request->input('expired_at'), + ]; + } + + try { + DB::beginTransaction(); + $users = []; + foreach ($usersData as $userData) { + $user = $userService->createUser($userData); + $user->save(); + $users[] = $user; + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + return $this->fail([500, '生成失败']); + } + + // 判断是否导出 CSV + if ($request->input('download_csv')) { + $headers = [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="users.csv"', + ]; + $callback = function () use ($users, $request) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, ['账号', '密码', '过期时间', 'UUID', '创建时间', '订阅地址']); + foreach ($users as $user) { + $user = $user->refresh(); + $expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']); + $createDate = date('Y-m-d H:i:s', $user['created_at']); + $password = $request->input('password') ?? $user['email']; + $subscribeUrl = Helper::getSubscribeUrl($user['token']); + fputcsv($handle, [$user['email'], $password, $expireDate, $user['uuid'], $createDate, $subscribeUrl]); + } + fclose($handle); + }; + return response()->streamDownload($callback, 'users.csv', $headers); + } + + // 默认返回 JSON + $data = collect($users)->map(function ($user) use ($request) { + return [ + 'email' => $user['email'], + 'password' => $request->input('password') ?? $user['email'], + 'expired_at' => $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']), + 'uuid' => $user['uuid'], + 'created_at' => date('Y-m-d H:i:s', $user['created_at']), + 'subscribe_url' => Helper::getSubscribeUrl($user['token']), + ]; + }); + return response()->json([ + 'code' => 0, + 'message' => '批量生成成功', + 'data' => $data, + ]); + } + + public function sendMail(UserSendMail $request) + { + ini_set('memory_limit', '-1'); + $scopeInfo = $this->resolveScope($request); + $scope = $scopeInfo['scope']; + $userIds = $scopeInfo['user_ids']; + + if ($scope === 'selected') { + if (empty($userIds)) { + return $this->fail([422, 'user_ids不能为空']); + } + } + + $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC'; + $sort = $request->input('sort') ? $request->input('sort') : 'created_at'; + + $builder = User::query() + ->with('plan:id,name') + ->orderBy('id', 'desc'); + + if ($scope === 'filtered') { + // filtered: apply filters/sort + $builder->orderBy($sort, $sortType); + $this->applyFiltersAndSorts($request, $builder); + } elseif ($scope === 'selected') { + $builder->whereIn('id', $userIds); + } // all: ignore filter/sort + + $subject = $request->input('subject'); + $content = $request->input('content'); + $appName = admin_setting('app_name', 'XBoard'); + $appUrl = admin_setting('app_url'); + + $chunkSize = 1000; + + $builder->chunk($chunkSize, function ($users) use ($subject, $content, $appName, $appUrl) { + foreach ($users as $user) { + $vars = [ + 'app.name' => $appName, + 'app.url' => $appUrl, + 'now' => now()->format('Y-m-d H:i:s'), + 'user.id' => $user->id, + 'user.email' => $user->email, + 'user.uuid' => $user->uuid, + 'user.plan_name' => $user->plan?->name ?? '', + 'user.expired_at' => $user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '', + 'user.transfer_enable' => (int) ($user->transfer_enable ?? 0), + 'user.transfer_used' => (int) (($user->u ?? 0) + ($user->d ?? 0)), + 'user.transfer_left' => (int) (($user->transfer_enable ?? 0) - (($user->u ?? 0) + ($user->d ?? 0))), + ]; + + $templateValue = [ + 'name' => $appName, + 'url' => $appUrl, + 'content' => $content, + 'vars' => $vars, + 'content_mode' => 'text', + ]; + + dispatch(new SendEmailJob([ + 'email' => $user->email, + 'subject' => $subject, + 'template_name' => 'notify', + 'template_value' => $templateValue + ], 'send_email_mass')); + } + }); + + return $this->success(true); + } + + public function ban(Request $request) + { + $scopeInfo = $this->resolveScope($request); + $scope = $scopeInfo['scope']; + $userIds = $scopeInfo['user_ids']; + + if ($scope === 'selected') { + if (empty($userIds)) { + return $this->fail([422, 'user_ids不能为空']); + } + } + + $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC'; + $sort = $request->input('sort') ? $request->input('sort') : 'created_at'; + + $builder = User::query()->orderBy('id', 'desc'); + + if ($scope === 'filtered') { + // filtered: keep current semantics + $builder->orderBy($sort, $sortType); + $this->applyFiltersAndSorts($request, $builder); + } elseif ($scope === 'selected') { + $builder->whereIn('id', $userIds); + } // all: ignore filter/sort + + try { + $builder->update([ + 'banned' => 1 + ]); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '处理失败']); + } + // Full refresh not implemented. + return $this->success(true); + } + + // Delete user and related data. + public function destroy(Request $request) + { + $request->validate([ + 'id' => 'required|exists:App\Models\User,id' + ], [ + 'id.required' => '用户ID不能为空', + 'id.exists' => '用户不存在' + ]); + $user = User::find($request->input('id')); + try { + DB::beginTransaction(); + $user->orders()->delete(); + $user->codes()->delete(); + $user->stat()->delete(); + $user->tickets()->delete(); + $user->delete(); + DB::commit(); + return $this->success(true); + } catch (\Exception $e) { + DB::rollBack(); + Log::error($e); + return $this->fail([500, '删除失败']); + } + } +} diff --git a/Xboard/app/Http/Controllers/V2/Client/AppController.php b/Xboard/app/Http/Controllers/V2/Client/AppController.php new file mode 100644 index 0000000..85ec531 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Client/AppController.php @@ -0,0 +1,153 @@ + [ + 'app_name' => admin_setting('app_name', 'XB加速器'), // 应用名称 + 'app_description' => admin_setting('app_description', '专业的网络加速服务'), // 应用描述 + 'app_url' => admin_setting('app_url', 'https://app.example.com'), // 应用官网 URL + 'logo' => admin_setting('logo', 'https://example.com/logo.png'), // 应用 Logo URL + 'version' => admin_setting('app_version', '1.0.0'), // 应用版本号 + ], + 'features' => [ + 'enable_register' => (bool) admin_setting('app_enable_register', true), // 是否开启注册功能 + 'enable_invite_system' => (bool) admin_setting('app_enable_invite_system', true), // 是否开启邀请系统 + 'enable_telegram_bot' => (bool) admin_setting('telegram_bot_enable', false), // 是否开启 Telegram 机器人 + 'enable_ticket_system' => (bool) admin_setting('app_enable_ticket_system', true), // 是否开启工单系统 + 'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0), // 工单是否需要等待管理员回复后才可继续发消息 + 'enable_commission_system' => (bool) admin_setting('app_enable_commission_system', true), // 是否开启佣金系统 + 'enable_traffic_log' => (bool) admin_setting('app_enable_traffic_log', true), // 是否开启流量日志 + 'enable_knowledge_base' => (bool) admin_setting('app_enable_knowledge_base', true), // 是否开启知识库 + 'enable_announcements' => (bool) admin_setting('app_enable_announcements', true), // 是否开启公告系统 + 'enable_auto_renewal' => (bool) admin_setting('app_enable_auto_renewal', false), // 是否开启自动续费 + 'enable_coupon_system' => (bool) admin_setting('app_enable_coupon_system', true), // 是否开启优惠券系统 + 'enable_speed_test' => (bool) admin_setting('app_enable_speed_test', true), // 是否开启测速功能 + 'enable_server_ping' => (bool) admin_setting('app_enable_server_ping', true), // 是否开启服务器延迟检测 + ], + 'ui_config' => [ + 'theme' => [ + 'primary_color' => admin_setting('app_primary_color', '#00C851'), // 主色调 (十六进制) + 'secondary_color' => admin_setting('app_secondary_color', '#007E33'), // 辅助色 (十六进制) + 'accent_color' => admin_setting('app_accent_color', '#FF6B35'), // 强调色 (十六进制) + 'background_color' => admin_setting('app_background_color', '#F5F5F5'), // 背景色 (十六进制) + 'text_color' => admin_setting('app_text_color', '#333333'), // 文字色 (十六进制) + ], + 'home_screen' => [ + 'show_speed_test' => (bool) admin_setting('app_show_speed_test', true), // 是否显示测速 + 'show_traffic_chart' => (bool) admin_setting('app_show_traffic_chart', true), // 是否显示流量图表 + 'show_server_ping' => (bool) admin_setting('app_show_server_ping', true), // 是否显示服务器延迟 + 'default_server_sort' => admin_setting('app_default_server_sort', 'ping'), // 默认服务器排序方式 + 'show_connection_status' => (bool) admin_setting('app_show_connection_status', true), // 是否显示连接状态 + ], + 'server_list' => [ + 'show_country_flags' => (bool) admin_setting('app_show_country_flags', true), // 是否显示国家旗帜 + 'show_ping_values' => (bool) admin_setting('app_show_ping_values', true), // 是否显示延迟值 + 'show_traffic_usage' => (bool) admin_setting('app_show_traffic_usage', true), // 是否显示流量使用 + 'group_by_country' => (bool) admin_setting('app_group_by_country', false), // 是否按国家分组 + 'show_server_status' => (bool) admin_setting('app_show_server_status', true), // 是否显示服务器状态 + ], + ], + 'business_rules' => [ + 'min_password_length' => (int) admin_setting('app_min_password_length', 8), // 最小密码长度 + 'max_login_attempts' => (int) admin_setting('app_max_login_attempts', 5), // 最大登录尝试次数 + 'session_timeout_minutes' => (int) admin_setting('app_session_timeout_minutes', 30), // 会话超时时间(分钟) + 'auto_disconnect_after_minutes' => (int) admin_setting('app_auto_disconnect_after_minutes', 60), // 自动断开连接时间(分钟) + 'max_concurrent_connections' => (int) admin_setting('app_max_concurrent_connections', 3), // 最大并发连接数 + 'traffic_warning_threshold' => (float) admin_setting('app_traffic_warning_threshold', 0.8), // 流量警告阈值(0-1) + 'subscription_reminder_days' => admin_setting('app_subscription_reminder_days', [7, 3, 1]), // 订阅到期提醒天数 + 'connection_timeout_seconds' => (int) admin_setting('app_connection_timeout_seconds', 10), // 连接超时时间(秒) + 'health_check_interval_seconds' => (int) admin_setting('app_health_check_interval_seconds', 30), // 健康检查间隔(秒) + ], + 'server_config' => [ + 'default_kernel' => admin_setting('app_default_kernel', 'clash'), // 默认内核 (clash/singbox) + 'auto_select_fastest' => (bool) admin_setting('app_auto_select_fastest', true), // 是否自动选择最快服务器 + 'fallback_servers' => admin_setting('app_fallback_servers', ['server1', 'server2']), // 备用服务器列表 + 'enable_auto_switch' => (bool) admin_setting('app_enable_auto_switch', true), // 是否开启自动切换 + 'switch_threshold_ms' => (int) admin_setting('app_switch_threshold_ms', 1000), // 切换阈值(毫秒) + ], + 'security_config' => [ + 'tos_url' => admin_setting('tos_url', 'https://example.com/tos'), // 服务条款 URL + 'privacy_policy_url' => admin_setting('app_privacy_policy_url', 'https://example.com/privacy'), // 隐私政策 URL + 'is_email_verify' => (int) admin_setting('email_verify', 1), // 是否开启邮箱验证 (0/1) + 'is_invite_force' => (int) admin_setting('invite_force', 0), // 是否强制邀请码 (0/1) + 'email_whitelist_suffix' => (int) admin_setting('email_whitelist_suffix', 0), // 邮箱白名单后缀 (0/1) + 'is_captcha' => (int) admin_setting('captcha_enable', 1), // 是否开启验证码 (0/1) + 'captcha_type' => admin_setting('captcha_type', 'recaptcha'), // 验证码类型 (recaptcha/turnstile) + 'recaptcha_site_key' => admin_setting('recaptcha_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA 站点密钥 + 'recaptcha_v3_site_key' => admin_setting('recaptcha_v3_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA v3 站点密钥 + 'recaptcha_v3_score_threshold' => (float) admin_setting('recaptcha_v3_score_threshold', 0.5), // reCAPTCHA v3 分数阈值 + 'turnstile_site_key' => admin_setting('turnstile_site_key', '0x4AAAAAAAABkMYinukE8nzUg'), // Turnstile 站点密钥 + ], + 'payment_config' => [ + 'currency' => admin_setting('currency', 'CNY'), // 货币类型 + 'currency_symbol' => admin_setting('currency_symbol', '¥'), // 货币符号 + 'withdraw_methods' => admin_setting('app_withdraw_methods', ['alipay', 'wechat', 'bank']), // 提现方式列表 + 'min_withdraw_amount' => (int) admin_setting('app_min_withdraw_amount', 100), // 最小提现金额(分) + 'withdraw_fee_rate' => (float) admin_setting('app_withdraw_fee_rate', 0.01), // 提现手续费率 + ], + 'notification_config' => [ + 'enable_push_notifications' => (bool) admin_setting('app_enable_push_notifications', true), // 是否开启推送通知 + 'enable_email_notifications' => (bool) admin_setting('app_enable_email_notifications', true), // 是否开启邮件通知 + 'enable_sms_notifications' => (bool) admin_setting('app_enable_sms_notifications', false), // 是否开启短信通知 + 'notification_schedule' => [ + 'traffic_warning' => (bool) admin_setting('app_notification_traffic_warning', true), // 流量警告通知 + 'subscription_expiry' => (bool) admin_setting('app_notification_subscription_expiry', true), // 订阅到期通知 + 'server_maintenance' => (bool) admin_setting('app_notification_server_maintenance', true), // 服务器维护通知 + 'promotional_offers' => (bool) admin_setting('app_notification_promotional_offers', false), // 促销优惠通知 + ], + ], + 'cache_config' => [ + 'config_cache_duration' => (int) admin_setting('app_config_cache_duration', 3600), // 配置缓存时长(秒) + 'server_list_cache_duration' => (int) admin_setting('app_server_list_cache_duration', 1800), // 服务器列表缓存时长(秒) + 'user_info_cache_duration' => (int) admin_setting('app_user_info_cache_duration', 900), // 用户信息缓存时长(秒) + ], + 'last_updated' => time(), // 最后更新时间戳 + ]; + $config['config_hash'] = md5(json_encode($config)); // 配置哈希值(用于校验) + + $config = $config ?? []; + return response()->json(['data' => $config]); + } + + public function getVersion(Request $request) + { + if ( + strpos($request->header('user-agent'), 'tidalab/4.0.0') !== false + || strpos($request->header('user-agent'), 'tunnelab/4.0.0') !== false + ) { + if (strpos($request->header('user-agent'), 'Win64') !== false) { + $data = [ + 'version' => admin_setting('windows_version'), + 'download_url' => admin_setting('windows_download_url') + ]; + } else { + $data = [ + 'version' => admin_setting('macos_version'), + 'download_url' => admin_setting('macos_download_url') + ]; + } + } else { + $data = [ + 'windows_version' => admin_setting('windows_version'), + 'windows_download_url' => admin_setting('windows_download_url'), + 'macos_version' => admin_setting('macos_version'), + 'macos_download_url' => admin_setting('macos_download_url'), + 'android_version' => admin_setting('android_version'), + 'android_download_url' => admin_setting('android_download_url') + ]; + } + return $this->success($data); + } +} diff --git a/Xboard/app/Http/Controllers/V2/Server/ServerController.php b/Xboard/app/Http/Controllers/V2/Server/ServerController.php new file mode 100644 index 0000000..ff58951 --- /dev/null +++ b/Xboard/app/Http/Controllers/V2/Server/ServerController.php @@ -0,0 +1,138 @@ + false]; + + if ((bool) admin_setting('server_ws_enable', 1)) { + $customUrl = trim((string) admin_setting('server_ws_url', '')); + + if ($customUrl !== '') { + $wsUrl = rtrim($customUrl, '/'); + } else { + $wsScheme = $request->isSecure() ? 'wss' : 'ws'; + $wsUrl = "{$wsScheme}://{$request->getHost()}:8076"; + } + + $websocket = [ + 'enabled' => true, + 'ws_url' => $wsUrl, + ]; + } + + return response()->json([ + 'websocket' => $websocket + ]); + } + + /** + * node report api - merge traffic + alive + status + * POST /api/v2/server/node/report + */ + public function report(Request $request): JsonResponse + { + $node = $request->attributes->get('node_info'); + $nodeType = $node->type; + $nodeId = $node->id; + + Cache::put(CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_CHECK_AT', $nodeId), time(), 3600); + + // hanle traffic data + $traffic = $request->input('traffic'); + if (is_array($traffic) && !empty($traffic)) { + $data = array_filter($traffic, function ($item) { + return is_array($item) + && count($item) === 2 + && is_numeric($item[0]) + && is_numeric($item[1]); + }); + + if (!empty($data)) { + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_ONLINE_USER', $nodeId), + count($data), + 3600 + ); + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_PUSH_AT', $nodeId), + time(), + 3600 + ); + $userService = new UserService(); + $userService->trafficFetch($node, $nodeType, $data); + } + } + + // handle alive data + $alive = $request->input('alive'); + if (is_array($alive) && !empty($alive)) { + $deviceStateService = app(DeviceStateService::class); + foreach ($alive as $uid => $ips) { + $deviceStateService->setDevices((int) $uid, $nodeId, (array) $ips); + } + } + + // handle active connections + $online = $request->input('online'); + if (is_array($online) && !empty($online)) { + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + foreach ($online as $uid => $conn) { + $cacheKey = CacheKey::get("USER_ONLINE_CONN_{$nodeType}_{$nodeId}", $uid); + Cache::put($cacheKey, (int) $conn, $cacheTime); + } + } + + // handle node status + $status = $request->input('status'); + if (is_array($status) && !empty($status)) { + $statusData = [ + 'cpu' => (float) ($status['cpu'] ?? 0), + 'mem' => [ + 'total' => (int) ($status['mem']['total'] ?? 0), + 'used' => (int) ($status['mem']['used'] ?? 0), + ], + 'swap' => [ + 'total' => (int) ($status['swap']['total'] ?? 0), + 'used' => (int) ($status['swap']['used'] ?? 0), + ], + 'disk' => [ + 'total' => (int) ($status['disk']['total'] ?? 0), + 'used' => (int) ($status['disk']['used'] ?? 0), + ], + 'updated_at' => now()->timestamp, + 'kernel_status' => $status['kernel_status'] ?? null, + ]; + + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + cache([ + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LOAD_STATUS', $nodeId) => $statusData, + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_LOAD_AT', $nodeId) => now()->timestamp, + ], $cacheTime); + } + + // handle node metrics (Metrics) + $metrics = $request->input('metrics'); + if (is_array($metrics) && !empty($metrics)) { + ServerService::updateMetrics($node, $metrics); + } + + return response()->json(['data' => true]); + } +} diff --git a/Xboard/app/Http/Kernel.php b/Xboard/app/Http/Kernel.php new file mode 100644 index 0000000..6fd86ab --- /dev/null +++ b/Xboard/app/Http/Kernel.php @@ -0,0 +1,99 @@ + + */ + protected $middleware = [ + \Illuminate\Http\Middleware\HandleCors::class, + \App\Http\Middleware\TrustProxies::class, + \App\Http\Middleware\CheckForMaintenanceMode::class, + \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, + \App\Http\Middleware\TrimStrings::class, + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + \App\Http\Middleware\InitializePlugins::class, + ]; + + /** + * The application's route middleware groups. + * + * @var array> + */ + protected $middlewareGroups = [ + 'web' => [ + // \App\Http\Middleware\EncryptCookies::class, +// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, +// \Illuminate\Session\Middleware\StartSession::class, + // \Illuminate\Session\Middleware\AuthenticateSession::class, +// \Illuminate\View\Middleware\ShareErrorsFromSession::class, +// \App\Http\Middleware\VerifyCsrfToken::class, +// \Illuminate\Routing\Middleware\SubstituteBindings::class, + \App\Http\Middleware\ApplyRuntimeSettings::class, + ], + + 'api' => [ + // \App\Http\Middleware\EncryptCookies::class, +// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, +// \Illuminate\Session\Middleware\StartSession::class, + // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + // \Illuminate\Routing\Middleware\ThrottleRequests::class . ':api', + // \Illuminate\Routing\Middleware\SubstituteBindings::class, + \App\Http\Middleware\ApplyRuntimeSettings::class, + \App\Http\Middleware\ForceJson::class, + \App\Http\Middleware\Language::class, + 'bindings', + ], + ]; + + /** + * The application's route middleware. + * + * These middleware may be assigned to groups or used individually. + * + * @var array + */ + protected $middlewareAliases = [ + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'user' => \App\Http\Middleware\User::class, + 'admin' => \App\Http\Middleware\Admin::class, + 'client' => \App\Http\Middleware\Client::class, + 'staff' => \App\Http\Middleware\Staff::class, + 'log' => \App\Http\Middleware\RequestLog::class, + 'server' => \App\Http\Middleware\Server::class, + 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, + 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, + ]; + + /** + * The priority-sorted list of middleware. + * + * This forces non-global middleware to always be in the given order. + * + * @var array + */ + protected $middlewarePriority = [ + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \Illuminate\Routing\Middleware\ThrottleRequests::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + \Illuminate\Auth\Middleware\Authorize::class, + ]; +} diff --git a/Xboard/app/Http/Middleware/Admin.php b/Xboard/app/Http/Middleware/Admin.php new file mode 100644 index 0000000..1a63b4f --- /dev/null +++ b/Xboard/app/Http/Middleware/Admin.php @@ -0,0 +1,30 @@ +user(); + + if (!$user || !$user->is_admin) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/ApplyRuntimeSettings.php b/Xboard/app/Http/Middleware/ApplyRuntimeSettings.php new file mode 100644 index 0000000..2c3e96a --- /dev/null +++ b/Xboard/app/Http/Middleware/ApplyRuntimeSettings.php @@ -0,0 +1,25 @@ +expectsJson() ? null : null; + } +} diff --git a/Xboard/app/Http/Middleware/CheckForMaintenanceMode.php b/Xboard/app/Http/Middleware/CheckForMaintenanceMode.php new file mode 100644 index 0000000..53fcdd5 --- /dev/null +++ b/Xboard/app/Http/Middleware/CheckForMaintenanceMode.php @@ -0,0 +1,18 @@ + + */ + protected $except = [ + // 示例: + // '/api/health-check', + // '/status' + ]; +} diff --git a/Xboard/app/Http/Middleware/Client.php b/Xboard/app/Http/Middleware/Client.php new file mode 100644 index 0000000..77645e0 --- /dev/null +++ b/Xboard/app/Http/Middleware/Client.php @@ -0,0 +1,33 @@ +input('token', $request->route('token')); + if (empty($token)) { + throw new ApiException('token is null',403); + } + $user = User::where('token', $token)->first(); + if (!$user) { + throw new ApiException('token is error',403); + } + + Auth::setUser($user); + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/EncryptCookies.php b/Xboard/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 0000000..31e9d1a --- /dev/null +++ b/Xboard/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,16 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/Xboard/app/Http/Middleware/EnsureTransactionState.php b/Xboard/app/Http/Middleware/EnsureTransactionState.php new file mode 100644 index 0000000..595dc50 --- /dev/null +++ b/Xboard/app/Http/Middleware/EnsureTransactionState.php @@ -0,0 +1,29 @@ + 0) { + DB::rollBack(); + } + } + } +} diff --git a/Xboard/app/Http/Middleware/ForceJson.php b/Xboard/app/Http/Middleware/ForceJson.php new file mode 100644 index 0000000..ef87160 --- /dev/null +++ b/Xboard/app/Http/Middleware/ForceJson.php @@ -0,0 +1,22 @@ +headers->set('accept', 'application/json'); + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/InitializePlugins.php b/Xboard/app/Http/Middleware/InitializePlugins.php new file mode 100644 index 0000000..0c5ae8d --- /dev/null +++ b/Xboard/app/Http/Middleware/InitializePlugins.php @@ -0,0 +1,37 @@ +pluginManager = $pluginManager; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle(Request $request, Closure $next) + { + // This single method call handles loading and booting all enabled plugins. + // It's safe to call multiple times, as it will only run once per request. + $this->pluginManager->initializeEnabledPlugins(); + + return $next($request); + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Middleware/Language.php b/Xboard/app/Http/Middleware/Language.php new file mode 100644 index 0000000..8bb51e7 --- /dev/null +++ b/Xboard/app/Http/Middleware/Language.php @@ -0,0 +1,17 @@ +header('content-language')) { + App::setLocale($request->header('content-language')); + } + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/RedirectIfAuthenticated.php b/Xboard/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..a7ef27c --- /dev/null +++ b/Xboard/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,26 @@ +check()) { + return redirect('/home'); + } + + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/RequestLog.php b/Xboard/app/Http/Middleware/RequestLog.php new file mode 100644 index 0000000..62dded3 --- /dev/null +++ b/Xboard/app/Http/Middleware/RequestLog.php @@ -0,0 +1,60 @@ +method() !== 'POST') { + return $next($request); + } + + $response = $next($request); + + try { + $admin = $request->user(); + if (!$admin || !$admin->is_admin) { + return $response; + } + + $action = $this->resolveAction($request->path()); + $data = collect($request->all())->except(self::SENSITIVE_KEYS)->toArray(); + + AdminAuditLog::insert([ + 'admin_id' => $admin->id, + 'action' => $action, + 'method' => $request->method(), + 'uri' => $request->getRequestUri(), + 'request_data' => json_encode($data, JSON_UNESCAPED_UNICODE), + 'ip' => $request->getClientIp(), + 'created_at' => time(), + 'updated_at' => time(), + ]); + } catch (\Throwable $e) { + \Log::warning('Audit log write failed: ' . $e->getMessage()); + } + + return $response; + } + + private function resolveAction(string $path): string + { + // api/v2/{secure_path}/user/update → user.update + $path = preg_replace('#^api/v[12]/[^/]+/#', '', $path); + // gift-card/create-template → gift_card.create_template + $path = str_replace('-', '_', $path); + // user/update → user.update, server/manage/sort → server_manage.sort + $segments = explode('/', $path); + $method = array_pop($segments); + $resource = implode('_', $segments); + + return $resource . '.' . $method; + } +} + diff --git a/Xboard/app/Http/Middleware/Server.php b/Xboard/app/Http/Middleware/Server.php new file mode 100644 index 0000000..15dd494 --- /dev/null +++ b/Xboard/app/Http/Middleware/Server.php @@ -0,0 +1,59 @@ +validateRequest($request); + $nodeType = $request->input('node_type', $nodeType); + $normalizedNodeType = ServerModel::normalizeType($nodeType); + $serverInfo = ServerService::getServer( + $request->input('node_id'), + $normalizedNodeType + ); + if (!$serverInfo) { + throw new ApiException('Server does not exist'); + } + + $request->attributes->set('node_info', $serverInfo); + return $next($request); + } + + private function validateRequest(Request $request): void + { + $request->validate([ + 'token' => [ + 'string', + 'required', + function ($attribute, $value, $fail) { + if ($value !== admin_setting('server_token')) { + $fail("Invalid {$attribute}"); + } + }, + ], + 'node_id' => 'required', + 'node_type' => [ + 'nullable', + function ($attribute, $value, $fail) use ($request) { + if ($value === "v2node") { + $value = null; + } + if (!ServerModel::isValidType($value)) { + $fail("Invalid node type specified"); + return; + } + $request->merge([$attribute => ServerModel::normalizeType($value)]); + }, + ] + ]); + } +} diff --git a/Xboard/app/Http/Middleware/Staff.php b/Xboard/app/Http/Middleware/Staff.php new file mode 100644 index 0000000..5c700c1 --- /dev/null +++ b/Xboard/app/Http/Middleware/Staff.php @@ -0,0 +1,30 @@ +input('auth_data') ?? $request->header('authorization'); + if (!$authorization) throw new ApiException( '未登录或登陆已过期', 403); + + $user = AuthService::decryptAuthData($authorization); + if (!$user || !$user['is_staff']) throw new ApiException('未登录或登陆已过期', 403); + $request->merge([ + 'user' => $user + ]); + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/TrimStrings.php b/Xboard/app/Http/Middleware/TrimStrings.php new file mode 100644 index 0000000..fb507a4 --- /dev/null +++ b/Xboard/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,19 @@ + + */ + protected $except = [ + 'password', + 'password_confirmation', + 'encrypted_data', + 'signature' + ]; +} diff --git a/Xboard/app/Http/Middleware/TrustProxies.php b/Xboard/app/Http/Middleware/TrustProxies.php new file mode 100644 index 0000000..83c5400 --- /dev/null +++ b/Xboard/app/Http/Middleware/TrustProxies.php @@ -0,0 +1,47 @@ +|string|null + */ + protected $proxies = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "127.0.0.0/8", + ]; + + /** + * 代理头映射 + * @var int + */ + protected $headers = + Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; +} diff --git a/Xboard/app/Http/Middleware/User.php b/Xboard/app/Http/Middleware/User.php new file mode 100644 index 0000000..14f3049 --- /dev/null +++ b/Xboard/app/Http/Middleware/User.php @@ -0,0 +1,27 @@ +check()) { + throw new ApiException('未登录或登陆已过期', 403); + } + return $next($request); + } +} diff --git a/Xboard/app/Http/Middleware/VerifyCsrfToken.php b/Xboard/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..9e7c0bd --- /dev/null +++ b/Xboard/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,22 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/Xboard/app/Http/Requests/Admin/ConfigSave.php b/Xboard/app/Http/Requests/Admin/ConfigSave.php new file mode 100644 index 0000000..bec69a6 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/ConfigSave.php @@ -0,0 +1,146 @@ + '', + 'invite_commission' => 'integer|nullable', + 'invite_gen_limit' => 'integer|nullable', + 'invite_never_expire' => '', + 'commission_first_time_enable' => '', + 'commission_auto_check_enable' => '', + 'commission_withdraw_limit' => 'nullable|numeric', + 'commission_withdraw_method' => 'nullable|array', + 'withdraw_close_enable' => '', + 'commission_distribution_enable' => '', + 'commission_distribution_l1' => 'nullable|numeric', + 'commission_distribution_l2' => 'nullable|numeric', + 'commission_distribution_l3' => 'nullable|numeric', + // site + 'logo' => 'nullable|url', + 'force_https' => '', + 'stop_register' => '', + 'app_name' => '', + 'app_description' => '', + 'app_url' => 'nullable|url', + 'subscribe_url' => 'nullable', + 'try_out_enable' => '', + 'try_out_plan_id' => 'integer', + 'try_out_hour' => 'numeric', + 'tos_url' => 'nullable|url', + 'currency' => '', + 'currency_symbol' => '', + 'ticket_must_wait_reply' => '', + // subscribe + 'plan_change_enable' => '', + 'reset_traffic_method' => 'in:0,1,2,3,4', + 'surplus_enable' => '', + 'new_order_event_id' => '', + 'renew_order_event_id' => '', + 'change_order_event_id' => '', + 'show_info_to_server_enable' => '', + 'show_protocol_to_server_enable' => '', + 'subscribe_path' => '', + // server + 'server_token' => 'nullable|min:16', + 'server_pull_interval' => 'integer', + 'server_push_interval' => 'integer', + 'device_limit_mode' => 'integer', + 'server_ws_enable' => 'boolean', + 'server_ws_url' => 'nullable|url', + // frontend + 'frontend_theme' => '', + 'frontend_theme_sidebar' => 'nullable|in:dark,light', + 'frontend_theme_header' => 'nullable|in:dark,light', + 'frontend_theme_color' => 'nullable|in:default,darkblue,black,green', + 'frontend_background_url' => 'nullable|url', + // email + 'email_template' => '', + 'email_host' => '', + 'email_port' => '', + 'email_username' => '', + 'email_password' => '', + 'email_encryption' => '', + 'email_from_address' => '', + 'remind_mail_enable' => '', + // telegram + 'telegram_bot_enable' => '', + 'telegram_bot_token' => '', + 'telegram_webhook_url' => 'nullable|url', + 'telegram_discuss_id' => '', + 'telegram_channel_id' => '', + 'telegram_discuss_link' => 'nullable|url', + // app + 'windows_version' => '', + 'windows_download_url' => '', + 'macos_version' => '', + 'macos_download_url' => '', + 'android_version' => '', + 'android_download_url' => '', + // safe + 'email_whitelist_enable' => 'boolean', + 'email_whitelist_suffix' => 'nullable|array', + 'email_gmail_limit_enable' => 'boolean', + 'captcha_enable' => 'boolean', + 'captcha_type' => 'in:recaptcha,turnstile,recaptcha-v3', + 'recaptcha_enable' => 'boolean', + 'recaptcha_key' => '', + 'recaptcha_site_key' => '', + 'recaptcha_v3_secret_key' => '', + 'recaptcha_v3_site_key' => '', + 'recaptcha_v3_score_threshold' => 'numeric|min:0|max:1', + 'turnstile_secret_key' => '', + 'turnstile_site_key' => '', + 'email_verify' => 'bool', + 'safe_mode_enable' => 'boolean', + 'register_limit_by_ip_enable' => 'boolean', + 'register_limit_count' => 'integer', + 'register_limit_expire' => 'integer', + 'secure_path' => 'min:8|regex:/^[\w-]*$/', + 'password_limit_enable' => 'boolean', + 'password_limit_count' => 'integer', + 'password_limit_expire' => 'integer', + 'default_remind_expire' => 'boolean', + 'default_remind_traffic' => 'boolean', + 'subscribe_template_singbox' => 'nullable', + 'subscribe_template_clash' => 'nullable', + 'subscribe_template_clashmeta' => 'nullable', + 'subscribe_template_stash' => 'nullable', + 'subscribe_template_surge' => 'nullable', + 'subscribe_template_surfboard' => 'nullable' + ]; + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return self::RULES; + } + + public function messages() + { + // illiteracy prompt + return [ + 'app_url.url' => '站点URL格式不正确,必须携带http(s)://', + 'subscribe_url.url' => '订阅URL格式不正确,必须携带http(s)://', + 'server_token.min' => '通讯密钥长度必须大于16位', + 'tos_url.url' => '服务条款URL格式不正确,必须携带http(s)://', + 'telegram_webhook_url.url' => 'Telegram Webhook地址格式不正确,必须携带http(s)://', + 'telegram_discuss_link.url' => 'Telegram群组地址必须为URL格式,必须携带http(s)://', + 'logo.url' => 'LOGO URL格式不正确,必须携带https(s)://', + 'secure_path.min' => '后台路径长度最小为8位', + 'secure_path.regex' => '后台路径只能为字母或数字', + 'captcha_type.in' => '人机验证类型只能选择 recaptcha、turnstile 或 recaptcha-v3', + 'recaptcha_v3_score_threshold.numeric' => 'reCAPTCHA v3 分数阈值必须为数字', + 'recaptcha_v3_score_threshold.min' => 'reCAPTCHA v3 分数阈值不能小于0', + 'recaptcha_v3_score_threshold.max' => 'reCAPTCHA v3 分数阈值不能大于1' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/CouponGenerate.php b/Xboard/app/Http/Requests/Admin/CouponGenerate.php new file mode 100644 index 0000000..70f3efc --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/CouponGenerate.php @@ -0,0 +1,51 @@ + 'nullable|integer|max:500', + 'name' => 'required', + 'type' => 'required|in:1,2', + 'value' => 'required|integer', + 'started_at' => 'required|integer', + 'ended_at' => 'required|integer', + 'limit_use' => 'nullable|integer', + 'limit_use_with_user' => 'nullable|integer', + 'limit_plan_ids' => 'nullable|array', + 'limit_period' => 'nullable|array', + 'code' => '' + ]; + } + + public function messages() + { + return [ + 'generate_count.integer' => '生成数量必须为数字', + 'generate_count.max' => '生成数量最大为500个', + 'name.required' => '名称不能为空', + 'type.required' => '类型不能为空', + 'type.in' => '类型格式有误', + 'value.required' => '金额或比例不能为空', + 'value.integer' => '金额或比例格式有误', + 'started_at.required' => '开始时间不能为空', + 'started_at.integer' => '开始时间格式有误', + 'ended_at.required' => '结束时间不能为空', + 'ended_at.integer' => '结束时间格式有误', + 'limit_use.integer' => '最大使用次数格式有误', + 'limit_use_with_user.integer' => '限制用户使用次数格式有误', + 'limit_plan_ids.array' => '指定订阅格式有误', + 'limit_period.array' => '指定周期格式有误' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/KnowledgeCategorySave.php b/Xboard/app/Http/Requests/Admin/KnowledgeCategorySave.php new file mode 100644 index 0000000..9aabb7e --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/KnowledgeCategorySave.php @@ -0,0 +1,29 @@ + 'required', + 'language' => 'required' + ]; + } + + public function messages() + { + return [ + 'name.required' => '分类名称不能为空', + 'language.required' => '分类语言不能为空' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/KnowledgeCategorySort.php b/Xboard/app/Http/Requests/Admin/KnowledgeCategorySort.php new file mode 100644 index 0000000..c76f810 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/KnowledgeCategorySort.php @@ -0,0 +1,28 @@ + 'required|array' + ]; + } + + public function messages() + { + return [ + 'knowledge_category_ids.required' => '分类不能为空', + 'knowledge_category_ids.array' => '分类格式有误' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/KnowledgeSave.php b/Xboard/app/Http/Requests/Admin/KnowledgeSave.php new file mode 100644 index 0000000..296ddbb --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/KnowledgeSave.php @@ -0,0 +1,35 @@ + 'required', + 'language' => 'required', + 'title' => 'required', + 'body' => 'required', + 'show' => 'nullable|boolean' + ]; + } + + public function messages() + { + return [ + 'title.required' => '标题不能为空', + 'category.required' => '分类不能为空', + 'body.required' => '内容不能为空', + 'language.required' => '语言不能为空', + 'show.boolean' => '显示状态必须为布尔值' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/KnowledgeSort.php b/Xboard/app/Http/Requests/Admin/KnowledgeSort.php new file mode 100644 index 0000000..d29a899 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/KnowledgeSort.php @@ -0,0 +1,28 @@ + 'required|array' + ]; + } + + public function messages() + { + return [ + 'knowledge_ids.required' => '知识ID不能为空', + 'knowledge_ids.array' => '知识ID格式有误' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/MailSend.php b/Xboard/app/Http/Requests/Admin/MailSend.php new file mode 100644 index 0000000..86247a3 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/MailSend.php @@ -0,0 +1,34 @@ + 'required|in:1,2,3,4', + 'subject' => 'required', + 'content' => 'required', + 'receiver' => 'array' + ]; + } + + public function messages() + { + return [ + 'type.required' => '发送类型不能为空', + 'type.in' => '发送类型格式有误', + 'subject.required' => '主题不能为空', + 'content.required' => '内容不能为空', + 'receiver.array' => '收件人格式有误' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/NoticeSave.php b/Xboard/app/Http/Requests/Admin/NoticeSave.php new file mode 100644 index 0000000..0f6dc0b --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/NoticeSave.php @@ -0,0 +1,33 @@ + 'required', + 'content' => 'required', + 'img_url' => 'nullable|url', + 'tags' => 'nullable|array' + ]; + } + + public function messages() + { + return [ + 'title.required' => '标题不能为空', + 'content.required' => '内容不能为空', + 'img_url.url' => '图片URL格式不正确', + 'tags.array' => '标签格式不正确' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/OrderAssign.php b/Xboard/app/Http/Requests/Admin/OrderAssign.php new file mode 100644 index 0000000..4b259a0 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/OrderAssign.php @@ -0,0 +1,34 @@ + 'required', + 'email' => 'required', + 'total_amount' => 'required', + 'period' => 'required|in:month_price,quarter_price,half_year_price,year_price,two_year_price,three_year_price,onetime_price,reset_price' + ]; + } + + public function messages() + { + return [ + 'plan_id.required' => '订阅不能为空', + 'email.required' => '邮箱不能为空', + 'total_amount.required' => '支付金额不能为空', + 'period.required' => '订阅周期不能为空', + 'period.in' => '订阅周期格式有误' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/OrderFetch.php b/Xboard/app/Http/Requests/Admin/OrderFetch.php new file mode 100644 index 0000000..9c4765b --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/OrderFetch.php @@ -0,0 +1,32 @@ + 'required|in:email,trade_no,status,commission_status,user_id,invite_user_id,callback_no,commission_balance', + 'filter.*.condition' => 'required|in:>,<,=,>=,<=,模糊,!=', + 'filter.*.value' => '' + ]; + } + + public function messages() + { + return [ + 'filter.*.key.required' => '过滤键不能为空', + 'filter.*.key.in' => '过滤键参数有误', + 'filter.*.condition.required' => '过滤条件不能为空', + 'filter.*.condition.in' => '过滤条件参数有误', + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/OrderUpdate.php b/Xboard/app/Http/Requests/Admin/OrderUpdate.php new file mode 100644 index 0000000..8a38d10 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/OrderUpdate.php @@ -0,0 +1,29 @@ + 'in:0,1,2,3', + 'commission_status' => 'in:0,1,3' + ]; + } + + public function messages() + { + return [ + 'status.in' => '销售状态格式不正确', + 'commission_status.in' => '佣金状态格式不正确' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/PlanSave.php b/Xboard/app/Http/Requests/Admin/PlanSave.php new file mode 100644 index 0000000..c35e4a7 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/PlanSave.php @@ -0,0 +1,157 @@ + 'nullable|integer', + 'name' => 'required|string|max:255', + 'content' => 'nullable|string', + 'reset_traffic_method' => 'integer|nullable', + 'transfer_enable' => 'integer|required|min:1', + 'prices' => 'nullable|array', + 'prices.*' => 'nullable|numeric|min:0', + 'group_id' => 'integer|nullable', + 'speed_limit' => 'integer|nullable|min:0', + 'device_limit' => 'integer|nullable|min:0', + 'capacity_limit' => 'integer|nullable|min:0', + 'tags' => 'array|nullable', + ]; + } + + /** + * 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(如不需要此周期请留空)" + ); + } + } + } + } + + /** + * 处理验证后的数据 + */ + protected function passedValidation(): void + { + // 清理和格式化价格数据 + $prices = $this->input('prices', []); + $cleanedPrices = []; + + foreach ($prices as $period => $price) { + // 只保留有效的正数价格 + if ($price !== null && $price !== '' && is_numeric($price)) { + $numericPrice = (float) $price; + if ($numericPrice > 0) { + // 转换为浮点数并保留两位小数 + $cleanedPrices[$period] = round($numericPrice, 2); + } + } + } + + // 更新请求中的价格数据 + $this->merge(['prices' => $cleanedPrices]); + } + + /** + * Get custom error messages for validator errors. + */ + public function messages(): array + { + return [ + 'name.required' => '套餐名称不能为空', + 'name.max' => '套餐名称不能超过 255 个字符', + 'transfer_enable.required' => '流量配额不能为空', + 'transfer_enable.integer' => '流量配额必须是整数', + 'transfer_enable.min' => '流量配额必须大于 0', + 'prices.array' => '价格配置格式错误', + 'prices.*.numeric' => '价格必须是数字', + 'prices.*.min' => '价格不能为负数', + 'group_id.integer' => '权限组ID必须是整数', + 'speed_limit.integer' => '速度限制必须是整数', + 'speed_limit.min' => '速度限制不能为负数', + 'device_limit.integer' => '设备限制必须是整数', + 'device_limit.min' => '设备限制不能为负数', + 'capacity_limit.integer' => '容量限制必须是整数', + 'capacity_limit.min' => '容量限制不能为负数', + 'tags.array' => '标签格式必须是数组', + ]; + } + + /** + * 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) + ); + } +} diff --git a/Xboard/app/Http/Requests/Admin/PlanSort.php b/Xboard/app/Http/Requests/Admin/PlanSort.php new file mode 100644 index 0000000..eb7987a --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/PlanSort.php @@ -0,0 +1,28 @@ + 'required|array' + ]; + } + + public function messages() + { + return [ + 'plan_ids.required' => '订阅计划ID不能为空', + 'plan_ids.array' => '订阅计划ID格式有误' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/PlanUpdate.php b/Xboard/app/Http/Requests/Admin/PlanUpdate.php new file mode 100644 index 0000000..d9463e2 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/PlanUpdate.php @@ -0,0 +1,29 @@ + 'in:0,1', + 'renew' => 'in:0,1' + ]; + } + + public function messages() + { + return [ + 'show.in' => '销售状态格式不正确', + 'renew.in' => '续费状态格式不正确' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/ServerSave.php b/Xboard/app/Http/Requests/Admin/ServerSave.php new file mode 100644 index 0000000..bd1f6b2 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/ServerSave.php @@ -0,0 +1,212 @@ + 'nullable|boolean', + 'utls.fingerprint' => 'nullable|string', + ]; + + private const MULTIPLEX_RULES = [ + 'multiplex.enabled' => 'nullable|boolean', + 'multiplex.protocol' => 'nullable|string', + 'multiplex.max_connections' => 'nullable|integer', + 'multiplex.min_streams' => 'nullable|integer', + 'multiplex.max_streams' => 'nullable|integer', + 'multiplex.padding' => 'nullable|boolean', + 'multiplex.brutal.enabled' => 'nullable|boolean', + 'multiplex.brutal.up_mbps' => 'nullable|integer', + 'multiplex.brutal.down_mbps' => 'nullable|integer', + ]; + + private const PROTOCOL_RULES = [ + 'shadowsocks' => [ + 'cipher' => 'required|string', + 'obfs' => 'nullable|string', + 'obfs_settings.path' => 'nullable|string', + 'obfs_settings.host' => 'nullable|string', + 'plugin' => 'nullable|string', + 'plugin_opts' => 'nullable|string', + ], + 'vmess' => [ + 'tls' => 'required|integer', + 'network' => 'required|string', + 'network_settings' => 'nullable|array', + 'tls_settings.server_name' => 'nullable|string', + 'tls_settings.allow_insecure' => 'nullable|boolean', + ], + 'trojan' => [ + 'tls' => 'nullable|integer', + 'network' => 'required|string', + 'network_settings' => 'nullable|array', + 'server_name' => 'nullable|string', + 'allow_insecure' => 'nullable|boolean', + 'reality_settings.allow_insecure' => 'nullable|boolean', + 'reality_settings.server_name' => 'nullable|string', + 'reality_settings.server_port' => 'nullable|integer', + 'reality_settings.public_key' => 'nullable|string', + 'reality_settings.private_key' => 'nullable|string', + 'reality_settings.short_id' => 'nullable|string', + ], + 'hysteria' => [ + 'version' => 'required|integer', + 'alpn' => 'nullable|string', + 'obfs.open' => 'nullable|boolean', + 'obfs.type' => 'string|nullable', + 'obfs.password' => 'string|nullable', + 'tls.server_name' => 'nullable|string', + 'tls.allow_insecure' => 'nullable|boolean', + 'bandwidth.up' => 'nullable|integer', + 'bandwidth.down' => 'nullable|integer', + 'hop_interval' => 'integer|nullable', + ], + 'vless' => [ + 'tls' => 'required|integer', + 'network' => 'required|string', + 'network_settings' => 'nullable|array', + 'flow' => 'nullable|string', + 'encryption' => 'nullable|array', + 'encryption.enabled' => 'nullable|boolean', + 'encryption.encryption' => 'nullable|string', + 'encryption.decryption' => 'nullable|string', + 'tls_settings.server_name' => 'nullable|string', + 'tls_settings.allow_insecure' => 'nullable|boolean', + 'reality_settings.allow_insecure' => 'nullable|boolean', + 'reality_settings.server_name' => 'nullable|string', + 'reality_settings.server_port' => 'nullable|integer', + 'reality_settings.public_key' => 'nullable|string', + 'reality_settings.private_key' => 'nullable|string', + 'reality_settings.short_id' => 'nullable|string', + ], + 'socks' => [ + ], + 'naive' => [ + 'tls' => 'required|integer', + 'tls_settings' => 'nullable|array', + ], + 'http' => [ + 'tls' => 'required|integer', + 'tls_settings' => 'nullable|array', + ], + 'mieru' => [ + 'transport' => 'required|string|in:TCP,UDP', + 'traffic_pattern' => 'string' + ], + 'anytls' => [ + 'tls' => 'nullable|array', + 'alpn' => 'nullable|string', + 'padding_scheme' => 'nullable|array', + ], + ]; + + private function getBaseRules(): array + { + return [ + 'type' => 'required|in:' . implode(',', Server::VALID_TYPES), + 'spectific_key' => 'nullable|string', + 'code' => 'nullable|string', + 'show' => '', + 'name' => 'required|string', + 'group_ids' => 'nullable|array', + 'route_ids' => 'nullable|array', + 'parent_id' => 'nullable|integer', + 'host' => 'required', + 'port' => 'required', + 'server_port' => 'required', + 'tags' => 'nullable|array', + 'excludes' => 'nullable|array', + 'ips' => 'nullable|array', + 'rate' => 'required|numeric', + 'rate_time_enable' => 'nullable|boolean', + 'rate_time_ranges' => 'nullable|array', + 'custom_outbounds' => 'nullable|array', + 'custom_routes' => 'nullable|array', + 'cert_config' => 'nullable|array', + 'rate_time_ranges.*.start' => 'required_with:rate_time_ranges|string|date_format:H:i', + 'rate_time_ranges.*.end' => 'required_with:rate_time_ranges|string|date_format:H:i', + 'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0', + 'protocol_settings' => 'array', + 'transfer_enable' => 'nullable|integer|min:0', + ]; + } + + public function rules(): array + { + $type = $this->input('type'); + $rules = $this->getBaseRules(); + + $protocolRules = self::PROTOCOL_RULES[$type] ?? []; + if (in_array($type, ['vmess', 'vless', 'trojan', 'mieru'])) { + $protocolRules = array_merge($protocolRules, self::MULTIPLEX_RULES, self::UTLS_RULES); + } + + foreach ($protocolRules as $field => $rule) { + $rules['protocol_settings.' . $field] = $rule; + } + + return $rules; + } + + public function attributes(): array + { + return [ + 'protocol_settings.cipher' => '加密方式', + 'protocol_settings.obfs' => '混淆类型', + 'protocol_settings.network' => '传输协议', + 'protocol_settings.port_range' => '端口范围', + 'protocol_settings.traffic_pattern' => 'Traffic Pattern', + 'protocol_settings.transport' => '传输方式', + 'protocol_settings.version' => '协议版本', + 'protocol_settings.password' => '密码', + 'protocol_settings.handshake.server' => '握手服务器', + 'protocol_settings.handshake.server_port' => '握手端口', + 'protocol_settings.multiplex.enabled' => '多路复用', + 'protocol_settings.multiplex.protocol' => '复用协议', + 'protocol_settings.multiplex.max_connections' => '最大连接数', + 'protocol_settings.multiplex.min_streams' => '最小流数', + 'protocol_settings.multiplex.max_streams' => '最大流数', + 'protocol_settings.multiplex.padding' => '复用填充', + 'protocol_settings.multiplex.brutal.enabled' => 'Brutal加速', + 'protocol_settings.multiplex.brutal.up_mbps' => 'Brutal上行速率', + 'protocol_settings.multiplex.brutal.down_mbps' => 'Brutal下行速率', + 'protocol_settings.utls.enabled' => 'uTLS', + 'protocol_settings.utls.fingerprint' => 'uTLS指纹', + ]; + } + + public function messages() + { + return [ + 'name.required' => '节点名称不能为空', + 'group_ids.required' => '权限组不能为空', + 'group_ids.array' => '权限组格式不正确', + 'route_ids.array' => '路由组格式不正确', + 'parent_id.integer' => '父ID格式不正确', + 'host.required' => '节点地址不能为空', + 'port.required' => '连接端口不能为空', + 'server_port.required' => '后端服务端口不能为空', + 'tls.required' => 'TLS不能为空', + 'tags.array' => '标签格式不正确', + 'rate.required' => '倍率不能为空', + 'rate.numeric' => '倍率格式不正确', + 'network.required' => '传输协议不能为空', + 'network.in' => '传输协议格式不正确', + 'networkSettings.array' => '传输协议配置有误', + 'ruleSettings.array' => '规则配置有误', + 'tlsSettings.array' => 'tls配置有误', + 'dnsSettings.array' => 'dns配置有误', + 'protocol_settings.*.required' => ':attribute 不能为空', + 'protocol_settings.*.string' => ':attribute 必须是字符串', + 'protocol_settings.*.integer' => ':attribute 必须是整数', + 'protocol_settings.*.in' => ':attribute 的值不合法', + 'transfer_enable.integer' => '流量上限必须是整数', + 'transfer_enable.min' => '流量上限不能小于0', + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/UserFetch.php b/Xboard/app/Http/Requests/Admin/UserFetch.php new file mode 100644 index 0000000..899c6a9 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/UserFetch.php @@ -0,0 +1,33 @@ + 'required|in:id,email,transfer_enable,d,expired_at,uuid,token,invite_by_email,invite_user_id,plan_id,banned,remarks,is_admin', + 'filter.*.condition' => 'required|in:>,<,=,>=,<=,模糊,!=', + 'filter.*.value' => 'required' + ]; + } + + public function messages() + { + return [ + 'filter.*.key.required' => '过滤键不能为空', + 'filter.*.key.in' => '过滤键参数有误', + 'filter.*.condition.required' => '过滤条件不能为空', + 'filter.*.condition.in' => '过滤条件参数有误', + 'filter.*.value.required' => '过滤值不能为空' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/UserGenerate.php b/Xboard/app/Http/Requests/Admin/UserGenerate.php new file mode 100644 index 0000000..41b0722 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/UserGenerate.php @@ -0,0 +1,33 @@ + 'nullable|integer|max:500', + 'expired_at' => 'nullable|integer', + 'plan_id' => 'nullable|integer', + 'email_prefix' => 'nullable', + 'email_suffix' => 'required', + 'password' => 'nullable' + ]; + } + + public function messages() + { + return [ + 'generate_count.integer' => '生成数量必须为数字', + 'generate_count.max' => '生成数量最大为500个' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/UserSendMail.php b/Xboard/app/Http/Requests/Admin/UserSendMail.php new file mode 100644 index 0000000..f885c36 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/UserSendMail.php @@ -0,0 +1,29 @@ + 'required', + 'content' => 'required', + ]; + } + + public function messages() + { + return [ + 'subject.required' => '主题不能为空', + 'content.required' => '发送内容不能为空' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Admin/UserUpdate.php b/Xboard/app/Http/Requests/Admin/UserUpdate.php new file mode 100644 index 0000000..afbf922 --- /dev/null +++ b/Xboard/app/Http/Requests/Admin/UserUpdate.php @@ -0,0 +1,69 @@ + 'required|integer', + 'email' => 'email:strict', + 'password' => 'nullable|min:8', + 'transfer_enable' => 'numeric', + 'expired_at' => 'nullable|integer', + 'banned' => 'bool', + 'plan_id' => 'nullable|integer', + 'commission_rate' => 'nullable|integer|min:0|max:100', + 'discount' => 'nullable|integer|min:0|max:100', + 'is_admin' => 'boolean', + 'is_staff' => 'boolean', + 'u' => 'integer', + 'd' => 'integer', + 'balance' => 'numeric', + 'commission_type' => 'integer', + 'commission_balance' => 'numeric', + 'remarks' => 'nullable', + 'speed_limit' => 'nullable|integer', + 'device_limit' => 'nullable|integer' + ]; + } + + public function messages() + { + return [ + 'email.required' => '邮箱不能为空', + 'email.email' => '邮箱格式不正确', + 'transfer_enable.numeric' => '流量格式不正确', + 'expired_at.integer' => '到期时间格式不正确', + 'banned.in' => '是否封禁格式不正确', + 'is_admin.required' => '是否管理员不能为空', + 'is_admin.in' => '是否管理员格式不正确', + 'is_staff.required' => '是否员工不能为空', + 'is_staff.in' => '是否员工格式不正确', + 'plan_id.integer' => '订阅计划格式不正确', + 'commission_rate.integer' => '推荐返利比例格式不正确', + 'commission_rate.nullable' => '推荐返利比例格式不正确', + 'commission_rate.min' => '推荐返利比例最小为0', + 'commission_rate.max' => '推荐返利比例最大为100', + 'discount.integer' => '专属折扣比例格式不正确', + 'discount.nullable' => '专属折扣比例格式不正确', + 'discount.min' => '专属折扣比例最小为0', + 'discount.max' => '专属折扣比例最大为100', + 'u.integer' => '上行流量格式不正确', + 'd.integer' => '下行流量格式不正确', + 'balance.integer' => '余额格式不正确', + 'commission_balance.integer' => '佣金格式不正确', + 'password.min' => '密码长度最小8位', + 'speed_limit.integer' => '限速格式不正确', + 'device_limit.integer' => '设备数量格式不正确' + ]; + } +} diff --git a/Xboard/app/Http/Requests/Passport/AuthForget.php b/Xboard/app/Http/Requests/Passport/AuthForget.php new file mode 100644 index 0000000..8106f28 --- /dev/null +++ b/Xboard/app/Http/Requests/Passport/AuthForget.php @@ -0,0 +1,33 @@ + 'required|email:strict', + 'password' => 'required|min:8', + 'email_code' => 'required' + ]; + } + + public function messages() + { + return [ + 'email.required' => __('Email can not be empty'), + 'email.email' => __('Email format is incorrect'), + 'password.required' => __('Password can not be empty'), + 'password.min' => __('Password must be greater than 8 digits'), + 'email_code.required' => __('Email verification code cannot be empty') + ]; + } +} diff --git a/Xboard/app/Http/Requests/Passport/AuthLogin.php b/Xboard/app/Http/Requests/Passport/AuthLogin.php new file mode 100644 index 0000000..6aa832c --- /dev/null +++ b/Xboard/app/Http/Requests/Passport/AuthLogin.php @@ -0,0 +1,31 @@ + 'required|email:strict', + 'password' => 'required|min:8' + ]; + } + + public function messages() + { + return [ + 'email.required' => __('Email can not be empty'), + 'email.email' => __('Email format is incorrect'), + 'password.required' => __('Password can not be empty'), + 'password.min' => __('Password must be greater than 8 digits') + ]; + } +} diff --git a/Xboard/app/Http/Requests/Passport/AuthRegister.php b/Xboard/app/Http/Requests/Passport/AuthRegister.php new file mode 100644 index 0000000..63e053a --- /dev/null +++ b/Xboard/app/Http/Requests/Passport/AuthRegister.php @@ -0,0 +1,31 @@ + 'required|email:strict', + 'password' => 'required|min:8' + ]; + } + + public function messages() + { + return [ + 'email.required' => __('Email can not be empty'), + 'email.email' => __('Email format is incorrect'), + 'password.required' => __('Password can not be empty'), + 'password.min' => __('Password must be greater than 8 digits') + ]; + } +} diff --git a/Xboard/app/Http/Requests/Passport/CommSendEmailVerify.php b/Xboard/app/Http/Requests/Passport/CommSendEmailVerify.php new file mode 100644 index 0000000..ff5ecdd --- /dev/null +++ b/Xboard/app/Http/Requests/Passport/CommSendEmailVerify.php @@ -0,0 +1,28 @@ + 'required|email:strict' + ]; + } + + public function messages() + { + return [ + 'email.required' => __('Email can not be empty'), + 'email.email' => __('Email format is incorrect') + ]; + } +} diff --git a/Xboard/app/Http/Requests/Staff/UserUpdate.php b/Xboard/app/Http/Requests/Staff/UserUpdate.php new file mode 100644 index 0000000..be22144 --- /dev/null +++ b/Xboard/app/Http/Requests/Staff/UserUpdate.php @@ -0,0 +1,56 @@ + 'required|email:strict', + 'password' => 'nullable', + 'transfer_enable' => 'numeric', + 'expired_at' => 'nullable|integer', + 'banned' => 'required|in:0,1', + 'plan_id' => 'nullable|integer', + 'commission_rate' => 'nullable|integer|min:0|max:100', + 'discount' => 'nullable|integer|min:0|max:100', + 'u' => 'integer', + 'd' => 'integer', + 'balance' => 'integer', + 'commission_balance' => 'integer' + ]; + } + + public function messages() + { + return [ + 'email.required' => '邮箱不能为空', + 'email.email' => '邮箱格式不正确', + 'transfer_enable.numeric' => '流量格式不正确', + 'expired_at.integer' => '到期时间格式不正确', + 'banned.required' => '是否封禁不能为空', + 'banned.in' => '是否封禁格式不正确', + 'plan_id.integer' => '订阅计划格式不正确', + 'commission_rate.integer' => '推荐返利比例格式不正确', + 'commission_rate.nullable' => '推荐返利比例格式不正确', + 'commission_rate.min' => '推荐返利比例最小为0', + 'commission_rate.max' => '推荐返利比例最大为100', + 'discount.integer' => '专属折扣比例格式不正确', + 'discount.nullable' => '专属折扣比例格式不正确', + 'discount.min' => '专属折扣比例最小为0', + 'discount.max' => '专属折扣比例最大为100', + 'u.integer' => '上行流量格式不正确', + 'd.integer' => '下行流量格式不正确', + 'balance.integer' => '余额格式不正确', + 'commission_balance.integer' => '佣金格式不正确' + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/GiftCardCheckRequest.php b/Xboard/app/Http/Requests/User/GiftCardCheckRequest.php new file mode 100644 index 0000000..ed0b5e3 --- /dev/null +++ b/Xboard/app/Http/Requests/User/GiftCardCheckRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + // + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/GiftCardRedeemRequest.php b/Xboard/app/Http/Requests/User/GiftCardRedeemRequest.php new file mode 100644 index 0000000..7feb4b4 --- /dev/null +++ b/Xboard/app/Http/Requests/User/GiftCardRedeemRequest.php @@ -0,0 +1,44 @@ + 'required|string|min:8|max:32', + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages() + { + return [ + 'code.required' => '请输入兑换码', + 'code.min' => '兑换码长度不能少于8位', + 'code.max' => '兑换码长度不能超过32位', + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/OrderSave.php b/Xboard/app/Http/Requests/User/OrderSave.php new file mode 100644 index 0000000..449bcaa --- /dev/null +++ b/Xboard/app/Http/Requests/User/OrderSave.php @@ -0,0 +1,30 @@ + 'required', + 'period' => 'required|in:month_price,quarter_price,half_year_price,year_price,two_year_price,three_year_price,onetime_price,reset_price' + ]; + } + + public function messages() + { + return [ + 'plan_id.required' => __('Plan ID cannot be empty'), + 'period.required' => __('Plan period cannot be empty'), + 'period.in' => __('Wrong plan period') + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/TicketSave.php b/Xboard/app/Http/Requests/User/TicketSave.php new file mode 100644 index 0000000..412778f --- /dev/null +++ b/Xboard/app/Http/Requests/User/TicketSave.php @@ -0,0 +1,32 @@ + 'required', + 'level' => 'required|in:0,1,2', + 'message' => 'required' + ]; + } + + public function messages() + { + return [ + 'subject.required' => __('Ticket subject cannot be empty'), + 'level.required' => __('Ticket level cannot be empty'), + 'level.in' => __('Incorrect ticket level format'), + 'message.required' => __('Message cannot be empty') + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/TicketWithdraw.php b/Xboard/app/Http/Requests/User/TicketWithdraw.php new file mode 100644 index 0000000..d0da905 --- /dev/null +++ b/Xboard/app/Http/Requests/User/TicketWithdraw.php @@ -0,0 +1,29 @@ + 'required', + 'withdraw_account' => 'required' + ]; + } + + public function messages() + { + return [ + 'withdraw_method.required' => __('The withdrawal method cannot be empty'), + 'withdraw_account.required' => __('The withdrawal account cannot be empty') + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/UserChangePassword.php b/Xboard/app/Http/Requests/User/UserChangePassword.php new file mode 100644 index 0000000..04e70c7 --- /dev/null +++ b/Xboard/app/Http/Requests/User/UserChangePassword.php @@ -0,0 +1,30 @@ + 'required', + 'new_password' => 'required|min:8' + ]; + } + + public function messages() + { + return [ + 'old_password.required' => __('Old password cannot be empty'), + 'new_password.required' => __('New password cannot be empty'), + 'new_password.min' => __('Password must be greater than 8 digits') + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/UserTransfer.php b/Xboard/app/Http/Requests/User/UserTransfer.php new file mode 100644 index 0000000..478c825 --- /dev/null +++ b/Xboard/app/Http/Requests/User/UserTransfer.php @@ -0,0 +1,29 @@ + 'required|integer|min:1' + ]; + } + + public function messages() + { + return [ + 'transfer_amount.required' => __('The transfer amount cannot be empty'), + 'transfer_amount.integer' => __('The transfer amount parameter is wrong'), + 'transfer_amount.min' => __('The transfer amount parameter is wrong') + ]; + } +} diff --git a/Xboard/app/Http/Requests/User/UserUpdate.php b/Xboard/app/Http/Requests/User/UserUpdate.php new file mode 100644 index 0000000..5ba6604 --- /dev/null +++ b/Xboard/app/Http/Requests/User/UserUpdate.php @@ -0,0 +1,29 @@ + 'in:0,1', + 'remind_traffic' => 'in:0,1' + ]; + } + + public function messages() + { + return [ + 'show.in' => __('Incorrect format of expiration reminder'), + 'renew.in' => __('Incorrect traffic alert format') + ]; + } +} diff --git a/Xboard/app/Http/Resources/ComissionLogResource.php b/Xboard/app/Http/Resources/ComissionLogResource.php new file mode 100644 index 0000000..8d86769 --- /dev/null +++ b/Xboard/app/Http/Resources/ComissionLogResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + "id"=> $this['id'], + "order_amount" => $this['order_amount'], + "trade_no" => $this['trade_no'], + "get_amount" => $this['get_amount'], + "created_at" => $this['created_at'] + ]; + } +} diff --git a/Xboard/app/Http/Resources/CouponResource.php b/Xboard/app/Http/Resources/CouponResource.php new file mode 100644 index 0000000..049a61f --- /dev/null +++ b/Xboard/app/Http/Resources/CouponResource.php @@ -0,0 +1,38 @@ + 转换后的数组 + */ + public function toArray(Request $request): array + { + return [ + ...$this->resource->toArray(), + 'limit_plan_ids' => empty($this->limit_plan_ids) ? null : collect($this->limit_plan_ids) + ->map(fn(mixed $id): string => (string) $id) + ->values() + ->all(), + 'limit_period' => empty($this->limit_period) ? null : collect($this->limit_period) + ->map(fn(mixed $period): string => (string) PlanService::convertToLegacyPeriod($period)) + ->values() + ->all(), + ]; + } +} diff --git a/Xboard/app/Http/Resources/InviteCodeResource.php b/Xboard/app/Http/Resources/InviteCodeResource.php new file mode 100644 index 0000000..927c782 --- /dev/null +++ b/Xboard/app/Http/Resources/InviteCodeResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + $data = [ + "user_id" => $this['user_id'], + "code" => $this['code'], + "pv" => $this['pv'], + "status" => $this['status'], + "created_at" => $this['created_at'], + "updated_at" => $this['updated_at'] + ]; + if(!config('hidden_features.enable_exposed_user_count_fix')) $data['user_id']= $this['user_id']; + return $data; + } +} diff --git a/Xboard/app/Http/Resources/KnowledgeResource.php b/Xboard/app/Http/Resources/KnowledgeResource.php new file mode 100644 index 0000000..cda796c --- /dev/null +++ b/Xboard/app/Http/Resources/KnowledgeResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + $data = [ + 'id' => $this['id'], + 'category' => $this['category'], + 'title' => $this['title'], + 'body' => $this->when(isset($this['body']), $this['body']), + 'updated_at' => $this['updated_at'], + ]; + + return HookManager::filter('user.knowledge.resource', $data, $request, $this); + } +} diff --git a/Xboard/app/Http/Resources/MessageResource.php b/Xboard/app/Http/Resources/MessageResource.php new file mode 100644 index 0000000..9a72e1c --- /dev/null +++ b/Xboard/app/Http/Resources/MessageResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + "id" => $this['id'], + "ticket_id" => $this['ticket_id'], + "is_me" => $this['is_from_user'], + "message" => $this["message"], + "created_at" => $this['created_at'], + "updated_at" => $this['updated_at'] + ]; + } +} diff --git a/Xboard/app/Http/Resources/NodeResource.php b/Xboard/app/Http/Resources/NodeResource.php new file mode 100644 index 0000000..d7f90af --- /dev/null +++ b/Xboard/app/Http/Resources/NodeResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this['id'], + 'type' => $this['type'], + 'version' => $this['version'] ?? null, + 'name' => $this['name'], + 'rate' => $this['rate'], + 'tags' => $this['tags'], + 'is_online' => $this['is_online'], + 'cache_key' => $this['cache_key'], + 'last_check_at' => $this['last_check_at'] + ]; + } +} diff --git a/Xboard/app/Http/Resources/OrderResource.php b/Xboard/app/Http/Resources/OrderResource.php new file mode 100644 index 0000000..ae3e6e4 --- /dev/null +++ b/Xboard/app/Http/Resources/OrderResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + ...parent::toArray($request), + 'period' => PlanService::getLegacyPeriod((string)$this->period), + 'plan' => $this->whenLoaded('plan', fn() => PlanResource::make($this->plan)), + ]; + } +} diff --git a/Xboard/app/Http/Resources/PlanResource.php b/Xboard/app/Http/Resources/PlanResource.php new file mode 100644 index 0000000..b78583a --- /dev/null +++ b/Xboard/app/Http/Resources/PlanResource.php @@ -0,0 +1,122 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource['id'], + 'group_id' => $this->resource['group_id'], + 'name' => $this->resource['name'], + 'tags' => $this->resource['tags'], + 'content' => $this->formatContent(), + ...$this->getPeriodPrices(), + 'capacity_limit' => $this->getFormattedCapacityLimit(), + 'transfer_enable' => $this->resource['transfer_enable'], + 'speed_limit' => $this->resource['speed_limit'], + 'device_limit' => $this->resource['device_limit'], + 'show' => (bool) $this->resource['show'], + 'sell' => (bool) $this->resource['sell'], + 'renew' => (bool) $this->resource['renew'], + 'reset_traffic_method' => $this->resource['reset_traffic_method'], + 'sort' => $this->resource['sort'], + 'created_at' => $this->resource['created_at'], + 'updated_at' => $this->resource['updated_at'] + ]; + } + + /** + * Get transformed period prices using Plan mapping + * + * @return array + */ + protected function getPeriodPrices(): array + { + return collect(Plan::LEGACY_PERIOD_MAPPING) + ->mapWithKeys(function (string $newPeriod, string $legacyPeriod): array { + $price = $this->resource['prices'][$newPeriod] ?? null; + return [ + $legacyPeriod => $price !== null + ? (float) $price * self::PRICE_MULTIPLIER + : null + ]; + }) + ->all(); + } + + /** + * Get formatted capacity limit value + * + * @return int|string|null + */ + protected function getFormattedCapacityLimit(): int|string|null + { + $limit = $this->resource['capacity_limit']; + + return match (true) { + $limit === null => null, + $limit <= 0 => __('Sold out'), + default => (int) $limit, + }; + } + + /** + * Format content with template variables + * + * @return string + */ + protected function formatContent(): string + { + $content = $this->resource['content'] ?? ''; + + $replacements = [ + '{{transfer}}' => $this->resource['transfer_enable'], + '{{speed}}' => $this->resource['speed_limit'] === NULL ? __('No Limit') : $this->resource['speed_limit'], + '{{devices}}' => $this->resource['device_limit'] === NULL ? __('No Limit') : $this->resource['device_limit'], + '{{reset_method}}' => $this->getResetMethodText(), + ]; + + return str_replace( + array_keys($replacements), + array_values($replacements), + $content + ); + } + + /** + * Get reset method text + * + * @return string + */ + protected function getResetMethodText(): string + { + $method = $this->resource['reset_traffic_method']; + + if ($method === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) { + $method = admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY); + } + return match ($method) { + Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => __('First Day of Month'), + Plan::RESET_TRAFFIC_MONTHLY => __('Monthly'), + Plan::RESET_TRAFFIC_NEVER => __('Never'), + Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => __('First Day of Year'), + Plan::RESET_TRAFFIC_YEARLY => __('Yearly'), + default => __('Monthly') + }; + } +} \ No newline at end of file diff --git a/Xboard/app/Http/Resources/TicketResource.php b/Xboard/app/Http/Resources/TicketResource.php new file mode 100644 index 0000000..9b9a773 --- /dev/null +++ b/Xboard/app/Http/Resources/TicketResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + $data = [ + "id" => $this['id'], + "level" => $this['level'], + "reply_status" => $this['reply_status'], + "status" => $this['status'], + "subject" => $this['subject'], + "message" => array_key_exists('message',$this->additional) ? MessageResource::collection($this['message']) : null, + "created_at" => $this['created_at'], + "updated_at" => $this['updated_at'] + ]; + if(!config('hidden_features.enable_exposed_user_count_fix')) $data['user_id']= $this['user_id']; + return $data; + + } +} diff --git a/Xboard/app/Http/Resources/TrafficLogResource.php b/Xboard/app/Http/Resources/TrafficLogResource.php new file mode 100644 index 0000000..798ea7e --- /dev/null +++ b/Xboard/app/Http/Resources/TrafficLogResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + $data = [ + "d" => $this['d'], + "u" => $this['u'], + "record_at" => $this['record_at'], + "server_rate" => $this['server_rate'], + ]; + if(!config('hidden_features.enable_exposed_user_count_fix')) $data['user_id']= $this['user_id']; + return $data; + } +} diff --git a/Xboard/app/Http/Routes/V1/ClientRoute.php b/Xboard/app/Http/Routes/V1/ClientRoute.php new file mode 100644 index 0000000..ad13989 --- /dev/null +++ b/Xboard/app/Http/Routes/V1/ClientRoute.php @@ -0,0 +1,23 @@ +group([ + 'prefix' => 'client', + 'middleware' => 'client' + ], function ($router) { + // Client + $router->get('/subscribe', [ClientController::class, 'subscribe'])->name('client.subscribe.legacy'); + // App + $router->get('/app/getConfig', [AppController::class, 'getConfig']); + $router->get('/app/getVersion', [AppController::class, 'getVersion']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V1/GuestRoute.php b/Xboard/app/Http/Routes/V1/GuestRoute.php new file mode 100644 index 0000000..3c4f571 --- /dev/null +++ b/Xboard/app/Http/Routes/V1/GuestRoute.php @@ -0,0 +1,27 @@ +group([ + 'prefix' => 'guest' + ], function ($router) { + // Plan + $router->get('/plan/fetch', [PlanController::class, 'fetch']); + // Telegram + $router->post('/telegram/webhook', [TelegramController::class, 'webhook']); + // Payment + $router->match(['get', 'post'], '/payment/notify/{method}/{uuid}', [PaymentController::class, 'notify']); + // Comm + $router->get('/comm/config', [CommController::class, 'config']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V1/PassportRoute.php b/Xboard/app/Http/Routes/V1/PassportRoute.php new file mode 100644 index 0000000..3134b96 --- /dev/null +++ b/Xboard/app/Http/Routes/V1/PassportRoute.php @@ -0,0 +1,27 @@ +group([ + 'prefix' => 'passport' + ], function ($router) { + // Auth + $router->post('/auth/register', [AuthController::class, 'register']); + $router->post('/auth/login', [AuthController::class, 'login']); + $router->get('/auth/token2Login', [AuthController::class, 'token2Login']); + $router->post('/auth/forget', [AuthController::class, 'forget']); + $router->post('/auth/getQuickLoginUrl', [AuthController::class, 'getQuickLoginUrl']); + $router->post('/auth/loginWithMailLink', [AuthController::class, 'loginWithMailLink']); + // Comm + $router->post('/comm/sendEmailVerify', [CommController::class, 'sendEmailVerify']); + $router->post('/comm/pv', [CommController::class, 'pv']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V1/ServerRoute.php b/Xboard/app/Http/Routes/V1/ServerRoute.php new file mode 100644 index 0000000..42f7b22 --- /dev/null +++ b/Xboard/app/Http/Routes/V1/ServerRoute.php @@ -0,0 +1,45 @@ +group([ + 'prefix' => 'server', + ], function ($router) { + $router->group([ + 'prefix' => 'UniProxy', + 'middleware' => 'server' + ], function ($route) { + $route->get('config', [UniProxyController::class, 'config']); + $route->get('user', [UniProxyController::class, 'user']); + $route->post('push', [UniProxyController::class, 'push']); + $route->post('alive', [UniProxyController::class, 'alive']); + $route->get('alivelist', [UniProxyController::class, 'alivelist']); + $route->post('status', [UniProxyController::class, 'status']); + }); + $router->group([ + 'prefix' => 'ShadowsocksTidalab', + 'middleware' => 'server:shadowsocks' + ], function ($route) { + $route->get('user', [ShadowsocksTidalabController::class, 'user']); + $route->post('submit', [ShadowsocksTidalabController::class, 'submit']); + }); + $router->group([ + 'prefix' => 'TrojanTidalab', + 'middleware' => 'server:trojan' + ], function ($route) { + $route->get('config', [TrojanTidalabController::class, 'config']); + $route->get('user', [TrojanTidalabController::class, 'user']); + $route->post('submit', [TrojanTidalabController::class, 'submit']); + }); + }); + } +} diff --git a/Xboard/app/Http/Routes/V1/UserRoute.php b/Xboard/app/Http/Routes/V1/UserRoute.php new file mode 100644 index 0000000..92c3605 --- /dev/null +++ b/Xboard/app/Http/Routes/V1/UserRoute.php @@ -0,0 +1,83 @@ +group([ + 'prefix' => 'user', + 'middleware' => 'user' + ], function ($router) { + // User + $router->get('/resetSecurity', [UserController::class, 'resetSecurity']); + $router->get('/info', [UserController::class, 'info']); + $router->post('/changePassword', [UserController::class, 'changePassword']); + $router->post('/update', [UserController::class, 'update']); + $router->get('/getSubscribe', [UserController::class, 'getSubscribe']); + $router->get('/getStat', [UserController::class, 'getStat']); + $router->get('/checkLogin', [UserController::class, 'checkLogin']); + $router->post('/transfer', [UserController::class, 'transfer']); + $router->post('/getQuickLoginUrl', [UserController::class, 'getQuickLoginUrl']); + $router->get('/getActiveSession', [UserController::class, 'getActiveSession']); + $router->post('/removeActiveSession', [UserController::class, 'removeActiveSession']); + // Order + $router->post('/order/save', [OrderController::class, 'save']); + $router->post('/order/checkout', [OrderController::class, 'checkout']); + $router->get('/order/check', [OrderController::class, 'check']); + $router->get('/order/detail', [OrderController::class, 'detail']); + $router->get('/order/fetch', [OrderController::class, 'fetch']); + $router->get('/order/getPaymentMethod', [OrderController::class, 'getPaymentMethod']); + $router->post('/order/cancel', [OrderController::class, 'cancel']); + // Plan + $router->get('/plan/fetch', [PlanController::class, 'fetch']); + // Invite + $router->get('/invite/save', [InviteController::class, 'save']); + $router->get('/invite/fetch', [InviteController::class, 'fetch']); + $router->get('/invite/details', [InviteController::class, 'details']); + // Notice + $router->get('/notice/fetch', [NoticeController::class, 'fetch']); + // Ticket + $router->post('/ticket/reply', [TicketController::class, 'reply']); + $router->post('/ticket/close', [TicketController::class, 'close']); + $router->post('/ticket/save', [TicketController::class, 'save']); + $router->get('/ticket/fetch', [TicketController::class, 'fetch']); + $router->post('/ticket/withdraw', [TicketController::class, 'withdraw']); + // Server + $router->get('/server/fetch', [ServerController::class, 'fetch']); + // Coupon + $router->post('/coupon/check', [CouponController::class, 'check']); + // Gift Card + $router->post('/gift-card/check', [GiftCardController::class, 'check']); + $router->post('/gift-card/redeem', [GiftCardController::class, 'redeem']); + $router->get('/gift-card/history', [GiftCardController::class, 'history']); + $router->get('/gift-card/detail', [GiftCardController::class, 'detail']); + $router->get('/gift-card/types', [GiftCardController::class, 'types']); + // Telegram + $router->get('/telegram/getBotInfo', [TelegramController::class, 'getBotInfo']); + // Comm + $router->get('/comm/config', [CommController::class, 'config']); + $router->Post('/comm/getStripePublicKey', [CommController::class, 'getStripePublicKey']); + // Knowledge + $router->get('/knowledge/fetch', [KnowledgeController::class, 'fetch']); + $router->get('/knowledge/getCategory', [KnowledgeController::class, 'getCategory']); + // Stat + $router->get('/stat/getTrafficLog', [StatController::class, 'getTrafficLog']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V2/AdminRoute.php b/Xboard/app/Http/Routes/V2/AdminRoute.php new file mode 100644 index 0000000..3d8c910 --- /dev/null +++ b/Xboard/app/Http/Routes/V2/AdminRoute.php @@ -0,0 +1,276 @@ +group([ + 'prefix' => admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))), + 'middleware' => ['admin', 'log'], + ], function ($router) { + // Config + $router->group([ + 'prefix' => 'config' + ], function ($router) { + $router->get('/fetch', [ConfigController::class, 'fetch']); + $router->post('/save', [ConfigController::class, 'save']); + $router->get('/getEmailTemplate', [ConfigController::class, 'getEmailTemplate']); + $router->get('/getThemeTemplate', [ConfigController::class, 'getThemeTemplate']); + $router->post('/setTelegramWebhook', [ConfigController::class, 'setTelegramWebhook']); + $router->post('/testSendMail', [ConfigController::class, 'testSendMail']); + }); + + // Plan + $router->group([ + 'prefix' => 'plan' + ], function ($router) { + $router->get('/fetch', [PlanController::class, 'fetch']); + $router->post('/save', [PlanController::class, 'save']); + $router->post('/drop', [PlanController::class, 'drop']); + $router->post('/update', [PlanController::class, 'update']); + $router->post('/sort', [PlanController::class, 'sort']); + }); + + // Server + $router->group([ + 'prefix' => 'server/group' + ], function ($router) { + $router->get('/fetch', [GroupController::class, 'fetch']); + $router->post('/save', [GroupController::class, 'save']); + $router->post('/drop', [GroupController::class, 'drop']); + }); + $router->group([ + 'prefix' => 'server/route' + ], function ($router) { + $router->get('/fetch', [RouteController::class, 'fetch']); + $router->post('/save', [RouteController::class, 'save']); + $router->post('/drop', [RouteController::class, 'drop']); + }); + $router->group([ + 'prefix' => 'server/manage' + ], function ($router) { + $router->get('/getNodes', [ManageController::class, 'getNodes']); + $router->post('/sort', [ManageController::class, 'sort']); + }); + + // 节点更新接口 + $router->group([ + 'prefix' => 'server/manage' + ], function ($router) { + $router->post('/update', [ManageController::class, 'update']); + $router->post('/save', [ManageController::class, 'save']); + $router->post('/drop', [ManageController::class, 'drop']); + $router->post('/copy', [ManageController::class, 'copy']); + $router->post('/sort', [ManageController::class, 'sort']); + $router->post('/batchDelete', [ManageController::class, 'batchDelete']); + $router->post('/resetTraffic', [ManageController::class, 'resetTraffic']); + $router->post('/batchResetTraffic', [ManageController::class, 'batchResetTraffic']); + }); + + // Order + $router->group([ + 'prefix' => 'order' + ], function ($router) { + $router->any('/fetch', [OrderController::class, 'fetch']); + $router->post('/update', [OrderController::class, 'update']); + $router->post('/assign', [OrderController::class, 'assign']); + $router->post('/paid', [OrderController::class, 'paid']); + $router->post('/cancel', [OrderController::class, 'cancel']); + $router->post('/detail', [OrderController::class, 'detail']); + }); + + // User + $router->group([ + 'prefix' => 'user' + ], function ($router) { + $router->any('/fetch', [UserController::class, 'fetch']); + $router->post('/update', [UserController::class, 'update']); + $router->get('/getUserInfoById', [UserController::class, 'getUserInfoById']); + $router->post('/generate', [UserController::class, 'generate']); + $router->post('/dumpCSV', [UserController::class, 'dumpCSV']); + $router->post('/sendMail', [UserController::class, 'sendMail']); + $router->post('/ban', [UserController::class, 'ban']); + $router->post('/resetSecret', [UserController::class, 'resetSecret']); + $router->post('/setInviteUser', [UserController::class, 'setInviteUser']); + $router->post('/destroy', [UserController::class, 'destroy']); + }); + + // Stat + $router->group([ + 'prefix' => 'stat' + ], function ($router) { + $router->get('/getOverride', [StatController::class, 'getOverride']); + $router->get('/getStats', [StatController::class, 'getStats']); + $router->get('/getServerLastRank', [StatController::class, 'getServerLastRank']); + $router->get('/getServerYesterdayRank', [StatController::class, 'getServerYesterdayRank']); + $router->get('/getOrder', [StatController::class, 'getOrder']); + $router->any('/getStatUser', [StatController::class, 'getStatUser']); + $router->get('/getRanking', [StatController::class, 'getRanking']); + $router->get('/getStatRecord', [StatController::class, 'getStatRecord']); + $router->get('/getTrafficRank', [StatController::class, 'getTrafficRank']); + }); + + // Notice + $router->group([ + 'prefix' => 'notice' + ], function ($router) { + $router->get('/fetch', [NoticeController::class, 'fetch']); + $router->post('/save', [NoticeController::class, 'save']); + $router->post('/update', [NoticeController::class, 'update']); + $router->post('/drop', [NoticeController::class, 'drop']); + $router->post('/show', [NoticeController::class, 'show']); + $router->post('/sort', [NoticeController::class, 'sort']); + }); + + // Ticket + $router->group([ + 'prefix' => 'ticket' + ], function ($router) { + $router->any('/fetch', [TicketController::class, 'fetch']); + $router->post('/reply', [TicketController::class, 'reply']); + $router->post('/close', [TicketController::class, 'close']); + }); + + // Coupon + $router->group([ + 'prefix' => 'coupon' + ], function ($router) { + $router->any('/fetch', [CouponController::class, 'fetch']); + $router->post('/generate', [CouponController::class, 'generate']); + $router->post('/drop', [CouponController::class, 'drop']); + $router->post('/show', [CouponController::class, 'show']); + $router->post('/update', [CouponController::class, 'update']); + }); + + // Gift Card + $router->group([ + 'prefix' => 'gift-card' + ], function ($router) { + // Template management + $router->any('/templates', [GiftCardController::class, 'templates']); + $router->post('/create-template', [GiftCardController::class, 'createTemplate']); + $router->post('/update-template', [GiftCardController::class, 'updateTemplate']); + $router->post('/delete-template', [GiftCardController::class, 'deleteTemplate']); + + // Code management + $router->post('/generate-codes', [GiftCardController::class, 'generateCodes']); + $router->any('/codes', [GiftCardController::class, 'codes']); + $router->post('/toggle-code', [GiftCardController::class, 'toggleCode']); + $router->get('/export-codes', [GiftCardController::class, 'exportCodes']); + $router->post('/update-code', [GiftCardController::class, 'updateCode']); + $router->post('/delete-code', [GiftCardController::class, 'deleteCode']); + + // Usage records + $router->any('/usages', [GiftCardController::class, 'usages']); + + // Statistics + $router->any('/statistics', [GiftCardController::class, 'statistics']); + $router->get('/types', [GiftCardController::class, 'types']); + }); + + // Knowledge + $router->group([ + 'prefix' => 'knowledge' + ], function ($router) { + $router->get('/fetch', [KnowledgeController::class, 'fetch']); + $router->get('/getCategory', [KnowledgeController::class, 'getCategory']); + $router->post('/save', [KnowledgeController::class, 'save']); + $router->post('/show', [KnowledgeController::class, 'show']); + $router->post('/drop', [KnowledgeController::class, 'drop']); + $router->post('/sort', [KnowledgeController::class, 'sort']); + }); + + // Payment + $router->group([ + 'prefix' => 'payment' + ], function ($router) { + $router->get('/fetch', [PaymentController::class, 'fetch']); + $router->get('/getPaymentMethods', [PaymentController::class, 'getPaymentMethods']); + $router->post('/getPaymentForm', [PaymentController::class, 'getPaymentForm']); + $router->post('/save', [PaymentController::class, 'save']); + $router->post('/drop', [PaymentController::class, 'drop']); + $router->post('/show', [PaymentController::class, 'show']); + $router->post('/sort', [PaymentController::class, 'sort']); + }); + + // System + $router->group([ + 'prefix' => 'system' + ], function ($router) { + $router->get('/getSystemStatus', [SystemController::class, 'getSystemStatus']); + $router->get('/getQueueStats', [SystemController::class, 'getQueueStats']); + $router->get('/getQueueWorkload', [SystemController::class, 'getQueueWorkload']); + $router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index'); + $router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']); + $router->any('/getAuditLog', [SystemController::class, 'getAuditLog']); + }); + + // Update + // $router->group([ + // 'prefix' => 'update' + // ], function ($router) { + // $router->get('/check', [UpdateController::class, 'checkUpdate']); + // $router->post('/execute', [UpdateController::class, 'executeUpdate']); + // }); + + // Theme + $router->group([ + 'prefix' => 'theme' + ], function ($router) { + $router->get('/getThemes', [ThemeController::class, 'getThemes']); + $router->post('/upload', [ThemeController::class, 'upload']); + $router->post('/delete', [ThemeController::class, 'delete']); + $router->post('/saveThemeConfig', [ThemeController::class, 'saveThemeConfig']); + $router->post('/getThemeConfig', [ThemeController::class, 'getThemeConfig']); + }); + + // Plugin + $router->group([ + 'prefix' => 'plugin' + ], function ($router) { + $router->get('/types', [\App\Http\Controllers\V2\Admin\PluginController::class, 'types']); + $router->get('/getPlugins', [\App\Http\Controllers\V2\Admin\PluginController::class, 'index']); + $router->post('/upload', [\App\Http\Controllers\V2\Admin\PluginController::class, 'upload']); + $router->post('/delete', [\App\Http\Controllers\V2\Admin\PluginController::class, 'delete']); + $router->post('install', [\App\Http\Controllers\V2\Admin\PluginController::class, 'install']); + $router->post('uninstall', [\App\Http\Controllers\V2\Admin\PluginController::class, 'uninstall']); + $router->post('enable', [\App\Http\Controllers\V2\Admin\PluginController::class, 'enable']); + $router->post('disable', [\App\Http\Controllers\V2\Admin\PluginController::class, 'disable']); + $router->get('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'getConfig']); + $router->post('config', [\App\Http\Controllers\V2\Admin\PluginController::class, 'updateConfig']); + $router->post('upgrade', [\App\Http\Controllers\V2\Admin\PluginController::class, 'upgrade']); + }); + + // 流量重置管理 + $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']); + }); + }); + + } +} diff --git a/Xboard/app/Http/Routes/V2/ClientRoute.php b/Xboard/app/Http/Routes/V2/ClientRoute.php new file mode 100644 index 0000000..693a40d --- /dev/null +++ b/Xboard/app/Http/Routes/V2/ClientRoute.php @@ -0,0 +1,20 @@ +group([ + 'prefix' => 'client', + 'middleware' => 'client' + ], function ($router) { + // App + $router->get('/app/getConfig', [AppController::class, 'getConfig']); + $router->get('/app/getVersion', [AppController::class, 'getVersion']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V2/PassportRoute.php b/Xboard/app/Http/Routes/V2/PassportRoute.php new file mode 100644 index 0000000..ed91d81 --- /dev/null +++ b/Xboard/app/Http/Routes/V2/PassportRoute.php @@ -0,0 +1,27 @@ +group([ + 'prefix' => 'passport' + ], function ($router) { + // Auth + $router->post('/auth/register', [AuthController::class, 'register']); + $router->post('/auth/login', [AuthController::class, 'login']); + $router->get ('/auth/token2Login', [AuthController::class, 'token2Login']); + $router->post('/auth/forget', [AuthController::class, 'forget']); + $router->post('/auth/getQuickLoginUrl', [AuthController::class, 'getQuickLoginUrl']); + $router->post('/auth/loginWithMailLink', [AuthController::class, 'loginWithMailLink']); + // Comm + $router->post('/comm/sendEmailVerify', [CommController::class, 'sendEmailVerify']); + $router->post('/comm/pv', [CommController::class, 'pv']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V2/ServerRoute.php b/Xboard/app/Http/Routes/V2/ServerRoute.php new file mode 100644 index 0000000..9742d31 --- /dev/null +++ b/Xboard/app/Http/Routes/V2/ServerRoute.php @@ -0,0 +1,29 @@ +group([ + 'prefix' => 'server', + 'middleware' => 'server' + ], function ($route) { + $route->post('handshake', [ServerController::class, 'handshake']); + $route->post('report', [ServerController::class, 'report']); + $route->get('config', [UniProxyController::class, 'config']); + $route->get('user', [UniProxyController::class, 'user']); + $route->post('push', [UniProxyController::class, 'push']); + $route->post('alive', [UniProxyController::class, 'alive']); + $route->get('alivelist', [UniProxyController::class, 'alivelist']); + $route->post('status', [UniProxyController::class, 'status']); + }); + } +} diff --git a/Xboard/app/Http/Routes/V2/UserRoute.php b/Xboard/app/Http/Routes/V2/UserRoute.php new file mode 100644 index 0000000..38bc12f --- /dev/null +++ b/Xboard/app/Http/Routes/V2/UserRoute.php @@ -0,0 +1,20 @@ +group([ + 'prefix' => 'user', + 'middleware' => 'user' + ], function ($router) { + // User + $router->get('/resetSecurity', [UserController::class, 'resetSecurity']); + $router->get('/info', [UserController::class, 'info']); + }); + } +} diff --git a/Xboard/app/Jobs/NodeUserSyncJob.php b/Xboard/app/Jobs/NodeUserSyncJob.php new file mode 100644 index 0000000..f49e108 --- /dev/null +++ b/Xboard/app/Jobs/NodeUserSyncJob.php @@ -0,0 +1,45 @@ +onQueue('node_sync'); + } + + public function handle(): void + { + $user = User::find($this->userId); + + if ($this->action === 'updated' || $this->action === 'created') { + if ($this->oldGroupId) { + NodeSyncService::notifyUserRemovedFromGroup($this->userId, $this->oldGroupId); + } + if ($user) { + NodeSyncService::notifyUserChanged($user); + } + } elseif ($this->action === 'deleted') { + if ($this->oldGroupId) { + NodeSyncService::notifyUserRemovedFromGroup($this->userId, $this->oldGroupId); + } + } + } +} diff --git a/Xboard/app/Jobs/OrderHandleJob.php b/Xboard/app/Jobs/OrderHandleJob.php new file mode 100644 index 0000000..960f659 --- /dev/null +++ b/Xboard/app/Jobs/OrderHandleJob.php @@ -0,0 +1,56 @@ +onQueue('order_handle'); + $this->tradeNo = $tradeNo; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $order = Order::where('trade_no', $this->tradeNo) + ->lockForUpdate() + ->first(); + if (!$order) return; + $orderService = new OrderService($order); + switch ($order->status) { + // cancel + case Order::STATUS_PENDING: + if ($order->created_at <= (time() - 3600 * 2)) { + $orderService->cancel(); + } + break; + case Order::STATUS_PROCESSING: + $orderService->open(); + break; + } + } +} diff --git a/Xboard/app/Jobs/SendEmailJob.php b/Xboard/app/Jobs/SendEmailJob.php new file mode 100644 index 0000000..d998f41 --- /dev/null +++ b/Xboard/app/Jobs/SendEmailJob.php @@ -0,0 +1,42 @@ +onQueue($queue); + $this->params = $params; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $mailLog = MailService::sendEmail($this->params); + if ($mailLog['error']) { + $this->release(); //发送失败将触发重试 + } + } +} diff --git a/Xboard/app/Jobs/SendTelegramJob.php b/Xboard/app/Jobs/SendTelegramJob.php new file mode 100644 index 0000000..8d30344 --- /dev/null +++ b/Xboard/app/Jobs/SendTelegramJob.php @@ -0,0 +1,43 @@ +onQueue('send_telegram'); + $this->telegramId = $telegramId; + $this->text = $text; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $telegramService = new TelegramService(); + $telegramService->sendMessage($this->telegramId, $this->text, 'markdown'); + } +} diff --git a/Xboard/app/Jobs/StatServerJob.php b/Xboard/app/Jobs/StatServerJob.php new file mode 100644 index 0000000..c055ca5 --- /dev/null +++ b/Xboard/app/Jobs/StatServerJob.php @@ -0,0 +1,174 @@ +onQueue('stat'); + $this->data = $data; + $this->server = $server; + $this->protocol = $protocol; + $this->recordType = $recordType; + } + + public function handle(): void + { + $recordAt = $this->recordType === 'm' + ? strtotime(date('Y-m-01')) + : strtotime(date('Y-m-d')); + + $u = $d = 0; + foreach ($this->data as $traffic) { + $u += $traffic[0]; + $d += $traffic[1]; + } + + try { + $this->processServerStat($u, $d, $recordAt); + $this->updateServerTraffic($u, $d); + } catch (\Exception $e) { + Log::error('StatServerJob failed for server ' . $this->server['id'] . ': ' . $e->getMessage()); + throw $e; + } + } + + protected function updateServerTraffic(int $u, int $d): void + { + DB::table('v2_server') + ->where('id', $this->server['id']) + ->incrementEach( + ['u' => $u, 'd' => $d], + ['updated_at' => Carbon::now()] + ); + } + + protected function processServerStat(int $u, int $d, int $recordAt): void + { + $driver = config('database.default'); + if ($driver === 'sqlite') { + $this->processServerStatForSqlite($u, $d, $recordAt); + } elseif ($driver === 'pgsql') { + $this->processServerStatForPostgres($u, $d, $recordAt); + } else { + $this->processServerStatForOtherDatabases($u, $d, $recordAt); + } + } + + protected function processServerStatForSqlite(int $u, int $d, int $recordAt): void + { + DB::transaction(function () use ($u, $d, $recordAt) { + $existingRecord = StatServer::where([ + 'record_at' => $recordAt, + 'server_id' => $this->server['id'], + 'server_type' => $this->protocol, + 'record_type' => $this->recordType, + ])->first(); + + if ($existingRecord) { + $existingRecord->update([ + 'u' => $existingRecord->u + $u, + 'd' => $existingRecord->d + $d, + 'updated_at' => time(), + ]); + } else { + StatServer::create([ + 'record_at' => $recordAt, + 'server_id' => $this->server['id'], + 'server_type' => $this->protocol, + 'record_type' => $this->recordType, + 'u' => $u, + 'd' => $d, + 'created_at' => time(), + 'updated_at' => time(), + ]); + } + }, 3); + } + + protected function processServerStatForOtherDatabases(int $u, int $d, int $recordAt): void + { + StatServer::upsert( + [ + 'record_at' => $recordAt, + 'server_id' => $this->server['id'], + 'server_type' => $this->protocol, + 'record_type' => $this->recordType, + 'u' => $u, + 'd' => $d, + 'created_at' => time(), + 'updated_at' => time(), + ], + ['server_id', 'server_type', 'record_at', 'record_type'], + [ + 'u' => DB::raw("u + VALUES(u)"), + 'd' => DB::raw("d + VALUES(d)"), + 'updated_at' => time(), + ] + ); + } + + /** + * PostgreSQL upsert with arithmetic increments using ON CONFLICT ... DO UPDATE + */ + protected function processServerStatForPostgres(int $u, int $d, int $recordAt): void + { + $table = (new StatServer())->getTable(); + $now = time(); + + // Use parameter binding to avoid SQL injection and keep maintainability + $sql = "INSERT INTO {$table} (record_at, server_id, server_type, record_type, u, d, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (server_id, server_type, record_at) + DO UPDATE SET + u = {$table}.u + EXCLUDED.u, + d = {$table}.d + EXCLUDED.d, + updated_at = EXCLUDED.updated_at"; + + DB::statement($sql, [ + $recordAt, + $this->server['id'], + $this->protocol, + $this->recordType, + $u, + $d, + $now, + $now, + ]); + } +} diff --git a/Xboard/app/Jobs/StatUserJob.php b/Xboard/app/Jobs/StatUserJob.php new file mode 100644 index 0000000..620443c --- /dev/null +++ b/Xboard/app/Jobs/StatUserJob.php @@ -0,0 +1,158 @@ +onQueue('stat'); + $this->data = $data; + $this->server = $server; + $this->protocol = $protocol; + $this->recordType = $recordType; + } + + public function handle(): void + { + $recordAt = $this->recordType === 'm' + ? strtotime(date('Y-m-01')) + : strtotime(date('Y-m-d')); + + foreach ($this->data as $uid => $v) { + try { + $this->processUserStat($uid, $v, $recordAt); + } catch (\Exception $e) { + Log::error('StatUserJob failed for user ' . $uid . ': ' . $e->getMessage()); + throw $e; + } + } + } + + protected function processUserStat(int $uid, array $v, int $recordAt): void + { + $driver = config('database.default'); + if ($driver === 'sqlite') { + $this->processUserStatForSqlite($uid, $v, $recordAt); + } elseif ($driver === 'pgsql') { + $this->processUserStatForPostgres($uid, $v, $recordAt); + } else { + $this->processUserStatForOtherDatabases($uid, $v, $recordAt); + } + } + + protected function processUserStatForSqlite(int $uid, array $v, int $recordAt): void + { + DB::transaction(function () use ($uid, $v, $recordAt) { + $existingRecord = StatUser::where([ + 'user_id' => $uid, + 'server_rate' => $this->server['rate'], + 'record_at' => $recordAt, + 'record_type' => $this->recordType, + ])->first(); + + if ($existingRecord) { + $existingRecord->update([ + 'u' => $existingRecord->u + intval($v[0] * $this->server['rate']), + 'd' => $existingRecord->d + intval($v[1] * $this->server['rate']), + 'updated_at' => time(), + ]); + } else { + StatUser::create([ + 'user_id' => $uid, + 'server_rate' => $this->server['rate'], + 'record_at' => $recordAt, + 'record_type' => $this->recordType, + 'u' => intval($v[0] * $this->server['rate']), + 'd' => intval($v[1] * $this->server['rate']), + 'created_at' => time(), + 'updated_at' => time(), + ]); + } + }, 3); + } + + protected function processUserStatForOtherDatabases(int $uid, array $v, int $recordAt): void + { + StatUser::upsert( + [ + 'user_id' => $uid, + 'server_rate' => $this->server['rate'], + 'record_at' => $recordAt, + 'record_type' => $this->recordType, + 'u' => intval($v[0] * $this->server['rate']), + 'd' => intval($v[1] * $this->server['rate']), + 'created_at' => time(), + 'updated_at' => time(), + ], + ['user_id', 'server_rate', 'record_at', 'record_type'], + [ + 'u' => DB::raw("u + VALUES(u)"), + 'd' => DB::raw("d + VALUES(d)"), + 'updated_at' => time(), + ] + ); + } + + /** + * PostgreSQL upsert with arithmetic increments using ON CONFLICT ... DO UPDATE + */ + protected function processUserStatForPostgres(int $uid, array $v, int $recordAt): void + { + $table = (new StatUser())->getTable(); + $now = time(); + $u = intval($v[0] * $this->server['rate']); + $d = intval($v[1] * $this->server['rate']); + + $sql = "INSERT INTO {$table} (user_id, server_rate, record_at, record_type, u, d, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, server_rate, record_at) + DO UPDATE SET + u = {$table}.u + EXCLUDED.u, + d = {$table}.d + EXCLUDED.d, + updated_at = EXCLUDED.updated_at"; + + DB::statement($sql, [ + $uid, + $this->server['rate'], + $recordAt, + $this->recordType, + $u, + $d, + $now, + $now, + ]); + } +} \ No newline at end of file diff --git a/Xboard/app/Jobs/TrafficFetchJob.php b/Xboard/app/Jobs/TrafficFetchJob.php new file mode 100644 index 0000000..1e398aa --- /dev/null +++ b/Xboard/app/Jobs/TrafficFetchJob.php @@ -0,0 +1,51 @@ +onQueue('traffic_fetch'); + $this->server = $server; + $this->data = $data; + $this->protocol = $protocol; + $this->timestamp = $timestamp; + } + + public function handle(): void + { + $userIds = array_keys($this->data); + + foreach ($this->data as $uid => $v) { + User::where('id', $uid) + ->incrementEach( + [ + 'u' => $v[0] * $this->server['rate'], + 'd' => $v[1] * $this->server['rate'], + ], + ['t' => time()] + ); + } + + if (!empty($userIds)) { + Redis::sadd('traffic:pending_check', ...$userIds); + } + } +} diff --git a/Xboard/app/Models/AdminAuditLog.php b/Xboard/app/Models/AdminAuditLog.php new file mode 100644 index 0000000..9e9404c --- /dev/null +++ b/Xboard/app/Models/AdminAuditLog.php @@ -0,0 +1,21 @@ + 'timestamp', + 'updated_at' => 'timestamp', + ]; + + public function admin() + { + return $this->belongsTo(User::class, 'admin_id'); + } +} diff --git a/Xboard/app/Models/CommissionLog.php b/Xboard/app/Models/CommissionLog.php new file mode 100644 index 0000000..744c5d5 --- /dev/null +++ b/Xboard/app/Models/CommissionLog.php @@ -0,0 +1,16 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; +} diff --git a/Xboard/app/Models/Coupon.php b/Xboard/app/Models/Coupon.php new file mode 100644 index 0000000..e6271b8 --- /dev/null +++ b/Xboard/app/Models/Coupon.php @@ -0,0 +1,28 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'limit_plan_ids' => 'array', + 'limit_period' => 'array', + 'show' => 'boolean', + ]; + + public function getLimitPeriodAttribute($value) + { + return collect(json_decode((string) $value, true))->map(function ($item) { + return PlanService::getPeriodKey($item); + })->toArray(); + } + +} diff --git a/Xboard/app/Models/GiftCardCode.php b/Xboard/app/Models/GiftCardCode.php new file mode 100644 index 0000000..dd09e8e --- /dev/null +++ b/Xboard/app/Models/GiftCardCode.php @@ -0,0 +1,260 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'used_at' => 'timestamp', + 'expires_at' => 'timestamp', + 'actual_rewards' => 'array', + 'metadata' => 'array' + ]; + + /** + * 获取状态映射 + */ + public static function getStatusMap(): array + { + return [ + self::STATUS_UNUSED => '未使用', + self::STATUS_USED => '已使用', + self::STATUS_EXPIRED => '已过期', + self::STATUS_DISABLED => '已禁用', + ]; + } + + /** + * 获取状态名称 + */ + public function getStatusNameAttribute(): string + { + return self::getStatusMap()[$this->status] ?? '未知状态'; + } + + /** + * 关联礼品卡模板 + */ + public function template(): BelongsTo + { + return $this->belongsTo(GiftCardTemplate::class, 'template_id'); + } + + /** + * 关联使用用户 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 关联使用记录 + */ + public function usages(): HasMany + { + return $this->hasMany(GiftCardUsage::class, 'code_id'); + } + + /** + * 检查是否可用 + */ + public function isAvailable(): bool + { + // 检查状态 + if (in_array($this->status, [self::STATUS_EXPIRED, self::STATUS_DISABLED])) { + return false; + } + + // 检查是否过期 + if ($this->expires_at && $this->expires_at < time()) { + return false; + } + + // 检查使用次数 + if ($this->usage_count >= $this->max_usage) { + return false; + } + + return true; + } + + /** + * 检查是否已过期 + */ + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at < time(); + } + + /** + * 标记为已使用 + */ + public function markAsUsed(User $user): bool + { + $this->status = self::STATUS_USED; + $this->user_id = $user->id; + $this->used_at = time(); + $this->usage_count += 1; + + return $this->save(); + } + + /** + * 标记为已过期 + */ + public function markAsExpired(): bool + { + $this->status = self::STATUS_EXPIRED; + return $this->save(); + } + + /** + * 标记为已禁用 + */ + public function markAsDisabled(): bool + { + $this->status = self::STATUS_DISABLED; + return $this->save(); + } + + /** + * 生成兑换码 + */ + public static function generateCode(string $prefix = 'GC'): string + { + do { + $safePrefix = (string) $prefix; + $code = $safePrefix . strtoupper(substr(md5(uniqid($safePrefix . mt_rand(), true)), 0, 12)); + } while (self::where('code', $code)->exists()); + + return $code; + } + + /** + * 批量生成兑换码 + */ + public static function batchGenerate(int $templateId, int $count, array $options = []): string + { + $batchId = uniqid('batch_'); + $prefix = $options['prefix'] ?? 'GC'; + $expiresAt = $options['expires_at'] ?? null; + $maxUsage = $options['max_usage'] ?? 1; + + $codes = []; + for ($i = 0; $i < $count; $i++) { + $codes[] = [ + 'template_id' => $templateId, + 'code' => self::generateCode($prefix), + 'batch_id' => $batchId, + 'status' => self::STATUS_UNUSED, + 'expires_at' => $expiresAt, + 'max_usage' => $maxUsage, + 'created_at' => time(), + 'updated_at' => time(), + ]; + } + + self::insert($codes); + + return $batchId; + } + + /** + * 设置实际奖励(用于盲盒等) + */ + public function setActualRewards(array $rewards): bool + { + $this->actual_rewards = $rewards; + return $this->save(); + } + + /** + * 获取实际奖励 + */ + public function getActualRewards(): array + { + return $this->actual_rewards ?? $this->template->rewards ?? []; + } + + /** + * 检查兑换码格式 + */ + public static function validateCodeFormat(string $code): bool + { + // 基本格式验证:字母数字组合,长度8-32 + return preg_match('/^[A-Z0-9]{8,32}$/', $code); + } + + /** + * 根据批次ID获取兑换码 + */ + public static function getByBatchId(string $batchId) + { + return self::where('batch_id', $batchId)->get(); + } + + /** + * 清理过期兑换码 + */ + public static function cleanupExpired(): int + { + $count = self::where('status', self::STATUS_UNUSED) + ->where('expires_at', '<', time()) + ->count(); + + self::where('status', self::STATUS_UNUSED) + ->where('expires_at', '<', time()) + ->update(['status' => self::STATUS_EXPIRED]); + + return $count; + } +} \ No newline at end of file diff --git a/Xboard/app/Models/GiftCardTemplate.php b/Xboard/app/Models/GiftCardTemplate.php new file mode 100644 index 0000000..87a04de --- /dev/null +++ b/Xboard/app/Models/GiftCardTemplate.php @@ -0,0 +1,254 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'conditions' => 'array', + 'rewards' => 'array', + 'limits' => 'array', + 'special_config' => 'array', + 'status' => 'boolean' + ]; + + /** + * 获取卡片类型映射 + */ + public static function getTypeMap(): array + { + return [ + self::TYPE_GENERAL => '通用礼品卡', + self::TYPE_PLAN => '套餐礼品卡', + self::TYPE_MYSTERY => '盲盒礼品卡', + ]; + } + + /** + * 获取类型名称 + */ + public function getTypeNameAttribute(): string + { + return self::getTypeMap()[$this->type] ?? '未知类型'; + } + + /** + * 关联兑换码 + */ + public function codes(): HasMany + { + return $this->hasMany(GiftCardCode::class, 'template_id'); + } + + /** + * 关联使用记录 + */ + public function usages(): HasMany + { + return $this->hasMany(GiftCardUsage::class, 'template_id'); + } + + /** + * 关联统计数据 + */ + public function stats(): HasMany + { + return $this->hasMany(GiftCardUsage::class, 'template_id'); + } + + /** + * 检查是否可用 + */ + public function isAvailable(): bool + { + return $this->status; + } + + /** + * 检查用户是否满足使用条件 + */ + public function checkUserConditions(User $user): bool + { + switch ($this->type) { + case self::TYPE_GENERAL: + $rewards = $this->rewards ?? []; + if (isset($rewards['transfer_enable']) || isset($rewards['expire_days']) || isset($rewards['reset_package'])) { + if (!$user->plan_id) { + return false; + } + } + break; + case self::TYPE_PLAN: + if ($user->isActive()) { + return false; + } + break; + } + + $conditions = $this->conditions ?? []; + + // 检查新用户条件 + if (isset($conditions['new_user_only']) && $conditions['new_user_only']) { + $maxDays = $conditions['new_user_max_days'] ?? 7; + if ($user->created_at < (time() - ($maxDays * 86400))) { + return false; + } + } + + // 检查付费用户条件 + if (isset($conditions['paid_user_only']) && $conditions['paid_user_only']) { + $paidOrderExists = $user->orders()->where('status', Order::STATUS_COMPLETED)->exists(); + if (!$paidOrderExists) { + return false; + } + } + + // 检查允许的套餐 + if (isset($conditions['allowed_plans']) && $user->plan_id) { + if (!in_array($user->plan_id, $conditions['allowed_plans'])) { + return false; + } + } + + // 检查是否需要邀请人 + if (isset($conditions['require_invite']) && $conditions['require_invite']) { + if (!$user->invite_user_id) { + return false; + } + } + + return true; + } + + /** + * 计算实际奖励 + */ + public function calculateActualRewards(User $user): array + { + $baseRewards = $this->rewards; + $actualRewards = $baseRewards; + + // 处理盲盒随机奖励 + if ($this->type === self::TYPE_MYSTERY && isset($this->rewards['random_rewards'])) { + $randomRewards = $this->rewards['random_rewards']; + $totalWeight = array_sum(array_column($randomRewards, 'weight')); + $random = mt_rand(1, $totalWeight); + $currentWeight = 0; + + foreach ($randomRewards as $reward) { + $currentWeight += $reward['weight']; + if ($random <= $currentWeight) { + $actualRewards = array_merge($actualRewards, $reward); + unset($actualRewards['weight']); + break; + } + } + } + + // 处理节日等特殊奖励(通用逻辑) + if (isset($this->special_config['festival_bonus'])) { + $now = time(); + $festivalConfig = $this->special_config; + + if (isset($festivalConfig['start_time']) && isset($festivalConfig['end_time'])) { + if ($now >= $festivalConfig['start_time'] && $now <= $festivalConfig['end_time']) { + $bonus = data_get($festivalConfig, 'festival_bonus', 1.0); + if ($bonus > 1.0) { + foreach ($actualRewards as $key => &$value) { + if (is_numeric($value)) { + $value = intval($value * $bonus); + } + } + unset($value); // 解除引用 + } + } + } + } + + return $actualRewards; + } + + /** + * 检查使用频率限制 + */ + public function checkUsageLimit(User $user): bool + { + $limits = $this->limits ?? []; + + // 检查每用户最大使用次数 + if (isset($limits['max_use_per_user'])) { + $usedCount = $this->usages() + ->where('user_id', $user->id) + ->count(); + if ($usedCount >= $limits['max_use_per_user']) { + return false; + } + } + + // 检查冷却时间 + if (isset($limits['cooldown_hours'])) { + $lastUsage = $this->usages() + ->where('user_id', $user->id) + ->orderBy('created_at', 'desc') + ->first(); + + if ($lastUsage && isset($lastUsage->created_at)) { + $cooldownTime = $lastUsage->created_at + ($limits['cooldown_hours'] * 3600); + if (time() < $cooldownTime) { + return false; + } + } + } + + return true; + } +} \ No newline at end of file diff --git a/Xboard/app/Models/GiftCardUsage.php b/Xboard/app/Models/GiftCardUsage.php new file mode 100644 index 0000000..69337b9 --- /dev/null +++ b/Xboard/app/Models/GiftCardUsage.php @@ -0,0 +1,112 @@ + 'timestamp', + 'rewards_given' => 'array', + 'invite_rewards' => 'array', + 'multiplier_applied' => 'float' + ]; + + /** + * 关联兑换码 + */ + public function code(): BelongsTo + { + return $this->belongsTo(GiftCardCode::class, 'code_id'); + } + + /** + * 关联模板 + */ + public function template(): BelongsTo + { + return $this->belongsTo(GiftCardTemplate::class, 'template_id'); + } + + /** + * 关联使用用户 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 关联邀请人 + */ + public function inviteUser(): BelongsTo + { + return $this->belongsTo(User::class, 'invite_user_id'); + } + + /** + * 创建使用记录 + */ + public static function createRecord( + GiftCardCode $code, + User $user, + array $rewards, + array $options = [] + ): self { + return self::create([ + 'code_id' => $code->id, + 'template_id' => $code->template_id, + 'user_id' => $user->id, + 'invite_user_id' => $user->invite_user_id, + 'rewards_given' => $rewards, + 'invite_rewards' => $options['invite_rewards'] ?? null, + 'user_level_at_use' => $user->plan ? $user->plan->sort : null, + 'plan_id_at_use' => $user->plan_id, + 'multiplier_applied' => $options['multiplier'] ?? 1.0, + // 'ip_address' => $options['ip_address'] ?? null, + 'user_agent' => $options['user_agent'] ?? null, + 'notes' => $options['notes'] ?? null, + 'created_at' => time(), + ]); + } +} \ No newline at end of file diff --git a/Xboard/app/Models/InviteCode.php b/Xboard/app/Models/InviteCode.php new file mode 100644 index 0000000..7d2716b --- /dev/null +++ b/Xboard/app/Models/InviteCode.php @@ -0,0 +1,19 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'status' => 'boolean', + ]; + + const STATUS_UNUSED = 0; + const STATUS_USED = 1; +} diff --git a/Xboard/app/Models/Knowledge.php b/Xboard/app/Models/Knowledge.php new file mode 100644 index 0000000..91e7736 --- /dev/null +++ b/Xboard/app/Models/Knowledge.php @@ -0,0 +1,17 @@ + 'boolean', + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + ]; +} diff --git a/Xboard/app/Models/MailLog.php b/Xboard/app/Models/MailLog.php new file mode 100644 index 0000000..0aef380 --- /dev/null +++ b/Xboard/app/Models/MailLog.php @@ -0,0 +1,16 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; +} diff --git a/Xboard/app/Models/Notice.php b/Xboard/app/Models/Notice.php new file mode 100644 index 0000000..3ae0cb0 --- /dev/null +++ b/Xboard/app/Models/Notice.php @@ -0,0 +1,18 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'tags' => 'array', + 'show' => 'boolean', + ]; +} diff --git a/Xboard/app/Models/Order.php b/Xboard/app/Models/Order.php new file mode 100644 index 0000000..dda0581 --- /dev/null +++ b/Xboard/app/Models/Order.php @@ -0,0 +1,120 @@ + $commission_log + */ +class Order extends Model +{ + protected $table = 'v2_order'; + protected $dateFormat = 'U'; + protected $guarded = ['id']; + protected $casts = [ + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + 'surplus_order_ids' => 'array', + 'handling_amount' => 'integer' + ]; + + const STATUS_PENDING = 0; // 待支付 + const STATUS_PROCESSING = 1; // 开通中 + const STATUS_CANCELLED = 2; // 已取消 + const STATUS_COMPLETED = 3; // 已完成 + const STATUS_DISCOUNTED = 4; // 已折抵 + + public static $statusMap = [ + self::STATUS_PENDING => '待支付', + self::STATUS_PROCESSING => '开通中', + self::STATUS_CANCELLED => '已取消', + self::STATUS_COMPLETED => '已完成', + self::STATUS_DISCOUNTED => '已折抵', + ]; + + const TYPE_NEW_PURCHASE = 1; // 新购 + const TYPE_RENEWAL = 2; // 续费 + const TYPE_UPGRADE = 3; // 升级 + const TYPE_RESET_TRAFFIC = 4; //流量重置包 + public static $typeMap = [ + self::TYPE_NEW_PURCHASE => '新购', + self::TYPE_RENEWAL => '续费', + self::TYPE_UPGRADE => '升级', + self::TYPE_RESET_TRAFFIC => '流量重置', + ]; + + /** + * 获取与订单关联的支付方式 + */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class, 'payment_id', 'id'); + } + + /** + * 获取与订单关联的用户 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } + + /** + * 获取邀请人 + */ + public function invite_user(): BelongsTo + { + return $this->belongsTo(User::class, 'invite_user_id', 'id'); + } + + /** + * 获取与订单关联的套餐 + */ + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class, 'plan_id', 'id'); + } + + /** + * 获取与订单关联的佣金记录 + */ + public function commission_log(): HasMany + { + return $this->hasMany(CommissionLog::class, 'trade_no', 'trade_no'); + } +} diff --git a/Xboard/app/Models/Payment.php b/Xboard/app/Models/Payment.php new file mode 100644 index 0000000..fec8b00 --- /dev/null +++ b/Xboard/app/Models/Payment.php @@ -0,0 +1,18 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'config' => 'array', + 'enable' => 'boolean' + ]; +} diff --git a/Xboard/app/Models/Plan.php b/Xboard/app/Models/Plan.php new file mode 100644 index 0000000..13d66f2 --- /dev/null +++ b/Xboard/app/Models/Plan.php @@ -0,0 +1,353 @@ + $order 关联的订单 + */ +class Plan extends Model +{ + use HasFactory; + + protected $table = 'v2_plan'; + protected $dateFormat = 'U'; + + // 定义流量重置方式 + public const RESET_TRAFFIC_FOLLOW_SYSTEM = null; // 跟随系统设置 + public const RESET_TRAFFIC_FIRST_DAY_MONTH = 0; // 每月1号 + public const RESET_TRAFFIC_MONTHLY = 1; // 按月重置 + public const RESET_TRAFFIC_NEVER = 2; // 不重置 + public const RESET_TRAFFIC_FIRST_DAY_YEAR = 3; // 每年1月1日 + public const RESET_TRAFFIC_YEARLY = 4; // 按年重置 + + // 定义价格类型 + public const PRICE_TYPE_RESET_TRAFFIC = 'reset_traffic'; // 重置流量价格 + + // 定义可用的订阅周期 + public const PERIOD_MONTHLY = 'monthly'; + public const PERIOD_QUARTERLY = 'quarterly'; + public const PERIOD_HALF_YEARLY = 'half_yearly'; + public const PERIOD_YEARLY = 'yearly'; + public const PERIOD_TWO_YEARLY = 'two_yearly'; + public const PERIOD_THREE_YEARLY = 'three_yearly'; + public const PERIOD_ONETIME = 'onetime'; + public const PERIOD_RESET_TRAFFIC = 'reset_traffic'; + + // 定义旧版周期映射 + public const LEGACY_PERIOD_MAPPING = [ + 'month_price' => self::PERIOD_MONTHLY, + 'quarter_price' => self::PERIOD_QUARTERLY, + 'half_year_price' => self::PERIOD_HALF_YEARLY, + 'year_price' => self::PERIOD_YEARLY, + 'two_year_price' => self::PERIOD_TWO_YEARLY, + 'three_year_price' => self::PERIOD_THREE_YEARLY, + 'onetime_price' => self::PERIOD_ONETIME, + 'reset_price' => self::PERIOD_RESET_TRAFFIC + ]; + + protected $fillable = [ + 'group_id', + 'transfer_enable', + 'name', + 'speed_limit', + 'show', + 'sort', + 'renew', + 'content', + 'prices', + 'reset_traffic_method', + 'capacity_limit', + 'sell', + 'device_limit', + 'tags' + ]; + + protected $casts = [ + 'show' => 'boolean', + 'renew' => 'boolean', + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + 'group_id' => 'integer', + 'prices' => 'array', + 'tags' => 'array', + 'reset_traffic_method' => 'integer', + ]; + + /** + * 获取所有可用的流量重置方式 + * + * @return array + */ + public static function getResetTrafficMethods(): array + { + return [ + self::RESET_TRAFFIC_FOLLOW_SYSTEM => '跟随系统设置', + self::RESET_TRAFFIC_FIRST_DAY_MONTH => '每月1号', + self::RESET_TRAFFIC_MONTHLY => '按月重置', + self::RESET_TRAFFIC_NEVER => '不重置', + self::RESET_TRAFFIC_FIRST_DAY_YEAR => '每年1月1日', + self::RESET_TRAFFIC_YEARLY => '按年重置', + ]; + } + + /** + * 获取所有可用的订阅周期 + * + * @return array + */ + public static function getAvailablePeriods(): array + { + return [ + self::PERIOD_MONTHLY => [ + 'name' => '月付', + 'days' => 30, + 'value' => 1 + ], + self::PERIOD_QUARTERLY => [ + 'name' => '季付', + 'days' => 90, + 'value' => 3 + ], + self::PERIOD_HALF_YEARLY => [ + 'name' => '半年付', + 'days' => 180, + 'value' => 6 + ], + self::PERIOD_YEARLY => [ + 'name' => '年付', + 'days' => 365, + 'value' => 12 + ], + self::PERIOD_TWO_YEARLY => [ + 'name' => '两年付', + 'days' => 730, + 'value' => 24 + ], + self::PERIOD_THREE_YEARLY => [ + 'name' => '三年付', + 'days' => 1095, + 'value' => 36 + ], + self::PERIOD_ONETIME => [ + 'name' => '一次性', + 'days' => -1, + 'value' => -1 + ], + self::PERIOD_RESET_TRAFFIC => [ + 'name' => '重置流量', + 'days' => -1, + 'value' => -1 + ], + ]; + } + + /** + * 获取指定周期的价格 + * + * @param string $period + * @return int|null + */ + public function getPriceByPeriod(string $period): ?int + { + return $this->prices[$period] ?? null; + } + + /** + * 获取所有已设置价格的周期 + * + * @return array + */ + public function getActivePeriods(): array + { + return array_filter( + self::getAvailablePeriods(), + fn($period) => isset($this->prices[$period]) + && $this->prices[$period] > 0, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * 设置指定周期的价格 + * + * @param string $period + * @param int $price + * @return void + * @throws InvalidArgumentException + */ + public function setPeriodPrice(string $period, int $price): void + { + if (!array_key_exists($period, self::getAvailablePeriods())) { + throw new InvalidArgumentException("Invalid period: {$period}"); + } + + $prices = $this->prices ?? []; + $prices[$period] = $price; + $this->prices = $prices; + } + + /** + * 移除指定周期的价格 + * + * @param string $period + * @return void + */ + public function removePeriodPrice(string $period): void + { + $prices = $this->prices ?? []; + unset($prices[$period]); + $this->prices = $prices; + } + + /** + * 获取所有价格及其对应的周期信息 + * + * @return array + */ + public function getPriceList(): array + { + $prices = $this->prices ?? []; + $periods = self::getAvailablePeriods(); + + $priceList = []; + foreach ($prices as $period => $price) { + if (isset($periods[$period]) && $price > 0) { + $priceList[$period] = [ + 'period' => $periods[$period], + 'price' => $price, + 'average_price' => $periods[$period]['value'] > 0 + ? round($price / $periods[$period]['value'], 2) + : $price + ]; + } + } + + return $priceList; + } + + /** + * 检查是否可以重置流量 + * + * @return bool + */ + public function canResetTraffic(): bool + { + return $this->reset_traffic_method !== self::RESET_TRAFFIC_NEVER + && $this->getResetTrafficPrice() > 0; + } + + /** + * 获取重置流量的价格 + * + * @return int + */ + public function getResetTrafficPrice(): int + { + return $this->prices[self::PRICE_TYPE_RESET_TRAFFIC] ?? 0; + } + + /** + * 计算指定周期的有效天数 + * + * @param string $period + * @return int -1表示永久有效 + * @throws InvalidArgumentException + */ + public static function getPeriodDays(string $period): int + { + $periods = self::getAvailablePeriods(); + if (!isset($periods[$period])) { + throw new InvalidArgumentException("Invalid period: {$period}"); + } + + return $periods[$period]['days']; + } + + /** + * 检查周期是否有效 + * + * @param string $period + * @return bool + */ + public static function isValidPeriod(string $period): bool + { + return array_key_exists($period, self::getAvailablePeriods()); + } + + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + public function group(): HasOne + { + return $this->hasOne(ServerGroup::class, 'id', 'group_id'); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * 设置流量重置方式 + * + * @param int $method + * @return void + * @throws InvalidArgumentException + */ + public function setResetTrafficMethod(int $method): void + { + if (!array_key_exists($method, self::getResetTrafficMethods())) { + throw new InvalidArgumentException("Invalid reset traffic method: {$method}"); + } + + $this->reset_traffic_method = $method; + } + + /** + * 设置重置流量价格 + * + * @param int $price + * @return void + */ + public function setResetTrafficPrice(int $price): void + { + $prices = $this->prices ?? []; + $prices[self::PRICE_TYPE_RESET_TRAFFIC] = max(0, $price); + $this->prices = $prices; + } + + public function order(): HasMany + { + return $this->hasMany(Order::class); + } +} \ No newline at end of file diff --git a/Xboard/app/Models/Plugin.php b/Xboard/app/Models/Plugin.php new file mode 100644 index 0000000..0425558 --- /dev/null +++ b/Xboard/app/Models/Plugin.php @@ -0,0 +1,77 @@ + 'boolean', + ]; + + public function scopeByType(Builder $query, string $type): Builder + { + return $query->where('type', $type); + } + + public function isFeaturePlugin(): bool + { + return $this->type === self::TYPE_FEATURE; + } + + public function isPaymentPlugin(): bool + { + return $this->type === self::TYPE_PAYMENT; + } + + public function isProtected(): bool + { + return in_array($this->code, self::PROTECTED_PLUGINS); + } + + public function canBeDeleted(): bool + { + return !$this->isProtected(); + } + +} diff --git a/Xboard/app/Models/Server.php b/Xboard/app/Models/Server.php new file mode 100644 index 0000000..19e2b5e --- /dev/null +++ b/Xboard/app/Models/Server.php @@ -0,0 +1,563 @@ + $stats 节点统计 + * + * @property-read int|null $last_check_at 最后检查时间(Unix时间戳) + * @property-read int|null $last_push_at 最后推送时间(Unix时间戳) + * @property-read int $online 在线用户数 + * @property-read int $online_conn 在线连接数 + * @property-read array|null $metrics 节点指标指标 + * @property-read int $is_online 是否在线(1在线 0离线) + * @property-read string $available_status 可用状态描述 + * @property-read string $cache_key 缓存键 + * @property string|null $ports 端口范围 + * @property string|null $password 密码 + * @property int|null $u 上行流量 + * @property int|null $d 下行流量 + * @property int|null $total 总流量 + * @property-read array|null $load_status 负载状态(包含CPU、内存、交换区、磁盘信息) + * + * @property int $transfer_enable 流量上限,0或者null表示不限制 + * @property int $u 当前上传流量 + * @property int $d 当前下载流量 + */ +class Server extends Model +{ + public const TYPE_HYSTERIA = 'hysteria'; + public const TYPE_VLESS = 'vless'; + public const TYPE_TROJAN = 'trojan'; + public const TYPE_VMESS = 'vmess'; + public const TYPE_TUIC = 'tuic'; + public const TYPE_SHADOWSOCKS = 'shadowsocks'; + public const TYPE_ANYTLS = 'anytls'; + public const TYPE_SOCKS = 'socks'; + public const TYPE_NAIVE = 'naive'; + public const TYPE_HTTP = 'http'; + public const TYPE_MIERU = 'mieru'; + public const STATUS_OFFLINE = 0; + public const STATUS_ONLINE_NO_PUSH = 1; + public const STATUS_ONLINE = 2; + + public const CHECK_INTERVAL = 300; // 5 minutes in seconds + + private const CIPHER_CONFIGURATIONS = [ + '2022-blake3-aes-128-gcm' => [ + 'serverKeySize' => 16, + 'userKeySize' => 16, + ], + '2022-blake3-aes-256-gcm' => [ + 'serverKeySize' => 32, + 'userKeySize' => 32, + ], + '2022-blake3-chacha20-poly1305' => [ + 'serverKeySize' => 32, + 'userKeySize' => 32, + ] + ]; + + public const TYPE_ALIASES = [ + 'v2ray' => self::TYPE_VMESS, + 'hysteria2' => self::TYPE_HYSTERIA, + ]; + + public const VALID_TYPES = [ + self::TYPE_HYSTERIA, + self::TYPE_VLESS, + self::TYPE_TROJAN, + self::TYPE_VMESS, + self::TYPE_TUIC, + self::TYPE_SHADOWSOCKS, + self::TYPE_ANYTLS, + self::TYPE_SOCKS, + self::TYPE_NAIVE, + self::TYPE_HTTP, + self::TYPE_MIERU, + ]; + + protected $table = 'v2_server'; + + protected $guarded = ['id']; + protected $casts = [ + 'group_ids' => 'array', + 'route_ids' => 'array', + 'tags' => 'array', + 'protocol_settings' => 'array', + 'custom_outbounds' => 'array', + 'custom_routes' => 'array', + 'cert_config' => 'array', + 'last_check_at' => 'integer', + 'last_push_at' => 'integer', + 'show' => 'boolean', + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + 'rate_time_ranges' => 'array', + 'rate_time_enable' => 'boolean', + 'transfer_enable' => 'integer', + 'u' => 'integer', + 'd' => 'integer', + ]; + + private const MULTIPLEX_CONFIGURATION = [ + 'multiplex' => [ + 'type' => 'object', + 'fields' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'protocol' => ['type' => 'string', 'default' => 'yamux'], + 'max_connections' => ['type' => 'integer', 'default' => null], + // 'min_streams' => ['type' => 'integer', 'default' => null], + // 'max_streams' => ['type' => 'integer', 'default' => null], + 'padding' => ['type' => 'boolean', 'default' => false], + 'brutal' => [ + 'type' => 'object', + 'fields' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'up_mbps' => ['type' => 'integer', 'default' => null], + 'down_mbps' => ['type' => 'integer', 'default' => null], + ] + ] + ] + ] + ]; + + private const REALITY_CONFIGURATION = [ + 'reality_settings' => [ + 'type' => 'object', + 'fields' => [ + 'server_name' => ['type' => 'string', 'default' => null], + 'server_port' => ['type' => 'string', 'default' => null], + 'public_key' => ['type' => 'string', 'default' => null], + 'private_key' => ['type' => 'string', 'default' => null], + 'short_id' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false], + ] + ] + ]; + + private const UTLS_CONFIGURATION = [ + 'utls' => [ + 'type' => 'object', + 'fields' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'fingerprint' => ['type' => 'string', 'default' => 'chrome'], + ] + ] + ]; + + private const PROTOCOL_CONFIGURATIONS = [ + self::TYPE_TROJAN => [ + 'tls' => ['type' => 'integer', 'default' => 1], + 'network' => ['type' => 'string', 'default' => null], + 'network_settings' => ['type' => 'array', 'default' => null], + 'server_name' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false], + ...self::REALITY_CONFIGURATION, + ...self::MULTIPLEX_CONFIGURATION, + ...self::UTLS_CONFIGURATION + ], + self::TYPE_VMESS => [ + 'tls' => ['type' => 'integer', 'default' => 0], + 'network' => ['type' => 'string', 'default' => null], + 'rules' => ['type' => 'array', 'default' => null], + 'network_settings' => ['type' => 'array', 'default' => null], + 'tls_settings' => ['type' => 'array', 'default' => null], + ...self::MULTIPLEX_CONFIGURATION, + ...self::UTLS_CONFIGURATION + ], + self::TYPE_VLESS => [ + 'tls' => ['type' => 'integer', 'default' => 0], + 'tls_settings' => ['type' => 'array', 'default' => null], + 'flow' => ['type' => 'string', 'default' => null], + 'encryption' => [ + 'type' => 'object', + 'default' => null, + 'fields' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'encryption' => ['type' => 'string', 'default' => null], // 客户端公钥 + 'decryption' => ['type' => 'string', 'default' => null], // 服务端私钥 + ] + ], + 'network' => ['type' => 'string', 'default' => null], + 'network_settings' => ['type' => 'array', 'default' => null], + ...self::REALITY_CONFIGURATION, + ...self::MULTIPLEX_CONFIGURATION, + ...self::UTLS_CONFIGURATION + ], + self::TYPE_SHADOWSOCKS => [ + 'cipher' => ['type' => 'string', 'default' => null], + 'obfs' => ['type' => 'string', 'default' => null], + 'obfs_settings' => ['type' => 'array', 'default' => null], + 'plugin' => ['type' => 'string', 'default' => null], + 'plugin_opts' => ['type' => 'string', 'default' => null] + ], + self::TYPE_HYSTERIA => [ + 'version' => ['type' => 'integer', 'default' => 2], + 'bandwidth' => [ + 'type' => 'object', + 'fields' => [ + 'up' => ['type' => 'integer', 'default' => null], + 'down' => ['type' => 'integer', 'default' => null] + ] + ], + 'obfs' => [ + 'type' => 'object', + 'fields' => [ + 'open' => ['type' => 'boolean', 'default' => false], + 'type' => ['type' => 'string', 'default' => 'salamander'], + 'password' => ['type' => 'string', 'default' => null] + ] + ], + 'tls' => [ + 'type' => 'object', + 'fields' => [ + 'server_name' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false] + ] + ], + 'hop_interval' => ['type' => 'integer', 'default' => null] + ], + self::TYPE_TUIC => [ + 'version' => ['type' => 'integer', 'default' => 5], + 'congestion_control' => ['type' => 'string', 'default' => 'cubic'], + 'alpn' => ['type' => 'array', 'default' => ['h3']], + 'udp_relay_mode' => ['type' => 'string', 'default' => 'native'], + 'tls' => [ + 'type' => 'object', + 'fields' => [ + 'server_name' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false] + ] + ] + ], + self::TYPE_ANYTLS => [ + 'padding_scheme' => [ + 'type' => 'array', + 'default' => [ + "stop=8", + "0=30-30", + "1=100-400", + "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000", + "3=9-9,500-1000", + "4=500-1000", + "5=500-1000", + "6=500-1000", + "7=500-1000" + ] + ], + 'tls' => [ + 'type' => 'object', + 'fields' => [ + 'server_name' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false] + ] + ] + ], + self::TYPE_SOCKS => [ + 'tls' => ['type' => 'integer', 'default' => 0], + 'tls_settings' => [ + 'type' => 'object', + 'fields' => [ + 'allow_insecure' => ['type' => 'boolean', 'default' => false] + ] + ] + ], + self::TYPE_NAIVE => [ + 'tls' => ['type' => 'integer', 'default' => 0], + 'tls_settings' => ['type' => 'array', 'default' => null] + ], + self::TYPE_HTTP => [ + 'tls' => ['type' => 'integer', 'default' => 0], + 'tls_settings' => [ + 'type' => 'object', + 'fields' => [ + 'allow_insecure' => ['type' => 'boolean', 'default' => false], + 'server_name' => ['type' => 'string', 'default' => null] + ] + ] + ], + self::TYPE_MIERU => [ + 'transport' => ['type' => 'string', 'default' => 'TCP'], + 'traffic_pattern' => ['type' => 'string', 'default' => ''], + ...self::MULTIPLEX_CONFIGURATION, + ] + ]; + + private function castValueWithConfig($value, array $config) + { + if ($value === null && $config['type'] !== 'object') { + return $config['default'] ?? null; + } + + return match ($config['type']) { + 'integer' => (int) $value, + 'boolean' => (bool) $value, + 'string' => (string) $value, + 'array' => (array) $value, + 'object' => is_array($value) ? + $this->castSettingsWithConfig($value, $config['fields']) : + $config['default'] ?? null, + default => $value + }; + } + + private function castSettingsWithConfig(array $settings, array $configs): array + { + $result = []; + foreach ($configs as $key => $config) { + $value = $settings[$key] ?? null; + $result[$key] = $this->castValueWithConfig($value, $config); + } + return $result; + } + + public function getProtocolSettingsAttribute($value) + { + $settings = json_decode($value, true) ?? []; + $configs = self::PROTOCOL_CONFIGURATIONS[$this->type] ?? []; + return $this->castSettingsWithConfig($settings, $configs); + } + + public function setProtocolSettingsAttribute($value) + { + if (is_string($value)) { + $value = json_decode($value, true); + } + + $configs = self::PROTOCOL_CONFIGURATIONS[$this->type] ?? []; + $castedSettings = $this->castSettingsWithConfig($value ?? [], $configs); + + $this->attributes['protocol_settings'] = json_encode($castedSettings); + } + + public function generateServerPassword(User $user): string + { + if ($this->type !== self::TYPE_SHADOWSOCKS) { + return $user->uuid; + } + + + $cipher = data_get($this, 'protocol_settings.cipher'); + if (!$cipher || !isset(self::CIPHER_CONFIGURATIONS[$cipher])) { + return $user->uuid; + } + + $config = self::CIPHER_CONFIGURATIONS[$cipher]; + // Use parent's created_at if this is a child node + $serverCreatedAt = $this->parent_id ? $this->parent->created_at : $this->created_at; + $serverKey = Helper::getServerKey($serverCreatedAt, $config['serverKeySize']); + $userKey = Helper::uuidToBase64($user->uuid, $config['userKeySize']); + return "{$serverKey}:{$userKey}"; + } + + public static function normalizeType(?string $type): string | null + { + return $type ? strtolower(self::TYPE_ALIASES[$type] ?? $type) : null; + } + + public static function isValidType(?string $type): bool + { + return $type ? in_array(self::normalizeType($type), self::VALID_TYPES, true) : true; + } + + public function getAvailableStatusAttribute(): int + { + $now = time(); + if (!$this->last_check_at || ($now - self::CHECK_INTERVAL) >= $this->last_check_at) { + return self::STATUS_OFFLINE; + } + if (!$this->last_push_at || ($now - self::CHECK_INTERVAL) >= $this->last_push_at) { + return self::STATUS_ONLINE_NO_PUSH; + } + return self::STATUS_ONLINE; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id', 'id'); + } + + public function stats(): HasMany + { + return $this->hasMany(StatServer::class, 'server_id', 'id'); + } + + public function groups() + { + return ServerGroup::whereIn('id', $this->group_ids)->get(); + } + + public function routes() + { + return ServerRoute::whereIn('id', $this->route_ids)->get(); + } + + /** + * 最后检查时间访问器 + */ + protected function lastCheckAt(): Attribute + { + return Attribute::make( + get: function () { + $type = strtoupper($this->type); + $serverId = $this->parent_id ?: $this->id; + return Cache::get(CacheKey::get("SERVER_{$type}_LAST_CHECK_AT", $serverId)); + } + ); + } + + /** + * 最后推送时间访问器 + */ + protected function lastPushAt(): Attribute + { + return Attribute::make( + get: function () { + $type = strtoupper($this->type); + $serverId = $this->parent_id ?: $this->id; + return Cache::get(CacheKey::get("SERVER_{$type}_LAST_PUSH_AT", $serverId)); + } + ); + } + + /** + * 在线用户数访问器 + */ + protected function online(): Attribute + { + return Attribute::make( + get: function () { + $type = strtoupper($this->type); + $serverId = $this->parent_id ?: $this->id; + return Cache::get(CacheKey::get("SERVER_{$type}_ONLINE_USER", $serverId)) ?? 0; + } + ); + } + + /** + * 是否在线访问器 + */ + protected function isOnline(): Attribute + { + return Attribute::make( + get: function () { + return (time() - 300 > $this->last_check_at) ? 0 : 1; + } + ); + } + + /** + * 缓存键访问器 + */ + protected function cacheKey(): Attribute + { + return Attribute::make( + get: function () { + return "{$this->type}-{$this->id}-{$this->updated_at}-{$this->is_online}"; + } + ); + } + + /** + * 服务器密钥访问器 + */ + protected function serverKey(): Attribute + { + return Attribute::make( + get: function () { + if ($this->type === self::TYPE_SHADOWSOCKS) { + return Helper::getServerKey($this->created_at, 16); + } + return null; + } + ); + } + + /** + * 指标指标访问器 + */ + protected function metrics(): Attribute + { + return Attribute::make( + get: function () { + $type = strtoupper($this->type); + $serverId = $this->parent_id ?: $this->id; + return Cache::get(CacheKey::get("SERVER_{$type}_METRICS", $serverId)); + } + ); + } + + /** + * 在线连接数访问器 + */ + protected function onlineConn(): Attribute + { + return Attribute::make( + get: function () { + return $this->metrics['active_connections'] ?? 0; + } + ); + } + + /** + * 负载状态访问器 + */ + protected function loadStatus(): Attribute + { + return Attribute::make( + get: function () { + $type = strtoupper($this->type); + $serverId = $this->parent_id ?: $this->id; + return Cache::get(CacheKey::get("SERVER_{$type}_LOAD_STATUS", $serverId)); + } + ); + } + + public function getCurrentRate(): float + { + if (!$this->rate_time_enable) { + return (float) $this->rate; + } + + $now = now()->format('H:i'); + $ranges = $this->rate_time_ranges ?? []; + $matchedRange = collect($ranges) + ->first(fn($range) => $now >= $range['start'] && $now <= $range['end']); + + return $matchedRange ? (float) $matchedRange['rate'] : (float) $this->rate; + } +} diff --git a/Xboard/app/Models/ServerGroup.php b/Xboard/app/Models/ServerGroup.php new file mode 100644 index 0000000..57f3514 --- /dev/null +++ b/Xboard/app/Models/ServerGroup.php @@ -0,0 +1,46 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; + + public function users(): HasMany + { + return $this->hasMany(User::class, 'group_id', 'id'); + } + + public function servers() + { + return Server::whereJsonContains('group_ids', (string) $this->id)->get(); + } + + /** + * 获取服务器数量 + */ + protected function serverCount(): Attribute + { + return Attribute::make( + get: fn () => Server::whereJsonContains('group_ids', (string) $this->id)->count(), + ); + } +} diff --git a/Xboard/app/Models/ServerLog.php b/Xboard/app/Models/ServerLog.php new file mode 100644 index 0000000..ef3590c --- /dev/null +++ b/Xboard/app/Models/ServerLog.php @@ -0,0 +1,16 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; +} diff --git a/Xboard/app/Models/ServerRoute.php b/Xboard/app/Models/ServerRoute.php new file mode 100644 index 0000000..024a7e4 --- /dev/null +++ b/Xboard/app/Models/ServerRoute.php @@ -0,0 +1,17 @@ + 'timestamp', + 'updated_at' => 'timestamp', + 'match' => 'array' + ]; +} diff --git a/Xboard/app/Models/ServerStat.php b/Xboard/app/Models/ServerStat.php new file mode 100644 index 0000000..5006ded --- /dev/null +++ b/Xboard/app/Models/ServerStat.php @@ -0,0 +1,16 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; +} diff --git a/Xboard/app/Models/Setting.php b/Xboard/app/Models/Setting.php new file mode 100644 index 0000000..b24a471 --- /dev/null +++ b/Xboard/app/Models/Setting.php @@ -0,0 +1,68 @@ + 'string', + 'value' => 'string', + ]; + + /** + * 获取实际内容值 + */ + public function getContentValue() + { + $rawValue = $this->attributes['value'] ?? null; + + if ($rawValue === null) { + return null; + } + + // 如果已经是数组,直接返回 + if (is_array($rawValue)) { + return $rawValue; + } + + // 如果是数字字符串,返回原值 + if (is_numeric($rawValue) && !preg_match('/[^\d.]/', $rawValue)) { + return $rawValue; + } + + // 尝试解析 JSON + if (is_string($rawValue)) { + $decodedValue = json_decode($rawValue, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decodedValue; + } + } + + return $rawValue; + } + + /** + * 兼容性:保持原有的 value 访问器 + */ + public function getValueAttribute($value) + { + return $this->getContentValue(); + } + + /** + * 创建或更新设置项 + */ + public static function createOrUpdate(string $name, $value): self + { + $processedValue = is_array($value) ? json_encode($value) : $value; + + return self::updateOrCreate( + ['name' => $name], + ['value' => $processedValue] + ); + } +} diff --git a/Xboard/app/Models/Stat.php b/Xboard/app/Models/Stat.php new file mode 100644 index 0000000..b0ed9ec --- /dev/null +++ b/Xboard/app/Models/Stat.php @@ -0,0 +1,16 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; +} diff --git a/Xboard/app/Models/StatServer.php b/Xboard/app/Models/StatServer.php new file mode 100644 index 0000000..efa1fc6 --- /dev/null +++ b/Xboard/app/Models/StatServer.php @@ -0,0 +1,33 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; + + public function server() + { + return $this->belongsTo(Server::class, 'server_id'); + } +} diff --git a/Xboard/app/Models/StatUser.php b/Xboard/app/Models/StatUser.php new file mode 100644 index 0000000..a956bd7 --- /dev/null +++ b/Xboard/app/Models/StatUser.php @@ -0,0 +1,28 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; +} diff --git a/Xboard/app/Models/SubscribeTemplate.php b/Xboard/app/Models/SubscribeTemplate.php new file mode 100644 index 0000000..fec76e0 --- /dev/null +++ b/Xboard/app/Models/SubscribeTemplate.php @@ -0,0 +1,46 @@ + 'string', + 'content' => 'string', + ]; + + private static string $cachePrefix = 'subscribe_template:'; + + public static function getContent(string $name): ?string + { + $cacheKey = self::$cachePrefix . $name; + + return Cache::store('redis')->remember($cacheKey, 3600, function () use ($name) { + return self::where('name', $name)->value('content'); + }); + } + + public static function setContent(string $name, ?string $content): void + { + self::updateOrCreate( + ['name' => $name], + ['content' => $content] + ); + Cache::store('redis')->forget(self::$cachePrefix . $name); + } + + public static function getAllContents(): array + { + return self::pluck('content', 'name')->toArray(); + } + + public static function flushCache(string $name): void + { + Cache::store('redis')->forget(self::$cachePrefix . $name); + } +} diff --git a/Xboard/app/Models/Ticket.php b/Xboard/app/Models/Ticket.php new file mode 100644 index 0000000..86ba9b6 --- /dev/null +++ b/Xboard/app/Models/Ticket.php @@ -0,0 +1,60 @@ + $messages 关联的工单消息 + */ +class Ticket extends Model +{ + protected $table = 'v2_ticket'; + protected $dateFormat = 'U'; + protected $guarded = ['id']; + protected $casts = [ + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp' + ]; + + const STATUS_OPENING = 0; + const STATUS_CLOSED = 1; + public static $statusMap = [ + self::STATUS_OPENING => '开启', + self::STATUS_CLOSED => '关闭' + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } + + /** + * 关联的工单消息 + */ + public function messages(): HasMany + { + return $this->hasMany(TicketMessage::class, 'ticket_id', 'id'); + } + + // 即将删除 + public function message(): HasMany + { + return $this->hasMany(TicketMessage::class, 'ticket_id', 'id'); + } +} diff --git a/Xboard/app/Models/TicketMessage.php b/Xboard/app/Models/TicketMessage.php new file mode 100644 index 0000000..c9d45a8 --- /dev/null +++ b/Xboard/app/Models/TicketMessage.php @@ -0,0 +1,56 @@ + 'timestamp', + 'updated_at' => 'timestamp' + ]; + + protected $appends = ['is_from_user', 'is_from_admin']; + + /** + * 关联的工单 + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id', 'id'); + } + + /** + * 判断消息是否由工单发起人发送 + */ + public function getIsFromUserAttribute(): bool + { + return $this->ticket->user_id === $this->user_id; + } + + /** + * 判断消息是否由管理员发送 + */ + public function getIsFromAdminAttribute(): bool + { + return $this->ticket->user_id !== $this->user_id; + } +} diff --git a/Xboard/app/Models/TrafficResetLog.php b/Xboard/app/Models/TrafficResetLog.php new file mode 100644 index 0000000..3b05a84 --- /dev/null +++ b/Xboard/app/Models/TrafficResetLog.php @@ -0,0 +1,149 @@ + '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 const SOURCE_ORDER = 'order'; + public const SOURCE_GIFT_CARD = 'gift_card'; + + /** + * 获取重置类型的多语言名称 + */ + 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]; + } +} \ No newline at end of file diff --git a/Xboard/app/Models/User.php b/Xboard/app/Models/User.php new file mode 100644 index 0000000..a826e4a --- /dev/null +++ b/Xboard/app/Models/User.php @@ -0,0 +1,215 @@ + $codes 邀请码列表 + * @property-read \Illuminate\Database\Eloquent\Collection $orders 订单列表 + * @property-read \Illuminate\Database\Eloquent\Collection $stat 统计信息 + * @property-read \Illuminate\Database\Eloquent\Collection $tickets 工单列表 + * @property-read \Illuminate\Database\Eloquent\Collection $trafficResetLogs 流量重置记录 + * @property-read User|null $parent 父账户 + * @property-read string $subscribe_url 订阅链接(动态生成) + */ +class User extends Authenticatable +{ + use HasApiTokens; + protected $table = 'v2_user'; + protected $dateFormat = 'U'; + protected $guarded = ['id']; + protected $casts = [ + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + 'banned' => 'boolean', + 'is_admin' => 'boolean', + 'is_staff' => 'boolean', + 'remind_expire' => 'boolean', + 'remind_traffic' => 'boolean', + 'commission_auto_check' => 'boolean', + 'commission_rate' => 'float', + 'next_reset_at' => 'timestamp', + 'last_reset_at' => 'timestamp', + ]; + protected $hidden = ['password']; + + public const COMMISSION_TYPE_SYSTEM = 0; + public const COMMISSION_TYPE_PERIOD = 1; + public const COMMISSION_TYPE_ONETIME = 2; + protected function email(): Attribute + { + return Attribute::make( + set: fn (string $value) => strtolower(trim($value)), + ); + } + + /** + * 按邮箱查询(大小写不敏感,兼容所有数据库) + */ + public function scopeByEmail(Builder $query, string $email): Builder + { + return $query->where('email', strtolower(trim($email))); + } + + // 获取邀请人信息 + public function invite_user(): BelongsTo + { + return $this->belongsTo(self::class, 'invite_user_id', 'id'); + } + + /** + * 获取用户订阅计划 + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class, 'plan_id', 'id'); + } + + public function group(): BelongsTo + { + return $this->belongsTo(ServerGroup::class, 'group_id', 'id'); + } + + // 获取用户邀请码列表 + public function codes(): HasMany + { + return $this->hasMany(InviteCode::class, 'user_id', 'id'); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class, 'user_id', 'id'); + } + + public function stat(): HasMany + { + return $this->hasMany(StatUser::class, 'user_id', 'id'); + } + + // 关联工单列表 + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'user_id', 'id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id', 'id'); + } + + /** + * 关联流量重置记录 + */ + public function trafficResetLogs(): HasMany + { + return $this->hasMany(TrafficResetLog::class, 'user_id', 'id'); + } + + /** + * 检查用户是否处于活跃状态 + */ + public function isActive(): bool + { + return !$this->banned && + ($this->expired_at === null || $this->expired_at > time()) && + $this->plan_id !== null; + } + + /** + * 检查用户是否可用节点流量且充足 + */ + public function isAvailable(): bool + { + return $this->isActive() && $this->getRemainingTraffic() > 0; + } + + /** + * 检查是否需要重置流量 + */ + 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); + } +} diff --git a/Xboard/app/Observers/PlanObserver.php b/Xboard/app/Observers/PlanObserver.php new file mode 100644 index 0000000..19dcef4 --- /dev/null +++ b/Xboard/app/Observers/PlanObserver.php @@ -0,0 +1,35 @@ +isDirty('reset_traffic_method')) { + return; + } + $trafficResetService = app(TrafficResetService::class); + User::where('plan_id', $plan->id) + ->where('banned', 0) + ->where(function ($query) { + $query->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }) + ->lazyById(500) + ->each(function (User $user) use ($trafficResetService) { + $nextResetTime = $trafficResetService->calculateNextResetTime($user); + $user->update([ + 'next_reset_at' => $nextResetTime?->timestamp, + ]); + }); + } +} + diff --git a/Xboard/app/Observers/ServerObserver.php b/Xboard/app/Observers/ServerObserver.php new file mode 100644 index 0000000..0f1ce9d --- /dev/null +++ b/Xboard/app/Observers/ServerObserver.php @@ -0,0 +1,37 @@ +isDirty([ + 'group_ids', + ]) + ) { + NodeSyncService::notifyUsersUpdatedByGroup($server->id); + } else if ( + $server->isDirty([ + 'server_port', + 'protocol_settings', + 'type', + 'route_ids', + 'custom_outbounds', + 'custom_routes', + 'cert_config', + ]) + ) { + NodeSyncService::notifyConfigUpdated($server->id); + } + } + + public function deleted(Server $server): void + { + NodeSyncService::notifyConfigUpdated($server->id); + } +} diff --git a/Xboard/app/Observers/ServerRouteObserver.php b/Xboard/app/Observers/ServerRouteObserver.php new file mode 100644 index 0000000..f8457d7 --- /dev/null +++ b/Xboard/app/Observers/ServerRouteObserver.php @@ -0,0 +1,31 @@ +notifyAffectedNodes($route->id); + } + + public function deleted(ServerRoute $route): void + { + $this->notifyAffectedNodes($route->id); + } + + private function notifyAffectedNodes(int $routeId): void + { + $servers = Server::where('show', 1)->get()->filter( + fn ($s) => in_array($routeId, $s->route_ids ?? []) + ); + + foreach ($servers as $server) { + NodeSyncService::notifyConfigUpdated($server->id); + } + } +} diff --git a/Xboard/app/Observers/UserObserver.php b/Xboard/app/Observers/UserObserver.php new file mode 100644 index 0000000..ffa74d0 --- /dev/null +++ b/Xboard/app/Observers/UserObserver.php @@ -0,0 +1,53 @@ +isDirty(['plan_id', 'expired_at'])) { + $this->recalculateNextResetAt($user); + } + + if ($user->isDirty(['group_id', 'uuid', 'speed_limit', 'device_limit', 'banned', 'expired_at', 'transfer_enable', 'u', 'd', 'plan_id'])) { + $oldGroupId = $user->isDirty('group_id') ? $user->getOriginal('group_id') : null; + NodeUserSyncJob::dispatch($user->id, 'updated', $oldGroupId); + } + } + + public function created(User $user): void + { + $this->recalculateNextResetAt($user); + NodeUserSyncJob::dispatch($user->id, 'created'); + } + + public function deleted(User $user): void + { + if ($user->group_id) { + NodeUserSyncJob::dispatch($user->id, 'deleted', $user->group_id); + } + } + + /** + * 根据当前用户状态重新计算 next_reset_at + */ + private function recalculateNextResetAt(User $user): void + { + $user->refresh(); + User::withoutEvents(function () use ($user) { + $nextResetTime = $this->trafficResetService->calculateNextResetTime($user); + $user->next_reset_at = $nextResetTime?->timestamp; + $user->save(); + }); + } +} \ No newline at end of file diff --git a/Xboard/app/Protocols/Clash.php b/Xboard/app/Protocols/Clash.php new file mode 100644 index 0000000..f84d99f --- /dev/null +++ b/Xboard/app/Protocols/Clash.php @@ -0,0 +1,332 @@ +servers; + $user = $this->user; + $appName = admin_setting('app_name', 'XBoard'); + + // 优先从数据库配置中获取模板 + $template = subscribe_template('clash'); + + $config = Yaml::parse($template); + $proxy = []; + $proxies = []; + + foreach ($servers as $item) { + + if ( + $item['type'] === Server::TYPE_SHADOWSOCKS + && in_array(data_get($item['protocol_settings'], 'cipher'), [ + 'aes-128-gcm', + 'aes-192-gcm', + 'aes-256-gcm', + 'chacha20-ietf-poly1305' + ]) + ) { + array_push($proxy, self::buildShadowsocks($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_VMESS) { + array_push($proxy, self::buildVmess($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_TROJAN) { + array_push($proxy, self::buildTrojan($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_SOCKS) { + array_push($proxy, self::buildSocks5($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_HTTP) { + array_push($proxy, self::buildHttp($item['password'], $item)); + array_push($proxies, $item['name']); + } + } + + $config['proxies'] = array_merge($config['proxies'] ? $config['proxies'] : [], $proxy); + foreach ($config['proxy-groups'] as $k => $v) { + if (!is_array($config['proxy-groups'][$k]['proxies'])) + $config['proxy-groups'][$k]['proxies'] = []; + $isFilter = false; + foreach ($config['proxy-groups'][$k]['proxies'] as $src) { + foreach ($proxies as $dst) { + if (!$this->isRegex($src)) + continue; + $isFilter = true; + $config['proxy-groups'][$k]['proxies'] = array_values(array_diff($config['proxy-groups'][$k]['proxies'], [$src])); + if ($this->isMatch($src, $dst)) { + array_push($config['proxy-groups'][$k]['proxies'], $dst); + } + } + if ($isFilter) + continue; + } + if ($isFilter) + continue; + $config['proxy-groups'][$k]['proxies'] = array_merge($config['proxy-groups'][$k]['proxies'], $proxies); + } + + $config['proxy-groups'] = array_filter($config['proxy-groups'], function ($group) { + return $group['proxies']; + }); + $config['proxy-groups'] = array_values($config['proxy-groups']); + + $config = $this->buildRules($config); + + + $yaml = Yaml::dump($config, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $yaml = str_replace('$app_name', admin_setting('app_name', 'XBoard'), $yaml); + return response($yaml) + ->header('content-type', 'text/yaml') + ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}") + ->header('profile-update-interval', '24') + ->header('content-disposition', 'attachment;filename*=UTF-8\'\'' . rawurlencode($appName)) + ->header('profile-web-page-url', admin_setting('app_url')); + } + + /** + * Build the rules for Clash. + */ + public function buildRules($config) + { + // Force the current subscription domain to be a direct rule + $subsDomain = request()->header('Host'); + if ($subsDomain) { + array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); + } + + return $config; + } + + public static function buildShadowsocks($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'ss'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['cipher'] = data_get($protocol_settings, 'cipher'); + $array['password'] = $uuid; + $array['udp'] = true; + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + $array['plugin'] = $plugin; + + // 解析插件选项 + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return []; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + + // 根据插件类型进行字段映射 + switch ($plugin) { + case 'obfs': + $array['plugin-opts'] = [ + 'mode' => $parsedOpts['obfs'] ?? data_get($protocol_settings, 'obfs', 'http'), + 'host' => $parsedOpts['obfs-host'] ?? data_get($protocol_settings, 'obfs_settings.host', ''), + ]; + + if (isset($parsedOpts['path'])) { + $array['plugin-opts']['path'] = $parsedOpts['path']; + } + break; + case 'v2ray-plugin': + $array['plugin-opts'] = [ + 'mode' => $parsedOpts['mode'] ?? 'websocket', + 'tls' => isset($parsedOpts['tls']) && $parsedOpts['tls'] == 'true', + 'host' => $parsedOpts['host'] ?? '', + 'path' => $parsedOpts['path'] ?? '/', + ]; + break; + default: + // 对于其他插件,直接使用解析出的键值对 + $array['plugin-opts'] = $parsedOpts; + } + } + return $array; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'vmess'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['uuid'] = $uuid; + $array['alterId'] = 0; + $array['cipher'] = 'auto'; + $array['udp'] = true; + + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['servername'] = $serverName; + } + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'none'); + $array['network'] = ($headerType === 'http') ? 'http' : 'tcp'; + if ($headerType === 'http') { + if ($httpOpts = array_filter([ + 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), + 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) + ])) { + $array['http-opts'] = $httpOpts; + } + } + break; + case 'ws': + $array['network'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + case 'grpc': + $array['network'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['grpc-opts']['grpc-service-name'] = $serviceName; + break; + default: + break; + } + return $array; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'trojan'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['password'] = $password; + $array['udp'] = true; + if ($serverName = data_get($protocol_settings, 'server_name')) { + $array['sni'] = $serverName; + } + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure'); + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $array['network'] = 'tcp'; + break; + case 'ws': + $array['network'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + case 'grpc': + $array['network'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['grpc-opts']['grpc-service-name'] = $serviceName; + break; + default: + $array['network'] = 'tcp'; + break; + } + return $array; + } + + public static function buildSocks5($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'socks5'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['udp'] = true; + + $array['username'] = $password; + $array['password'] = $password; + + // TLS 配置 + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + } + + return $array; + } + + public static function buildHttp($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'http'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + + $array['username'] = $password; + $array['password'] = $password; + + // TLS 配置 + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + } + + return $array; + } + + private function isMatch($exp, $str) + { + try { + return preg_match($exp, $str) === 1; + } catch (\Exception $e) { + return false; + } + } + + private function isRegex($exp) + { + if (empty($exp)) { + return false; + } + try { + return preg_match($exp, '') !== false; + } catch (\Exception $e) { + return false; + } + } +} diff --git a/Xboard/app/Protocols/ClashMeta.php b/Xboard/app/Protocols/ClashMeta.php new file mode 100644 index 0000000..feda7d0 --- /dev/null +++ b/Xboard/app/Protocols/ClashMeta.php @@ -0,0 +1,708 @@ + [ + 'whitelist' => [ + 'tcp' => '0.0.0', + 'ws' => '0.0.0', + 'grpc' => '0.0.0', + 'http' => '0.0.0', + 'h2' => '0.0.0', + 'httpupgrade' => '0.0.0', + ], + 'strict' => true, + ], + 'nekobox.hysteria.protocol_settings.version' => [ + 1 => '0.0.0', + 2 => '1.2.7', + ], + 'clashmetaforandroid.hysteria.protocol_settings.version' => [ + 2 => '2.9.0', + ], + 'nekoray.hysteria.protocol_settings.version' => [ + 2 => '3.24', + ], + 'verge.hysteria.protocol_settings.version' => [ + 2 => '1.3.8', + ], + 'ClashX Meta.hysteria.protocol_settings.version' => [ + 2 => '1.3.5', + ], + 'flclash.hysteria.protocol_settings.version' => [ + 2 => '0.8.0', + ], + ]; + + public function handle() + { + $servers = $this->servers; + $user = $this->user; + $appName = admin_setting('app_name', 'XBoard'); + + $template = subscribe_template('clashmeta'); + + $config = Yaml::parse($template); + $proxy = []; + $proxies = []; + + foreach ($servers as $item) { + if ($item['type'] === Server::TYPE_SHADOWSOCKS) { + array_push($proxy, self::buildShadowsocks($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_VMESS) { + array_push($proxy, self::buildVmess($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_TROJAN) { + array_push($proxy, self::buildTrojan($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_VLESS) { + array_push($proxy, self::buildVless($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_HYSTERIA) { + array_push($proxy, self::buildHysteria($item['password'], $item, $user)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_TUIC) { + array_push($proxy, self::buildTuic($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_ANYTLS) { + array_push($proxy, self::buildAnyTLS($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_SOCKS) { + array_push($proxy, self::buildSocks5($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_HTTP) { + array_push($proxy, self::buildHttp($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_MIERU) { + array_push($proxy, self::buildMieru($item['password'], $item)); + array_push($proxies, $item['name']); + } + } + + $config['proxies'] = array_merge($config['proxies'] ? $config['proxies'] : [], $proxy); + foreach ($config['proxy-groups'] as $k => $v) { + if (!is_array($config['proxy-groups'][$k]['proxies'])) + $config['proxy-groups'][$k]['proxies'] = []; + $isFilter = false; + foreach ($config['proxy-groups'][$k]['proxies'] as $src) { + foreach ($proxies as $dst) { + if (!$this->isRegex($src)) + continue; + $isFilter = true; + $config['proxy-groups'][$k]['proxies'] = array_values(array_diff($config['proxy-groups'][$k]['proxies'], [$src])); + if ($this->isMatch($src, $dst)) { + array_push($config['proxy-groups'][$k]['proxies'], $dst); + } + } + if ($isFilter) + continue; + } + if ($isFilter) + continue; + $config['proxy-groups'][$k]['proxies'] = array_merge($config['proxy-groups'][$k]['proxies'], $proxies); + } + $config['proxy-groups'] = array_filter($config['proxy-groups'], function ($group) { + return $group['proxies']; + }); + $config['proxy-groups'] = array_values($config['proxy-groups']); + $config = $this->buildRules($config); + + $yaml = Yaml::dump($config, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $yaml = str_replace('$app_name', admin_setting('app_name', 'XBoard'), $yaml); + return response($yaml) + ->header('content-type', 'text/yaml') + ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}") + ->header('profile-update-interval', '24') + ->header('content-disposition', 'attachment;filename*=UTF-8\'\'' . rawurlencode($appName)); + } + + /** + * Build the rules for Clash. + */ + public function buildRules($config) + { + // Force the current subscription domain to be a direct rule + $subsDomain = request()->header('Host'); + if ($subsDomain) { + array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); + } + // // Force the nodes ip to be a direct rule + // collect($this->servers)->pluck('host')->map(function ($host) { + // $host = trim($host); + // return filter_var($host, FILTER_VALIDATE_IP) ? [$host] : Helper::getIpByDomainName($host); + // })->flatten()->unique()->each(function ($nodeIP) use (&$config) { + // array_unshift($config['rules'], "IP-CIDR,{$nodeIP}/32,DIRECT,no-resolve"); + // }); + + return $config; + } + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'ss'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['cipher'] = data_get($server['protocol_settings'], 'cipher'); + $array['password'] = data_get($server, 'password', $password); + $array['udp'] = true; + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + $array['plugin'] = $plugin; + + // 解析插件选项 + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return [trim($pair) => true]; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + + // 根据插件类型进行字段映射 + switch ($plugin) { + case 'obfs': + case 'obfs-local': + $array['plugin'] = 'obfs'; + $array['plugin-opts'] = array_filter([ + 'mode' => $parsedOpts['obfs'] ?? ($parsedOpts['mode'] ?? 'http'), + 'host' => $parsedOpts['obfs-host'] ?? ($parsedOpts['host'] ?? 'www.bing.com'), + ]); + break; + + case 'v2ray-plugin': + $array['plugin-opts'] = array_filter([ + 'mode' => $parsedOpts['mode'] ?? 'websocket', + 'tls' => isset($parsedOpts['tls']) || isset($parsedOpts['server']), + 'host' => $parsedOpts['host'] ?? null, + 'path' => $parsedOpts['path'] ?? '/', + 'mux' => isset($parsedOpts['mux']) ? true : null, + 'headers' => isset($parsedOpts['host']) ? ['Host' => $parsedOpts['host']] : null + ], fn($v) => $v !== null); + break; + + case 'shadow-tls': + $array['plugin-opts'] = array_filter([ + 'host' => $parsedOpts['host'] ?? null, + 'password' => $parsedOpts['password'] ?? null, + 'version' => isset($parsedOpts['version']) ? (int) $parsedOpts['version'] : 2 + ], fn($v) => $v !== null); + break; + + case 'restls': + $array['plugin-opts'] = array_filter([ + 'host' => $parsedOpts['host'] ?? null, + 'password' => $parsedOpts['password'] ?? null, + 'restls-script' => $parsedOpts['restls-script'] ?? '123' + ], fn($v) => $v !== null); + break; + + default: + $array['plugin-opts'] = $parsedOpts; + } + } + return $array; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'vmess', + 'server' => $server['host'], + 'port' => $server['port'], + 'uuid' => $uuid, + 'alterId' => 0, + 'cipher' => 'auto', + 'udp' => true + ]; + + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = (bool) data_get($protocol_settings, 'tls'); + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + $array['servername'] = data_get($protocol_settings, 'tls_settings.server_name'); + } + + self::appendUtls($array, $protocol_settings); + self::appendMultiplex($array, $protocol_settings); + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'none'); + $array['network'] = ($headerType === 'http') ? 'http' : 'tcp'; + if ($headerType === 'http') { + if ( + $httpOpts = array_filter([ + 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), + 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) + ]) + ) { + $array['http-opts'] = $httpOpts; + } + } + break; + case 'ws': + $array['network'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + case 'grpc': + $array['network'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['grpc-opts']['grpc-service-name'] = $serviceName; + break; + case 'h2': + $array['network'] = 'h2'; + $array['h2-opts'] = []; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['h2-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['h2-opts']['host'] = is_array($host) ? $host : [$host]; + break; + case 'httpupgrade': + $array['network'] = 'ws'; + $array['ws-opts'] = ['v2ray-http-upgrade' => true]; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + default: + break; + } + + return $array; + } + + public static function buildVless($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'vless', + 'server' => $server['host'], + 'port' => $server['port'], + 'uuid' => $password, + 'alterId' => 0, + 'cipher' => 'auto', + 'udp' => true, + 'flow' => data_get($protocol_settings, 'flow'), + 'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) { + true => data_get($protocol_settings, 'encryption.encryption', 'none'), + default => 'none' + }, + 'tls' => false + ]; + + switch (data_get($protocol_settings, 'tls')) { + case 1: + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['servername'] = $serverName; + } + self::appendUtls($array, $protocol_settings); + break; + case 2: + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false); + $array['servername'] = data_get($protocol_settings, 'reality_settings.server_name'); + $array['reality-opts'] = [ + 'public-key' => data_get($protocol_settings, 'reality_settings.public_key'), + 'short-id' => data_get($protocol_settings, 'reality_settings.short_id') + ]; + self::appendUtls($array, $protocol_settings); + break; + default: + break; + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $array['network'] = 'tcp'; + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'none'); + if ($headerType === 'http') { + $array['network'] = 'http'; + if ( + $httpOpts = array_filter([ + 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), + 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) + ]) + ) { + $array['http-opts'] = $httpOpts; + } + } + break; + case 'ws': + $array['network'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + case 'grpc': + $array['network'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['grpc-opts']['grpc-service-name'] = $serviceName; + break; + case 'h2': + $array['network'] = 'h2'; + $array['h2-opts'] = []; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['h2-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['h2-opts']['host'] = is_array($host) ? $host : [$host]; + break; + case 'httpupgrade': + $array['network'] = 'ws'; + $array['ws-opts'] = ['v2ray-http-upgrade' => true]; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + default: + break; + } + + self::appendMultiplex($array, $protocol_settings); + + return $array; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'trojan', + 'server' => $server['host'], + 'port' => $server['port'], + 'password' => $password, + 'udp' => true, + ]; + + $tlsMode = (int) data_get($protocol_settings, 'tls', 1); + switch ($tlsMode) { + case 2: // Reality + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) { + $array['sni'] = $serverName; + } + $array['reality-opts'] = [ + 'public-key' => data_get($protocol_settings, 'reality_settings.public_key'), + 'short-id' => data_get($protocol_settings, 'reality_settings.short_id'), + ]; + break; + default: // Standard TLS + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'server_name')) { + $array['sni'] = $serverName; + } + break; + } + + self::appendUtls($array, $protocol_settings); + self::appendMultiplex($array, $protocol_settings); + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $array['network'] = 'tcp'; + break; + case 'ws': + $array['network'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + case 'grpc': + $array['network'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['grpc-opts']['grpc-service-name'] = $serviceName; + break; + case 'h2': + $array['network'] = 'h2'; + $array['h2-opts'] = []; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['h2-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['h2-opts']['host'] = is_array($host) ? $host : [$host]; + break; + case 'httpupgrade': + $array['network'] = 'ws'; + $array['ws-opts'] = ['v2ray-http-upgrade' => true]; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; + default: + $array['network'] = 'tcp'; + break; + } + + return $array; + } + + public static function buildHysteria($password, $server, $user) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'server' => $server['host'], + 'port' => $server['port'], + 'sni' => data_get($protocol_settings, 'tls.server_name'), + 'up' => data_get($protocol_settings, 'bandwidth.up'), + 'down' => data_get($protocol_settings, 'bandwidth.down'), + 'skip-cert-verify' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false), + ]; + if (isset($server['ports'])) { + $array['ports'] = $server['ports']; + } + if ($hopInterval = data_get($protocol_settings, 'hop_interval')) { + $array['hop-interval'] = (int) $hopInterval; + } + switch (data_get($protocol_settings, 'version')) { + case 1: + $array['type'] = 'hysteria'; + $array['auth_str'] = $password; + $array['protocol'] = 'udp'; // 支持 udp/wechat-video/faketcp + if (data_get($protocol_settings, 'obfs.open')) { + $array['obfs'] = data_get($protocol_settings, 'obfs.password'); + } + $array['fast-open'] = true; + $array['disable_mtu_discovery'] = true; + break; + case 2: + $array['type'] = 'hysteria2'; + $array['password'] = $password; + if (data_get($protocol_settings, 'obfs.open')) { + $array['obfs'] = data_get($protocol_settings, 'obfs.type'); + $array['obfs-password'] = data_get($protocol_settings, 'obfs.password'); + } + break; + } + + return $array; + } + + public static function buildTuic($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'tuic', + 'server' => $server['host'], + 'port' => $server['port'], + 'udp' => true, + ]; + + if (data_get($protocol_settings, 'version') === 4) { + $array['token'] = $password; + } else { + $array['uuid'] = $password; + $array['password'] = $password; + } + + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $array['sni'] = $serverName; + } + + if ($alpn = data_get($protocol_settings, 'alpn')) { + $array['alpn'] = $alpn; + } + + $array['congestion-controller'] = data_get($protocol_settings, 'congestion_control', 'cubic'); + $array['udp-relay-mode'] = data_get($protocol_settings, 'udp_relay_mode', 'native'); + + return $array; + } + + public static function buildAnyTLS($password, $server) + { + + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'anytls', + 'server' => $server['host'], + 'port' => $server['port'], + 'password' => $password, + 'udp' => true, + ]; + + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $array['sni'] = $serverName; + } + if ($allowInsecure = data_get($protocol_settings, 'tls.allow_insecure')) { + $array['skip-cert-verify'] = (bool) $allowInsecure; + } + + return $array; + } + + public static function buildMieru($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'mieru', + 'server' => $server['host'], + 'port' => $server['port'], + 'username' => $password, + 'password' => $password, + 'transport' => strtoupper(data_get($protocol_settings, 'transport', 'TCP')) + ]; + + // 如果配置了端口范围 + if (isset($server['ports'])) { + $array['port-range'] = $server['ports']; + } + + return $array; + } + + public static function buildSocks5($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'socks5'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['udp'] = true; + + $array['username'] = $password; + $array['password'] = $password; + + // TLS 配置 + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + } + + return $array; + } + + public static function buildHttp($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'http'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + + $array['username'] = $password; + $array['password'] = $password; + + // TLS 配置 + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + } + + return $array; + } + + private function isMatch($exp, $str) + { + try { + return preg_match($exp, $str) === 1; + } catch (\Exception $e) { + return false; + } + } + + private function isRegex($exp) + { + if (empty($exp)) { + return false; + } + try { + return preg_match($exp, '') !== false; + } catch (\Exception $e) { + return false; + } + } + + protected static function appendMultiplex(&$array, $protocol_settings) + { + if ($multiplex = data_get($protocol_settings, 'multiplex')) { + if (data_get($multiplex, 'enabled')) { + $array['smux'] = array_filter([ + 'enabled' => true, + 'protocol' => data_get($multiplex, 'protocol', 'yamux'), + 'max-connections' => data_get($multiplex, 'max_connections'), + // 'min-streams' => data_get($multiplex, 'min_streams'), + // 'max-streams' => data_get($multiplex, 'max_streams'), + 'padding' => data_get($multiplex, 'padding') ? true : null, + ]); + + if (data_get($multiplex, 'brutal.enabled')) { + $array['smux']['brutal-opts'] = [ + 'enabled' => true, + 'up' => data_get($multiplex, 'brutal.up_mbps'), + 'down' => data_get($multiplex, 'brutal.down_mbps'), + ]; + } + } + } + } + + protected static function appendUtls(&$array, $protocol_settings) + { + if ($utls = data_get($protocol_settings, 'utls')) { + if (data_get($utls, 'enabled')) { + $array['client-fingerprint'] = Helper::getTlsFingerprint($utls); + } + } + } +} \ No newline at end of file diff --git a/Xboard/app/Protocols/General.php b/Xboard/app/Protocols/General.php new file mode 100644 index 0000000..5071027 --- /dev/null +++ b/Xboard/app/Protocols/General.php @@ -0,0 +1,448 @@ + [2 => '1.9.5'], + 'v2rayn.hysteria.protocol_settings.version' => [2 => '6.31'], + ]; + + public function handle() + { + $servers = $this->servers; + $user = $this->user; + $uri = ''; + + foreach ($servers as $item) { + $uri .= match ($item['type']) { + Server::TYPE_VMESS => self::buildVmess($item['password'], $item), + Server::TYPE_VLESS => self::buildVless($item['password'], $item), + Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item), + Server::TYPE_TROJAN => self::buildTrojan($item['password'], $item), + Server::TYPE_HYSTERIA => self::buildHysteria($item['password'], $item), + Server::TYPE_ANYTLS => self::buildAnyTLS($item['password'], $item), + Server::TYPE_SOCKS => self::buildSocks($item['password'], $item), + Server::TYPE_TUIC => self::buildTuic($item['password'], $item), + Server::TYPE_HTTP => self::buildHttp($item['password'], $item), + default => '', + }; + } + return response(base64_encode($uri)) + ->header('content-type', 'text/plain') + ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}"); + } + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $password = data_get($server, 'password', $password); + $str = str_replace( + ['+', '/', '='], + ['-', '_', ''], + base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}") + ); + $addr = Helper::wrapIPv6($server['host']); + $plugin = data_get($protocol_settings, 'plugin'); + $plugin_opts = data_get($protocol_settings, 'plugin_opts'); + $url = "ss://{$str}@{$addr}:{$server['port']}"; + if ($plugin && $plugin_opts) { + $url .= '/?' . 'plugin=' . rawurlencode($plugin . ';' . $plugin_opts); + } + $url .= "#{$name}\r\n"; + return $url; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "v" => "2", + "ps" => $server['name'], + "add" => $server['host'], + "port" => (string) $server['port'], + "id" => $uuid, + "aid" => '0', + "net" => data_get($server, 'protocol_settings.network'), + "type" => "none", + "host" => "", + "path" => "", + "tls" => data_get($protocol_settings, 'tls') ? "tls" : "", + ]; + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $config['sni'] = $serverName; + } + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $config['fp'] = $fp; + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') { + $config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'http'); + $config['path'] = Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])); + $config['host'] = + data_get($protocol_settings, 'network_settings.header.request.headers.Host') + ? Arr::random(data_get($protocol_settings, 'network_settings.header.request.headers.Host', ['/']), ) + : null; + } + break; + case 'ws': + $config['type'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $config['host'] = $host; + break; + case 'grpc': + $config['type'] = 'grpc'; + if ($path = data_get($protocol_settings, 'network_settings.serviceName')) + $config['path'] = $path; + break; + case 'h2': + $config['net'] = 'h2'; + $config['type'] = 'h2'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $config['host'] = is_array($host) ? implode(',', $host) : $host; + break; + case 'httpupgrade': + $config['net'] = 'httpupgrade'; + $config['type'] = 'httpupgrade'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + $config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']); + break; + default: + break; + } + return "vmess://" . base64_encode(json_encode($config)) . "\r\n"; + } + + public static function buildVless($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $host = $server['host']; //节点地址 + $port = $server['port']; //节点端口 + $name = $server['name']; //节点名称 + + $config = [ + 'mode' => 'multi', //grpc传输模式 + 'security' => '', //传输层安全 tls/reality + 'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) { + true => data_get($protocol_settings, 'encryption.encryption', 'none'), + default => 'none' + }, + 'type' => data_get($server, 'protocol_settings.network'), //传输协议 + 'flow' => data_get($protocol_settings, 'flow'), + ]; + // 处理TLS + switch (data_get($server, 'protocol_settings.tls')) { + case 1: + $config['security'] = "tls"; + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $config['fp'] = $fp; + } + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $config['sni'] = $serverName; + } + if (data_get($protocol_settings, 'tls_settings.allow_insecure')) { + $config['allowInsecure'] = '1'; + } + break; + case 2: //reality + $config['security'] = "reality"; + $config['pbk'] = data_get($protocol_settings, 'reality_settings.public_key'); + $config['sid'] = data_get($protocol_settings, 'reality_settings.short_id'); + $config['sni'] = data_get($protocol_settings, 'reality_settings.server_name'); + $config['servername'] = data_get($protocol_settings, 'reality_settings.server_name'); + $config['spx'] = "/"; + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $config['fp'] = $fp; + } + break; + default: + break; + } + // 处理传输协议 + switch (data_get($server, 'protocol_settings.network')) { + case 'ws': + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + if ($wsHost = data_get($protocol_settings, 'network_settings.headers.Host')) + $config['host'] = $wsHost; + break; + case 'grpc': + if ($path = data_get($protocol_settings, 'network_settings.serviceName')) + $config['serviceName'] = $path; + break; + case 'h2': + $config['type'] = 'http'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + if ($h2Host = data_get($protocol_settings, 'network_settings.host')) + $config['host'] = is_array($h2Host) ? implode(',', $h2Host) : $h2Host; + break; + case 'kcp': + if ($path = data_get($protocol_settings, 'network_settings.seed')) + $config['path'] = $path; + $config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'none'); + break; + case 'httpupgrade': + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + $config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']); + break; + case 'xhttp': + $config['path'] = data_get($protocol_settings, 'network_settings.path'); + $config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']); + $config['mode'] = data_get($protocol_settings, 'network_settings.mode', 'auto'); + $config['extra'] = json_encode(data_get($protocol_settings, 'network_settings.extra')); + break; + } + + $user = $uuid . '@' . Helper::wrapIPv6($host) . ':' . $port; + $query = http_build_query($config); + $fragment = urlencode($name); + $link = sprintf("vless://%s?%s#%s\r\n", $user, $query, $fragment); + return $link; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $array = []; + $tlsMode = (int) data_get($protocol_settings, 'tls', 1); + + switch ($tlsMode) { + case 2: // Reality + $array['security'] = 'reality'; + $array['pbk'] = data_get($protocol_settings, 'reality_settings.public_key'); + $array['sid'] = data_get($protocol_settings, 'reality_settings.short_id'); + $array['sni'] = data_get($protocol_settings, 'reality_settings.server_name'); + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $array['fp'] = $fp; + } + break; + default: // Standard TLS + $array['allowInsecure'] = data_get($protocol_settings, 'allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'server_name')) { + $array['peer'] = $serverName; + $array['sni'] = $serverName; + } + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $array['fp'] = $fp; + } + break; + } + + switch (data_get($server, 'protocol_settings.network')) { + case 'ws': + $array['type'] = 'ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $array['host'] = $host; + break; + case 'grpc': + // Follow V2rayN family standards + $array['type'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['serviceName'] = $serviceName; + break; + case 'h2': + $array['type'] = 'http'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['host'] = is_array($host) ? implode(',', $host) : $host; + break; + case 'httpupgrade': + $array['type'] = 'httpupgrade'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['path'] = $path; + $array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']); + break; + default: + break; + } + $query = http_build_query($array); + $addr = Helper::wrapIPv6($server['host']); + + $uri = "trojan://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildHysteria($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $params = []; + $version = data_get($protocol_settings, 'version', 2); + + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $params['sni'] = $serverName; + } + $params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure') ? '1' : '0'; + + $name = rawurlencode($server['name']); + $addr = Helper::wrapIPv6($server['host']); + + if ($version === 2) { + if (data_get($protocol_settings, 'obfs.open')) { + $params['obfs'] = 'salamander'; + $params['obfs-password'] = data_get($protocol_settings, 'obfs.password'); + } + if (isset($server['ports'])) { + $params['mport'] = $server['ports']; + } + + $query = http_build_query($params); + $uri = "hysteria2://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; + } else { + $params['protocol'] = 'udp'; + $params['auth'] = $password; + if ($upMbps = data_get($protocol_settings, 'bandwidth.up')) + $params['upmbps'] = $upMbps; + if ($downMbps = data_get($protocol_settings, 'bandwidth.down')) + $params['downmbps'] = $downMbps; + if (data_get($protocol_settings, 'obfs.open') && ($obfsPassword = data_get($protocol_settings, 'obfs.password'))) { + $params['obfs'] = 'xplus'; + $params['obfsParam'] = $obfsPassword; + } + + $query = http_build_query($params); + $uri = "hysteria://{$addr}:{$server['port']}?{$query}#{$name}"; + } + $uri .= "\r\n"; + + return $uri; + } + + + public static function buildTuic($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $name = rawurlencode($server['name']); + $addr = Helper::wrapIPv6($server['host']); + $port = $server['port']; + $uuid = $password; // v2rayN格式里,uuid和password都是密码部分 + $pass = $password; + + $queryParams = []; + + // 填充sni参数 + if ($sni = data_get($protocol_settings, 'tls.server_name')) { + $queryParams['sni'] = $sni; + } + + // alpn参数,支持多值时用逗号连接 + if ($alpn = data_get($protocol_settings, 'alpn')) { + if (is_array($alpn)) { + $queryParams['alpn'] = implode(',', $alpn); + } else { + $queryParams['alpn'] = $alpn; + } + } + + // congestion_controller参数,默认cubic + $congestion = data_get($protocol_settings, 'congestion_control', 'cubic'); + $queryParams['congestion_control'] = $congestion; + + // udp_relay_mode参数,默认native + $udpRelay = data_get($protocol_settings, 'udp_relay_mode', 'native'); + $queryParams['udp-relay-mode'] = $udpRelay; + + if (data_get($protocol_settings, 'tls.allow_insecure')) { + $queryParams['insecure'] = '1'; + } + + $query = http_build_query($queryParams); + + // 构造完整URI,格式: + // Tuic://uuid:password@host:port?sni=xxx&alpn=xxx&congestion_controller=xxx&udp_relay_mode=xxx#别名 + $uri = "tuic://{$uuid}:{$pass}@{$addr}:{$port}"; + + if (!empty($query)) { + $uri .= "?{$query}"; + } + + $uri .= "#{$name}\r\n"; + + return $uri; + } + + + + + + public static function buildAnyTLS($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $params = [ + 'sni' => data_get($protocol_settings, 'tls.server_name'), + 'insecure' => data_get($protocol_settings, 'tls.allow_insecure') + ]; + $query = http_build_query($params); + $addr = Helper::wrapIPv6($server['host']); + $uri = "anytls://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildSocks($password, $server) + { + $name = rawurlencode($server['name']); + $credentials = base64_encode("{$password}:{$password}"); + $addr = Helper::wrapIPv6($server['host']); + return "socks://{$credentials}@{$addr}:{$server['port']}#{$name}\r\n"; + } + + public static function buildHttp($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $name = rawurlencode($server['name']); + $addr = Helper::wrapIPv6($server['host']); + $credentials = base64_encode("{$password}:{$password}"); + + $params = []; + if (data_get($protocol_settings, 'tls')) { + $params['security'] = 'tls'; + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $params['sni'] = $serverName; + } + $params['allowInsecure'] = data_get($protocol_settings, 'tls_settings.allow_insecure') ? '1' : '0'; + } + + $uri = "http://{$credentials}@{$addr}:{$server['port']}"; + if (!empty($params)) { + $uri .= '?' . http_build_query($params); + } + $uri .= "#{$name}\r\n"; + return $uri; + } +} diff --git a/Xboard/app/Protocols/Loon.php b/Xboard/app/Protocols/Loon.php new file mode 100644 index 0000000..56df854 --- /dev/null +++ b/Xboard/app/Protocols/Loon.php @@ -0,0 +1,357 @@ + [2 => '637'], + 'loon.trojan.protocol_settings.tls' => [0 => '3.2.1', 1 => '3.2.1',2 => '999.9.9'], + ]; + + public function handle() + { + $servers = $this->servers; + $user = $this->user; + + $uri = ''; + + foreach ($servers as $item) { + if ( + $item['type'] === Server::TYPE_SHADOWSOCKS + ) { + $uri .= self::buildShadowsocks($item['password'], $item); + } + if ($item['type'] === Server::TYPE_VMESS) { + $uri .= self::buildVmess($item['password'], $item); + } + if ($item['type'] === Server::TYPE_TROJAN) { + $uri .= self::buildTrojan($item['password'], $item); + } + if ($item['type'] === Server::TYPE_HYSTERIA) { + $uri .= self::buildHysteria($item['password'], $item, $user); + } + if ($item['type'] === Server::TYPE_VLESS) { + $uri .= self::buildVless($item['password'], $item); + } + if ($item['type'] === Server::TYPE_ANYTLS) { + $uri .= self::buildAnyTLS($item['password'], $item); + } + } + return response($uri) + ->header('content-type', 'text/plain') + ->header('Subscription-Userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}"); + } + + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $cipher = data_get($protocol_settings, 'cipher'); + + $config = [ + "{$server['name']}=Shadowsocks", + "{$server['host']}", + "{$server['port']}", + "{$cipher}", + "{$password}", + 'fast-open=false', + 'udp=true' + ]; + + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + // 解析插件选项 + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return []; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + switch ($plugin) { + case 'obfs': + $config[] = "obfs-name={$parsedOpts['obfs']}"; + if (isset($parsedOpts['obfs-host'])) { + $config[] = "obfs-host={$parsedOpts['obfs-host']}"; + } + if (isset($parsedOpts['path'])) { + $config[] = "obfs-uri={$parsedOpts['path']}"; + } + break; + } + } + + $config = array_filter($config); + $uri = implode(',', $config) . "\r\n"; + return $uri; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']}=vmess", + "{$server['host']}", + "{$server['port']}", + 'auto', + "{$uuid}", + 'fast-open=false', + 'udp=true', + "alterId=0" + ]; + + if (data_get($protocol_settings, 'tls')) { + $config[] = 'over-tls=true'; + if (data_get($protocol_settings, 'tls_settings')) { + $tls_settings = data_get($protocol_settings, 'tls_settings'); + $config[] = 'skip-cert-verify=' . (data_get($tls_settings, 'allow_insecure') ? 'true' : 'false'); + if (data_get($tls_settings, 'server_name')) + $config[] = "tls-name={$tls_settings['server_name']}"; + } + } + + switch (data_get($server['protocol_settings'], 'network')) { + case 'tcp': + $config[] = 'transport=tcp'; + $tcpSettings = data_get($protocol_settings, 'network_settings'); + if (data_get($tcpSettings, 'header.type')) + $config = str_replace('transport=tcp', "transport={$tcpSettings['header']['type']}", $config); + if (data_get($tcpSettings, key: 'header.request.path')) { + $paths = data_get($tcpSettings, key: 'header.request.path'); + $path = $paths[array_rand($paths)]; + $config[] = "path={$path}"; + } + if (data_get($tcpSettings, key: 'header.request.headers.Host')) { + $hosts = data_get($tcpSettings, key: 'header.request.headers.Host'); + $host = $hosts[array_rand($hosts)]; + $config[] = "host={$host}"; + } + break; + case 'ws': + $config[] = 'transport=ws'; + $wsSettings = data_get($protocol_settings, 'network_settings'); + if (data_get($wsSettings, key: 'path')) + $config[] = "path={$wsSettings['path']}"; + if (data_get($wsSettings, key: 'headers.Host')) + $config[] = "host={$wsSettings['headers']['Host']}"; + break; + case 'grpc': + $config[] = 'transport=grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $config[] = "grpc-service-name={$serviceName}"; + break; + case 'h2': + $config[] = 'transport=h2'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config[] = "path={$path}"; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $config[] = "host=" . (is_array($host) ? $host[0] : $host); + break; + case 'httpupgrade': + $config[] = 'transport=httpupgrade'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config[] = "path={$path}"; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $config[] = "host={$host}"; + break; + } + + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']}=trojan", + "{$server['host']}", + "{$server['port']}", + "{$password}", + ]; + + $tlsMode = (int) data_get($protocol_settings, 'tls', 1); + switch ($tlsMode) { + case 2: // Reality + if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) { + $config[] = "tls-name={$serverName}"; + } + if ($pubkey = data_get($protocol_settings, 'reality_settings.public_key')) { + $config[] = "public-key={$pubkey}"; + } + if ($shortid = data_get($protocol_settings, 'reality_settings.short_id')) { + $config[] = "short-id={$shortid}"; + } + $config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? 'true' : 'false'); + break; + default: // Standard TLS + if ($serverName = data_get($protocol_settings, 'server_name')) { + $config[] = "tls-name={$serverName}"; + } + $config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'allow_insecure') ? 'true' : 'false'); + break; + } + + switch (data_get($protocol_settings, 'network', 'tcp')) { + case 'ws': + $config[] = 'transport=ws'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config[] = "path={$path}"; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) + $config[] = "host={$host}"; + break; + case 'grpc': + $config[] = 'transport=grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $config[] = "grpc-service-name={$serviceName}"; + break; + } + + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildVless($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + + $config = [ + "{$server['name']}=VLESS", + "{$server['host']}", + "{$server['port']}", + "{$password}", + "alterId=0", + "udp=true" + ]; + + // flow + if ($flow = data_get($protocol_settings, 'flow')) { + $config[] = "flow={$flow}"; + } + + // TLS/Reality + switch (data_get($protocol_settings, 'tls')) { + case 1: + $config[] = "over-tls=true"; + $config[] = "skip-cert-verify=" . (data_get($protocol_settings, 'tls_settings.allow_insecure', false) ? "true" : "false"); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $config[] = "sni={$serverName}"; + } + break; + case 2: + $config[] = "over-tls=true"; + $config[] = "skip-cert-verify=" . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? "true" : "false"); + if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) { + $config[] = "sni={$serverName}"; + } + if ($pubkey = data_get($protocol_settings, 'reality_settings.public_key')) { + $config[] = "public-key={$pubkey}"; + } + if ($shortid = data_get($protocol_settings, 'reality_settings.short_id')) { + $config[] = "short-id={$shortid}"; + } + break; + default: + $config[] = "over-tls=false"; + break; + } + + // network + switch (data_get($protocol_settings, 'network')) { + case 'ws': + $config[] = "transport=ws"; + if ($path = data_get($protocol_settings, 'network_settings.path')) { + $config[] = "path={$path}"; + } + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $config[] = "host={$host}"; + } + break; + case 'grpc': + $config[] = "transport=grpc"; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) { + $config[] = "grpc-service-name={$serviceName}"; + } + break; + default: + $config[] = "transport=tcp"; + break; + } + + $config = array_filter($config); + $uri = implode(',', $config) . "\r\n"; + return $uri; + } + + public static function buildHysteria($password, $server, $user) + { + $protocol_settings = $server['protocol_settings']; + if ($protocol_settings['version'] != 2) { + return; + } + $config = [ + "{$server['name']}=Hysteria2", + $server['host'], + $server['port'], + $password, + $protocol_settings['tls']['server_name'] ? "sni={$protocol_settings['tls']['server_name']}" : "(null)" + ]; + if (data_get($protocol_settings, 'tls.allow_insecure')) + $config[] = "skip-cert-verify=true"; + if ($down = data_get($protocol_settings, 'bandwidth.down')) { + $config[] = "download-bandwidth={$down}"; + } + $config[] = "udp=true"; + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildAnyTLS($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + + $config = [ + "{$server['name']}=anytls", + "{$server['host']}", + "{$server['port']}", + "{$password}", + "udp=true" + ]; + + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $config[] = "sni={$serverName}"; + } + // ✅ 跳过证书校验 + if (data_get($protocol_settings, 'tls.allow_insecure')) { + $config[] = 'skip-cert-verify=true'; + } + + $config = array_filter($config); + + return implode(',', $config) . "\r\n"; + } +} diff --git a/Xboard/app/Protocols/QuantumultX.php b/Xboard/app/Protocols/QuantumultX.php new file mode 100644 index 0000000..8488e0a --- /dev/null +++ b/Xboard/app/Protocols/QuantumultX.php @@ -0,0 +1,232 @@ +servers; + $user = $this->user; + $uri = ''; + foreach ($servers as $item) { + $uri .= match ($item['type']) { + Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item), + Server::TYPE_VMESS => self::buildVmess($item['password'], $item), + Server::TYPE_VLESS => self::buildVless($item['password'], $item), + Server::TYPE_TROJAN => self::buildTrojan($item['password'], $item), + Server::TYPE_SOCKS => self::buildSocks5($item['password'], $item), + Server::TYPE_HTTP => self::buildHttp($item['password'], $item), + default => '' + }; + } + return response(base64_encode($uri)) + ->header('content-type', 'text/plain') + ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}"); + } + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $password = data_get($server, 'password', $password); + $addr = Helper::wrapIPv6($server['host']); + $config = [ + "shadowsocks={$addr}:{$server['port']}", + "method=" . data_get($protocol_settings, 'cipher'), + "password={$password}", + ]; + + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return []; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + if ($plugin === 'obfs') { + if (isset($parsedOpts['obfs'])) { + $config[] = "obfs={$parsedOpts['obfs']}"; + } + if (isset($parsedOpts['obfs-host'])) { + $config[] = "obfs-host={$parsedOpts['obfs-host']}"; + } + if (isset($parsedOpts['path'])) { + $config[] = "obfs-uri={$parsedOpts['path']}"; + } + } + } + + self::applyCommonSettings($config, $server); + + return implode(',', array_filter($config)) . "\r\n"; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $addr = Helper::wrapIPv6($server['host']); + $config = [ + "vmess={$addr}:{$server['port']}", + "method=" . data_get($protocol_settings, 'cipher', 'auto'), + "password={$uuid}", + ]; + + self::applyTransportSettings($config, $protocol_settings); + self::applyCommonSettings($config, $server); + + return implode(',', array_filter($config)) . "\r\n"; + } + + public static function buildVless($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $addr = Helper::wrapIPv6($server['host']); + $config = [ + "vless={$addr}:{$server['port']}", + 'method=none', + "password={$uuid}", + ]; + + self::applyTransportSettings($config, $protocol_settings); + + if ($flow = data_get($protocol_settings, 'flow')) { + $config[] = "vless-flow={$flow}"; + } + + self::applyCommonSettings($config, $server); + + return implode(',', array_filter($config)) . "\r\n"; + } + + private static function applyTransportSettings(&$config, $settings, bool $nativeTls = false, ?array $tlsData = null) + { + $tlsMode = (int) data_get($settings, 'tls', 0); + $network = data_get($settings, 'network', 'tcp'); + $host = null; + $isWs = $network === 'ws'; + + switch ($network) { + case 'ws': + $config[] = $tlsMode ? 'obfs=wss' : 'obfs=ws'; + if ($path = data_get($settings, 'network_settings.path')) { + $config[] = "obfs-uri={$path}"; + } + $host = data_get($settings, 'network_settings.headers.Host'); + break; + case 'tcp': + $headerType = data_get($settings, 'network_settings.header.type', 'tcp'); + if ($headerType === 'http') { + $config[] = 'obfs=http'; + $paths = data_get($settings, 'network_settings.header.request.path', ['/']); + $config[] = 'obfs-uri=' . (is_array($paths) ? ($paths[0] ?? '/') : $paths); + $hostVal = data_get($settings, 'network_settings.header.request.headers.Host'); + $host = is_array($hostVal) ? ($hostVal[0] ?? null) : $hostVal; + } elseif ($tlsMode) { + $config[] = $nativeTls ? 'over-tls=true' : 'obfs=over-tls'; + } + break; + } + + switch ($tlsMode) { + case 2: // Reality + $host = $host ?? data_get($settings, 'reality_settings.server_name'); + if ($pubKey = data_get($settings, 'reality_settings.public_key')) { + $config[] = "reality-base64-pubkey={$pubKey}"; + } + if ($shortId = data_get($settings, 'reality_settings.short_id')) { + $config[] = "reality-hex-shortid={$shortId}"; + } + break; + case 1: // TLS + $resolved = $tlsData ?? (array) data_get($settings, 'tls_settings', []); + $allowInsecure = (bool) ($resolved['allow_insecure'] ?? false); + $config[] = 'tls-verification=' . ($allowInsecure ? 'false' : 'true'); + $host = $host ?? ($resolved['server_name'] ?? null); + break; + } + + if ($host) { + $config[] = ($nativeTls && !$isWs) ? "tls-host={$host}" : "obfs-host={$host}"; + } + } + + private static function applyCommonSettings(&$config, $server) + { + $config[] = 'fast-open=true'; + if ($server['type'] !== Server::TYPE_HTTP) { + $config[] = 'udp-relay=true'; + } + $config[] = "tag={$server['name']}"; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $addr = Helper::wrapIPv6($server['host']); + $config = [ + "trojan={$addr}:{$server['port']}", + "password={$password}", + ]; + + $tlsData = [ + 'allow_insecure' => data_get($protocol_settings, 'allow_insecure', false), + 'server_name' => data_get($protocol_settings, 'server_name'), + ]; + self::applyTransportSettings($config, $protocol_settings, true, $tlsData); + self::applyCommonSettings($config, $server); + + return implode(',', array_filter($config)) . "\r\n"; + } + + public static function buildSocks5($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $addr = Helper::wrapIPv6($server['host']); + $config = [ + "socks5={$addr}:{$server['port']}", + "username={$password}", + "password={$password}", + ]; + + self::applyTransportSettings($config, $protocol_settings, true); + self::applyCommonSettings($config, $server); + + return implode(',', array_filter($config)) . "\r\n"; + } + + public static function buildHttp($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $addr = Helper::wrapIPv6($server['host']); + $config = [ + "http={$addr}:{$server['port']}", + "username={$password}", + "password={$password}", + ]; + + self::applyTransportSettings($config, $protocol_settings, true); + self::applyCommonSettings($config, $server); + + return implode(',', array_filter($config)) . "\r\n"; + } +} diff --git a/Xboard/app/Protocols/Shadowrocket.php b/Xboard/app/Protocols/Shadowrocket.php new file mode 100644 index 0000000..37028e3 --- /dev/null +++ b/Xboard/app/Protocols/Shadowrocket.php @@ -0,0 +1,415 @@ + [2 => '1993'], + 'shadowrocket.anytls.base_version' => '2592', + ]; + + public function handle() + { + $servers = $this->servers; + $user = $this->user; + + $uri = ''; + //display remaining traffic and expire date + $upload = round($user['u'] / (1024 * 1024 * 1024), 2); + $download = round($user['d'] / (1024 * 1024 * 1024), 2); + $totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2); + $expiredDate = $user['expired_at'] === null ? 'N/A' : date('Y-m-d', $user['expired_at']); + $uri .= "STATUS=🚀↑:{$upload}GB,↓:{$download}GB,TOT:{$totalTraffic}GB💡Expires:{$expiredDate}\r\n"; + foreach ($servers as $item) { + if ($item['type'] === Server::TYPE_SHADOWSOCKS) { + $uri .= self::buildShadowsocks($item['password'], $item); + } + if ($item['type'] === Server::TYPE_VMESS) { + $uri .= self::buildVmess($item['password'], $item); + } + if ($item['type'] === Server::TYPE_VLESS) { + $uri .= self::buildVless($item['password'], $item); + } + if ($item['type'] === Server::TYPE_TROJAN) { + $uri .= self::buildTrojan($item['password'], $item); + } + if ($item['type'] === Server::TYPE_HYSTERIA) { + $uri .= self::buildHysteria($item['password'], $item); + } + if ($item['type'] === Server::TYPE_TUIC) { + $uri .= self::buildTuic($item['password'], $item); + } + if ($item['type'] === Server::TYPE_ANYTLS) { + $uri .= self::buildAnyTLS($item['password'], $item); + } + if ($item['type'] === Server::TYPE_SOCKS) { + $uri .= self::buildSocks($item['password'], $item); + } + } + return response(base64_encode($uri)) + ->header('content-type', 'text/plain'); + } + + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $password = data_get($server, 'password', $password); + $str = str_replace( + ['+', '/', '='], + ['-', '_', ''], + base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}") + ); + $addr = Helper::wrapIPv6($server['host']); + + $uri = "ss://{$str}@{$addr}:{$server['port']}"; + $plugin = data_get($protocol_settings, 'plugin') == 'obfs' ? 'obfs-local' : data_get($protocol_settings, 'plugin'); + $plugin_opts = data_get($protocol_settings, 'plugin_opts'); + if ($plugin && $plugin_opts) { + $uri .= '/?' . 'plugin=' . $plugin . ';' . rawurlencode($plugin_opts); + } + return $uri . "#{$name}\r\n"; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $userinfo = base64_encode('auto:' . $uuid . '@' . Helper::wrapIPv6($server['host']) . ':' . $server['port']); + $config = [ + 'tfo' => 1, + 'remark' => $server['name'], + 'alterId' => 0 + ]; + if (data_get($protocol_settings, 'tls')) { + $config['tls'] = 1; + if (data_get($protocol_settings, 'tls_settings')) { + if (!!data_get($protocol_settings, 'tls_settings.allow_insecure')) + $config['allowInsecure'] = (int) data_get($protocol_settings, 'tls_settings.allow_insecure'); + if (!!data_get($protocol_settings, 'tls_settings.server_name')) + $config['peer'] = data_get($protocol_settings, 'tls_settings.server_name'); + } + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') { + $config['obfs'] = data_get($protocol_settings, 'network_settings.header.type'); + $config['path'] = \Illuminate\Support\Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])); + $config['obfsParam'] = \Illuminate\Support\Arr::random(data_get($protocol_settings, 'network_settings.header.request.headers.Host', ['www.example.com'])); + } + break; + case 'ws': + $config['obfs'] = "websocket"; + $config['path'] = data_get($protocol_settings, 'network_settings.path'); + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $config['obfsParam'] = $host; + } + break; + case 'grpc': + $config['obfs'] = "grpc"; + $config['path'] = data_get($protocol_settings, 'network_settings.serviceName'); + $config['host'] = data_get($protocol_settings, 'tls_settings.server_name') ?? $server['host']; + break; + case 'httpupgrade': + $config['obfs'] = "httpupgrade"; + if ($path = data_get($protocol_settings, 'network_settings.path')) { + $config['path'] = $path; + } + if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) { + $config['obfsParam'] = $host; + } + break; + case 'h2': + $config['obfs'] = "h2"; + if ($path = data_get($protocol_settings, 'network_settings.path')) { + $config['path'] = $path; + } + if ($host = data_get($protocol_settings, 'network_settings.host')) { + $config['obfsParam'] = $host[0] ?? $server['host']; + $config['peer'] = $host [0] ?? $server['host']; + } + break; + } + $query = http_build_query($config, '', '&', PHP_QUERY_RFC3986); + $uri = "vmess://{$userinfo}?{$query}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildVless($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $userinfo = base64_encode('auto:' . $uuid . '@' . Helper::wrapIPv6($server['host']) . ':' . $server['port']); + $config = [ + 'tfo' => 1, + 'remark' => $server['name'], + 'alterId' => 0 + ]; + + // 判断是否开启xtls + if (data_get($protocol_settings, 'flow')) { + $xtlsMap = [ + 'none' => 0, + 'xtls-rprx-direct' => 1, + 'xtls-rprx-vision' => 2 + ]; + if (array_key_exists(data_get($protocol_settings, 'flow'), $xtlsMap)) { + $config['tls'] = 1; + $config['xtls'] = $xtlsMap[data_get($protocol_settings, 'flow')]; + } + } + switch (data_get($protocol_settings, 'tls')) { + case 1: + $config['tls'] = 1; + $config['allowInsecure'] = (int) data_get($protocol_settings, 'tls_settings.allow_insecure'); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $config['peer'] = $serverName; + } + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $config['fp'] = $fp; + } + break; + case 2: + $config['tls'] = 1; + $config['sni'] = data_get($protocol_settings, 'reality_settings.server_name'); + $config['pbk'] = data_get($protocol_settings, 'reality_settings.public_key'); + $config['sid'] = data_get($protocol_settings, 'reality_settings.short_id'); + if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $config['fp'] = $fp; + } + break; + default: + break; + } + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') { + $config['obfs'] = data_get($protocol_settings, 'network_settings.header.type'); + $config['path'] = \Illuminate\Support\Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])); + $config['obfsParam'] = \Illuminate\Support\Arr::random(data_get($protocol_settings, 'network_settings.header.request.headers.Host', ['www.example.com'])); + } + break; + case 'ws': + $config['obfs'] = "websocket"; + if (data_get($protocol_settings, 'network_settings.path')) { + $config['path'] = data_get($protocol_settings, 'network_settings.path'); + } + + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $config['obfsParam'] = $host; + } + break; + case 'grpc': + $config['obfs'] = "grpc"; + $config['path'] = data_get($protocol_settings, 'network_settings.serviceName'); + $config['host'] = data_get($protocol_settings, 'tls_settings.server_name') ?? $server['host']; + break; + case 'kcp': + $config['obfs'] = "kcp"; + if ($seed = data_get($protocol_settings, 'network_settings.seed')) { + $config['path'] = $seed; + } + $config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'none'); + break; + case 'h2': + $config['obfs'] = "h2"; + if ($path = data_get($protocol_settings, 'network_settings.path')) { + $config['path'] = $path; + } + if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) { + $config['obfsParam'] = $host; + } + break; + case 'httpupgrade': + $config['obfs'] = "httpupgrade"; + if ($path = data_get($protocol_settings, 'network_settings.path')) { + $config['path'] = $path; + } + if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) { + $config['obfsParam'] = $host; + } + break; + case 'xhttp': + $config['obfs'] = "xhttp"; + if ($path = data_get($protocol_settings, 'network_settings.path')) { + $config['path'] = $path; + } + if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) { + $config['obfsParam'] = $host; + } + if ($mode = data_get($protocol_settings, 'network_settings.mode', 'auto')) { + $config['mode'] = $mode; + } + break; + } + + $query = http_build_query($config, '', '&', PHP_QUERY_RFC3986); + $uri = "vless" . "://{$userinfo}?{$query}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $params = []; + $tlsMode = (int) data_get($protocol_settings, 'tls', 1); + + switch ($tlsMode) { + case 2: // Reality + $params['security'] = 'reality'; + $params['pbk'] = data_get($protocol_settings, 'reality_settings.public_key'); + $params['sid'] = data_get($protocol_settings, 'reality_settings.short_id'); + $params['sni'] = data_get($protocol_settings, 'reality_settings.server_name'); + break; + default: // Standard TLS + $params['allowInsecure'] = data_get($protocol_settings, 'allow_insecure'); + if ($serverName = data_get($protocol_settings, 'server_name')) { + $params['peer'] = $serverName; + } + break; + } + + switch (data_get($protocol_settings, 'network')) { + case 'grpc': + $params['obfs'] = 'grpc'; + $params['path'] = data_get($protocol_settings, 'network_settings.serviceName'); + break; + case 'ws': + $host = data_get($protocol_settings, 'network_settings.headers.Host'); + $path = data_get($protocol_settings, 'network_settings.path'); + $params['plugin'] = "obfs-local;obfs=websocket;obfs-host={$host};obfs-uri={$path}"; + break; + } + $query = http_build_query($params); + $addr = Helper::wrapIPv6($server['host']); + + $uri = "trojan://{$password}@{$addr}:{$server['port']}?{$query}&tfo=1#{$name}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildHysteria($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $uri = ''; // 初始化变量 + + switch (data_get($protocol_settings, 'version')) { + case 1: + $params = [ + "auth" => $password, + "upmbps" => data_get($protocol_settings, 'bandwidth.up'), + "downmbps" => data_get($protocol_settings, 'bandwidth.down'), + "protocol" => 'udp', + "fastopen" => 1, + ]; + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $params['peer'] = $serverName; + } + if (data_get($protocol_settings, 'obfs.open')) { + $params["obfs"] = "xplus"; + $params["obfsParam"] = data_get($protocol_settings, 'obfs.password'); + } + $params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure'); + if (isset($server['ports'])) + $params['mport'] = $server['ports']; + $query = http_build_query($params); + $addr = Helper::wrapIPv6($server['host']); + + $uri = "hysteria://{$addr}:{$server['port']}?{$query}#{$server['name']}"; + $uri .= "\r\n"; + break; + case 2: + $params = [ + "obfs" => 'none', + "fastopen" => 1 + ]; + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $params['peer'] = $serverName; + } + if (data_get($protocol_settings, 'obfs.open')) { + $params['obfs'] = data_get($protocol_settings, 'obfs.type'); + $params['obfs-password'] = data_get($protocol_settings, 'obfs.password'); + } + $params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure'); + if (isset($protocol_settings['hop_interval'])) { + $params['keepalive'] = data_get($protocol_settings, 'hop_interval'); + } + if (isset($server['ports'])) { + $params['mport'] = $server['ports']; + } + $query = http_build_query($params); + $addr = Helper::wrapIPv6($server['host']); + + $uri = "hysteria2://{$password}@{$addr}:{$server['port']}?{$query}#{$server['name']}"; + $uri .= "\r\n"; + break; + } + return $uri; + } + public static function buildTuic($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $params = [ + 'alpn' => data_get($protocol_settings, 'alpn'), + 'sni' => data_get($protocol_settings, 'tls.server_name'), + 'insecure' => data_get($protocol_settings, 'tls.allow_insecure') + ]; + if (data_get($protocol_settings, 'version') === 4) { + $params['token'] = $password; + } else { + $params['uuid'] = $password; + $params['password'] = $password; + } + $query = http_build_query($params); + $addr = Helper::wrapIPv6($server['host']); + $uri = "tuic://{$addr}:{$server['port']}?{$query}#{$name}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildAnyTLS($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $params = [ + 'sni' => data_get($protocol_settings, 'tls.server_name'), + 'insecure' => data_get($protocol_settings, 'tls.allow_insecure') + ]; + $query = http_build_query($params); + $addr = Helper::wrapIPv6($server['host']); + $uri = "anytls://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; + $uri .= "\r\n"; + return $uri; + } + + public static function buildSocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $name = rawurlencode($server['name']); + $addr = Helper::wrapIPv6($server['host']); + $uri = 'socks://' . base64_encode("{$password}:{$password}@{$addr}:{$server['port']}") . "?method=auto#{$name}"; + $uri .= "\r\n"; + return $uri; + } +} diff --git a/Xboard/app/Protocols/Shadowsocks.php b/Xboard/app/Protocols/Shadowsocks.php new file mode 100644 index 0000000..36f2d2a --- /dev/null +++ b/Xboard/app/Protocols/Shadowsocks.php @@ -0,0 +1,60 @@ +servers; + $user = $this->user; + + $configs = []; + $subs = []; + $subs['servers'] = []; + $subs['bytes_used'] = ''; + $subs['bytes_remaining'] = ''; + + $bytesUsed = $user['u'] + $user['d']; + $bytesRemaining = $user['transfer_enable'] - $bytesUsed; + + foreach ($servers as $item) { + if ( + $item['type'] === 'shadowsocks' + && in_array(data_get($item, 'protocol_settings.cipher'), ['aes-128-gcm', 'aes-256-gcm', 'aes-192-gcm', 'chacha20-ietf-poly1305']) + ) { + array_push($configs, self::SIP008($item, $user)); + } + } + + $subs['version'] = 1; + $subs['bytes_used'] = $bytesUsed; + $subs['bytes_remaining'] = $bytesRemaining; + $subs['servers'] = array_merge($subs['servers'], $configs); + + return response()->json($subs) + ->header('content-type', 'application/json'); + } + + public static function SIP008($server, $user) + { + $config = [ + "id" => $server['id'], + "remarks" => $server['name'], + "server" => $server['host'], + "server_port" => $server['port'], + "password" => $server['password'], + "method" => data_get($server, 'protocol_settings.cipher') + ]; + return $config; + } +} diff --git a/Xboard/app/Protocols/SingBox.php b/Xboard/app/Protocols/SingBox.php new file mode 100644 index 0000000..b412787 --- /dev/null +++ b/Xboard/app/Protocols/SingBox.php @@ -0,0 +1,757 @@ + [ + 'vless' => [ + 'base_version' => '1.5.0', + 'protocol_settings.flow' => [ + 'xtls-rprx-vision' => '1.5.0' + ], + 'protocol_settings.tls' => [ + '2' => '1.6.0' // Reality + ] + ], + 'hysteria' => [ + 'base_version' => '1.5.0', + 'protocol_settings.version' => [ + '2' => '1.5.0' // Hysteria 2 + ] + ], + 'tuic' => [ + 'base_version' => '1.5.0' + ], + 'ssh' => [ + 'base_version' => '1.8.0' + ], + 'juicity' => [ + 'base_version' => '1.7.0' + ], + 'wireguard' => [ + 'base_version' => '1.5.0' + ], + 'anytls' => [ + 'base_version' => '1.12.0' + ], + ] + ]; + + public function handle() + { + $appName = admin_setting('app_name', 'XBoard'); + $this->config = $this->loadConfig(); + $this->buildOutbounds(); + $this->buildRule(); + $this->adaptConfigForVersion(); + $user = $this->user; + + return response() + ->json($this->config) + ->header('profile-title', 'base64:' . base64_encode($appName)) + ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}") + ->header('profile-update-interval', '24'); + } + + protected function loadConfig() + { + $jsonData = subscribe_template('singbox'); + + return is_array($jsonData) ? $jsonData : json_decode($jsonData, true); + } + + protected function buildOutbounds() + { + $outbounds = $this->config['outbounds']; + $proxies = []; + foreach ($this->servers as $item) { + $protocol_settings = $item['protocol_settings']; + if ($item['type'] === Server::TYPE_SHADOWSOCKS) { + $ssConfig = $this->buildShadowsocks($item['password'], $item); + $proxies[] = $ssConfig; + } + if ($item['type'] === Server::TYPE_TROJAN) { + $trojanConfig = $this->buildTrojan($this->user['uuid'], $item); + $proxies[] = $trojanConfig; + } + if ($item['type'] === Server::TYPE_VMESS) { + $vmessConfig = $this->buildVmess($this->user['uuid'], $item); + $proxies[] = $vmessConfig; + } + if ( + $item['type'] === Server::TYPE_VLESS + && in_array(data_get($protocol_settings, 'network'), ['tcp', 'ws', 'grpc', 'http', 'quic', 'httpupgrade']) + ) { + $vlessConfig = $this->buildVless($this->user['uuid'], $item); + $proxies[] = $vlessConfig; + } + if ($item['type'] === Server::TYPE_HYSTERIA) { + $hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item); + $proxies[] = $hysteriaConfig; + } + if ($item['type'] === Server::TYPE_TUIC) { + $tuicConfig = $this->buildTuic($this->user['uuid'], $item); + $proxies[] = $tuicConfig; + } + if ($item['type'] === Server::TYPE_ANYTLS) { + $anytlsConfig = $this->buildAnyTLS($this->user['uuid'], $item); + $proxies[] = $anytlsConfig; + } + if ($item['type'] === Server::TYPE_SOCKS) { + $socksConfig = $this->buildSocks($this->user['uuid'], $item); + $proxies[] = $socksConfig; + } + if ($item['type'] === Server::TYPE_HTTP) { + $httpConfig = $this->buildHttp($this->user['uuid'], $item); + $proxies[] = $httpConfig; + } + } + foreach ($outbounds as &$outbound) { + if (in_array($outbound['type'], ['urltest', 'selector'])) { + array_push($outbound['outbounds'], ...array_column($proxies, 'tag')); + } + } + + $outbounds = array_merge($outbounds, $proxies); + $this->config['outbounds'] = $outbounds; + return $outbounds; + } + + /** + * Build rule + */ + protected function buildRule() + { + $rules = $this->config['route']['rules']; + $this->config['route']['rules'] = $rules; + } + + /** + * 根据客户端版本自适应配置格式 + * 模板基准格式: 1.13.0+ (最新) + */ + protected function adaptConfigForVersion(): void + { + $coreVersion = $this->getSingBoxCoreVersion(); + if (empty($coreVersion)) { + return; + } + + // >= 1.13.0: 移除已删除的 block/dns 出站 + if (version_compare($coreVersion, '1.13.0', '>=')) { + $this->upgradeSpecialOutboundsToActions(); + } + + // < 1.11.0: rule action 降级为旧出站; 恢复废弃字段 + if (version_compare($coreVersion, '1.11.0', '<')) { + $this->downgradeActionsToSpecialOutbounds(); + $this->restoreDeprecatedInboundFields(); + } + + // < 1.12.0: DNS type+server → 旧 address 格式 + if (version_compare($coreVersion, '1.12.0', '<')) { + $this->convertDnsServersToLegacy(); + } + + // < 1.10.0: tun address 数组 → inet4_address/inet6_address + if (version_compare($coreVersion, '1.10.0', '<')) { + $this->convertTunAddressToLegacy(); + } + } + + /** + * 获取核心版本 (Hiddify/SFM 等映射到内核版本) + */ + private function getSingBoxCoreVersion(): ?string + { + // 优先从 UA 提取核心版本 + if (!empty($this->userAgent)) { + if (preg_match('/sing-box\s+v?(\d+(?:\.\d+){0,2})/i', $this->userAgent, $matches)) { + return $matches[1]; + } + } + + if (empty($this->clientVersion)) { + return null; + } + + if ($this->clientName === 'sing-box') { + return $this->clientVersion; + } + + return '1.13.0'; + } + + /** + * sing-box >= 1.13.0: block/dns 出站升级为 action + */ + private function upgradeSpecialOutboundsToActions(): void + { + $removedTags = []; + $this->config['outbounds'] = array_values(array_filter( + $this->config['outbounds'] ?? [], + function ($outbound) use (&$removedTags) { + if (in_array($outbound['type'] ?? '', ['block', 'dns'])) { + $removedTags[$outbound['tag']] = $outbound['type']; + return false; + } + return true; + } + )); + + if (empty($removedTags)) { + return; + } + + if (isset($this->config['route']['rules'])) { + foreach ($this->config['route']['rules'] as &$rule) { + if (!isset($rule['outbound']) || !isset($removedTags[$rule['outbound']])) { + continue; + } + $type = $removedTags[$rule['outbound']]; + unset($rule['outbound']); + $rule['action'] = $type === 'dns' ? 'hijack-dns' : 'reject'; + } + unset($rule); + } + } + + /** + * sing-box < 1.11.0: rule action 降级为旧 block/dns 出站 + */ + private function downgradeActionsToSpecialOutbounds(): void + { + $needsDnsOutbound = false; + $needsBlockOutbound = false; + + if (isset($this->config['route']['rules'])) { + foreach ($this->config['route']['rules'] as &$rule) { + if (!isset($rule['action'])) { + continue; + } + switch ($rule['action']) { + case 'hijack-dns': + unset($rule['action']); + $rule['outbound'] = 'dns-out'; + $needsDnsOutbound = true; + break; + case 'reject': + unset($rule['action']); + $rule['outbound'] = 'block'; + $needsBlockOutbound = true; + break; + } + } + unset($rule); + } + + if ($needsBlockOutbound) { + $this->config['outbounds'][] = ['type' => 'block', 'tag' => 'block']; + } + if ($needsDnsOutbound) { + $this->config['outbounds'][] = ['type' => 'dns', 'tag' => 'dns-out']; + } + } + + /** + * sing-box < 1.11.0: 恢复废弃的入站字段 + */ + private function restoreDeprecatedInboundFields(): void + { + if (!isset($this->config['inbounds'])) { + return; + } + foreach ($this->config['inbounds'] as &$inbound) { + if ($inbound['type'] === 'tun') { + $inbound['endpoint_independent_nat'] = true; + } + if (!empty($inbound['sniff'])) { + $inbound['sniff_override_destination'] = true; + } + } + } + + /** + * sing-box < 1.12.0: 将新 DNS server type+server 格式转换为旧 address 格式 + */ + private function convertDnsServersToLegacy(): void + { + if (!isset($this->config['dns']['servers'])) { + return; + } + foreach ($this->config['dns']['servers'] as &$server) { + if (!isset($server['type'])) { + continue; + } + $type = $server['type']; + $host = $server['server'] ?? null; + switch ($type) { + case 'https': + $server['address'] = "https://{$host}/dns-query"; + break; + case 'tls': + $server['address'] = "tls://{$host}"; + break; + case 'tcp': + $server['address'] = "tcp://{$host}"; + break; + case 'quic': + $server['address'] = "quic://{$host}"; + break; + case 'udp': + $server['address'] = $host; + break; + case 'block': + $server['address'] = 'rcode://refused'; + break; + case 'rcode': + $server['address'] = 'rcode://' . ($server['rcode'] ?? 'success'); + unset($server['rcode']); + break; + default: + $server['address'] = $host; + break; + } + unset($server['type'], $server['server']); + } + unset($server); + } + + /** + * sing-box < 1.10.0: 将 tun address 数组转换为 inet4_address/inet6_address + */ + private function convertTunAddressToLegacy(): void + { + if (!isset($this->config['inbounds'])) { + return; + } + foreach ($this->config['inbounds'] as &$inbound) { + if ($inbound['type'] !== 'tun' || !isset($inbound['address'])) { + continue; + } + foreach ($inbound['address'] as $addr) { + if (str_contains($addr, ':')) { + $inbound['inet6_address'] = $addr; + } else { + $inbound['inet4_address'] = $addr; + } + } + unset($inbound['address']); + } + } + + protected function buildShadowsocks($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings'); + $array = []; + $array['tag'] = $server['name']; + $array['type'] = 'shadowsocks'; + $array['server'] = $server['host']; + $array['server_port'] = $server['port']; + $array['method'] = data_get($protocol_settings, 'cipher'); + $array['password'] = data_get($server, 'password', $password); + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $array['plugin'] = data_get($protocol_settings, 'plugin'); + $array['plugin_opts'] = data_get($protocol_settings, 'plugin_opts', ''); + } + + return $array; + } + + + protected function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = [ + 'tag' => $server['name'], + 'type' => 'vmess', + 'server' => $server['host'], + 'server_port' => $server['port'], + 'uuid' => $uuid, + 'security' => 'auto', + 'alter_id' => 0, + ]; + + if ($protocol_settings['tls']) { + $array['tls'] = [ + 'enabled' => true, + 'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'), + ]; + + $this->appendUtls($array['tls'], $protocol_settings); + + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['tls']['server_name'] = $serverName; + } + } + + $this->appendMultiplex($array, $protocol_settings); + + if ($transport = $this->buildTransport($protocol_settings, $server)) { + $array['transport'] = $transport; + } + return $array; + } + + protected function buildVless($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + "type" => "vless", + "tag" => $server['name'], + "server" => $server['host'], + "server_port" => $server['port'], + "uuid" => $password, + "packet_encoding" => "xudp", + ]; + if ($flow = data_get($protocol_settings, 'flow')) { + $array['flow'] = $flow; + } + + if (data_get($protocol_settings, 'tls')) { + $tlsMode = (int) data_get($protocol_settings, 'tls', 0); + $tlsConfig = [ + 'enabled' => true, + 'insecure' => $tlsMode === 2 + ? (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false) + : (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false), + ]; + + $this->appendUtls($tlsConfig, $protocol_settings); + + switch ($tlsMode) { + case 1: + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $tlsConfig['server_name'] = $serverName; + } + break; + case 2: + $tlsConfig['server_name'] = data_get($protocol_settings, 'reality_settings.server_name'); + $tlsConfig['reality'] = [ + 'enabled' => true, + 'public_key' => data_get($protocol_settings, 'reality_settings.public_key'), + 'short_id' => data_get($protocol_settings, 'reality_settings.short_id') + ]; + break; + } + + $array['tls'] = $tlsConfig; + } + + $this->appendMultiplex($array, $protocol_settings); + + if ($transport = $this->buildTransport($protocol_settings, $server)) { + $array['transport'] = $transport; + } + + return $array; + } + + protected function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = [ + 'tag' => $server['name'], + 'type' => 'trojan', + 'server' => $server['host'], + 'server_port' => $server['port'], + 'password' => $password, + ]; + + $tlsMode = (int) data_get($protocol_settings, 'tls', 1); + $tlsConfig = ['enabled' => true]; + + switch ($tlsMode) { + case 2: // Reality + $tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false); + $tlsConfig['server_name'] = data_get($protocol_settings, 'reality_settings.server_name'); + $tlsConfig['reality'] = [ + 'enabled' => true, + 'public_key' => data_get($protocol_settings, 'reality_settings.public_key'), + 'short_id' => data_get($protocol_settings, 'reality_settings.short_id'), + ]; + break; + default: // Standard TLS + $tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'server_name')) { + $tlsConfig['server_name'] = $serverName; + } + break; + } + + $this->appendUtls($tlsConfig, $protocol_settings); + $array['tls'] = $tlsConfig; + + $this->appendMultiplex($array, $protocol_settings); + + if ($transport = $this->buildTransport($protocol_settings, $server)) { + $array['transport'] = $transport; + } + return $array; + } + + protected function buildHysteria($password, $server): array + { + $protocol_settings = $server['protocol_settings']; + $baseConfig = [ + 'server' => $server['host'], + 'server_port' => $server['port'], + 'tag' => $server['name'], + 'tls' => [ + 'enabled' => true, + 'insecure' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false), + ] + ]; + // 支持 1.11.0 版本及以上 `server_ports` 和 `hop_interval` 配置 + if ($this->supportsFeature('sing-box', '1.11.0')) { + if (isset($server['ports'])) { + $baseConfig['server_ports'] = [str_replace('-', ':', $server['ports'])]; + } + if (isset($protocol_settings['hop_interval'])) { + $baseConfig['hop_interval'] = "{$protocol_settings['hop_interval']}s"; + } + } + + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $baseConfig['tls']['server_name'] = $serverName; + } + $speedConfig = [ + 'up_mbps' => data_get($protocol_settings, 'bandwidth.up'), + 'down_mbps' => data_get($protocol_settings, 'bandwidth.down'), + ]; + $versionConfig = match (data_get($protocol_settings, 'version', 1)) { + 2 => [ + 'type' => 'hysteria2', + 'password' => $password, + 'obfs' => data_get($protocol_settings, 'obfs.open') ? [ + 'type' => data_get($protocol_settings, 'obfs.type'), + 'password' => data_get($protocol_settings, 'obfs.password') + ] : null, + ], + default => [ + 'type' => 'hysteria', + 'auth_str' => $password, + 'obfs' => data_get($protocol_settings, 'obfs.password'), + 'disable_mtu_discovery' => true, + ] + }; + + return array_filter( + array_merge($baseConfig, $speedConfig, $versionConfig), + fn($v) => !is_null($v) + ); + } + + protected function buildTuic($password, $server): array + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'type' => 'tuic', + 'tag' => $server['name'], + 'server' => $server['host'], + 'server_port' => $server['port'], + 'congestion_control' => data_get($protocol_settings, 'congestion_control', 'cubic'), + 'udp_relay_mode' => data_get($protocol_settings, 'udp_relay_mode', 'native'), + 'zero_rtt_handshake' => true, + 'heartbeat' => '10s', + 'tls' => [ + 'enabled' => true, + 'insecure' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false), + 'alpn' => data_get($protocol_settings, 'alpn', ['h3']), + ] + ]; + + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $array['tls']['server_name'] = $serverName; + } + + if (data_get($protocol_settings, 'version') === 4) { + $array['token'] = $password; + } else { + $array['uuid'] = $password; + $array['password'] = $password; + } + + return $array; + } + + protected function buildAnyTLS($password, $server): array + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'type' => 'anytls', + 'tag' => $server['name'], + 'server' => $server['host'], + 'password' => $password, + 'server_port' => $server['port'], + 'tls' => [ + 'enabled' => true, + 'insecure' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false), + 'alpn' => data_get($protocol_settings, 'alpn', ['h3']), + ] + ]; + + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $array['tls']['server_name'] = $serverName; + } + + return $array; + } + + protected function buildSocks($password, $server): array + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'type' => 'socks', + 'tag' => $server['name'], + 'server' => $server['host'], + 'server_port' => $server['port'], + 'version' => '5', // 默认使用 socks5 + 'username' => $password, + 'password' => $password, + ]; + + if (data_get($protocol_settings, 'udp_over_tcp')) { + $array['udp_over_tcp'] = true; + } + + return $array; + } + + protected function buildHttp($password, $server): array + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'type' => 'http', + 'tag' => $server['name'], + 'server' => $server['host'], + 'server_port' => $server['port'], + 'username' => $password, + 'password' => $password, + ]; + + if ($path = data_get($protocol_settings, 'path')) { + $array['path'] = $path; + } + + if ($headers = data_get($protocol_settings, 'headers')) { + $array['headers'] = $headers; + } + + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = [ + 'enabled' => true, + 'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false), + ]; + + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['tls']['server_name'] = $serverName; + } + } + + return $array; + } + + protected function buildTransport(array $protocol_settings, array $server): ?array + { + $transport = match (data_get($protocol_settings, 'network')) { + 'tcp' => data_get($protocol_settings, 'network_settings.header.type') === 'http' ? [ + 'type' => 'http', + 'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])), + 'host' => data_get($protocol_settings, 'network_settings.header.request.headers.Host', []) + ] : null, + 'ws' => [ + 'type' => 'ws', + 'path' => data_get($protocol_settings, 'network_settings.path'), + 'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null, + 'max_early_data' => 0, + // 'early_data_header_name' => 'Sec-WebSocket-Protocol' + ], + 'grpc' => [ + 'type' => 'grpc', + 'service_name' => data_get($protocol_settings, 'network_settings.serviceName') + ], + 'h2' => [ + 'type' => 'http', + 'host' => data_get($protocol_settings, 'network_settings.host'), + 'path' => data_get($protocol_settings, 'network_settings.path') + ], + 'httpupgrade' => [ + 'type' => 'httpupgrade', + 'path' => data_get($protocol_settings, 'network_settings.path'), + 'host' => data_get($protocol_settings, 'network_settings.host', $server['host']), + 'headers' => data_get($protocol_settings, 'network_settings.headers') + ], + 'quic' => ['type' => 'quic'], + default => null + }; + + if (!$transport) { + return null; + } + + return array_filter($transport, fn($v) => !is_null($v)); + } + + protected function appendMultiplex(&$array, $protocol_settings) + { + if ($multiplex = data_get($protocol_settings, 'multiplex')) { + if (data_get($multiplex, 'enabled')) { + $array['multiplex'] = [ + 'enabled' => true, + 'protocol' => data_get($multiplex, 'protocol', 'yamux'), + 'max_connections' => data_get($multiplex, 'max_connections'), + 'min_streams' => data_get($multiplex, 'min_streams'), + 'max_streams' => data_get($multiplex, 'max_streams'), + 'padding' => (bool) data_get($multiplex, 'padding', false), + ]; + if (data_get($multiplex, 'brutal.enabled')) { + $array['multiplex']['brutal'] = [ + 'enabled' => true, + 'up_mbps' => data_get($multiplex, 'brutal.up_mbps'), + 'down_mbps' => data_get($multiplex, 'brutal.down_mbps'), + ]; + } + $array['multiplex'] = array_filter($array['multiplex'], fn($v) => !is_null($v)); + } + } + } + + protected function appendUtls(&$tlsConfig, $protocol_settings) + { + if ($utls = data_get($protocol_settings, 'utls')) { + if (data_get($utls, 'enabled')) { + $tlsConfig['utls'] = [ + 'enabled' => true, + 'fingerprint' => Helper::getTlsFingerprint($utls) + ]; + } + } + } +} diff --git a/Xboard/app/Protocols/Stash.php b/Xboard/app/Protocols/Stash.php new file mode 100644 index 0000000..8a3eadc --- /dev/null +++ b/Xboard/app/Protocols/Stash.php @@ -0,0 +1,587 @@ + [ + 'trojan' => [ + 'protocol_settings.tls' => [ + '2' => '9999.0.0', // Trojan Reality not supported in Stash + ], + ], + 'vmess' => [ + 'protocol_settings.network' => [ + 'httpupgrade' => '9999.0.0', // httpupgrade not supported in Stash + ], + ], + ], + 'stash' => [ + 'anytls' => [ + 'base_version' => '3.3.0' // AnyTLS 协议在3.3.0版本中添加 + ], + 'vless' => [ + 'protocol_settings.tls' => [ + '2' => '3.1.0' // Reality 在3.1.0版本中添加 + ], + 'protocol_settings.flow' => [ + 'xtls-rprx-vision' => '3.1.0', + ] + ], + 'hysteria' => [ + 'base_version' => '2.0.0', + 'protocol_settings.version' => [ + '1' => '2.0.0', // Hysteria 1 + '2' => '2.5.0' // Hysteria 2,2.5.0 版本开始支持(2023年11月8日) + ], + // 'protocol_settings.ports' => [ + // 'true' => '2.6.4' // Hysteria 2 端口跳转功能于2.6.4版本支持(2024年8月4日) + // ] + ], + 'tuic' => [ + 'base_version' => '2.3.0' // TUIC 协议自身需要 2.3.0+ + ], + 'shadowsocks' => [ + 'base_version' => '2.0.0', + // ShadowSocks2022 在3.0.0版本中添加(2025年4月2日) + 'protocol_settings.cipher' => [ + '2022-blake3-aes-128-gcm' => '3.0.0', + '2022-blake3-aes-256-gcm' => '3.0.0', + '2022-blake3-chacha20-poly1305' => '3.0.0' + ] + ], + 'shadowtls' => [ + 'base_version' => '3.0.0' // ShadowTLS 在3.0.0版本中添加(2025年4月2日) + ], + 'ssh' => [ + 'base_version' => '2.6.4' // SSH 协议在2.6.4中添加(2024年8月4日) + ], + 'juicity' => [ + 'base_version' => '2.6.4' // Juicity 协议在2.6.4中添加(2024年8月4日) + ] + ] + ]; + + const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.stash.yaml'; + const CUSTOM_CLASH_TEMPLATE_FILE = 'resources/rules/custom.clash.yaml'; + const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.clash.yaml'; + + public function handle() + { + $servers = $this->servers; + $user = $this->user; + $appName = admin_setting('app_name', 'XBoard'); + + $template = subscribe_template('stash'); + + $config = Yaml::parse($template); + $proxy = []; + $proxies = []; + + foreach ($servers as $item) { + if ($item['type'] === Server::TYPE_SHADOWSOCKS) { + array_push($proxy, self::buildShadowsocks($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_VMESS) { + array_push($proxy, self::buildVmess($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_VLESS) { + array_push($proxy, $this->buildVless($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_HYSTERIA) { + array_push($proxy, self::buildHysteria($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_TROJAN) { + array_push($proxy, self::buildTrojan($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_TUIC) { + array_push($proxy, self::buildTuic($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_ANYTLS) { + array_push($proxy, self::buildAnyTLS($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_SOCKS) { + array_push($proxy, self::buildSocks5($item['password'], $item)); + array_push($proxies, $item['name']); + } + if ($item['type'] === Server::TYPE_HTTP) { + array_push($proxy, self::buildHttp($item['password'], $item)); + array_push($proxies, $item['name']); + } + } + + $config['proxies'] = array_merge($config['proxies'] ? $config['proxies'] : [], $proxy); + foreach ($config['proxy-groups'] as $k => $v) { + if (!is_array($config['proxy-groups'][$k]['proxies'])) + $config['proxy-groups'][$k]['proxies'] = []; + $isFilter = false; + foreach ($config['proxy-groups'][$k]['proxies'] as $src) { + foreach ($proxies as $dst) { + if (!$this->isRegex($src)) + continue; + $isFilter = true; + $config['proxy-groups'][$k]['proxies'] = array_values(array_diff($config['proxy-groups'][$k]['proxies'], [$src])); + if ($this->isMatch($src, $dst)) { + array_push($config['proxy-groups'][$k]['proxies'], $dst); + } + } + if ($isFilter) + continue; + } + if ($isFilter) + continue; + $config['proxy-groups'][$k]['proxies'] = array_merge($config['proxy-groups'][$k]['proxies'], $proxies); + } + $config['proxy-groups'] = array_filter($config['proxy-groups'], function ($group) { + return $group['proxies']; + }); + $config['proxy-groups'] = array_values($config['proxy-groups']); + // Force the current subscription domain to be a direct rule + $subsDomain = request()->header('Host'); + if ($subsDomain) { + array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); + } + + $yaml = Yaml::dump($config, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); + $yaml = str_replace('$app_name', admin_setting('app_name', 'XBoard'), $yaml); + return response($yaml) + ->header('content-type', 'text/yaml') + ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}") + ->header('profile-update-interval', '24') + ->header('content-disposition', 'attachment;filename*=UTF-8\'\'' . rawurlencode($appName)); + } + + public static function buildShadowsocks($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'ss'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['cipher'] = data_get($protocol_settings, 'cipher'); + $array['password'] = $uuid; + $array['udp'] = true; + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + $array['plugin'] = $plugin; + + // 解析插件选项 + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return []; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + + // 根据插件类型进行字段映射 + switch ($plugin) { + case 'obfs': + $array['plugin-opts'] = [ + 'mode' => $parsedOpts['obfs'], + 'host' => $parsedOpts['obfs-host'], + ]; + + // 可选path参数 + if (isset($parsedOpts['path'])) { + $array['plugin-opts']['path'] = $parsedOpts['path']; + } + break; + + case 'v2ray-plugin': + $array['plugin-opts'] = [ + 'mode' => $parsedOpts['mode'] ?? 'websocket', + 'tls' => isset($parsedOpts['tls']) && $parsedOpts['tls'] == 'true', + 'host' => $parsedOpts['host'] ?? '', + 'path' => $parsedOpts['path'] ?? '/', + ]; + break; + + default: + // 对于其他插件,直接使用解析出的键值对 + $array['plugin-opts'] = $parsedOpts; + } + } + return $array; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'vmess'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['uuid'] = $uuid; + $array['alterId'] = 0; + $array['cipher'] = 'auto'; + $array['udp'] = true; + + $array['tls'] = (bool) data_get($protocol_settings, 'tls'); + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['servername'] = $serverName; + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp'); + $array['network'] = ($headerType === 'http') ? 'http' : 'tcp'; + if ($headerType === 'http') { + $array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']); + if ($host = data_get($protocol_settings, 'network_settings.header.request.headers.Host')) { + $array['http-opts']['headers']['Host'] = $host; + } + } + break; + case 'ws': + $array['network'] = 'ws'; + $array['ws-opts']['path'] = data_get($protocol_settings, 'network_settings.path'); + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $array['ws-opts']['headers'] = ['Host' => $host]; + } + break; + case 'grpc': + $array['network'] = 'grpc'; + $array['grpc-opts'] = []; + $array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName'); + break; + case 'h2': + $array['network'] = 'h2'; + $array['tls'] = true; + $array['h2-opts'] = []; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['h2-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['h2-opts']['host'] = is_array($host) ? $host : [$host]; + break; + default: + break; + } + return $array; + } + + public function buildVless($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = []; + $array['name'] = $server['name']; + $array['type'] = 'vless'; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['uuid'] = $uuid; + $array['udp'] = true; + + if ($fingerprint = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) { + $array['client-fingerprint'] = $fingerprint; + } + + switch (data_get($protocol_settings, 'tls')) { + case 1: + $array['tls'] = true; + $array['skip-cert-verify'] = data_get($protocol_settings, 'tls_settings.allow_insecure'); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['servername'] = $serverName; + } + break; + case 2: + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) { + $array['servername'] = $serverName; + $array['sni'] = $serverName; + } + $array['flow'] = data_get($protocol_settings, 'flow'); + $array['reality-opts'] = [ + 'public-key' => data_get($protocol_settings, 'reality_settings.public_key'), + 'short-id' => data_get($protocol_settings, 'reality_settings.short_id') + ]; + break; + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp'); + $array['network'] = ($headerType === 'http') ? 'http' : 'tcp'; + if ($headerType === 'http') { + if ( + $httpOpts = array_filter([ + 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), + 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) + ]) + ) { + $array['http-opts'] = $httpOpts; + } + } + break; + case 'ws': + $array['network'] = 'ws'; + $array['ws-opts']['path'] = data_get($protocol_settings, 'network_settings.path'); + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $array['ws-opts']['headers'] = ['Host' => $host]; + } + break; + case 'grpc': + $array['network'] = 'grpc'; + $array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName'); + break; + case 'h2': + $array['network'] = 'h2'; + $array['h2-opts'] = []; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['h2-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['h2-opts']['host'] = is_array($host) ? $host : [$host]; + break; + } + + return $array; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = [ + 'name' => $server['name'], + 'type' => 'trojan', + 'server' => $server['host'], + 'port' => $server['port'], + 'password' => $password, + 'udp' => true, + ]; + + $tlsMode = (int) data_get($protocol_settings, 'tls', 1); + switch ($tlsMode) { + case 2: // Reality + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) { + $array['sni'] = $serverName; + } + $array['reality-opts'] = [ + 'public-key' => data_get($protocol_settings, 'reality_settings.public_key'), + 'short-id' => data_get($protocol_settings, 'reality_settings.short_id'), + ]; + break; + default: // Standard TLS + if ($serverName = data_get($protocol_settings, 'server_name')) { + $array['sni'] = $serverName; + } + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false); + break; + } + + switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp'); + $array['network'] = ($headerType === 'http') ? 'http' : 'tcp'; + if ($headerType === 'http') { + $array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']); + } + break; + case 'ws': + $array['network'] = 'ws'; + $array['ws-opts']['path'] = data_get($protocol_settings, 'network_settings.path'); + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $array['ws-opts']['headers'] = ['Host' => $host]; + } + break; + case 'grpc': + $array['network'] = 'grpc'; + if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) + $array['grpc-opts']['grpc-service-name'] = $serviceName; + break; + } + + return $array; + } + + public static function buildHysteria($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array['name'] = $server['name']; + $array['server'] = $server['host']; + $array['port'] = $server['port']; + $array['up-speed'] = data_get($protocol_settings, 'bandwidth.up'); + $array['down-speed'] = data_get($protocol_settings, 'bandwidth.down'); + $array['skip-cert-verify'] = data_get($protocol_settings, 'tls.allow_insecure'); + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $array['sni'] = $serverName; + } + if (isset($server['ports'])) { + $array['ports'] = $server['ports']; + } + switch (data_get($protocol_settings, 'version')) { + case 1: + $array['type'] = 'hysteria'; + $array['auth-str'] = $password; + $array['protocol'] = 'udp'; + if (data_get($protocol_settings, 'obfs.open')) { + $array['obfs'] = data_get($protocol_settings, 'obfs.password'); + } + break; + case 2: + $array['type'] = 'hysteria2'; + $array['auth'] = $password; + $array['fast-open'] = true; + if (data_get($protocol_settings, 'obfs.open')) { + $array['obfs'] = data_get($protocol_settings, 'obfs.type', 'salamander'); + $array['obfs-password'] = data_get($protocol_settings, 'obfs.password'); + } + break; + } + return $array; + } + + public static function buildTuic($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'tuic', + 'server' => $server['host'], + 'port' => $server['port'], + 'congestion-controller' => data_get($protocol_settings, 'congestion_control', 'cubic'), + 'udp-relay-mode' => data_get($protocol_settings, 'udp_relay_mode', 'native'), + 'alpn' => data_get($protocol_settings, 'alpn', ['h3']), + 'reduce-rtt' => true, + 'fast-open' => true, + 'heartbeat-interval' => 10000, + 'request-timeout' => 8000, + 'max-udp-relay-packet-size' => 1500, + 'version' => data_get($protocol_settings, 'version', 5), + ]; + + if (data_get($protocol_settings, 'version') === 4) { + $array['token'] = $password; + } else { + $array['uuid'] = $password; + $array['password'] = $password; + } + + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $array['sni'] = $serverName; + } + + return $array; + } + + public static function buildAnyTLS($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'name' => $server['name'], + 'type' => 'anytls', + 'server' => $server['host'], + 'port' => $server['port'], + 'password' => $password, + 'sni' => data_get($protocol_settings, 'tls.server_name'), + 'skip-cert-verify' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false), + 'udp' => true, + ]; + + return $array; + } + + public static function buildSocks5($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = [ + 'name' => $server['name'], + 'type' => 'socks5', + 'server' => $server['host'], + 'port' => $server['port'], + 'username' => $password, + 'password' => $password, + 'udp' => true, + ]; + + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['sni'] = $serverName; + } + } + + return $array; + } + + public static function buildHttp($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $array = [ + 'name' => $server['name'], + 'type' => 'http', + 'server' => $server['host'], + 'port' => $server['port'], + 'username' => $password, + 'password' => $password, + ]; + + if (data_get($protocol_settings, 'tls')) { + $array['tls'] = true; + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['sni'] = $serverName; + } + } + + return $array; + } + + private function isRegex($exp) + { + if (empty($exp)) { + return false; + } + try { + return preg_match($exp, '') !== false; + } catch (\Exception $e) { + return false; + } + } + + private function isMatch($exp, $str) + { + try { + return preg_match($exp, $str); + } catch (\Exception $e) { + return false; + } + } +} diff --git a/Xboard/app/Protocols/Surfboard.php b/Xboard/app/Protocols/Surfboard.php new file mode 100644 index 0000000..23fcf09 --- /dev/null +++ b/Xboard/app/Protocols/Surfboard.php @@ -0,0 +1,229 @@ +servers; + $user = $this->user; + + $appName = admin_setting('app_name', 'XBoard'); + + $proxies = ''; + $proxyGroup = ''; + + foreach ($servers as $item) { + if ( + $item['type'] === Server::TYPE_SHADOWSOCKS + && in_array(data_get($item, 'protocol_settings.cipher'), [ + 'aes-128-gcm', + 'aes-192-gcm', + 'aes-256-gcm', + 'chacha20-ietf-poly1305', + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-chacha20-poly1305' + ]) + ) { + // [Proxy] + $proxies .= self::buildShadowsocks($item['password'], $item); + // [Proxy Group] + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_VMESS) { + // [Proxy] + $proxies .= self::buildVmess($item['password'], $item); + // [Proxy Group] + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_TROJAN) { + // [Proxy] + $proxies .= self::buildTrojan($item['password'], $item); + // [Proxy Group] + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_ANYTLS) { + $proxies .= self::buildAnyTLS($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + } + + $config = subscribe_template('surfboard'); + // Subscription link + $subsURL = Helper::getSubscribeUrl($user['token']); + $subsDomain = request()->header('Host'); + + $config = str_replace('$subs_link', $subsURL, $config); + $config = str_replace('$subs_domain', $subsDomain, $config); + $config = str_replace('$proxies', $proxies, $config); + $config = str_replace('$proxy_group', rtrim($proxyGroup, ', '), $config); + + $upload = round($user['u'] / (1024 * 1024 * 1024), 2); + $download = round($user['d'] / (1024 * 1024 * 1024), 2); + $useTraffic = $upload + $download; + $totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2); + $unusedTraffic = $totalTraffic - $useTraffic; + $expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']); + $subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{$unusedTraffic}GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}"; + $config = str_replace('$subscribe_info', $subscribeInfo, $config); + + return response($config, 200) + ->header('content-disposition', "attachment;filename*=UTF-8''" . rawurlencode($appName) . ".conf"); + } + + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']}=ss", + "{$server['host']}", + "{$server['port']}", + "encrypt-method=" . data_get($protocol_settings, 'cipher'), + "password={$password}", + 'tfo=true', + 'udp-relay=true' + ]; + + + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + // 解析插件选项 + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return []; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + switch ($plugin) { + case 'obfs': + $config[] = "obfs={$parsedOpts['obfs']}"; + if (isset($parsedOpts['obfs-host'])) { + $config[] = "obfs-host={$parsedOpts['obfs-host']}"; + } + if (isset($parsedOpts['path'])) { + $config[] = "obfs-uri={$parsedOpts['path']}"; + } + break; + } + } + + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']}=vmess", + "{$server['host']}", + "{$server['port']}", + "username={$uuid}", + "vmess-aead=true", + 'tfo=true', + 'udp-relay=true' + ]; + + if (data_get($protocol_settings, 'tls')) { + array_push($config, 'tls=true'); + if (data_get($protocol_settings, 'tls_settings')) { + $tlsSettings = data_get($protocol_settings, 'tls_settings'); + if (data_get($tlsSettings, 'allow_insecure')) { + array_push($config, 'skip-cert-verify=' . ($tlsSettings['allow_insecure'] ? 'true' : 'false')); + } + if ($sni = data_get($tlsSettings, 'server_name')) { + array_push($config, "sni={$sni}"); + } + } + } + if (data_get($protocol_settings, 'network') === 'ws') { + array_push($config, 'ws=true'); + if (data_get($protocol_settings, 'network_settings')) { + $wsSettings = data_get($protocol_settings, 'network_settings'); + if (isset($wsSettings['path']) && !empty($wsSettings['path'])) + array_push($config, "ws-path={$wsSettings['path']}"); + if (isset($wsSettings['headers']['Host']) && !empty($wsSettings['headers']['Host'])) + array_push($config, "ws-headers=Host:{$wsSettings['headers']['Host']}"); + } + } + + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']}=trojan", + "{$server['host']}", + "{$server['port']}", + "password={$password}", + data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "", + 'tfo=true', + 'udp-relay=true' + ]; + if (data_get($protocol_settings, 'allow_insecure')) { + array_push($config, !!data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false'); + } + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildAnyTLS($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + + $config = [ + "{$server['name']}=anytls", + "{$server['host']}", + "{$server['port']}", + "password={$password}", + "tfo=true", + "udp-relay=true" + ]; + + // SNI + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $config[] = "sni={$serverName}"; + } + + // 跳过证书校验 + if (data_get($protocol_settings, 'tls.allow_insecure')) { + $config[] = "skip-cert-verify=true"; + } + + $config = array_filter($config); + + return implode(',', $config) . "\r\n"; + } +} diff --git a/Xboard/app/Protocols/Surge.php b/Xboard/app/Protocols/Surge.php new file mode 100644 index 0000000..ee1ea62 --- /dev/null +++ b/Xboard/app/Protocols/Surge.php @@ -0,0 +1,319 @@ + [2 => '2398'], + ]; + + public function handle() + { + $servers = $this->servers; + $user = $this->user; + + $appName = admin_setting('app_name', 'XBoard'); + + $proxies = ''; + $proxyGroup = ''; + + foreach ($servers as $item) { + if ( + $item['type'] === Server::TYPE_SHADOWSOCKS + && in_array(data_get($item, 'protocol_settings.cipher'), [ + 'aes-128-gcm', + 'aes-192-gcm', + 'aes-256-gcm', + 'chacha20-ietf-poly1305', + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm' + ]) + ) { + $proxies .= self::buildShadowsocks($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_VMESS) { + $proxies .= self::buildVmess($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_TROJAN) { + $proxies .= self::buildTrojan($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_HYSTERIA) { + $proxies .= self::buildHysteria($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_ANYTLS) { + $proxies .= self::buildAnyTLS($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_SOCKS) { + $proxies .= self::buildSocks($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + if ($item['type'] === Server::TYPE_HTTP) { + $proxies .= self::buildHttp($item['password'], $item); + $proxyGroup .= $item['name'] . ', '; + } + } + + + $config = subscribe_template('surge'); + + // Subscription link + $subsDomain = request()->header('Host'); + $subsURL = Helper::getSubscribeUrl($user['token'], $subsDomain ? 'https://' . $subsDomain : null); + + $config = str_replace('$subs_link', $subsURL, $config); + $config = str_replace('$subs_domain', $subsDomain, $config); + $config = str_replace('$proxies', $proxies, $config); + $config = str_replace('$proxy_group', rtrim($proxyGroup, ', '), $config); + + $upload = round($user['u'] / (1024 * 1024 * 1024), 2); + $download = round($user['d'] / (1024 * 1024 * 1024), 2); + $useTraffic = $upload + $download; + $totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2); + $unusedTraffic = $totalTraffic - $useTraffic; + $expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']); + $subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{$unusedTraffic}GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}"; + $config = str_replace('$subscribe_info', $subscribeInfo, $config); + + return response($config, 200) + ->header('content-type', 'application/octet-stream') + ->header('content-disposition', "attachment;filename*=UTF-8''" . rawurlencode($appName) . ".conf"); + } + + + public static function buildShadowsocks($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']} = ss", + "{$server['host']}", + "{$server['port']}", + "encrypt-method={$protocol_settings['cipher']}", + "password={$password}", + 'tfo=true', + 'udp-relay=true' + ]; + if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) { + $plugin = data_get($protocol_settings, 'plugin'); + $pluginOpts = data_get($protocol_settings, 'plugin_opts', ''); + // 解析插件选项 + $parsedOpts = collect(explode(';', $pluginOpts)) + ->filter() + ->mapWithKeys(function ($pair) { + if (!str_contains($pair, '=')) { + return []; + } + [$key, $value] = explode('=', $pair, 2); + return [trim($key) => trim($value)]; + }) + ->all(); + switch ($plugin) { + case 'obfs': + $config[] = "obfs={$parsedOpts['obfs']}"; + if (isset($parsedOpts['obfs-host'])) { + $config[] = "obfs-host={$parsedOpts['obfs-host']}"; + } + if (isset($parsedOpts['path'])) { + $config[] = "obfs-uri={$parsedOpts['path']}"; + } + break; + } + } + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildVmess($uuid, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']} = vmess", + "{$server['host']}", + "{$server['port']}", + "username={$uuid}", + "vmess-aead=true", + 'tfo=true', + 'udp-relay=true' + ]; + + if (data_get($protocol_settings, 'tls')) { + array_push($config, 'tls=true'); + if (data_get($protocol_settings, 'tls_settings')) { + $tlsSettings = data_get($protocol_settings, 'tls_settings'); + if (data_get($tlsSettings, 'allow_insecure')) + array_push($config, 'skip-cert-verify=' . ($tlsSettings['allow_insecure'] ? 'true' : 'false')); + if (data_get($tlsSettings, 'server_name')) + array_push($config, "sni={$tlsSettings['server_name']}"); + } + } + if (data_get($protocol_settings, 'network') === 'ws') { + array_push($config, 'ws=true'); + if (data_get($protocol_settings, 'network_settings')) { + $wsSettings = data_get($protocol_settings, 'network_settings'); + if (data_get($wsSettings, 'path')) + array_push($config, "ws-path={$wsSettings['path']}"); + if (data_get($wsSettings, 'headers.Host')) + array_push($config, "ws-headers=Host:{$wsSettings['headers']['Host']}"); + } + } + + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + public static function buildTrojan($password, $server) + { + $protocol_settings = $server['protocol_settings']; + $config = [ + "{$server['name']} = trojan", + "{$server['host']}", + "{$server['port']}", + "password={$password}", + data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "", + 'tfo=true', + 'udp-relay=true' + ]; + if (!empty($protocol_settings['allow_insecure'])) { + array_push($config, !!data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false'); + } + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + //参考文档: https://manual.nssurge.com/policy/proxy.html + public static function buildAnyTLS($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $config = [ + "{$server['name']} = anytls", + "{$server['host']}", + "{$server['port']}", + "password={$password}", + ]; + if ($serverName = data_get($protocol_settings, 'tls.server_name')) { + $config[] = "sni={$serverName}"; + } + if (data_get($protocol_settings, 'tls.allow_insecure')) { + $config[] = 'skip-cert-verify=true'; + } + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + //参考文档: https://manual.nssurge.com/policy/proxy.html + public static function buildHysteria($password, $server) + { + $protocol_settings = $server['protocol_settings']; + if ($protocol_settings['version'] != 2) + return ''; + $config = [ + "{$server['name']} = hysteria2", + "{$server['host']}", + "{$server['port']}", + "password={$password}", + $protocol_settings['tls']['server_name'] ? "sni={$protocol_settings['tls']['server_name']}" : "", + // 'tfo=true', + 'udp-relay=true' + ]; + if (data_get($protocol_settings, 'bandwidth.up')) { + $config[] = "upload-bandwidth={$protocol_settings['bandwidth']['up']}"; + } + if (data_get($protocol_settings, 'bandwidth.down')) { + $config[] = "download-bandwidth={$protocol_settings['bandwidth']['down']}"; + } + if (data_get($protocol_settings, 'tls.allow_insecure')) { + $config[] = !!data_get($protocol_settings, 'tls.allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false'; + } + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + //参考文档: https://manual.nssurge.com/policy/proxy.html + public static function buildSocks($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $type = data_get($protocol_settings, 'tls') ? 'socks5-tls' : 'socks5'; + $config = [ + "{$server['name']} = {$type}", + "{$server['host']}", + "{$server['port']}", + "{$password}", + "{$password}", + ]; + + if (data_get($protocol_settings, 'tls')) { + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $config[] = "sni={$serverName}"; + } + if (data_get($protocol_settings, 'tls_settings.allow_insecure')) { + $config[] = 'skip-cert-verify=true'; + } + } + $config[] = 'udp-relay=true'; + + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } + + //参考文档: https://manual.nssurge.com/policy/proxy.html + public static function buildHttp($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $type = data_get($protocol_settings, 'tls') ? 'https' : 'http'; + $config = [ + "{$server['name']} = {$type}", + "{$server['host']}", + "{$server['port']}", + "{$password}", + "{$password}", + ]; + + if (data_get($protocol_settings, 'tls')) { + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $config[] = "sni={$serverName}"; + } + if (data_get($protocol_settings, 'tls_settings.allow_insecure')) { + $config[] = 'skip-cert-verify=true'; + } + } + + $config = array_filter($config); + $uri = implode(',', $config); + $uri .= "\r\n"; + return $uri; + } +} diff --git a/Xboard/app/Providers/AuthServiceProvider.php b/Xboard/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..b92a7dd --- /dev/null +++ b/Xboard/app/Providers/AuthServiceProvider.php @@ -0,0 +1,28 @@ + + */ + protected $policies = [ + // 'App\Model' => 'App\Policies\ModelPolicy', + ]; + + /** + * 注册任何认证/授权服务 + * @return void + */ + public function boot() + { + $this->registerPolicies(); + + // + } +} diff --git a/Xboard/app/Providers/BroadcastServiceProvider.php b/Xboard/app/Providers/BroadcastServiceProvider.php new file mode 100644 index 0000000..395c518 --- /dev/null +++ b/Xboard/app/Providers/BroadcastServiceProvider.php @@ -0,0 +1,21 @@ +> + */ + protected $listen = [ + ]; + + /** + * 注册任何事件 + * @return void + */ + public function boot() + { + parent::boot(); + + User::observe(UserObserver::class); + Plan::observe(PlanObserver::class); + Server::observe(ServerObserver::class); + ServerRoute::observe(ServerRouteObserver::class); + + + } +} diff --git a/Xboard/app/Providers/HorizonServiceProvider.php b/Xboard/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..119fd69 --- /dev/null +++ b/Xboard/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,43 @@ +email, [ + // + ]); + }); + } +} diff --git a/Xboard/app/Providers/OctaneServiceProvider.php b/Xboard/app/Providers/OctaneServiceProvider.php new file mode 100644 index 0000000..4cdd91c --- /dev/null +++ b/Xboard/app/Providers/OctaneServiceProvider.php @@ -0,0 +1,43 @@ +app->runningInConsole()) { + return; + } + if ($this->app->bound('octane')) { + $this->app['events']->listen(WorkerStarting::class, function () { + app(UpdateService::class)->updateVersionCache(); + HookManager::reset(); + }); + } + // 每半钟执行一次调度检查 + Octane::tick('scheduler', function () { + $lock = Cache::lock('scheduler-lock', 30); + + if ($lock->get()) { + try { + Artisan::call('schedule:run'); + } finally { + $lock->release(); + } + } + })->seconds(30); + } +} \ No newline at end of file diff --git a/Xboard/app/Providers/PluginServiceProvider.php b/Xboard/app/Providers/PluginServiceProvider.php new file mode 100644 index 0000000..f0352ec --- /dev/null +++ b/Xboard/app/Providers/PluginServiceProvider.php @@ -0,0 +1,29 @@ +app->scoped(PluginManager::class, function ($app) { + return new PluginManager(); + }); + } + + public function boot(): void + { + if (!file_exists(base_path('plugins'))) { + mkdir(base_path('plugins'), 0755, true); + } + } +} \ No newline at end of file diff --git a/Xboard/app/Providers/ProtocolServiceProvider.php b/Xboard/app/Providers/ProtocolServiceProvider.php new file mode 100644 index 0000000..dfa80ee --- /dev/null +++ b/Xboard/app/Providers/ProtocolServiceProvider.php @@ -0,0 +1,50 @@ +app->scoped('protocols.manager', function ($app) { + return new ProtocolManager($app); + }); + + $this->app->scoped('protocols.flags', function ($app) { + return $app->make('protocols.manager')->getAllFlags(); + }); + } + + /** + * 启动服务 + * + * @return void + */ + public function boot() + { + // 在启动时预加载协议类并缓存 + $this->app->make('protocols.manager')->registerAllProtocols(); + + } + + /** + * 提供的服务 + * + * @return array + */ + public function provides() + { + return [ + 'protocols.manager', + 'protocols.flags', + ]; + } +} \ No newline at end of file diff --git a/Xboard/app/Providers/RouteServiceProvider.php b/Xboard/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..d26a599 --- /dev/null +++ b/Xboard/app/Providers/RouteServiceProvider.php @@ -0,0 +1,87 @@ +mapApiRoutes(); + $this->mapWebRoutes(); + + // + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->namespace) + ->group(base_path('routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::group([ + 'prefix' => '/api/v1', + 'middleware' => 'api', + 'namespace' => $this->namespace + ], function ($router) { + foreach (glob(app_path('Http//Routes//V1') . '/*.php') as $file) { + $this->app->make('App\\Http\\Routes\\V1\\' . basename($file, '.php'))->map($router); + } + }); + + + Route::group([ + 'prefix' => '/api/v2', + 'middleware' => 'api', + 'namespace' => $this->namespace + ], function ($router) { + foreach (glob(app_path('Http//Routes//V2') . '/*.php') as $file) { + $this->app->make('App\\Http\\Routes\\V2\\' . basename($file, '.php'))->map($router); + } + }); + } +} diff --git a/Xboard/app/Providers/SettingServiceProvider.php b/Xboard/app/Providers/SettingServiceProvider.php new file mode 100644 index 0000000..eee267f --- /dev/null +++ b/Xboard/app/Providers/SettingServiceProvider.php @@ -0,0 +1,33 @@ +app->scoped(Setting::class, function (Application $app) { + return new Setting(); + }); + + } + + /** + * Bootstrap services. + * + * @return void + */ + public function boot() + { + // App URL is forced per-request via middleware (Octane-safe). + } +} diff --git a/Xboard/app/Scope/FilterScope.php b/Xboard/app/Scope/FilterScope.php new file mode 100644 index 0000000..fcb92a5 --- /dev/null +++ b/Xboard/app/Scope/FilterScope.php @@ -0,0 +1,50 @@ +validate([ + 'filter.*.key' => "required|in:{$allowKeys}", + 'filter.*.condition' => 'required|in:in,is,not,like,lt,gt', + 'filter.*.value' => 'required' + ]); + $filters = $request->input('filter'); + if ($filters) { + foreach ($filters as $k => $filter) { + if ($filter['condition'] === 'in') { + $builder->whereIn($filter['key'], $filter['value']); + continue; + } + if ($filter['condition'] === 'is') { + $builder->where($filter['key'], $filter['value']); + continue; + } + if ($filter['condition'] === 'not') { + $builder->where($filter['key'], '!=', $filter['value']); + continue; + } + if ($filter['condition'] === 'gt') { + $builder->where($filter['key'], '>', $filter['value']); + continue; + } + if ($filter['condition'] === 'lt') { + $builder->where($filter['key'], '<', $filter['value']); + continue; + } + if ($filter['condition'] === 'like') { + $builder->where($filter['key'], 'like', "%{$filter['value']}%"); + continue; + } + } + } + return $builder; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Auth/LoginService.php b/Xboard/app/Services/Auth/LoginService.php new file mode 100644 index 0000000..363e82b --- /dev/null +++ b/Xboard/app/Services/Auth/LoginService.php @@ -0,0 +1,154 @@ += (int) admin_setting('password_limit_count', 5)) { + return [ + false, + [ + 429, + __('There are too many password errors, please try again after :minute minutes.', [ + 'minute' => admin_setting('password_limit_expire', 60) + ]) + ] + ]; + } + } + + // 查找用户 + $user = User::byEmail($email)->first(); + if (!$user) { + return [false, [400, __('Incorrect email or password')]]; + } + + // 验证密码 + if ( + !Helper::multiPasswordVerify( + $user->password_algo, + $user->password_salt, + $password, + $user->password + ) + ) { + // 增加密码错误计数 + if ((int) admin_setting('password_limit_enable', true)) { + $passwordErrorCount = (int) Cache::get(CacheKey::get('PASSWORD_ERROR_LIMIT', $email), 0); + Cache::put( + CacheKey::get('PASSWORD_ERROR_LIMIT', $email), + (int) $passwordErrorCount + 1, + 60 * (int) admin_setting('password_limit_expire', 60) + ); + } + return [false, [400, __('Incorrect email or password')]]; + } + + // 检查账户状态 + if ($user->banned) { + return [false, [400, __('Your account has been suspended')]]; + } + + // 更新最后登录时间 + $user->last_login_at = time(); + $user->save(); + + HookManager::call('user.login.after', $user); + return [true, $user]; + } + + /** + * 处理密码重置 + * + * @param string $email 用户邮箱 + * @param string $emailCode 邮箱验证码 + * @param string $password 新密码 + * @return array [成功状态, 结果或错误信息] + */ + public function resetPassword(string $email, string $emailCode, string $password): array + { + // 检查重置请求限制 + $forgetRequestLimitKey = CacheKey::get('FORGET_REQUEST_LIMIT', $email); + $forgetRequestLimit = (int) Cache::get($forgetRequestLimitKey); + if ($forgetRequestLimit >= 3) { + return [false, [429, __('Reset failed, Please try again later')]]; + } + + // 验证邮箱验证码 + if ((string) Cache::get(CacheKey::get('EMAIL_VERIFY_CODE', $email)) !== (string) $emailCode) { + Cache::put($forgetRequestLimitKey, $forgetRequestLimit ? $forgetRequestLimit + 1 : 1, 300); + return [false, [400, __('Incorrect email verification code')]]; + } + + // 查找用户 + $user = User::byEmail($email)->first(); + if (!$user) { + return [false, [400, __('This email is not registered in the system')]]; + } + + // 更新密码 + $user->password = password_hash($password, PASSWORD_DEFAULT); + $user->password_algo = NULL; + $user->password_salt = NULL; + + if (!$user->save()) { + return [false, [500, __('Reset failed')]]; + } + + HookManager::call('user.password.reset.after', $user); + + // 清除邮箱验证码 + Cache::forget(CacheKey::get('EMAIL_VERIFY_CODE', $email)); + + return [true, true]; + } + + + /** + * 生成临时登录令牌和快速登录URL + * + * @param User $user 用户对象 + * @param string $redirect 重定向路径 + * @return string|null 快速登录URL + */ + public function generateQuickLoginUrl(User $user, ?string $redirect = null): ?string + { + if (!$user || !$user->exists) { + return null; + } + + $code = Helper::guid(); + $key = CacheKey::get('TEMP_TOKEN', $code); + + Cache::put($key, $user->id, 60); + + $redirect = $redirect ?: 'dashboard'; + $loginRedirect = '/#/login?verify=' . $code . '&redirect=' . rawurlencode($redirect); + + if (admin_setting('app_url')) { + $url = admin_setting('app_url') . $loginRedirect; + } else { + $url = url($loginRedirect); + } + + return $url; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Auth/MailLinkService.php b/Xboard/app/Services/Auth/MailLinkService.php new file mode 100644 index 0000000..259ced7 --- /dev/null +++ b/Xboard/app/Services/Auth/MailLinkService.php @@ -0,0 +1,100 @@ +first(); + if (!$user) { + return [true, true]; // 成功但用户不存在,保护用户隐私 + } + + $code = Helper::guid(); + $key = CacheKey::get('TEMP_TOKEN', $code); + Cache::put($key, $user->id, 300); + Cache::put(CacheKey::get('LAST_SEND_LOGIN_WITH_MAIL_LINK_TIMESTAMP', $email), time(), 60); + + $redirectUrl = '/#/login?verify=' . $code . '&redirect=' . ($redirect ? $redirect : 'dashboard'); + if (admin_setting('app_url')) { + $link = admin_setting('app_url') . $redirectUrl; + } else { + $link = url($redirectUrl); + } + + $this->sendMailLinkEmail($user, $link); + + return [true, $link]; + } + + /** + * 发送邮件链接登录邮件 + * + * @param User $user 用户对象 + * @param string $link 登录链接 + * @return void + */ + private function sendMailLinkEmail(User $user, string $link): void + { + SendEmailJob::dispatch([ + 'email' => $user->email, + 'subject' => __('Login to :name', [ + 'name' => admin_setting('app_name', 'XBoard') + ]), + 'template_name' => 'login', + 'template_value' => [ + 'name' => admin_setting('app_name', 'XBoard'), + 'link' => $link, + 'url' => admin_setting('app_url') + ] + ]); + } + + /** + * 处理Token登录 + * + * @param string $token 登录令牌 + * @return int|null 用户ID或null + */ + public function handleTokenLogin(string $token): ?int + { + $key = CacheKey::get('TEMP_TOKEN', $token); + $userId = Cache::get($key); + + if (!$userId) { + return null; + } + + $user = User::find($userId); + + if (!$user || $user->banned) { + return null; + } + + Cache::forget($key); + + return $userId; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Auth/RegisterService.php b/Xboard/app/Services/Auth/RegisterService.php new file mode 100644 index 0000000..78d207e --- /dev/null +++ b/Xboard/app/Services/Auth/RegisterService.php @@ -0,0 +1,193 @@ +ip())) ?? 0; + if ((int) $registerCountByIP >= (int) admin_setting('register_limit_count', 3)) { + return [ + false, + [ + 429, + __('Register frequently, please try again after :minute minute', [ + 'minute' => admin_setting('register_limit_expire', 60) + ]) + ] + ]; + } + } + + // 检查验证码 + $captchaService = app(CaptchaService::class); + [$captchaValid, $captchaError] = $captchaService->verify($request); + if (!$captchaValid) { + return [false, $captchaError]; + } + + // 检查邮箱白名单 + if ((int) admin_setting('email_whitelist_enable', 0)) { + if ( + !Helper::emailSuffixVerify( + $request->input('email'), + admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT) + ) + ) { + return [false, [400, __('Email suffix is not in the Whitelist')]]; + } + } + + // 检查Gmail限制 + if ((int) admin_setting('email_gmail_limit_enable', 0)) { + $prefix = explode('@', $request->input('email'))[0]; + if (strpos($prefix, '.') !== false || strpos($prefix, '+') !== false) { + return [false, [400, __('Gmail alias is not supported')]]; + } + } + + // 检查是否关闭注册 + if ((int) admin_setting('stop_register', 0)) { + return [false, [400, __('Registration has closed')]]; + } + + // 检查邀请码要求 + if ((int) admin_setting('invite_force', 0)) { + if (empty($request->input('invite_code'))) { + return [false, [422, __('You must use the invitation code to register')]]; + } + } + + // 检查邮箱验证 + if ((int) admin_setting('email_verify', 0)) { + if (empty($request->input('email_code'))) { + return [false, [422, __('Email verification code cannot be empty')]]; + } + if ((string) Cache::get(CacheKey::get('EMAIL_VERIFY_CODE', $request->input('email'))) !== (string) $request->input('email_code')) { + return [false, [400, __('Incorrect email verification code')]]; + } + } + + // 检查邮箱是否存在 + $exist = User::byEmail($request->input('email'))->first(); + if ($exist) { + return [false, [400201, __('Email already exists')]]; + } + + return [true, null]; + } + + /** + * 处理邀请码 + * + * @param string $inviteCode 邀请码 + * @return int|null 邀请人ID + */ + public function handleInviteCode(string $inviteCode): int|null + { + $inviteCodeModel = InviteCode::where('code', $inviteCode) + ->where('status', InviteCode::STATUS_UNUSED) + ->first(); + + if (!$inviteCodeModel) { + if ((int) admin_setting('invite_force', 0)) { + throw new ApiException(__('Invalid invitation code')); + } + return null; + } + + if (!(int) admin_setting('invite_never_expire', 0)) { + $inviteCodeModel->status = InviteCode::STATUS_USED; + $inviteCodeModel->save(); + } + + return $inviteCodeModel->user_id; + } + + + + /** + * 注册用户 + * + * @param Request $request 请求对象 + * @return array [成功状态, 用户对象或错误信息] + */ + public function register(Request $request): array + { + // 验证注册数据 + [$valid, $error] = $this->validateRegister($request); + if (!$valid) { + return [false, $error]; + } + + HookManager::call('user.register.before', $request); + + $email = $request->input('email'); + $password = $request->input('password'); + $inviteCode = $request->input('invite_code'); + + // 处理邀请码获取邀请人ID + $inviteUserId = null; + if ($inviteCode) { + $inviteUserId = $this->handleInviteCode($inviteCode); + } + + // 创建用户 + $userService = app(UserService::class); + $user = $userService->createUser([ + 'email' => $email, + 'password' => $password, + 'invite_user_id' => $inviteUserId, + ]); + + // 保存用户 + if (!$user->save()) { + return [false, [500, __('Register failed')]]; + } + + HookManager::call('user.register.after', $user); + + // 清除邮箱验证码 + if ((int) admin_setting('email_verify', 0)) { + Cache::forget(CacheKey::get('EMAIL_VERIFY_CODE', $email)); + } + + // 更新最近登录时间 + $user->last_login_at = time(); + $user->save(); + + // 更新IP注册计数 + if ((int) admin_setting('register_limit_by_ip_enable', 0)) { + $registerCountByIP = Cache::get(CacheKey::get('REGISTER_IP_RATE_LIMIT', $request->ip())) ?? 0; + Cache::put( + CacheKey::get('REGISTER_IP_RATE_LIMIT', $request->ip()), + (int) $registerCountByIP + 1, + (int) admin_setting('register_limit_expire', 60) * 60 + ); + } + + return [true, $user]; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/AuthService.php b/Xboard/app/Services/AuthService.php new file mode 100644 index 0000000..299f610 --- /dev/null +++ b/Xboard/app/Services/AuthService.php @@ -0,0 +1,87 @@ +user = $user; + } + + public function generateAuthData(): array + { + // Create a new Sanctum token with device info + $token = $this->user->createToken( + Str::random(20), // token name (device identifier) + ['*'], // abilities + now()->addYear() // expiration + ); + + // Format token: remove ID prefix and add Bearer + $tokenParts = explode('|', $token->plainTextToken); + $formattedToken = 'Bearer ' . ($tokenParts[1] ?? $tokenParts[0]); + + return [ + 'token' => $this->user->token, + 'auth_data' => $formattedToken, + 'is_admin' => $this->user->is_admin, + ]; + } + + public function getSessions(): array + { + return $this->user->tokens()->get()->toArray(); + } + + public function removeSession(string $sessionId): bool + { + $this->user->tokens()->where('id', $sessionId)->delete(); + return true; + } + + public function removeAllSessions(): bool + { + $this->user->tokens()->delete(); + return true; + } + + public static function findUserByBearerToken(string $bearerToken): ?User + { + $token = str_replace('Bearer ', '', $bearerToken); + + $accessToken = PersonalAccessToken::findToken($token); + + $tokenable = $accessToken?->tokenable; + + return $tokenable instanceof User ? $tokenable : null; + } + + /** + * 解密认证数据 + * + * @param string $authorization + * @return array|null 用户数据或null + */ + public static function decryptAuthData(string $authorization): ?array + { + $user = self::findUserByBearerToken($authorization); + + if (!$user) { + return null; + } + + return [ + 'id' => $user->id, + 'email' => $user->email, + 'is_admin' => (bool)$user->is_admin, + 'is_staff' => (bool)$user->is_staff + ]; + } +} diff --git a/Xboard/app/Services/CaptchaService.php b/Xboard/app/Services/CaptchaService.php new file mode 100644 index 0000000..b6a4f05 --- /dev/null +++ b/Xboard/app/Services/CaptchaService.php @@ -0,0 +1,112 @@ + $this->verifyTurnstile($request), + 'recaptcha-v3' => $this->verifyRecaptchaV3($request), + 'recaptcha' => $this->verifyRecaptcha($request), + default => [false, [400, __('Invalid captcha type')]] + }; + } + + /** + * 验证 Cloudflare Turnstile + * + * @param Request $request + * @return array + */ + private function verifyTurnstile(Request $request): array + { + $turnstileToken = $request->input('turnstile_token'); + if (!$turnstileToken) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + $response = Http::post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => admin_setting('turnstile_secret_key'), + 'response' => $turnstileToken, + 'remoteip' => $request->ip() + ]); + + $result = $response->json(); + if (!$result['success']) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + return [true, null]; + } + + /** + * 验证 Google reCAPTCHA v3 + * + * @param Request $request + * @return array + */ + private function verifyRecaptchaV3(Request $request): array + { + $recaptchaV3Token = $request->input('recaptcha_v3_token'); + if (!$recaptchaV3Token) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + $recaptcha = new ReCaptcha(admin_setting('recaptcha_v3_secret_key')); + $recaptchaResp = $recaptcha->verify($recaptchaV3Token, $request->ip()); + + if (!$recaptchaResp->isSuccess()) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + // 检查分数阈值(如果有的话) + $score = $recaptchaResp->getScore(); + $threshold = admin_setting('recaptcha_v3_score_threshold', 0.5); + if ($score < $threshold) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + return [true, null]; + } + + /** + * 验证 Google reCAPTCHA v2 + * + * @param Request $request + * @return array + */ + private function verifyRecaptcha(Request $request): array + { + $recaptchaData = $request->input('recaptcha_data'); + if (!$recaptchaData) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + $recaptcha = new ReCaptcha(admin_setting('recaptcha_key')); + $recaptchaResp = $recaptcha->verify($recaptchaData); + + if (!$recaptchaResp->isSuccess()) { + return [false, [400, __('Invalid code is incorrect')]]; + } + + return [true, null]; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/CouponService.php b/Xboard/app/Services/CouponService.php new file mode 100644 index 0000000..e4bdae7 --- /dev/null +++ b/Xboard/app/Services/CouponService.php @@ -0,0 +1,122 @@ +coupon = Coupon::where('code', $code) + ->lockForUpdate() + ->first(); + } + + public function use(Order $order): bool + { + $this->setPlanId($order->plan_id); + $this->setUserId($order->user_id); + $this->setPeriod($order->period); + $this->check(); + switch ($this->coupon->type) { + case 1: + $order->discount_amount = $this->coupon->value; + break; + case 2: + $order->discount_amount = $order->total_amount * ($this->coupon->value / 100); + break; + } + if ($order->discount_amount > $order->total_amount) { + $order->discount_amount = $order->total_amount; + } + if ($this->coupon->limit_use !== NULL) { + if ($this->coupon->limit_use <= 0) + return false; + $this->coupon->limit_use = $this->coupon->limit_use - 1; + if (!$this->coupon->save()) { + return false; + } + } + return true; + } + + public function getId() + { + return $this->coupon->id; + } + + public function getCoupon() + { + return $this->coupon; + } + + public function setPlanId($planId) + { + $this->planId = $planId; + } + + public function setUserId($userId) + { + $this->userId = $userId; + } + + public function setPeriod($period) + { + if ($period) { + $this->period = PlanService::getPeriodKey($period); + } + } + + public function checkLimitUseWithUser(): bool + { + $usedCount = Order::where('coupon_id', $this->coupon->id) + ->where('user_id', $this->userId) + ->whereNotIn('status', [0, 2]) + ->count(); + if ($usedCount >= $this->coupon->limit_use_with_user) + return false; + return true; + } + + public function check() + { + if (!$this->coupon || !$this->coupon->show) { + throw new ApiException(__('Invalid coupon')); + } + if ($this->coupon->limit_use <= 0 && $this->coupon->limit_use !== NULL) { + throw new ApiException(__('This coupon is no longer available')); + } + if (time() < $this->coupon->started_at) { + throw new ApiException(__('This coupon has not yet started')); + } + if (time() > $this->coupon->ended_at) { + throw new ApiException(__('This coupon has expired')); + } + if ($this->coupon->limit_plan_ids && $this->planId) { + if (!in_array($this->planId, $this->coupon->limit_plan_ids)) { + throw new ApiException(__('The coupon code cannot be used for this subscription')); + } + } + if ($this->coupon->limit_period && $this->period) { + if (!in_array($this->period, $this->coupon->limit_period)) { + throw new ApiException(__('The coupon code cannot be used for this period')); + } + } + if ($this->coupon->limit_use_with_user !== NULL && $this->userId) { + if (!$this->checkLimitUseWithUser()) { + throw new ApiException(__('The coupon can only be used :limit_use_with_user per person', [ + 'limit_use_with_user' => $this->coupon->limit_use_with_user + ])); + } + } + } +} diff --git a/Xboard/app/Services/DeviceStateService.php b/Xboard/app/Services/DeviceStateService.php new file mode 100644 index 0000000..09e106c --- /dev/null +++ b/Xboard/app/Services/DeviceStateService.php @@ -0,0 +1,187 @@ +removeNodeDevices($nodeId, $userId); + + if (!empty($ips)) { + $fields = []; + foreach ($ips as $ip) { + $fields["{$nodeId}:{$ip}"] = $timestamp; + } + Redis::hMset($key, $fields); + Redis::expire($key, self::TTL); + } + + $this->notifyUpdate($userId); + } + + /** + * 获取某节点的所有设备数据 + * 返回: {userId: [ip1, ip2, ...], ...} + */ + public function getNodeDevices(int $nodeId): array + { + $keys = Redis::keys(self::PREFIX . '*'); + $prefix = "{$nodeId}:"; + $result = []; + foreach ($keys as $key) { + $actualKey = $this->removeRedisPrefix($key); + $uid = (int) substr($actualKey, strlen(self::PREFIX)); + $data = Redis::hgetall($actualKey); + foreach ($data as $field => $timestamp) { + if (str_starts_with($field, $prefix)) { + $ip = substr($field, strlen($prefix)); + $result[$uid][] = $ip; + } + } + } + + return $result; + } + + /** + * 删除某节点某用户的设备 + */ + public function removeNodeDevices(int $nodeId, int $userId): void + { + $key = self::PREFIX . $userId; + $prefix = "{$nodeId}:"; + + foreach (Redis::hkeys($key) as $field) { + if (str_starts_with($field, $prefix)) { + Redis::hdel($key, $field); + } + } + } + + /** + * 清除节点所有设备数据(用于节点断开连接) + */ + public function clearAllNodeDevices(int $nodeId): array + { + $oldDevices = $this->getNodeDevices($nodeId); + $prefix = "{$nodeId}:"; + + foreach ($oldDevices as $userId => $ips) { + $key = self::PREFIX . $userId; + foreach (Redis::hkeys($key) as $field) { + if (str_starts_with($field, $prefix)) { + Redis::hdel($key, $field); + } + } + } + + return array_keys($oldDevices); + } + + /** + * get user device count (deduplicated by IP, filter expired data) + */ + public function getDeviceCount(int $userId): int + { + $data = Redis::hgetall(self::PREFIX . $userId); + $now = time(); + $ips = []; + + foreach ($data as $field => $timestamp) { + if ($now - $timestamp <= self::TTL) { + $ips[] = substr($field, strpos($field, ':') + 1); + } + } + + return count(array_unique($ips)); + } + + /** + * get user device count (for alivelist interface) + */ + public function getAliveList(Collection $users): array + { + if ($users->isEmpty()) { + return []; + } + + $result = []; + foreach ($users as $user) { + $count = $this->getDeviceCount($user->id); + if ($count > 0) { + $result[$user->id] = $count; + } + } + + return $result; + } + + /** + * get devices of multiple users (for sync.devices, filter expired data) + */ + public function getUsersDevices(array $userIds): array + { + $result = []; + $now = time(); + foreach ($userIds as $userId) { + $data = Redis::hgetall(self::PREFIX . $userId); + if (!empty($data)) { + $ips = []; + foreach ($data as $field => $timestamp) { + if ($now - $timestamp <= self::TTL) { + $ips[] = substr($field, strpos($field, ':') + 1); + } + } + if (!empty($ips)) { + $result[$userId] = array_unique($ips); + } + } + } + + return $result; + } + + /** + * notify update (throttle control) + */ + public function notifyUpdate(int $userId): void + { + $dbThrottleKey = "device:db_throttle:{$userId}"; + + // if (Redis::setnx($dbThrottleKey, 1)) { + // Redis::expire($dbThrottleKey, self::DB_THROTTLE); + + User::query() + ->whereKey($userId) + ->update([ + 'online_count' => $this->getDeviceCount($userId), + 'last_online_at' => now(), + ]); + // } + } +} diff --git a/Xboard/app/Services/GiftCardService.php b/Xboard/app/Services/GiftCardService.php new file mode 100644 index 0000000..8f14cbe --- /dev/null +++ b/Xboard/app/Services/GiftCardService.php @@ -0,0 +1,334 @@ +code = GiftCardCode::where('code', $code)->first() + ?? throw new ApiException('兑换码不存在'); + + $this->template = $this->code->template; + } + + /** + * 设置使用用户 + */ + public function setUser(User $user): self + { + $this->user = $user; + return $this; + } + + /** + * 验证兑换码 + */ + public function validate(): self + { + $this->validateIsActive(); + + $eligibility = $this->checkUserEligibility(); + if (!$eligibility['can_redeem']) { + throw new ApiException($eligibility['reason']); + } + + return $this; + } + + /** + * 验证礼品卡本身是否可用 (不检查用户条件) + * @throws ApiException + */ + public function validateIsActive(): self + { + if (!$this->template->isAvailable()) { + throw new ApiException('该礼品卡类型已停用'); + } + + if (!$this->code->isAvailable()) { + throw new ApiException('兑换码不可用:' . $this->code->status_name); + } + return $this; + } + + /** + * 检查用户是否满足兑换条件 (不抛出异常) + */ + public function checkUserEligibility(): array + { + if (!$this->user) { + return [ + 'can_redeem' => false, + 'reason' => '用户信息未提供' + ]; + } + + if (!$this->template->checkUserConditions($this->user)) { + return [ + 'can_redeem' => false, + 'reason' => '您不满足此礼品卡的使用条件' + ]; + } + + if (!$this->template->checkUsageLimit($this->user)) { + return [ + 'can_redeem' => false, + 'reason' => '您已达到此礼品卡的使用限制' + ]; + } + + return ['can_redeem' => true, 'reason' => null]; + } + + /** + * 使用礼品卡 + */ + public function redeem(array $options = []): array + { + if (!$this->user) { + throw new ApiException('未设置使用用户'); + } + + return DB::transaction(function () use ($options) { + $actualRewards = $this->template->calculateActualRewards($this->user); + + if ($this->template->type === GiftCardTemplate::TYPE_MYSTERY) { + $this->code->setActualRewards($actualRewards); + } + + $this->giveRewards($actualRewards); + + $inviteRewards = null; + if ($this->user->invite_user_id && isset($actualRewards['invite_reward_rate'])) { + $inviteRewards = $this->giveInviteRewards($actualRewards); + } + + $this->code->markAsUsed($this->user); + + GiftCardUsage::createRecord( + $this->code, + $this->user, + $actualRewards, + array_merge($options, [ + 'invite_rewards' => $inviteRewards, + 'multiplier' => $this->calculateMultiplier(), + ]) + ); + + return [ + 'rewards' => $actualRewards, + 'invite_rewards' => $inviteRewards, + 'code' => $this->code->code, + 'template_name' => $this->template->name, + ]; + }); + } + + /** + * 发放奖励 + */ + protected function giveRewards(array $rewards): void + { + $userService = app(UserService::class); + + if (isset($rewards['balance']) && $rewards['balance'] > 0) { + if (!$userService->addBalance($this->user->id, $rewards['balance'])) { + throw new ApiException('余额发放失败'); + } + } + + if (isset($rewards['transfer_enable']) && $rewards['transfer_enable'] > 0) { + $this->user->transfer_enable = ($this->user->transfer_enable ?? 0) + $rewards['transfer_enable']; + } + + if (isset($rewards['device_limit']) && $rewards['device_limit'] > 0) { + $this->user->device_limit = ($this->user->device_limit ?? 0) + $rewards['device_limit']; + } + + if (isset($rewards['reset_package']) && $rewards['reset_package']) { + if ($this->user->plan_id) { + app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_GIFT_CARD); + } + } + + if (isset($rewards['plan_id'])) { + $plan = Plan::find($rewards['plan_id']); + if ($plan) { + $userService->assignPlan( + $this->user, + $plan, + $rewards['plan_validity_days'] ?? 0 + ); + } + } else { + // 只有在不是套餐卡的情况下,才处理独立的有效期奖励 + if (isset($rewards['expire_days']) && $rewards['expire_days'] > 0) { + $userService->extendSubscription($this->user, $rewards['expire_days']); + } + } + + // 保存用户更改 + if (!$this->user->save()) { + throw new ApiException('用户信息更新失败'); + } + } + + /** + * 发放邀请人奖励 + */ + protected function giveInviteRewards(array $rewards): ?array + { + if (!$this->user->invite_user_id) { + return null; + } + + $inviteUser = User::find($this->user->invite_user_id); + if (!$inviteUser) { + return null; + } + + $rate = $rewards['invite_reward_rate'] ?? 0.2; + $inviteRewards = []; + + $userService = app(UserService::class); + + // 邀请人余额奖励 + if (isset($rewards['balance']) && $rewards['balance'] > 0) { + $inviteBalance = intval($rewards['balance'] * $rate); + if ($inviteBalance > 0) { + $userService->addBalance($inviteUser->id, $inviteBalance); + $inviteRewards['balance'] = $inviteBalance; + } + } + + // 邀请人流量奖励 + if (isset($rewards['transfer_enable']) && $rewards['transfer_enable'] > 0) { + $inviteTransfer = intval($rewards['transfer_enable'] * $rate); + if ($inviteTransfer > 0) { + $inviteUser->transfer_enable = ($inviteUser->transfer_enable ?? 0) + $inviteTransfer; + $inviteUser->save(); + $inviteRewards['transfer_enable'] = $inviteTransfer; + } + } + + return $inviteRewards; + } + + /** + * 计算倍率 + */ + protected function calculateMultiplier(): float + { + return $this->getFestivalBonus(); + } + + /** + * 获取节日加成倍率 + */ + private function getFestivalBonus(): float + { + $festivalConfig = $this->template->special_config ?? []; + $now = time(); + + if ( + isset($festivalConfig['start_time'], $festivalConfig['end_time']) && + $now >= $festivalConfig['start_time'] && + $now <= $festivalConfig['end_time'] + ) { + return $festivalConfig['festival_bonus'] ?? 1.0; + } + + return 1.0; + } + + /** + * 获取兑换码信息(不包含敏感信息) + */ + public function getCodeInfo(): array + { + $info = [ + 'code' => $this->code->code, + 'template' => [ + 'name' => $this->template->name, + 'description' => $this->template->description, + 'type' => $this->template->type, + 'type_name' => $this->template->type_name, + 'icon' => $this->template->icon, + 'background_image' => $this->template->background_image, + 'theme_color' => $this->template->theme_color, + ], + 'status' => $this->code->status, + 'status_name' => $this->code->status_name, + 'expires_at' => $this->code->expires_at, + 'usage_count' => $this->code->usage_count, + 'max_usage' => $this->code->max_usage, + ]; + if ($this->template->type === GiftCardTemplate::TYPE_PLAN) { + $plan = Plan::find($this->code->template->rewards['plan_id']); + if ($plan) { + $info['plan_info'] = PlanResource::make($plan)->toArray(request()); + } + } + return $info; + } + + /** + * 预览奖励(不实际发放) + */ + public function previewRewards(): array + { + if (!$this->user) { + throw new ApiException('未设置使用用户'); + } + + return $this->template->calculateActualRewards($this->user); + } + + /** + * 获取兑换码 + */ + public function getCode(): GiftCardCode + { + return $this->code; + } + + /** + * 获取模板 + */ + public function getTemplate(): GiftCardTemplate + { + return $this->template; + } + + /** + * 记录日志 + */ + protected function logUsage(string $action, array $data = []): void + { + Log::info('礼品卡使用记录', [ + 'action' => $action, + 'code' => $this->code->code, + 'template_id' => $this->template->id, + 'user_id' => $this->user?->id, + 'data' => $data, + 'ip' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + } +} diff --git a/Xboard/app/Services/MailService.php b/Xboard/app/Services/MailService.php new file mode 100644 index 0000000..7630111 --- /dev/null +++ b/Xboard/app/Services/MailService.php @@ -0,0 +1,295 @@ +where('remind_expire', true) + ->orWhere('remind_traffic', true); + }) + ->where('banned', false) + ->whereNotNull('email') + ->count(); + } + + /** + * 分块处理用户提醒邮件 + */ + public function processUsersInChunks(int $chunkSize, ?callable $progressCallback = null): array + { + $statistics = [ + 'processed_users' => 0, + 'expire_emails' => 0, + 'traffic_emails' => 0, + 'errors' => 0, + 'skipped' => 0, + ]; + + User::select('id', 'email', 'expired_at', 'transfer_enable', 'u', 'd', 'remind_expire', 'remind_traffic') + ->where(function ($query) { + $query->where('remind_expire', true) + ->orWhere('remind_traffic', true); + }) + ->where('banned', false) + ->whereNotNull('email') + ->chunk($chunkSize, function ($users) use (&$statistics, $progressCallback) { + $this->processUserChunk($users, $statistics); + + if ($progressCallback) { + $progressCallback(); + } + + // 定期清理内存 + if ($statistics['processed_users'] % 2500 === 0) { + gc_collect_cycles(); + } + }); + + return $statistics; + } + + /** + * 处理用户块 + */ + private function processUserChunk($users, array &$statistics): void + { + foreach ($users as $user) { + try { + $statistics['processed_users']++; + $emailsSent = 0; + + // 检查并发送过期提醒 + if ($user->remind_expire && $this->shouldSendExpireRemind($user)) { + $this->remindExpire($user); + $statistics['expire_emails']++; + $emailsSent++; + } + + // 检查并发送流量提醒 + if ($user->remind_traffic && $this->shouldSendTrafficRemind($user)) { + $this->remindTraffic($user); + $statistics['traffic_emails']++; + $emailsSent++; + } + + if ($emailsSent === 0) { + $statistics['skipped']++; + } + + } catch (\Exception $e) { + $statistics['errors']++; + + Log::error('发送提醒邮件失败', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'error' => $e->getMessage() + ]); + } + } + } + + /** + * 检查是否应该发送过期提醒 + */ + private function shouldSendExpireRemind(User $user): bool + { + if ($user->expired_at === NULL) { + return false; + } + $expiredAt = $user->expired_at; + $now = time(); + if (($expiredAt - 86400) < $now && $expiredAt > $now) { + return true; + } + return false; + } + + /** + * 检查是否应该发送流量提醒 + */ + private function shouldSendTrafficRemind(User $user): bool + { + if ($user->transfer_enable <= 0) { + return false; + } + + $usedBytes = $user->u + $user->d; + $usageRatio = $usedBytes / $user->transfer_enable; + + // 流量使用超过80%时发送提醒 + return $usageRatio >= 0.8; + } + + public function remindTraffic(User $user) + { + if (!$user->remind_traffic) + return; + if (!$this->remindTrafficIsWarnValue($user->u, $user->d, $user->transfer_enable)) + return; + $flag = CacheKey::get('LAST_SEND_EMAIL_REMIND_TRAFFIC', $user->id); + if (Cache::get($flag)) + return; + if (!Cache::put($flag, 1, 24 * 3600)) + return; + + SendEmailJob::dispatch([ + 'email' => $user->email, + 'subject' => __('The traffic usage in :app_name has reached 80%', [ + 'app_name' => admin_setting('app_name', 'XBoard') + ]), + 'template_name' => 'remindTraffic', + 'template_value' => [ + 'name' => admin_setting('app_name', 'XBoard'), + 'url' => admin_setting('app_url') + ] + ]); + } + + public function remindExpire(User $user) + { + if (!$this->shouldSendExpireRemind($user)) { + return; + } + + SendEmailJob::dispatch([ + 'email' => $user->email, + 'subject' => __('The service in :app_name is about to expire', [ + 'app_name' => admin_setting('app_name', 'XBoard') + ]), + 'template_name' => 'remindExpire', + 'template_value' => [ + 'name' => admin_setting('app_name', 'XBoard'), + 'url' => admin_setting('app_url') + ] + ]); + } + + private function remindTrafficIsWarnValue($u, $d, $transfer_enable) + { + $ud = $u + $d; + if (!$ud) + return false; + if (!$transfer_enable) + return false; + $percentage = ($ud / $transfer_enable) * 100; + if ($percentage < 80) + return false; + if ($percentage >= 100) + return false; + return true; + } + + /** + * 发送邮件 + * + * @param array $params 包含邮件参数的数组,必须包含以下字段: + * - email: 收件人邮箱地址 + * - subject: 邮件主题 + * - template_name: 邮件模板名称,例如 "welcome" 或 "password_reset" + * - template_value: 邮件模板变量,一个关联数组,包含模板中需要替换的变量和对应的值 + * @return array 包含邮件发送结果的数组,包含以下字段: + * - email: 收件人邮箱地址 + * - subject: 邮件主题 + * - template_name: 邮件模板名称 + * - error: 如果邮件发送失败,包含错误信息;否则为 null + * @throws \InvalidArgumentException 如果 $params 参数缺少必要的字段,抛出此异常 + */ + public static function sendEmail(array $params) + { + if (admin_setting('email_host')) { + Config::set('mail.host', admin_setting('email_host', config('mail.host'))); + Config::set('mail.port', admin_setting('email_port', config('mail.port'))); + Config::set('mail.encryption', admin_setting('email_encryption', config('mail.encryption'))); + Config::set('mail.username', admin_setting('email_username', config('mail.username'))); + Config::set('mail.password', admin_setting('email_password', config('mail.password'))); + Config::set('mail.from.address', admin_setting('email_from_address', config('mail.from.address'))); + Config::set('mail.from.name', admin_setting('app_name', 'XBoard')); + } + $email = $params['email']; + $subject = $params['subject']; + + $templateValue = $params['template_value'] ?? []; + $vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : []; + $contentMode = is_array($templateValue) ? ($templateValue['content_mode'] ?? null) : null; + + if (is_array($vars) && !empty($vars)) { + $subject = self::renderPlaceholders((string) $subject, $vars); + + if (is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) { + $templateValue['content'] = self::renderPlaceholders($templateValue['content'], $vars); + } + } + + // Mass mail default: treat admin content as plain text and escape. + if ($contentMode === 'text' && is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) { + $templateValue['content'] = e($templateValue['content']); + } + + $params['template_value'] = $templateValue; + $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name']; + try { + Mail::send( + $params['template_name'], + $params['template_value'], + function ($message) use ($email, $subject) { + $message->to($email)->subject($subject); + } + ); + $error = null; + } catch (\Exception $e) { + Log::error($e); + $error = $e->getMessage(); + } + $log = [ + 'email' => $params['email'], + 'subject' => $params['subject'], + 'template_name' => $params['template_name'], + 'error' => $error, + 'config' => config('mail') + ]; + MailLog::create($log); + return $log; + } +} diff --git a/Xboard/app/Services/NodeRegistry.php b/Xboard/app/Services/NodeRegistry.php new file mode 100644 index 0000000..c0f7d13 --- /dev/null +++ b/Xboard/app/Services/NodeRegistry.php @@ -0,0 +1,77 @@ + nodeId → connection */ + private static array $connections = []; + + public static function add(int $nodeId, TcpConnection $conn): void + { + // Close existing connection for this node (if reconnecting) + if (isset(self::$connections[$nodeId])) { + self::$connections[$nodeId]->close(); + } + self::$connections[$nodeId] = $conn; + } + + public static function remove(int $nodeId): void + { + unset(self::$connections[$nodeId]); + } + + public static function get(int $nodeId): ?TcpConnection + { + return self::$connections[$nodeId] ?? null; + } + + /** + * Send a JSON message to a specific node. + */ + public static function send(int $nodeId, string $event, array $data): bool + { + $conn = self::get($nodeId); + if (!$conn) { + return false; + } + + $payload = json_encode([ + 'event' => $event, + 'data' => $data, + 'timestamp' => time(), + ]); + + $conn->send($payload); + return true; + } + + /** + * Get the connection for a node by ID, checking if it's still alive. + */ + public static function isOnline(int $nodeId): bool + { + $conn = self::get($nodeId); + return $conn !== null && $conn->getStatus() === TcpConnection::STATUS_ESTABLISHED; + } + + /** + * Get all connected node IDs. + * @return int[] + */ + public static function getConnectedNodeIds(): array + { + return array_keys(self::$connections); + } + + public static function count(): int + { + return count(self::$connections); + } +} diff --git a/Xboard/app/Services/NodeSyncService.php b/Xboard/app/Services/NodeSyncService.php new file mode 100644 index 0000000..ebab769 --- /dev/null +++ b/Xboard/app/Services/NodeSyncService.php @@ -0,0 +1,143 @@ + ServerService::buildNodeConfig($node)]); + } + + /** + * Push all users to all nodes in the group + */ + public static function notifyUsersUpdatedByGroup(int $groupId): void + { + $servers = Server::whereJsonContains('group_ids', (string) $groupId) + ->get(); + + foreach ($servers as $server) { + if (!self::isNodeOnline($server->id)) + continue; + + $users = ServerService::getAvailableUsers($server)->toArray(); + self::push($server->id, 'sync.users', ['users' => $users]); + } + } + + /** + * Push user changes (add/remove) to affected nodes + */ + public static function notifyUserChanged(User $user): void + { + if (!$user->group_id) + return; + + $servers = Server::whereJsonContains('group_ids', (string) $user->group_id)->get(); + foreach ($servers as $server) { + if (!self::isNodeOnline($server->id)) + continue; + + if ($user->isAvailable()) { + self::push($server->id, 'sync.user.delta', [ + 'action' => 'add', + 'users' => [ + [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'speed_limit' => $user->speed_limit, + 'device_limit' => $user->device_limit, + ] + ], + ]); + } else { + self::push($server->id, 'sync.user.delta', [ + 'action' => 'remove', + 'users' => [['id' => $user->id]], + ]); + } + } + } + + /** + * Push user removal from a specific group's nodes + */ + public static function notifyUserRemovedFromGroup(int $userId, int $groupId): void + { + $servers = Server::whereJsonContains('group_ids', (string) $groupId) + ->get(); + + foreach ($servers as $server) { + if (!self::isNodeOnline($server->id)) + continue; + + self::push($server->id, 'sync.user.delta', [ + 'action' => 'remove', + 'users' => [['id' => $userId]], + ]); + } + } + + /** + * Full sync: push config + users to a node + */ + public static function notifyFullSync(int $nodeId): void + { + if (!self::isNodeOnline($nodeId)) + return; + + $node = Server::find($nodeId); + if (!$node) + return; + + self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]); + + $users = ServerService::getAvailableUsers($node)->toArray(); + self::push($nodeId, 'sync.users', ['users' => $users]); + } + + /** + * Publish a push command to Redis — picked up by the Workerman WS server + */ + public static function push(int $nodeId, string $event, array $data): void + { + try { + Redis::publish('node:push', json_encode([ + 'node_id' => $nodeId, + 'event' => $event, + 'data' => $data, + ])); + } catch (\Throwable $e) { + Log::warning("[NodePush] Redis publish failed: {$e->getMessage()}", [ + 'node_id' => $nodeId, + 'event' => $event, + ]); + } + } +} diff --git a/Xboard/app/Services/OrderService.php b/Xboard/app/Services/OrderService.php new file mode 100644 index 0000000..900be59 --- /dev/null +++ b/Xboard/app/Services/OrderService.php @@ -0,0 +1,429 @@ + 1, + Plan::PERIOD_QUARTERLY => 3, + Plan::PERIOD_HALF_YEARLY => 6, + Plan::PERIOD_YEARLY => 12, + Plan::PERIOD_TWO_YEARLY => 24, + Plan::PERIOD_THREE_YEARLY => 36 + ]; + public $order; + public $user; + + public function __construct(Order $order) + { + $this->order = $order; + } + + /** + * Create an order from a request. + * + * @param User $user + * @param Plan $plan + * @param string $period + * @param string|null $couponCode + * @return Order + * @throws ApiException + */ + public static function createFromRequest( + User $user, + Plan $plan, + string $period, + ?string $couponCode = null, + ): Order { + $userService = app(UserService::class); + $planService = new PlanService($plan); + + $planService->validatePurchase($user, $period); + HookManager::call('order.create.before', [$user, $plan, $period, $couponCode]); + + return DB::transaction(function () use ($user, $plan, $period, $couponCode, $userService) { + $newPeriod = PlanService::getPeriodKey($period); + + $order = new Order([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'period' => $newPeriod, + 'trade_no' => Helper::generateOrderNo(), + 'total_amount' => (int) (optional($plan->prices)[$newPeriod] * 100), + ]); + + $orderService = new self($order); + + if ($couponCode) { + $orderService->applyCoupon($couponCode); + } + + $orderService->setVipDiscount($user); + $orderService->setOrderType($user); + $orderService->setInvite(user: $user); + + if ($user->balance && $order->total_amount > 0) { + $orderService->handleUserBalance($user, $userService); + } + + if (!$order->save()) { + throw new ApiException(__('Failed to create order')); + } + + HookManager::call('order.create.after', $order); + // 兼容旧钩子 + HookManager::call('order.after_create', $order); + + return $order; + }); + } + + public function open(): void + { + $order = $this->order; + $plan = Plan::find($order->plan_id); + + HookManager::call('order.open.before', $order); + + + DB::transaction(function () use ($order, $plan) { + $this->user = User::lockForUpdate()->find($order->user_id); + + if ($order->refund_amount) { + $this->user->balance += $order->refund_amount; + } + + if ($order->surplus_order_ids) { + Order::whereIn('id', $order->surplus_order_ids) + ->update(['status' => Order::STATUS_DISCOUNTED]); + } + + match ((string) $order->period) { + Plan::PERIOD_ONETIME => $this->buyByOneTime($plan), + Plan::PERIOD_RESET_TRAFFIC => app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER), + default => $this->buyByPeriod($order, $plan), + }; + + $this->setSpeedLimit($plan->speed_limit); + $this->setDeviceLimit($plan->device_limit); + + if (!$this->user->save()) { + throw new \RuntimeException('用户信息保存失败'); + } + + $order->status = Order::STATUS_COMPLETED; + if (!$order->save()) { + throw new \RuntimeException('订单信息保存失败'); + } + }); + + $eventId = match ((int) $order->type) { + Order::STATUS_PROCESSING => admin_setting('new_order_event_id', 0), + Order::TYPE_RENEWAL => admin_setting('renew_order_event_id', 0), + Order::TYPE_UPGRADE => admin_setting('change_order_event_id', 0), + default => 0, + }; + + if ($eventId) { + $this->openEvent($eventId); + } + + HookManager::call('order.open.after', $order); + } + + + public function setOrderType(User $user) + { + $order = $this->order; + if ($order->period === Plan::PERIOD_RESET_TRAFFIC) { + $order->type = Order::TYPE_RESET_TRAFFIC; + } else if ($user->plan_id !== NULL && $order->plan_id !== $user->plan_id && ($user->expired_at > time() || $user->expired_at === NULL)) { + if (!(int) admin_setting('plan_change_enable', 1)) + throw new ApiException('目前不允许更改订阅,请联系客服或提交工单操作'); + $order->type = Order::TYPE_UPGRADE; + if ((int) admin_setting('surplus_enable', 1)) + $this->getSurplusValue($user, $order); + if ($order->surplus_amount >= $order->total_amount) { + $order->refund_amount = (int) ($order->surplus_amount - $order->total_amount); + $order->total_amount = 0; + } else { + $order->total_amount = (int) ($order->total_amount - $order->surplus_amount); + } + } else if (($user->expired_at === null || $user->expired_at > time()) && $order->plan_id == $user->plan_id) { // 用户订阅未过期或按流量订阅 且购买订阅与当前订阅相同 === 续费 + $order->type = Order::TYPE_RENEWAL; + } else { // 新购 + $order->type = Order::TYPE_NEW_PURCHASE; + } + } + + public function setVipDiscount(User $user) + { + $order = $this->order; + if ($user->discount) { + $order->discount_amount = $order->discount_amount + ($order->total_amount * ($user->discount / 100)); + } + $order->total_amount = $order->total_amount - $order->discount_amount; + } + + public function setInvite(User $user): void + { + $order = $this->order; + if ($user->invite_user_id && ($order->total_amount <= 0)) + return; + $order->invite_user_id = $user->invite_user_id; + $inviter = User::find($user->invite_user_id); + if (!$inviter) + return; + $commissionType = (int) $inviter->commission_type; + if ($commissionType === User::COMMISSION_TYPE_SYSTEM) { + $commissionType = (bool) admin_setting('commission_first_time_enable', true) ? User::COMMISSION_TYPE_ONETIME : User::COMMISSION_TYPE_PERIOD; + } + $isCommission = false; + switch ($commissionType) { + case User::COMMISSION_TYPE_PERIOD: + $isCommission = true; + break; + case User::COMMISSION_TYPE_ONETIME: + $isCommission = !$this->haveValidOrder($user); + break; + } + + if (!$isCommission) + return; + if ($inviter->commission_rate) { + $order->commission_balance = $order->total_amount * ($inviter->commission_rate / 100); + } else { + $order->commission_balance = $order->total_amount * (admin_setting('invite_commission', 10) / 100); + } + } + + private function haveValidOrder(User $user): Order|null + { + return Order::where('user_id', $user->id) + ->whereNotIn('status', [Order::STATUS_PENDING, Order::STATUS_CANCELLED]) + ->first(); + } + + private function getSurplusValue(User $user, Order $order) + { + if ($user->expired_at === NULL) { + $lastOneTimeOrder = Order::where('user_id', $user->id) + ->where('period', Plan::PERIOD_ONETIME) + ->where('status', Order::STATUS_COMPLETED) + ->orderBy('id', 'DESC') + ->first(); + if (!$lastOneTimeOrder) + return; + $nowUserTraffic = Helper::transferToGB($user->transfer_enable); + if (!$nowUserTraffic) + return; + $paidTotalAmount = ($lastOneTimeOrder->total_amount + $lastOneTimeOrder->balance_amount); + if (!$paidTotalAmount) + return; + $trafficUnitPrice = $paidTotalAmount / $nowUserTraffic; + $notUsedTraffic = $nowUserTraffic - Helper::transferToGB($user->u + $user->d); + $result = $trafficUnitPrice * $notUsedTraffic; + $order->surplus_amount = (int) ($result > 0 ? $result : 0); + $order->surplus_order_ids = Order::where('user_id', $user->id) + ->where('period', '!=', Plan::PERIOD_RESET_TRAFFIC) + ->where('status', Order::STATUS_COMPLETED) + ->pluck('id') + ->all(); + } else { + $orders = Order::query() + ->where('user_id', $user->id) + ->whereNotIn('period', [Plan::PERIOD_RESET_TRAFFIC, Plan::PERIOD_ONETIME]) + ->where('status', Order::STATUS_COMPLETED) + ->get(); + + if ($orders->isEmpty()) { + $order->surplus_amount = 0; + $order->surplus_order_ids = []; + return; + } + + $orderAmountSum = $orders->sum(fn($item) => $item->total_amount + $item->balance_amount + $item->surplus_amount - $item->refund_amount); + $orderMonthSum = $orders->sum(fn($item) => self::STR_TO_TIME[PlanService::getPeriodKey($item->period)] ?? 0); + $firstOrderAt = $orders->min('created_at'); + $expiredAt = Carbon::createFromTimestamp($firstOrderAt)->addMonths($orderMonthSum); + + $now = now(); + $totalSeconds = $expiredAt->timestamp - $firstOrderAt; + $remainSeconds = max(0, $expiredAt->timestamp - $now->timestamp); + $cycleRatio = $totalSeconds > 0 ? $remainSeconds / $totalSeconds : 0; + + $plan = Plan::find($user->plan_id); + $totalTraffic = $plan?->transfer_enable * $orderMonthSum; + $usedTraffic = Helper::transferToGB($user->u + $user->d); + $remainTraffic = max(0, $totalTraffic - $usedTraffic); + $trafficRatio = $totalTraffic > 0 ? $remainTraffic / $totalTraffic : 0; + + $ratio = $cycleRatio; + if (admin_setting('change_order_event_id', 0) == 1) { + $ratio = min($cycleRatio, $trafficRatio); + } + + + $order->surplus_amount = (int) max(0, $orderAmountSum * $ratio); + $order->surplus_order_ids = $orders->pluck('id')->all(); + } + } + + public function paid(string $callbackNo) + { + $order = $this->order; + if ($order->status !== Order::STATUS_PENDING) + return true; + $order->status = Order::STATUS_PROCESSING; + $order->paid_at = time(); + $order->callback_no = $callbackNo; + if (!$order->save()) + return false; + try { + OrderHandleJob::dispatchSync($order->trade_no); + } catch (\Exception $e) { + Log::error($e); + return false; + } + return true; + } + + public function cancel(): bool + { + $order = $this->order; + HookManager::call('order.cancel.before', $order); + try { + DB::beginTransaction(); + $order->status = Order::STATUS_CANCELLED; + if (!$order->save()) { + throw new \Exception('Failed to save order status.'); + } + if ($order->balance_amount) { + $userService = new UserService(); + if (!$userService->addBalance($order->user_id, $order->balance_amount)) { + throw new \Exception('Failed to add balance.'); + } + } + DB::commit(); + HookManager::call('order.cancel.after', $order); + return true; + } catch (\Exception $e) { + DB::rollBack(); + Log::error($e); + return false; + } + } + + private function setSpeedLimit($speedLimit) + { + $this->user->speed_limit = $speedLimit; + } + + private function setDeviceLimit($deviceLimit) + { + $this->user->device_limit = $deviceLimit; + } + + private function buyByPeriod(Order $order, Plan $plan) + { + // change plan process + if ((int) $order->type === Order::TYPE_UPGRADE) { + $this->user->expired_at = time(); + } + $this->user->transfer_enable = $plan->transfer_enable * 1073741824; + // 从一次性转换到循环或者新购的时候,重置流量 + if ($this->user->expired_at === NULL || $order->type === Order::TYPE_NEW_PURCHASE) + app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER); + $this->user->plan_id = $plan->id; + $this->user->group_id = $plan->group_id; + $this->user->expired_at = $this->getTime($order->period, $this->user->expired_at); + } + + private function buyByOneTime(Plan $plan) + { + app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER); + $this->user->transfer_enable = $plan->transfer_enable * 1073741824; + $this->user->plan_id = $plan->id; + $this->user->group_id = $plan->group_id; + $this->user->expired_at = NULL; + } + + /** + * 计算套餐到期时间 + * @param string $periodKey + * @param int $timestamp + * @return int + * @throws ApiException + */ + private function getTime(string $periodKey, ?int $timestamp = null): int + { + $timestamp = $timestamp < time() ? time() : $timestamp; + $periodKey = PlanService::getPeriodKey($periodKey); + + if (isset(self::STR_TO_TIME[$periodKey])) { + $months = self::STR_TO_TIME[$periodKey]; + return Carbon::createFromTimestamp($timestamp)->addMonths($months)->timestamp; + } + + throw new ApiException('无效的套餐周期'); + } + + private function openEvent($eventId) + { + switch ((int) $eventId) { + case 0: + break; + case 1: + app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER); + break; + } + } + + protected function applyCoupon(string $couponCode): void + { + $couponService = new CouponService($couponCode); + if (!$couponService->use($this->order)) { + throw new ApiException(__('Coupon failed')); + } + $this->order->coupon_id = $couponService->getId(); + } + + /** + * Summary of handleUserBalance + * @param User $user + * @param UserService $userService + * @return void + */ + protected function handleUserBalance(User $user, UserService $userService): void + { + $remainingBalance = $user->balance - $this->order->total_amount; + + if ($remainingBalance >= 0) { + if (!$userService->addBalance($this->order->user_id, -$this->order->total_amount)) { + throw new ApiException(__('Insufficient balance')); + } + $this->order->balance_amount = $this->order->total_amount; + $this->order->total_amount = 0; + } else { + if (!$userService->addBalance($this->order->user_id, -$user->balance)) { + throw new ApiException(__('Insufficient balance')); + } + $this->order->balance_amount = $user->balance; + $this->order->total_amount = $this->order->total_amount - $user->balance; + } + } +} diff --git a/Xboard/app/Services/PaymentService.php b/Xboard/app/Services/PaymentService.php new file mode 100644 index 0000000..c496d38 --- /dev/null +++ b/Xboard/app/Services/PaymentService.php @@ -0,0 +1,135 @@ +method = $method; + $this->pluginManager = app(PluginManager::class); + + if ($method === 'temp') { + return; + } + + if ($id) { + $paymentModel = Payment::find($id); + if (!$paymentModel) { + throw new ApiException('payment not found'); + } + $payment = $paymentModel->toArray(); + } + if ($uuid) { + $paymentModel = Payment::where('uuid', $uuid)->first(); + if (!$paymentModel) { + throw new ApiException('payment not found'); + } + $payment = $paymentModel->toArray(); + } + + $this->config = []; + if (isset($payment)) { + $this->config = is_string($payment['config']) ? json_decode($payment['config'], true) : $payment['config']; + $this->config['enable'] = $payment['enable']; + $this->config['id'] = $payment['id']; + $this->config['uuid'] = $payment['uuid']; + $this->config['notify_domain'] = $payment['notify_domain'] ?? ''; + } + + $paymentMethods = $this->getAvailablePaymentMethods(); + if (isset($paymentMethods[$this->method])) { + $pluginCode = $paymentMethods[$this->method]['plugin_code']; + $paymentPlugins = $this->pluginManager->getEnabledPaymentPlugins(); + foreach ($paymentPlugins as $plugin) { + if ($plugin->getPluginCode() === $pluginCode) { + $plugin->setConfig($this->config); + $this->payment = $plugin; + return; + } + } + } + + $this->payment = new $this->class($this->config); + } + + public function notify($params) + { + if (!$this->config['enable']) + throw new ApiException('gate is not enable'); + return $this->payment->notify($params); + } + + public function pay($order) + { + // custom notify domain name + $notifyUrl = url("/api/v1/guest/payment/notify/{$this->method}/{$this->config['uuid']}"); + if ($this->config['notify_domain']) { + $parseUrl = parse_url($notifyUrl); + $notifyUrl = $this->config['notify_domain'] . $parseUrl['path']; + } + + return $this->payment->pay([ + 'notify_url' => $notifyUrl, + 'return_url' => source_base_url('/#/order/' . $order['trade_no']), + 'trade_no' => $order['trade_no'], + 'total_amount' => $order['total_amount'], + 'user_id' => $order['user_id'], + 'stripe_token' => $order['stripe_token'] + ]); + } + + public function form() + { + $form = $this->payment->form(); + $result = []; + foreach ($form as $key => $field) { + $result[$key] = [ + 'type' => $field['type'], + 'label' => $field['label'] ?? '', + 'placeholder' => $field['placeholder'] ?? '', + 'description' => $field['description'] ?? '', + 'value' => $this->config[$key] ?? $field['default'] ?? '', + 'options' => $field['select_options'] ?? $field['options'] ?? [] + ]; + } + return $result; + } + + /** + * 获取所有可用的支付方式 + */ + public function getAvailablePaymentMethods(): array + { + $methods = []; + + $methods = HookManager::filter('available_payment_methods', $methods); + + return $methods; + } + + /** + * 获取所有支付方式名称列表(用于管理后台) + */ + public static function getAllPaymentMethodNames(): array + { + $pluginManager = app(PluginManager::class); + $pluginManager->initializeEnabledPlugins(); + + $instance = new self('temp'); + $methods = $instance->getAvailablePaymentMethods(); + + return array_keys($methods); + } +} diff --git a/Xboard/app/Services/PlanService.php b/Xboard/app/Services/PlanService.php new file mode 100644 index 0000000..e67363d --- /dev/null +++ b/Xboard/app/Services/PlanService.php @@ -0,0 +1,194 @@ +plan = $plan; + } + + /** + * 获取所有可销售的订阅计划列表 + * 条件:show 和 sell 为 true,且容量充足 + * + * @return Collection + */ + public function getAvailablePlans(): Collection + { + return Plan::where('show', true) + ->where('sell', true) + ->orderBy('sort') + ->get() + ->filter(function ($plan) { + return $this->hasCapacity($plan); + }); + } + + /** + * 获取指定订阅计划的可用状态 + * 条件:renew 和 sell 为 true + * + * @param int $planId + * @return Plan|null + */ + public function getAvailablePlan(int $planId): ?Plan + { + return Plan::where('id', $planId) + ->where('sell', true) + ->where('renew', true) + ->first(); + } + + /** + * 检查指定计划是否可用于指定用户 + * + * @param Plan $plan + * @param User $user + * @return bool + */ + public function isPlanAvailableForUser(Plan $plan, User $user): bool + { + // 如果是续费 + if ($user->plan_id === $plan->id) { + return $plan->renew; + } + + // 如果是新购 + return $plan->show && $plan->sell && $this->hasCapacity($plan); + } + + public function validatePurchase(User $user, string $period): void + { + if (!$this->plan) { + throw new ApiException(__('Subscription plan does not exist')); + } + + // 转换周期格式为新版格式 + $periodKey = self::getPeriodKey($period); + $price = $this->plan->prices[$periodKey] ?? null; + + if ($price === null) { + throw new ApiException(__('This payment period cannot be purchased, please choose another period')); + } + + if ($periodKey === Plan::PERIOD_RESET_TRAFFIC) { + $this->validateResetTrafficPurchase($user); + return; + } + + if ($user->plan_id !== $this->plan->id && !$this->hasCapacity($this->plan)) { + throw new ApiException(__('Current product is sold out')); + } + + $this->validatePlanAvailability($user); + } + + /** + * 智能转换周期格式为新版格式 + * 如果是新版格式直接返回,如果是旧版格式则转换为新版格式 + * + * @param string $period + * @return string + */ + public static function getPeriodKey(string $period): string + { + // 如果是新版格式直接返回 + if (in_array($period, self::getNewPeriods())) { + return $period; + } + + // 如果是旧版格式则转换为新版格式 + return Plan::LEGACY_PERIOD_MAPPING[$period] ?? $period; + } + /** + * 只能转换周期格式为旧版本 + */ + public static function convertToLegacyPeriod(string $period): string + { + $flippedMapping = array_flip(Plan::LEGACY_PERIOD_MAPPING); + return $flippedMapping[$period] ?? $period; + } + + /** + * 获取所有支持的新版周期格式 + * + * @return array + */ + public static function getNewPeriods(): array + { + return array_values(Plan::LEGACY_PERIOD_MAPPING); + } + + /** + * 获取旧版周期格式 + * + * @param string $period + * @return string + */ + public static function getLegacyPeriod(string $period): string + { + $flipped = array_flip(Plan::LEGACY_PERIOD_MAPPING); + return $flipped[$period] ?? $period; + } + + protected function validateResetTrafficPurchase(User $user): void + { + if (!app(UserService::class)->isAvailable($user) || $this->plan->id !== $user->plan_id) { + throw new ApiException(__('Subscription has expired or no active subscription, unable to purchase Data Reset Package')); + } + } + + protected function validatePlanAvailability(User $user): void + { + if ((!$this->plan->show && !$this->plan->renew) || (!$this->plan->show && $user->plan_id !== $this->plan->id)) { + throw new ApiException(__('This subscription has been sold out, please choose another subscription')); + } + + if (!$this->plan->renew && $user->plan_id == $this->plan->id) { + throw new ApiException(__('This subscription cannot be renewed, please change to another subscription')); + } + + if (!$this->plan->show && $this->plan->renew && !app(UserService::class)->isAvailable($user)) { + throw new ApiException(__('This subscription has expired, please change to another subscription')); + } + } + + public function hasCapacity(Plan $plan): bool + { + if ($plan->capacity_limit === null) { + return true; + } + + $activeUserCount = User::where('plan_id', $plan->id) + ->where(function ($query) { + $query->where('expired_at', '>=', time()) + ->orWhereNull('expired_at'); + }) + ->count(); + + return ($plan->capacity_limit - $activeUserCount) > 0; + } + + public function getAvailablePeriods(Plan $plan): array + { + return array_filter( + $plan->getActivePeriods(), + fn($period) => isset($plan->prices[$period]) && $plan->prices[$period] > 0 + ); + } + + public function canResetTraffic(Plan $plan): bool + { + return $plan->reset_traffic_method !== Plan::RESET_TRAFFIC_NEVER + && $plan->getResetTrafficPrice() > 0; + } +} diff --git a/Xboard/app/Services/Plugin/AbstractPlugin.php b/Xboard/app/Services/Plugin/AbstractPlugin.php new file mode 100644 index 0000000..23da9a3 --- /dev/null +++ b/Xboard/app/Services/Plugin/AbstractPlugin.php @@ -0,0 +1,222 @@ +pluginCode = $pluginCode; + $this->namespace = 'Plugin\\' . Str::studly($pluginCode); + $reflection = new \ReflectionClass($this); + $this->basePath = dirname($reflection->getFileName()); + } + + /** + * 获取插件代码 + */ + public function getPluginCode(): string + { + return $this->pluginCode; + } + + /** + * 获取插件命名空间 + */ + public function getNamespace(): string + { + return $this->namespace; + } + + /** + * 获取插件基础路径 + */ + public function getBasePath(): string + { + return $this->basePath; + } + + /** + * 设置配置 + */ + public function setConfig(array $config): void + { + $this->config = $config; + } + + /** + * 获取配置 + */ + public function getConfig(?string $key = null, $default = null): mixed + { + $config = $this->config; + if ($key) { + $config = $config[$key] ?? $default; + } + return $config; + } + + /** + * 获取视图 + */ + protected function view(string $view, array $data = [], array $mergeData = []): \Illuminate\Contracts\View\View + { + return view(Str::studly($this->pluginCode) . '::' . $view, $data, $mergeData); + } + + /** + * 注册动作钩子监听器 + */ + protected function listen(string $hook, callable $callback, int $priority = 20): void + { + HookManager::register($hook, $callback, $priority); + } + + /** + * 注册过滤器钩子 + */ + protected function filter(string $hook, callable $callback, int $priority = 20): void + { + HookManager::registerFilter($hook, $callback, $priority); + } + + /** + * 移除事件监听器 + */ + protected function removeListener(string $hook): void + { + HookManager::remove($hook); + } + + /** + * 注册 Artisan 命令 + */ + protected function registerCommand(string $commandClass): void + { + if (class_exists($commandClass)) { + app('Illuminate\Contracts\Console\Kernel')->registerCommand(new $commandClass()); + } + } + + /** + * 注册插件命令目录 + */ + public function registerCommands(): void + { + $commandsPath = $this->basePath . '/Commands'; + if (File::exists($commandsPath)) { + $files = File::glob($commandsPath . '/*.php'); + foreach ($files as $file) { + $className = pathinfo($file, PATHINFO_FILENAME); + $commandClass = $this->namespace . '\\Commands\\' . $className; + + if (class_exists($commandClass)) { + $this->registerCommand($commandClass); + } + } + } + } + + /** + * 中断当前请求并返回新的响应 + * + * @param Response|string|array $response + * @return never + */ + protected function intercept(Response|string|array $response): never + { + HookManager::intercept($response); + } + + /** + * 插件启动时调用 + */ + public function boot(): void + { + // 插件启动时的初始化逻辑 + } + + /** + * 插件安装时调用 + */ + public function install(): void + { + // 插件安装时的初始化逻辑 + } + + /** + * 插件卸载时调用 + */ + public function cleanup(): void + { + // 插件卸载时的清理逻辑 + } + + /** + * 插件更新时调用 + */ + public function update(string $oldVersion, string $newVersion): void + { + // 插件更新时的迁移逻辑 + } + + /** + * 获取插件资源URL + */ + protected function asset(string $path): string + { + return asset('plugins/' . $this->pluginCode . '/' . ltrim($path, '/')); + } + + /** + * 获取插件配置项 + */ + protected function getConfigValue(string $key, $default = null) + { + return $this->config[$key] ?? $default; + } + + /** + * 获取插件数据库迁移路径 + */ + protected function getMigrationsPath(): string + { + return $this->basePath . '/database/migrations'; + } + + /** + * 获取插件视图路径 + */ + protected function getViewsPath(): string + { + return $this->basePath . '/resources/views'; + } + + /** + * 获取插件资源路径 + */ + protected function getAssetsPath(): string + { + return $this->basePath . '/resources/assets'; + } + + /** + * Register plugin scheduled tasks. Plugins can override this method. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + */ + public function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void + { + // Plugin can override this method to register scheduled tasks + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Plugin/HookManager.php b/Xboard/app/Services/Plugin/HookManager.php new file mode 100644 index 0000000..5b6e9af --- /dev/null +++ b/Xboard/app/Services/Plugin/HookManager.php @@ -0,0 +1,286 @@ +json($response); + } + + throw new InterceptResponseException($response); + } + + /** + * Trigger action hook + * + * @param string $hook Hook name + * @param mixed $payload Data passed to hook + * @return void + */ + public static function call(string $hook, mixed $payload = null): void + { + $actions = self::getActions(); + + if (!isset($actions[$hook])) { + return; + } + + ksort($actions[$hook]); + + foreach ($actions[$hook] as $callbacks) { + foreach ($callbacks as $callback) { + $callback($payload); + } + } + } + + /** + * Trigger filter hook + * + * @param string $hook Hook name + * @param mixed $value Value to filter + * @param mixed ...$args Other parameters + * @return mixed + */ + public static function filter(string $hook, mixed $value, mixed ...$args): mixed + { + $filters = self::getFilters(); + + if (!isset($filters[$hook])) { + return $value; + } + + ksort($filters[$hook]); + + $result = $value; + foreach ($filters[$hook] as $callbacks) { + foreach ($callbacks as $callback) { + $result = $callback($result, ...$args); + } + } + + return $result; + } + + /** + * Register action hook listener + * + * @param string $hook Hook name + * @param callable $callback Callback function + * @param int $priority Priority + * @return void + */ + public static function register(string $hook, callable $callback, int $priority = 20): void + { + $actions = self::getActions(); + + if (!isset($actions[$hook])) { + $actions[$hook] = []; + } + + if (!isset($actions[$hook][$priority])) { + $actions[$hook][$priority] = []; + } + + $actions[$hook][$priority][self::getCallableId($callback)] = $callback; + + self::setActions($actions); + } + + /** + * Register filter hook + * + * @param string $hook Hook name + * @param callable $callback Callback function + * @param int $priority Priority + * @return void + */ + public static function registerFilter(string $hook, callable $callback, int $priority = 20): void + { + $filters = self::getFilters(); + + if (!isset($filters[$hook])) { + $filters[$hook] = []; + } + + if (!isset($filters[$hook][$priority])) { + $filters[$hook][$priority] = []; + } + + $filters[$hook][$priority][self::getCallableId($callback)] = $callback; + + self::setFilters($filters); + } + + /** + * Remove hook listener + * + * @param string $hook Hook name + * @param callable|null $callback Callback function + * @return void + */ + public static function remove(string $hook, ?callable $callback = null): void + { + $actions = self::getActions(); + $filters = self::getFilters(); + + if ($callback === null) { + if (isset($actions[$hook])) { + unset($actions[$hook]); + self::setActions($actions); + } + + if (isset($filters[$hook])) { + unset($filters[$hook]); + self::setFilters($filters); + } + + return; + } + + $callbackId = self::getCallableId($callback); + + if (isset($actions[$hook])) { + foreach ($actions[$hook] as $priority => $callbacks) { + if (isset($callbacks[$callbackId])) { + unset($actions[$hook][$priority][$callbackId]); + + if (empty($actions[$hook][$priority])) { + unset($actions[$hook][$priority]); + } + + if (empty($actions[$hook])) { + unset($actions[$hook]); + } + } + } + self::setActions($actions); + } + + if (isset($filters[$hook])) { + foreach ($filters[$hook] as $priority => $callbacks) { + if (isset($callbacks[$callbackId])) { + unset($filters[$hook][$priority][$callbackId]); + + if (empty($filters[$hook][$priority])) { + unset($filters[$hook][$priority]); + } + + if (empty($filters[$hook])) { + unset($filters[$hook]); + } + } + } + self::setFilters($filters); + } + } + + /** + * Check if hook exists + * + * @param string $hook Hook name + * @return bool + */ + public static function hasHook(string $hook): bool + { + $actions = self::getActions(); + $filters = self::getFilters(); + + return isset($actions[$hook]) || isset($filters[$hook]); + } + + /** + * Clear all hooks (called when Octane resets) + */ + public static function reset(): void + { + App::instance('hook.actions', []); + App::instance('hook.filters', []); + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Plugin/InterceptResponseException.php b/Xboard/app/Services/Plugin/InterceptResponseException.php new file mode 100644 index 0000000..298d8d3 --- /dev/null +++ b/Xboard/app/Services/Plugin/InterceptResponseException.php @@ -0,0 +1,22 @@ +response = $response; + } + + public function getResponse(): Response + { + return $this->response; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Plugin/PluginConfigService.php b/Xboard/app/Services/Plugin/PluginConfigService.php new file mode 100644 index 0000000..977614b --- /dev/null +++ b/Xboard/app/Services/Plugin/PluginConfigService.php @@ -0,0 +1,111 @@ +pluginManager = app(PluginManager::class); + } + + /** + * 获取插件配置 + * + * @param string $pluginCode + * @return array + */ + public function getConfig(string $pluginCode): array + { + $defaultConfig = $this->getDefaultConfig($pluginCode); + if (empty($defaultConfig)) { + return []; + } + $dbConfig = $this->getDbConfig($pluginCode); + + $result = []; + foreach ($defaultConfig as $key => $item) { + $result[$key] = [ + 'type' => $item['type'], + 'label' => $item['label'] ?? '', + 'placeholder' => $item['placeholder'] ?? '', + 'description' => $item['description'] ?? '', + 'value' => $dbConfig[$key] ?? $item['default'], + 'options' => $item['options'] ?? [] + ]; + } + + return $result; + } + + /** + * 更新插件配置 + * + * @param string $pluginCode + * @param array $config + * @return bool + */ + public function updateConfig(string $pluginCode, array $config): bool + { + $defaultConfig = $this->getDefaultConfig($pluginCode); + if (empty($defaultConfig)) { + throw new \Exception('插件配置结构不存在'); + } + $values = []; + foreach ($config as $key => $value) { + if (!isset($defaultConfig[$key])) { + continue; + } + $values[$key] = $value; + } + Plugin::query() + ->where('code', $pluginCode) + ->update([ + 'config' => json_encode($values), + 'updated_at' => now() + ]); + + return true; + } + + /** + * 获取插件默认配置 + * + * @param string $pluginCode + * @return array + */ + protected function getDefaultConfig(string $pluginCode): array + { + $configFile = $this->pluginManager->getPluginPath($pluginCode) . '/config.json'; + if (!File::exists($configFile)) { + return []; + } + + $config = json_decode(File::get($configFile), true); + return $config['config'] ?? []; + } + + /** + * 获取数据库中的配置 + * + * @param string $pluginCode + * @return array + */ + public function getDbConfig(string $pluginCode): array + { + $plugin = Plugin::query() + ->where('code', $pluginCode) + ->first(); + + if (!$plugin || empty($plugin->config)) { + return []; + } + + return json_decode($plugin->config, true); + } +} \ No newline at end of file diff --git a/Xboard/app/Services/Plugin/PluginManager.php b/Xboard/app/Services/Plugin/PluginManager.php new file mode 100644 index 0000000..7859da0 --- /dev/null +++ b/Xboard/app/Services/Plugin/PluginManager.php @@ -0,0 +1,727 @@ +pluginPath = base_path('plugins'); + } + + /** + * 获取插件的命名空间 + */ + public function getPluginNamespace(string $pluginCode): string + { + return 'Plugin\\' . Str::studly($pluginCode); + } + + /** + * 获取插件的基础路径 + */ + public function getPluginPath(string $pluginCode): string + { + return $this->pluginPath . '/' . Str::studly($pluginCode); + } + + /** + * 加载插件类 + */ + protected function loadPlugin(string $pluginCode): ?AbstractPlugin + { + if (isset($this->loadedPlugins[$pluginCode])) { + return $this->loadedPlugins[$pluginCode]; + } + + $pluginClass = $this->getPluginNamespace($pluginCode) . '\\Plugin'; + + if (!class_exists($pluginClass)) { + $pluginFile = $this->getPluginPath($pluginCode) . '/Plugin.php'; + if (!File::exists($pluginFile)) { + Log::warning("Plugin class file not found: {$pluginFile}"); + Plugin::query()->where('code', $pluginCode)->delete(); + return null; + } + require_once $pluginFile; + } + + if (!class_exists($pluginClass)) { + Log::error("Plugin class not found: {$pluginClass}"); + return null; + } + + $plugin = new $pluginClass($pluginCode); + $this->loadedPlugins[$pluginCode] = $plugin; + + return $plugin; + } + + /** + * 注册插件的服务提供者 + */ + protected function registerServiceProvider(string $pluginCode): void + { + $providerClass = $this->getPluginNamespace($pluginCode) . '\\Providers\\PluginServiceProvider'; + + if (class_exists($providerClass)) { + app()->register($providerClass); + } + } + + /** + * 加载插件的路由 + */ + protected function loadRoutes(string $pluginCode): void + { + $routesPath = $this->getPluginPath($pluginCode) . '/routes'; + if (File::exists($routesPath)) { + $webRouteFile = $routesPath . '/web.php'; + $apiRouteFile = $routesPath . '/api.php'; + if (File::exists($webRouteFile)) { + Route::middleware('web') + ->namespace($this->getPluginNamespace($pluginCode) . '\\Controllers') + ->group(function () use ($webRouteFile) { + require $webRouteFile; + }); + } + if (File::exists($apiRouteFile)) { + Route::middleware('api') + ->namespace($this->getPluginNamespace($pluginCode) . '\\Controllers') + ->group(function () use ($apiRouteFile) { + require $apiRouteFile; + }); + } + } + } + + /** + * 加载插件的视图 + */ + protected function loadViews(string $pluginCode): void + { + $viewsPath = $this->getPluginPath($pluginCode) . '/resources/views'; + if (File::exists($viewsPath)) { + View::addNamespace(Str::studly($pluginCode), $viewsPath); + return; + } + } + + /** + * 注册插件命令 + */ + protected function registerPluginCommands(string $pluginCode, AbstractPlugin $pluginInstance): void + { + try { + // 调用插件的命令注册方法 + $pluginInstance->registerCommands(); + } catch (\Exception $e) { + Log::error("Failed to register commands for plugin '{$pluginCode}': " . $e->getMessage()); + } + } + + /** + * 安装插件 + */ + public function install(string $pluginCode): bool + { + $configFile = $this->getPluginPath($pluginCode) . '/config.json'; + + if (!File::exists($configFile)) { + throw new \Exception('Plugin config file not found'); + } + + $config = json_decode(File::get($configFile), true); + if (!$this->validateConfig($config)) { + throw new \Exception('Invalid plugin config'); + } + + // 检查插件是否已安装 + if (Plugin::where('code', $pluginCode)->exists()) { + throw new \Exception('Plugin already installed'); + } + + // 检查依赖 + if (!$this->checkDependencies($config['require'] ?? [])) { + throw new \Exception('Dependencies not satisfied'); + } + + // 运行数据库迁移 + $this->runMigrations(pluginCode: $pluginCode); + + DB::beginTransaction(); + try { + // 提取配置默认值 + $defaultValues = $this->extractDefaultConfig($config); + + // 创建插件实例 + $plugin = $this->loadPlugin($pluginCode); + + // 注册到数据库 + Plugin::create([ + 'code' => $pluginCode, + 'name' => $config['name'], + 'version' => $config['version'], + 'type' => $config['type'] ?? Plugin::TYPE_FEATURE, + 'is_enabled' => false, + 'config' => json_encode($defaultValues), + 'installed_at' => now(), + ]); + + // 运行插件安装方法 + if (method_exists($plugin, 'install')) { + $plugin->install(); + } + + // 发布插件资源 + $this->publishAssets($pluginCode); + + DB::commit(); + return true; + } catch (\Exception $e) { + if (DB::transactionLevel() > 0) { + DB::rollBack(); + } + throw $e; + } + } + + /** + * 提取插件默认配置 + */ + protected function extractDefaultConfig(array $config): array + { + $defaultValues = []; + if (isset($config['config']) && is_array($config['config'])) { + foreach ($config['config'] as $key => $item) { + if (is_array($item)) { + $defaultValues[$key] = $item['default'] ?? null; + } else { + $defaultValues[$key] = $item; + } + } + } + return $defaultValues; + } + + /** + * 运行插件数据库迁移 + */ + protected function runMigrations(string $pluginCode): void + { + $migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations'; + + if (File::exists($migrationsPath)) { + Artisan::call('migrate', [ + '--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations", + '--force' => true + ]); + } + } + + /** + * 回滚插件数据库迁移 + */ + protected function runMigrationsRollback(string $pluginCode): void + { + $migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations'; + + if (File::exists($migrationsPath)) { + Artisan::call('migrate:rollback', [ + '--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations", + '--force' => true + ]); + } + } + + /** + * 发布插件资源 + */ + protected function publishAssets(string $pluginCode): void + { + $assetsPath = $this->getPluginPath($pluginCode) . '/resources/assets'; + if (File::exists($assetsPath)) { + $publishPath = public_path('plugins/' . $pluginCode); + File::ensureDirectoryExists($publishPath); + File::copyDirectory($assetsPath, $publishPath); + } + } + + /** + * 验证配置文件 + */ + protected function validateConfig(array $config): bool + { + $requiredFields = [ + 'name', + 'code', + 'version', + 'description', + 'author' + ]; + + foreach ($requiredFields as $field) { + if (!isset($config[$field]) || empty($config[$field])) { + return false; + } + } + + // 验证插件代码格式 + if (!preg_match('/^[a-z0-9_]+$/', $config['code'])) { + return false; + } + + // 验证版本号格式 + if (!preg_match('/^\d+\.\d+\.\d+$/', $config['version'])) { + return false; + } + + // 验证插件类型 + if (isset($config['type'])) { + $validTypes = ['feature', 'payment']; + if (!in_array($config['type'], $validTypes)) { + return false; + } + } + + return true; + } + + /** + * 启用插件 + */ + public function enable(string $pluginCode): bool + { + $plugin = $this->loadPlugin($pluginCode); + + if (!$plugin) { + Plugin::where('code', $pluginCode)->delete(); + throw new \Exception('Plugin not found: ' . $pluginCode); + } + + // 获取插件配置 + $dbPlugin = Plugin::query() + ->where('code', $pluginCode) + ->first(); + + if ($dbPlugin && !empty($dbPlugin->config)) { + $values = json_decode($dbPlugin->config, true) ?: []; + $values = $this->castConfigValuesByType($pluginCode, $values); + $plugin->setConfig($values); + } + + // 注册服务提供者 + $this->registerServiceProvider($pluginCode); + + // 加载路由 + $this->loadRoutes($pluginCode); + + // 加载视图 + $this->loadViews($pluginCode); + + // 更新数据库状态 + Plugin::query() + ->where('code', $pluginCode) + ->update([ + 'is_enabled' => true, + 'updated_at' => now(), + ]); + // 初始化插件 + $plugin->boot(); + + return true; + } + + /** + * 禁用插件 + */ + public function disable(string $pluginCode): bool + { + $plugin = $this->loadPlugin($pluginCode); + if (!$plugin) { + throw new \Exception('Plugin not found'); + } + + Plugin::query() + ->where('code', $pluginCode) + ->update([ + 'is_enabled' => false, + 'updated_at' => now(), + ]); + + $plugin->cleanup(); + + return true; + } + + /** + * 卸载插件 + */ + public function uninstall(string $pluginCode): bool + { + $this->disable($pluginCode); + $this->runMigrationsRollback($pluginCode); + Plugin::query()->where('code', $pluginCode)->delete(); + + return true; + } + + /** + * 删除插件 + * + * @param string $pluginCode + * @return bool + * @throws \Exception + */ + public function delete(string $pluginCode): bool + { + // 先卸载插件 + if (Plugin::where('code', $pluginCode)->exists()) { + $this->uninstall($pluginCode); + } + + $pluginPath = $this->getPluginPath($pluginCode); + if (!File::exists($pluginPath)) { + throw new \Exception('插件不存在'); + } + + // 删除插件目录 + File::deleteDirectory($pluginPath); + + return true; + } + + /** + * 检查依赖关系 + */ + protected function checkDependencies(array $requires): bool + { + foreach ($requires as $package => $version) { + if ($package === 'xboard') { + // 检查xboard版本 + // 实现版本比较逻辑 + } + } + return true; + } + + /** + * 升级插件 + * + * @param string $pluginCode + * @return bool + * @throws \Exception + */ + public function update(string $pluginCode): bool + { + $dbPlugin = Plugin::where('code', $pluginCode)->first(); + if (!$dbPlugin) { + throw new \Exception('Plugin not installed: ' . $pluginCode); + } + + // 获取插件配置文件中的最新版本 + $configFile = $this->getPluginPath($pluginCode) . '/config.json'; + if (!File::exists($configFile)) { + throw new \Exception('Plugin config file not found'); + } + + $config = json_decode(File::get($configFile), true); + if (!$config || !isset($config['version'])) { + throw new \Exception('Invalid plugin config or missing version'); + } + + $newVersion = $config['version']; + $oldVersion = $dbPlugin->version; + + if (version_compare($newVersion, $oldVersion, '<=')) { + throw new \Exception('Plugin is already up to date'); + } + + $this->disable($pluginCode); + $this->runMigrations($pluginCode); + + $plugin = $this->loadPlugin($pluginCode); + if ($plugin) { + if (!empty($dbPlugin->config)) { + $values = json_decode($dbPlugin->config, true) ?: []; + $values = $this->castConfigValuesByType($pluginCode, $values); + $plugin->setConfig($values); + } + + $plugin->update($oldVersion, $newVersion); + } + + $dbPlugin->update([ + 'version' => $newVersion, + 'updated_at' => now(), + ]); + + $this->enable($pluginCode); + + return true; + } + + /** + * 上传插件 + * + * @param \Illuminate\Http\UploadedFile $file + * @return bool + * @throws \Exception + */ + public function upload($file): bool + { + $tmpPath = storage_path('tmp/plugins'); + if (!File::exists($tmpPath)) { + File::makeDirectory($tmpPath, 0755, true); + } + + $extractPath = $tmpPath . '/' . uniqid(); + $zip = new \ZipArchive(); + + if ($zip->open($file->path()) !== true) { + throw new \Exception('无法打开插件包文件'); + } + + $zip->extractTo($extractPath); + $zip->close(); + + $configFile = File::glob($extractPath . '/*/config.json'); + if (empty($configFile)) { + $configFile = File::glob($extractPath . '/config.json'); + } + + if (empty($configFile)) { + File::deleteDirectory($extractPath); + throw new \Exception('插件包格式错误:缺少配置文件'); + } + + $pluginPath = dirname(reset($configFile)); + $config = json_decode(File::get($pluginPath . '/config.json'), true); + + if (!$this->validateConfig($config)) { + File::deleteDirectory($extractPath); + throw new \Exception('插件配置文件格式错误'); + } + + $targetPath = $this->pluginPath . '/' . Str::studly($config['code']); + if (File::exists($targetPath)) { + $installedConfigPath = $targetPath . '/config.json'; + if (!File::exists($installedConfigPath)) { + throw new \Exception('已安装插件缺少配置文件,无法判断是否可升级'); + } + $installedConfig = json_decode(File::get($installedConfigPath), true); + + $oldVersion = $installedConfig['version'] ?? null; + $newVersion = $config['version'] ?? null; + if (!$oldVersion || !$newVersion) { + throw new \Exception('插件缺少版本号,无法判断是否可升级'); + } + if (version_compare($newVersion, $oldVersion, '<=')) { + throw new \Exception('上传插件版本不高于已安装版本,无法升级'); + } + + File::deleteDirectory($targetPath); + } + + File::copyDirectory($pluginPath, $targetPath); + File::deleteDirectory($pluginPath); + File::deleteDirectory($extractPath); + + if (Plugin::where('code', $config['code'])->exists()) { + return $this->update($config['code']); + } + + return true; + } + + /** + * Initializes all enabled plugins from the database. + * This method ensures that plugins are loaded, and their routes, views, + * and service providers are registered only once per request cycle. + */ + public function initializeEnabledPlugins(): void + { + if ($this->pluginsInitialized) { + return; + } + + $enabledPlugins = Plugin::where('is_enabled', true)->get(); + + foreach ($enabledPlugins as $dbPlugin) { + try { + $pluginCode = $dbPlugin->code; + + $pluginInstance = $this->loadPlugin($pluginCode); + if (!$pluginInstance) { + continue; + } + + if (!empty($dbPlugin->config)) { + $values = json_decode($dbPlugin->config, true) ?: []; + $values = $this->castConfigValuesByType($pluginCode, $values); + $pluginInstance->setConfig($values); + } + + $this->registerServiceProvider($pluginCode); + $this->loadRoutes($pluginCode); + $this->loadViews($pluginCode); + $this->registerPluginCommands($pluginCode, $pluginInstance); + + $pluginInstance->boot(); + + } catch (\Exception $e) { + Log::error("Failed to initialize plugin '{$dbPlugin->code}': " . $e->getMessage()); + } + } + + $this->pluginsInitialized = true; + } + + /** + * Register scheduled tasks for all enabled plugins. + * Called from Console Kernel. Only loads main plugin class and config for scheduling. + * Avoids full HTTP/plugin boot overhead. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + */ + public function registerPluginSchedules(Schedule $schedule): void + { + Plugin::where('is_enabled', true) + ->get() + ->each(function ($dbPlugin) use ($schedule) { + try { + $pluginInstance = $this->loadPlugin($dbPlugin->code); + if (!$pluginInstance) { + return; + } + if (!empty($dbPlugin->config)) { + $values = json_decode($dbPlugin->config, true) ?: []; + $values = $this->castConfigValuesByType($dbPlugin->code, $values); + $pluginInstance->setConfig($values); + } + $pluginInstance->schedule($schedule); + + } catch (\Exception $e) { + Log::error("Failed to register schedule for plugin '{$dbPlugin->code}': " . $e->getMessage()); + } + }); + } + + /** + * Get all enabled plugin instances. + * + * This method ensures that all enabled plugins are initialized and then returns them. + * It's the central point for accessing active plugins. + * + * @return array + */ + public function getEnabledPlugins(): array + { + $this->initializeEnabledPlugins(); + + $enabledPluginCodes = Plugin::where('is_enabled', true) + ->pluck('code') + ->all(); + + return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes)); + } + + /** + * Get enabled plugins by type + */ + public function getEnabledPluginsByType(string $type): array + { + $this->initializeEnabledPlugins(); + + $enabledPluginCodes = Plugin::where('is_enabled', true) + ->byType($type) + ->pluck('code') + ->all(); + + return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes)); + } + + /** + * Get enabled payment plugins + */ + public function getEnabledPaymentPlugins(): array + { + return $this->getEnabledPluginsByType('payment'); + } + + /** + * install default plugins + */ + public static function installDefaultPlugins(): void + { + foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) { + if (!Plugin::where('code', $pluginCode)->exists()) { + $pluginManager = app(self::class); + $pluginManager->install($pluginCode); + $pluginManager->enable($pluginCode); + Log::info("Installed and enabled default plugin: {$pluginCode}"); + } + } + } + + /** + * 根据 config.json 的类型信息对配置值进行类型转换(仅处理 type=json 键)。 + */ + protected function castConfigValuesByType(string $pluginCode, array $values): array + { + $types = $this->getConfigTypes($pluginCode); + foreach ($values as $key => $value) { + $type = $types[$key] ?? null; + + if ($type === 'json') { + if (is_array($value)) { + continue; + } + + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $values[$key] = $decoded; + } + } + } + } + return $values; + } + + /** + * 读取并缓存插件 config.json 中的键类型映射。 + */ + protected function getConfigTypes(string $pluginCode): array + { + if (isset($this->configTypesCache[$pluginCode])) { + return $this->configTypesCache[$pluginCode]; + } + $types = []; + $configFile = $this->getPluginPath($pluginCode) . '/config.json'; + if (File::exists($configFile)) { + $config = json_decode(File::get($configFile), true); + $fields = $config['config'] ?? []; + foreach ($fields as $key => $meta) { + $types[$key] = is_array($meta) ? ($meta['type'] ?? 'string') : 'string'; + } + } + $this->configTypesCache[$pluginCode] = $types; + return $types; + } +} \ No newline at end of file diff --git a/Xboard/app/Services/ServerService.php b/Xboard/app/Services/ServerService.php new file mode 100644 index 0000000..00491ca --- /dev/null +++ b/Xboard/app/Services/ServerService.php @@ -0,0 +1,296 @@ +get()->append([ + 'last_check_at', + 'last_push_at', + 'online', + 'is_online', + 'available_status', + 'cache_key', + 'load_status', + 'metrics', + 'online_conn' + ]); + } + + /** + * 获取指定用户可用的服务器列表 + * @param User $user + * @return array + */ + public static function getAvailableServers(User $user): array + { + $servers = Server::whereJsonContains('group_ids', (string) $user->group_id) + ->where('show', true) + ->where(function ($query) { + $query->whereNull('transfer_enable') + ->orWhere('transfer_enable', 0) + ->orWhereRaw('u + d < transfer_enable'); + }) + ->orderBy('sort', 'ASC') + ->get() + ->append(['last_check_at', 'last_push_at', 'online', 'is_online', 'available_status', 'cache_key', 'server_key']); + + $servers = collect($servers)->map(function ($server) use ($user) { + // 判断动态端口 + if (str_contains($server->port, '-')) { + $port = $server->port; + $server->port = (int) Helper::randomPort($port); + $server->ports = $port; + } else { + $server->port = (int) $server->port; + } + $server->password = $server->generateServerPassword($user); + $server->rate = $server->getCurrentRate(); + return $server; + })->toArray(); + + return $servers; + } + + /** + * 根据权限组获取可用的用户列表 + * @param array $groupIds + * @return Collection + */ + public static function getAvailableUsers(Server $node) + { + $users = User::toBase() + ->whereIn('group_id', $node->group_ids) + ->whereRaw('u + d < transfer_enable') + ->where(function ($query) { + $query->where('expired_at', '>=', time()) + ->orWhere('expired_at', NULL); + }) + ->where('banned', 0) + ->select([ + 'id', + 'uuid', + 'speed_limit', + 'device_limit' + ]) + ->get(); + return HookManager::filter('server.users.get', $users, $node); + } + + // 获取路由规则 + public static function getRoutes(array $routeIds) + { + $routes = ServerRoute::select(['id', 'match', 'action', 'action_value'])->whereIn('id', $routeIds)->get(); + return $routes; + } + + /** + * Update node metrics and load status + */ + public static function updateMetrics(Server $node, array $metrics): void + { + $nodeType = strtoupper($node->type); + $nodeId = $node->id; + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + + $metricsData = [ + 'uptime' => (int) ($metrics['uptime'] ?? 0), + 'goroutines' => (int) ($metrics['goroutines'] ?? 0), + 'active_connections' => (int) ($metrics['active_connections'] ?? 0), + 'total_connections' => (int) ($metrics['total_connections'] ?? 0), + 'total_users' => (int) ($metrics['total_users'] ?? 0), + 'active_users' => (int) ($metrics['active_users'] ?? 0), + 'inbound_speed' => (int) ($metrics['inbound_speed'] ?? 0), + 'outbound_speed' => (int) ($metrics['outbound_speed'] ?? 0), + 'cpu_per_core' => $metrics['cpu_per_core'] ?? [], + 'load' => $metrics['load'] ?? [], + 'speed_limiter' => $metrics['speed_limiter'] ?? [], + 'gc' => $metrics['gc'] ?? [], + 'api' => $metrics['api'] ?? [], + 'ws' => $metrics['ws'] ?? [], + 'limits' => $metrics['limits'] ?? [], + 'updated_at' => now()->timestamp, + 'kernel_status' => (bool) ($metrics['kernel_status'] ?? false), + ]; + + \Illuminate\Support\Facades\Cache::put( + \App\Utils\CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId), + $metricsData, + $cacheTime + ); + } + + public static function buildNodeConfig(Server $node): array + { + $nodeType = $node->type; + $protocolSettings = $node->protocol_settings; + $serverPort = $node->server_port; + $host = $node->host; + + $baseConfig = [ + 'protocol' => $nodeType, + 'listen_ip' => '0.0.0.0', + 'server_port' => (int) $serverPort, + 'network' => data_get($protocolSettings, 'network'), + 'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null, + ]; + + $response = match ($nodeType) { + 'shadowsocks' => [ + ...$baseConfig, + 'cipher' => $protocolSettings['cipher'], + 'plugin' => $protocolSettings['plugin'], + 'plugin_opts' => $protocolSettings['plugin_opts'], + 'server_key' => match ($protocolSettings['cipher']) { + '2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16), + '2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32), + default => null, + }, + ], + 'vmess' => [ + ...$baseConfig, + 'tls' => (int) $protocolSettings['tls'], + 'multiplex' => data_get($protocolSettings, 'multiplex'), + ], + 'trojan' => [ + ...$baseConfig, + 'host' => $host, + 'server_name' => $protocolSettings['server_name'], + 'multiplex' => data_get($protocolSettings, 'multiplex'), + 'tls' => (int) $protocolSettings['tls'], + 'tls_settings' => match ((int) $protocolSettings['tls']) { + 2 => $protocolSettings['reality_settings'], + default => null, + }, + ], + 'vless' => [ + ...$baseConfig, + 'tls' => (int) $protocolSettings['tls'], + 'flow' => $protocolSettings['flow'], + 'decryption' => data_get($protocolSettings, 'encryption.decryption'), + 'tls_settings' => match ((int) $protocolSettings['tls']) { + 2 => $protocolSettings['reality_settings'], + default => $protocolSettings['tls_settings'], + }, + 'multiplex' => data_get($protocolSettings, 'multiplex'), + ], + 'hysteria' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'version' => (int) $protocolSettings['version'], + 'host' => $host, + 'server_name' => $protocolSettings['tls']['server_name'], + 'up_mbps' => (int) $protocolSettings['bandwidth']['up'], + 'down_mbps' => (int) $protocolSettings['bandwidth']['down'], + ...match ((int) $protocolSettings['version']) { + 1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null], + 2 => [ + 'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null, + 'obfs-password' => $protocolSettings['obfs']['password'] ?? null, + ], + default => [], + }, + ], + 'tuic' => [ + ...$baseConfig, + 'version' => (int) $protocolSettings['version'], + 'server_port' => (int) $serverPort, + 'server_name' => $protocolSettings['tls']['server_name'], + 'congestion_control' => $protocolSettings['congestion_control'], + 'tls_settings' => data_get($protocolSettings, 'tls_settings'), + 'auth_timeout' => '3s', + 'zero_rtt_handshake' => false, + 'heartbeat' => '3s', + ], + 'anytls' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'server_name' => $protocolSettings['tls']['server_name'], + 'padding_scheme' => $protocolSettings['padding_scheme'], + ], + 'socks' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + ], + 'naive' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'tls' => (int) $protocolSettings['tls'], + 'tls_settings' => $protocolSettings['tls_settings'], + ], + 'http' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'tls' => (int) $protocolSettings['tls'], + 'tls_settings' => $protocolSettings['tls_settings'], + ], + 'mieru' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'transport' => data_get($protocolSettings, 'transport', 'TCP'), + 'traffic_pattern' => $protocolSettings['traffic_pattern'], + // 'multiplex' => data_get($protocolSettings, 'multiplex'), + ], + default => [], + }; + + // $response = array_filter( + // $response, + // static fn ($value) => $value !== null + // ); + + if (!empty($node['route_ids'])) { + $response['routes'] = self::getRoutes($node['route_ids']); + } + + if (!empty($node['custom_outbounds'])) { + $response['custom_outbounds'] = $node['custom_outbounds']; + } + + if (!empty($node['custom_routes'])) { + $response['custom_routes'] = $node['custom_routes']; + } + + if (!empty($node['cert_config']) && data_get($node['cert_config'],'cert_mode') !== 'none' ) { + $response['cert_config'] = $node['cert_config']; + } + + return $response; + } + + /** + * 根据协议类型和标识获取服务器 + * @param int $serverId + * @param string $serverType + * @return Server|null + */ + public static function getServer($serverId, ?string $serverType = null): Server | null + { + return Server::query() + ->when($serverType, function ($query) use ($serverType) { + $query->where('type', Server::normalizeType($serverType)); + }) + ->where(function ($query) use ($serverId) { + $query->where('code', $serverId) + ->orWhere('id', $serverId); + }) + ->orderByRaw('CASE WHEN code = ? THEN 0 ELSE 1 END', [$serverId]) + ->first(); + } +} diff --git a/Xboard/app/Services/SettingService.php b/Xboard/app/Services/SettingService.php new file mode 100644 index 0000000..37e34d9 --- /dev/null +++ b/Xboard/app/Services/SettingService.php @@ -0,0 +1,18 @@ +first(); + return $setting ? $setting->value : $default; + } + + public function getAll(){ + return SettingModel::all()->pluck('value', 'name')->toArray(); + } +} diff --git a/Xboard/app/Services/StatisticalService.php b/Xboard/app/Services/StatisticalService.php new file mode 100644 index 0000000..e57d6ae --- /dev/null +++ b/Xboard/app/Services/StatisticalService.php @@ -0,0 +1,378 @@ +redis = Redis::connection(); + + } + + public function setStartAt($timestamp) + { + $this->startAt = $timestamp; + $this->statServerKey = "stat_server_{$this->startAt}"; + $this->statUserKey = "stat_user_{$this->startAt}"; + } + + public function setEndAt($timestamp) + { + $this->endAt = $timestamp; + } + + /** + * 生成统计报表 + */ + public function generateStatData(): array + { + $startAt = $this->startAt; + $endAt = $this->endAt; + if (!$startAt || !$endAt) { + $startAt = strtotime(date('Y-m-d')); + $endAt = strtotime('+1 day', $startAt); + } + $data = []; + $data['order_count'] = Order::where('created_at', '>=', $startAt) + ->where('created_at', '<', $endAt) + ->count(); + $data['order_total'] = Order::where('created_at', '>=', $startAt) + ->where('created_at', '<', $endAt) + ->sum('total_amount'); + $data['paid_count'] = Order::where('paid_at', '>=', $startAt) + ->where('paid_at', '<', $endAt) + ->whereNotIn('status', [0, 2]) + ->count(); + $data['paid_total'] = Order::where('paid_at', '>=', $startAt) + ->where('paid_at', '<', $endAt) + ->whereNotIn('status', [0, 2]) + ->sum('total_amount'); + $commissionLogBuilder = CommissionLog::where('created_at', '>=', $startAt) + ->where('created_at', '<', $endAt); + $data['commission_count'] = $commissionLogBuilder->count(); + $data['commission_total'] = $commissionLogBuilder->sum('get_amount'); + $data['register_count'] = User::where('created_at', '>=', $startAt) + ->where('created_at', '<', $endAt) + ->count(); + $data['invite_count'] = User::where('created_at', '>=', $startAt) + ->where('created_at', '<', $endAt) + ->whereNotNull('invite_user_id') + ->count(); + $data['transfer_used_total'] = StatServer::where('created_at', '>=', $startAt) + ->where('created_at', '<', $endAt) + ->select(DB::raw('SUM(u) + SUM(d) as total')) + ->value('total') ?? 0; + return $data; + } + + /** + * 往服务器报表缓存正追加流量使用数据 + */ + public function statServer($serverId, $serverType, $u, $d) + { + $u_menber = "{$serverType}_{$serverId}_u"; //储存上传流量的集合成员 + $d_menber = "{$serverType}_{$serverId}_d"; //储存下载流量的集合成员 + $this->redis->zincrby($this->statServerKey, $u, $u_menber); + $this->redis->zincrby($this->statServerKey, $d, $d_menber); + } + + /** + * 追加用户使用流量 + */ + public function statUser($rate, $userId, $u, $d) + { + $u_menber = "{$rate}_{$userId}_u"; //储存上传流量的集合成员 + $d_menber = "{$rate}_{$userId}_d"; //储存下载流量的集合成员 + $this->redis->zincrby($this->statUserKey, $u, $u_menber); + $this->redis->zincrby($this->statUserKey, $d, $d_menber); + } + + /** + * 获取指定用户的流量使用情况 + */ + public function getStatUserByUserID(int|string $userId): array + { + + $stats = []; + $statsUser = $this->redis->zrange($this->statUserKey, 0, -1, true); + foreach ($statsUser as $member => $value) { + list($rate, $uid, $type) = explode('_', $member); + if (intval($uid) !== intval($userId)) + continue; + $key = "{$rate}_{$uid}"; + $stats[$key] = $stats[$key] ?? [ + 'record_at' => $this->startAt, + 'server_rate' => number_format((float) $rate, 2, '.', ''), + 'u' => 0, + 'd' => 0, + 'user_id' => intval($userId), + ]; + $stats[$key][$type] += $value; + } + return array_values($stats); + } + + /** + * 获取缓存中的用户报表 + */ + public function getStatUser() + { + $stats = []; + $statsUser = $this->redis->zrange($this->statUserKey, 0, -1, true); + foreach ($statsUser as $member => $value) { + list($rate, $uid, $type) = explode('_', $member); + $key = "{$rate}_{$uid}"; + $stats[$key] = $stats[$key] ?? [ + 'record_at' => $this->startAt, + 'server_rate' => $rate, + 'u' => 0, + 'd' => 0, + 'user_id' => intval($uid), + ]; + $stats[$key][$type] += $value; + } + return array_values($stats); + } + + + /** + * Retrieve server statistics from Redis cache. + * + * @return array + */ + public function getStatServer(): array + { + /** @var array $stats */ + $stats = []; + $statsServer = $this->redis->zrange($this->statServerKey, 0, -1, true); + + foreach ($statsServer as $member => $value) { + $parts = explode('_', $member); + if (count($parts) !== 3) { + continue; // Skip malformed members + } + [$serverType, $serverId, $type] = $parts; + + if (!in_array($type, ['u', 'd'], true)) { + continue; // Skip invalid types + } + + $key = "{$serverType}_{$serverId}"; + if (!isset($stats[$key])) { + $stats[$key] = [ + 'server_id' => (int) $serverId, + 'server_type' => $serverType, + 'u' => 0.0, + 'd' => 0.0, + ]; + } + $stats[$key][$type] += (float) $value; + } + + return array_values($stats); + } + + /** + * 清除用户报表缓存数据 + */ + public function clearStatUser() + { + $this->redis->del($this->statUserKey); + } + + /** + * 清除服务器报表缓存数据 + */ + public function clearStatServer() + { + $this->redis->del($this->statServerKey); + } + + public function getStatRecord($type) + { + switch ($type) { + case "paid_total": { + return Stat::select([ + '*', + DB::raw('paid_total / 100 as paid_total') + ]) + ->where('record_at', '>=', $this->startAt) + ->where('record_at', '<', $this->endAt) + ->orderBy('record_at', 'ASC') + ->get(); + } + case "commission_total": { + return Stat::select([ + '*', + DB::raw('commission_total / 100 as commission_total') + ]) + ->where('record_at', '>=', $this->startAt) + ->where('record_at', '<', $this->endAt) + ->orderBy('record_at', 'ASC') + ->get(); + } + case "register_count": { + return Stat::where('record_at', '>=', $this->startAt) + ->where('record_at', '<', $this->endAt) + ->orderBy('record_at', 'ASC') + ->get(); + } + } + } + + public function getRanking($type, $limit = 20) + { + switch ($type) { + case 'server_traffic_rank': { + return $this->buildServerTrafficRank($limit); + } + case 'user_consumption_rank': { + return $this->buildUserConsumptionRank($limit); + } + case 'invite_rank': { + return $this->buildInviteRank($limit); + } + } + } + + /** + * 获取指定日期范围内的节点流量排行 + * @param mixed ...$times 可选值:'today', 'tomorrow', 'last_week',或指定日期范围,格式:timestamp + * @return array + */ + + public static function getServerRank(...$times) + { + $startAt = 0; + $endAt = Carbon::tomorrow()->endOfDay()->timestamp; + + if (count($times) == 1) { + switch ($times[0]) { + case 'today': + $startAt = Carbon::today()->startOfDay()->timestamp; + $endAt = Carbon::today()->endOfDay()->timestamp; + break; + case 'yesterday': + $startAt = Carbon::yesterday()->startOfDay()->timestamp; + $endAt = Carbon::yesterday()->endOfDay()->timestamp; + break; + case 'last_week': + $startAt = Carbon::now()->subWeek()->startOfWeek()->timestamp; + $endAt = Carbon::now()->endOfDay()->timestamp; + break; + } + } else if (count($times) == 2) { + $startAt = $times[0]; + $endAt = $times[1]; + } + + $statistics = Server::whereHas( + 'stats', + function ($query) use ($startAt, $endAt) { + $query->where('record_at', '>=', $startAt) + ->where('record_at', '<', $endAt) + ->where('record_type', 'd'); + } + ) + ->withSum('stats as u', 'u') // 预加载 u 的总和 + ->withSum('stats as d', 'd') // 预加载 d 的总和 + ->get() + ->map(function ($item) { + return [ + 'server_name' => optional($item->parent)->name ?? $item->name, + 'server_id' => $item->id, + 'server_type' => $item->type, + 'u' => (int) $item->u, + 'd' => (int) $item->d, + 'total' => (int) $item->u + (int) $item->d, + ]; + }) + ->sortByDesc('total') + ->values() + ->toArray(); + return $statistics; + } + + private function buildInviteRank($limit) + { + $stats = User::select([ + 'invite_user_id', + DB::raw('count(*) as count') + ]) + ->where('created_at', '>=', $this->startAt) + ->where('created_at', '<', $this->endAt) + ->whereNotNull('invite_user_id') + ->groupBy('invite_user_id') + ->orderBy('count', 'DESC') + ->limit($limit) + ->get(); + + $users = User::whereIn('id', $stats->pluck('invite_user_id')->toArray())->get()->keyBy('id'); + foreach ($stats as $k => $v) { + if (!isset($users[$v['invite_user_id']])) + continue; + $stats[$k]['email'] = $users[$v['invite_user_id']]['email']; + } + return $stats; + } + + private function buildUserConsumptionRank($limit) + { + $stats = StatUser::select([ + 'user_id', + DB::raw('sum(u) as u'), + DB::raw('sum(d) as d'), + DB::raw('sum(u) + sum(d) as total') + ]) + ->where('record_at', '>=', $this->startAt) + ->where('record_at', '<', $this->endAt) + ->groupBy('user_id') + ->orderBy('total', 'DESC') + ->limit($limit) + ->get(); + $users = User::whereIn('id', $stats->pluck('user_id')->toArray())->get()->keyBy('id'); + foreach ($stats as $k => $v) { + if (!isset($users[$v['user_id']])) + continue; + $stats[$k]['email'] = $users[$v['user_id']]['email']; + } + return $stats; + } + + private function buildServerTrafficRank($limit) + { + return StatServer::select([ + 'server_id', + 'server_type', + DB::raw('sum(u) as u'), + DB::raw('sum(d) as d'), + DB::raw('sum(u) + sum(d) as total') + ]) + ->where('record_at', '>=', $this->startAt) + ->where('record_at', '<', $this->endAt) + ->groupBy('server_id', 'server_type') + ->orderBy('total', 'DESC') + ->limit($limit) + ->get(); + } +} diff --git a/Xboard/app/Services/TelegramService.php b/Xboard/app/Services/TelegramService.php new file mode 100644 index 0000000..83f52c7 --- /dev/null +++ b/Xboard/app/Services/TelegramService.php @@ -0,0 +1,160 @@ +apiUrl = "https://api.telegram.org/bot{$botToken}/"; + + $this->http = Http::timeout(30) + ->retry(3, 1000) + ->withHeaders([ + 'Accept' => 'application/json', + ]); + } + + public function sendMessage(int $chatId, string $text, string $parseMode = ''): void + { + $text = $parseMode === 'markdown' ? str_replace('_', '\_', $text) : $text; + + $this->request('sendMessage', [ + 'chat_id' => $chatId, + 'text' => $text, + 'parse_mode' => $parseMode ?: null, + ]); + } + + public function approveChatJoinRequest(int $chatId, int $userId): void + { + $this->request('approveChatJoinRequest', [ + 'chat_id' => $chatId, + 'user_id' => $userId, + ]); + } + + public function declineChatJoinRequest(int $chatId, int $userId): void + { + $this->request('declineChatJoinRequest', [ + 'chat_id' => $chatId, + 'user_id' => $userId, + ]); + } + + public function getMe(): object + { + return $this->request('getMe'); + } + + public function setWebhook(string $url): object + { + $result = $this->request('setWebhook', ['url' => $url]); + return $result; + } + + /** + * 注册 Bot 命令列表 + */ + public function registerBotCommands(): void + { + try { + $commands = HookManager::filter('telegram.bot.commands', []); + + if (empty($commands)) { + Log::warning('没有找到任何 Telegram Bot 命令'); + return; + } + + $this->request('setMyCommands', [ + 'commands' => json_encode($commands), + 'scope' => json_encode(['type' => 'default']) + ]); + + Log::info('Telegram Bot 命令注册成功', [ + 'commands_count' => count($commands), + 'commands' => $commands + ]); + + } catch (\Exception $e) { + Log::error('Telegram Bot 命令注册失败', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + } + + /** + * 获取当前注册的命令列表 + */ + public function getMyCommands(): object + { + return $this->request('getMyCommands'); + } + + /** + * 删除所有命令 + */ + public function deleteMyCommands(): object + { + return $this->request('deleteMyCommands'); + } + + public function sendMessageWithAdmin(string $message, bool $isStaff = false): void + { + $query = User::where('telegram_id', '!=', null); + $query->where( + fn($q) => $q->where('is_admin', 1) + ->when($isStaff, fn($q) => $q->orWhere('is_staff', 1)) + ); + $users = $query->get(); + foreach ($users as $user) { + SendTelegramJob::dispatch($user->telegram_id, $message); + } + } + + protected function request(string $method, array $params = []): object + { + try { + $response = $this->http->get($this->apiUrl . $method, $params); + + if (!$response->successful()) { + throw new ApiException("HTTP 请求失败: {$response->status()}"); + } + + $data = $response->object(); + + if (!isset($data->ok)) { + throw new ApiException('无效的 Telegram API 响应'); + } + + if (!$data->ok) { + $description = $data->description ?? '未知错误'; + throw new ApiException("Telegram API 错误: {$description}"); + } + + return $data; + + } catch (\Exception $e) { + Log::error('Telegram API 请求失败', [ + 'method' => $method, + 'params' => $params, + 'error' => $e->getMessage(), + ]); + + throw new ApiException("Telegram 服务错误: {$e->getMessage()}"); + } + } +} diff --git a/Xboard/app/Services/ThemeService.php b/Xboard/app/Services/ThemeService.php new file mode 100644 index 0000000..232682d --- /dev/null +++ b/Xboard/app/Services/ThemeService.php @@ -0,0 +1,424 @@ +registerThemeViewPaths(); + } + + /** + * Register theme view paths + */ + private function registerThemeViewPaths(): void + { + $systemPath = base_path(self::SYSTEM_THEME_DIR); + if (File::exists($systemPath)) { + View::addNamespace('theme', $systemPath); + } + + $userPath = base_path(self::USER_THEME_DIR); + if (File::exists($userPath)) { + View::prependNamespace('theme', $userPath); + } + } + + /** + * Get theme view path + */ + public function getThemeViewPath(string $theme): ?string + { + $themePath = $this->getThemePath($theme); + if (!$themePath) { + return null; + } + return $themePath . '/dashboard.blade.php'; + } + + /** + * Get all available themes + */ + public function getList(): array + { + $themes = []; + + // 获取系统主题 + $systemPath = base_path(self::SYSTEM_THEME_DIR); + if (File::exists($systemPath)) { + $themes = $this->getThemesFromPath($systemPath, false); + } + + // 获取用户主题 + $userPath = base_path(self::USER_THEME_DIR); + if (File::exists($userPath)) { + $themes = array_merge($themes, $this->getThemesFromPath($userPath, true)); + } + + return $themes; + } + + /** + * Get themes from specified path + */ + private function getThemesFromPath(string $path, bool $canDelete): array + { + return collect(File::directories($path)) + ->mapWithKeys(function ($dir) use ($canDelete) { + $name = basename($dir); + if ( + !File::exists($dir . '/' . self::CONFIG_FILE) || + !File::exists($dir . '/dashboard.blade.php') + ) { + return []; + } + $config = $this->readConfigFile($name); + if (!$config) { + return []; + } + + $config['can_delete'] = $canDelete && $name !== admin_setting('current_theme'); + $config['is_system'] = !$canDelete; + return [$name => $config]; + })->toArray(); + } + + /** + * Upload new theme + */ + public function upload(UploadedFile $file): bool + { + $zip = new ZipArchive; + $tmpPath = storage_path('tmp/' . uniqid()); + + try { + if ($zip->open($file->path()) !== true) { + throw new Exception('Invalid theme package'); + } + + $configEntry = collect(range(0, $zip->numFiles - 1)) + ->map(fn($i) => $zip->getNameIndex($i)) + ->first(fn($name) => basename($name) === self::CONFIG_FILE); + + if (!$configEntry) { + throw new Exception('Theme config file not found'); + } + + $zip->extractTo($tmpPath); + $zip->close(); + + $sourcePath = $tmpPath . '/' . rtrim(dirname($configEntry), '.'); + $configFile = $sourcePath . '/' . self::CONFIG_FILE; + + if (!File::exists($configFile)) { + throw new Exception('Theme config file not found'); + } + + $config = json_decode(File::get($configFile), true); + if (empty($config['name'])) { + throw new Exception('Theme name not configured'); + } + + if (in_array($config['name'], self::SYSTEM_THEMES)) { + throw new Exception('Cannot upload theme with same name as system theme'); + } + + if (!File::exists($sourcePath . '/dashboard.blade.php')) { + throw new Exception('Missing required theme file: dashboard.blade.php'); + } + + $userThemePath = base_path(self::USER_THEME_DIR); + if (!File::exists($userThemePath)) { + File::makeDirectory($userThemePath, 0755, true); + } + + $targetPath = $userThemePath . $config['name']; + if (File::exists($targetPath)) { + $oldConfigFile = $targetPath . '/config.json'; + if (!File::exists($oldConfigFile)) { + throw new Exception('Existing theme missing config file'); + } + $oldConfig = json_decode(File::get($oldConfigFile), true); + $oldVersion = $oldConfig['version'] ?? '0.0.0'; + $newVersion = $config['version'] ?? '0.0.0'; + if (version_compare($newVersion, $oldVersion, '>')) { + $this->cleanupThemeFiles($config['name']); + File::deleteDirectory($targetPath); + File::copyDirectory($sourcePath, $targetPath); + // 更新主题时保留用户配置 + $this->initConfig($config['name'], true); + return true; + } else { + throw new Exception('Theme exists and not a newer version'); + } + } + + File::copyDirectory($sourcePath, $targetPath); + $this->initConfig($config['name']); + + return true; + + } catch (Exception $e) { + throw $e; + } finally { + if (File::exists($tmpPath)) { + File::deleteDirectory($tmpPath); + } + } + } + + /** + * Switch theme + */ + public function switch(string|null $theme): bool + { + if ($theme === null) { + return true; + } + + $currentTheme = admin_setting('current_theme'); + + try { + $themePath = $this->getThemePath($theme); + if (!$themePath) { + throw new Exception('Theme not found'); + } + + if (!File::exists($this->getThemeViewPath($theme))) { + throw new Exception('Theme view file not found'); + } + + if ($currentTheme && $currentTheme !== $theme) { + $this->cleanupThemeFiles($currentTheme); + } + + $targetPath = public_path('theme/' . $theme); + if (!File::copyDirectory($themePath, $targetPath)) { + throw new Exception('Failed to copy theme files'); + } + + admin_setting(['current_theme' => $theme]); + return true; + + } catch (Exception $e) { + Log::error('Theme switch failed', ['theme' => $theme, 'error' => $e->getMessage()]); + throw $e; + } + } + + /** + * Delete theme + */ + public function delete(string $theme): bool + { + try { + if (in_array($theme, self::SYSTEM_THEMES)) { + throw new Exception('System theme cannot be deleted'); + } + + if ($theme === admin_setting('current_theme')) { + throw new Exception('Current theme cannot be deleted'); + } + + $themePath = base_path(self::USER_THEME_DIR . $theme); + if (!File::exists($themePath)) { + throw new Exception('Theme not found'); + } + + $this->cleanupThemeFiles($theme); + File::deleteDirectory($themePath); + admin_setting([self::SETTING_PREFIX . $theme => null]); + return true; + + } catch (Exception $e) { + Log::error('Theme deletion failed', ['theme' => $theme, 'error' => $e->getMessage()]); + throw $e; + } + } + + /** + * Check if theme exists + */ + public function exists(string $theme): bool + { + return $this->getThemePath($theme) !== null; + } + + /** + * Get theme path + */ + public function getThemePath(string $theme): ?string + { + $systemPath = base_path(self::SYSTEM_THEME_DIR . $theme); + if (File::exists($systemPath)) { + return $systemPath; + } + + $userPath = base_path(self::USER_THEME_DIR . $theme); + if (File::exists($userPath)) { + return $userPath; + } + + return null; + } + + /** + * Get theme config + */ + public function getConfig(string $theme): ?array + { + $config = admin_setting(self::SETTING_PREFIX . $theme); + if ($config === null) { + $this->initConfig($theme); + $config = admin_setting(self::SETTING_PREFIX . $theme); + } + return $config; + } + + /** + * Update theme config + */ + public function updateConfig(string $theme, array $config): bool + { + try { + if (!$this->getThemePath($theme)) { + throw new Exception('Theme not found'); + } + + $schema = $this->readConfigFile($theme); + if (!$schema) { + throw new Exception('Invalid theme config file'); + } + + $validFields = collect($schema['configs'] ?? [])->pluck('field_name')->toArray(); + $validConfig = collect($config) + ->only($validFields) + ->toArray(); + + $currentConfig = $this->getConfig($theme) ?? []; + $newConfig = array_merge($currentConfig, $validConfig); + + admin_setting([self::SETTING_PREFIX . $theme => $newConfig]); + return true; + + } catch (Exception $e) { + Log::error('Config update failed', ['theme' => $theme, 'error' => $e->getMessage()]); + throw $e; + } + } + + /** + * Read theme config file + */ + private function readConfigFile(string $theme): ?array + { + $themePath = $this->getThemePath($theme); + if (!$themePath) { + return null; + } + + $file = $themePath . '/' . self::CONFIG_FILE; + return File::exists($file) ? json_decode(File::get($file), true) : null; + } + + /** + * Clean up theme files including public directory + */ + public function cleanupThemeFiles(string $theme): void + { + try { + $publicThemePath = public_path('theme/' . $theme); + if (File::exists($publicThemePath)) { + File::deleteDirectory($publicThemePath); + Log::info('Cleaned up public theme files', ['theme' => $theme, 'path' => $publicThemePath]); + } + + $cacheKey = "theme_{$theme}_assets"; + if (cache()->has($cacheKey)) { + cache()->forget($cacheKey); + Log::info('Cleaned up theme cache', ['theme' => $theme, 'cache_key' => $cacheKey]); + } + + } catch (Exception $e) { + Log::warning('Failed to cleanup theme files', [ + 'theme' => $theme, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Force refresh current theme public files + */ + public function refreshCurrentTheme(): bool + { + try { + $currentTheme = admin_setting('current_theme'); + if (!$currentTheme) { + return false; + } + + $this->cleanupThemeFiles($currentTheme); + + $themePath = $this->getThemePath($currentTheme); + if (!$themePath) { + throw new Exception('Current theme path not found'); + } + + $targetPath = public_path('theme/' . $currentTheme); + if (!File::copyDirectory($themePath, $targetPath)) { + throw new Exception('Failed to copy theme files'); + } + + Log::info('Refreshed current theme files', ['theme' => $currentTheme]); + return true; + + } catch (Exception $e) { + Log::error('Failed to refresh current theme', [ + 'theme' => $currentTheme, + 'error' => $e->getMessage() + ]); + return false; + } + } + + /** + * Initialize theme config + * + * @param string $theme 主题名称 + * @param bool $preserveExisting 是否保留现有配置(更新主题时使用) + */ + private function initConfig(string $theme, bool $preserveExisting = false): void + { + $config = $this->readConfigFile($theme); + if (!$config) { + return; + } + + $defaults = collect($config['configs'] ?? []) + ->mapWithKeys(fn($col) => [$col['field_name'] => $col['default_value'] ?? '']) + ->toArray(); + + if ($preserveExisting) { + $existingConfig = admin_setting(self::SETTING_PREFIX . $theme) ?? []; + $mergedConfig = array_merge($defaults, $existingConfig); + admin_setting([self::SETTING_PREFIX . $theme => $mergedConfig]); + } else { + admin_setting([self::SETTING_PREFIX . $theme => $defaults]); + } + } +} diff --git a/Xboard/app/Services/TicketService.php b/Xboard/app/Services/TicketService.php new file mode 100644 index 0000000..f99d7c7 --- /dev/null +++ b/Xboard/app/Services/TicketService.php @@ -0,0 +1,125 @@ + $userId, + 'ticket_id' => $ticket->id, + 'message' => $message + ]); + if ($userId !== $ticket->user_id) { + $ticket->reply_status = Ticket::STATUS_OPENING; + } else { + $ticket->reply_status = Ticket::STATUS_CLOSED; + } + if (!$ticketMessage || !$ticket->save()) { + throw new \Exception(); + } + DB::commit(); + return $ticketMessage; + } catch (\Exception $e) { + DB::rollback(); + return false; + } + } + + public function replyByAdmin($ticketId, $message, $userId): void + { + $ticket = Ticket::where('id', $ticketId) + ->first(); + if (!$ticket) { + throw new ApiException('工单不存在'); + } + $ticket->status = Ticket::STATUS_OPENING; + try { + DB::beginTransaction(); + $ticketMessage = TicketMessage::create([ + 'user_id' => $userId, + 'ticket_id' => $ticket->id, + 'message' => $message + ]); + if ($userId !== $ticket->user_id) { + $ticket->reply_status = Ticket::STATUS_OPENING; + } else { + $ticket->reply_status = Ticket::STATUS_CLOSED; + } + if (!$ticketMessage || !$ticket->save()) { + throw new ApiException('工单回复失败'); + } + DB::commit(); + HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]); + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + $this->sendEmailNotify($ticket, $ticketMessage); + } + + public function createTicket($userId, $subject, $level, $message) + { + try { + DB::beginTransaction(); + if (Ticket::where('status', 0)->where('user_id', $userId)->lockForUpdate()->first()) { + DB::rollBack(); + throw new ApiException('存在未关闭的工单'); + } + $ticket = Ticket::create([ + 'user_id' => $userId, + 'subject' => $subject, + 'level' => $level + ]); + if (!$ticket) { + throw new ApiException('工单创建失败'); + } + $ticketMessage = TicketMessage::create([ + 'user_id' => $userId, + 'ticket_id' => $ticket->id, + 'message' => $message + ]); + if (!$ticketMessage) { + DB::rollBack(); + throw new ApiException('工单消息创建失败'); + } + DB::commit(); + return $ticket; + } catch (\Exception $e) { + DB::rollBack(); + throw $e; + } + } + + // 半小时内不再重复通知 + private function sendEmailNotify(Ticket $ticket, TicketMessage $ticketMessage) + { + $user = User::find($ticket->user_id); + $cacheKey = 'ticket_sendEmailNotify_' . $ticket->user_id; + if (!Cache::get($cacheKey)) { + Cache::put($cacheKey, 1, 1800); + SendEmailJob::dispatch([ + 'email' => $user->email, + 'subject' => '您在' . admin_setting('app_name', 'XBoard') . '的工单得到了回复', + 'template_name' => 'notify', + 'template_value' => [ + 'name' => admin_setting('app_name', 'XBoard'), + 'url' => admin_setting('app_url'), + 'content' => "主题:{$ticket->subject}\r\n回复内容:{$ticketMessage->message}" + ] + ]); + } + } +} diff --git a/Xboard/app/Services/TrafficResetService.php b/Xboard/app/Services/TrafficResetService.php new file mode 100644 index 0000000..256fd69 --- /dev/null +++ b/Xboard/app/Services/TrafficResetService.php @@ -0,0 +1,415 @@ +shouldResetTraffic()) { + return false; + } + + return $this->performReset($user, $triggerSource); + } + + /** + * Perform the traffic reset for a user. + */ + public function performReset(User $user, string $triggerSource = TrafficResetLog::SOURCE_MANUAL): bool + { + try { + return DB::transaction(function () use ($user, $triggerSource) { + $oldUpload = $user->u ?? 0; + $oldDownload = $user->d ?? 0; + $oldTotal = $oldUpload + $oldDownload; + + $nextResetTime = $this->calculateNextResetTime($user); + + $user->update([ + 'u' => 0, + 'd' => 0, + 'last_reset_at' => time(), + 'reset_count' => $user->reset_count + 1, + 'next_reset_at' => $nextResetTime ? $nextResetTime->timestamp : null, + ]); + + $this->recordResetLog($user, [ + 'reset_type' => $this->getResetTypeFromPlan($user->plan), + 'trigger_source' => $triggerSource, + 'old_upload' => $oldUpload, + 'old_download' => $oldDownload, + 'old_total' => $oldTotal, + 'new_upload' => 0, + 'new_download' => 0, + 'new_total' => 0, + ]); + + $this->clearUserCache($user); + HookManager::call('traffic.reset.after', $user); + return true; + }); + } catch (\Exception $e) { + Log::error(__('traffic_reset.reset_failed'), [ + 'user_id' => $user->id, + 'email' => $user->email, + 'error' => $e->getMessage(), + 'trigger_source' => $triggerSource, + ]); + + return false; + } + } + + /** + * Calculate the next traffic reset time for a user. + */ + public function calculateNextResetTime(User $user): ?Carbon + { + if ( + !$user->plan + || $user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_NEVER + || ($user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM + && (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY) === Plan::RESET_TRAFFIC_NEVER) + || $user->expired_at === NULL + ) { + return null; + } + + $resetMethod = $user->plan->reset_traffic_method; + + if ($resetMethod === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) { + $resetMethod = (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY); + } + + $now = Carbon::now(config('app.timezone')); + + return match ($resetMethod) { + Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => $this->getNextMonthFirstDay($now), + Plan::RESET_TRAFFIC_MONTHLY => $this->getNextMonthlyReset($user, $now), + Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => $this->getNextYearFirstDay($now), + Plan::RESET_TRAFFIC_YEARLY => $this->getNextYearlyReset($user, $now), + default => null, + }; + } + + /** + * Get the first day of the next month. + */ + private function getNextMonthFirstDay(Carbon $from): Carbon + { + return $from->copy()->addMonth()->startOfMonth(); + } + + /** + * Get the next monthly reset time based on the user's expiration date. + * + * Logic: + * 1. If the user has no expiration date, reset on the 1st of each month. + * 2. If the user has an expiration date, use the day of that date as the monthly reset day. + * 3. Prioritize the reset day in the current month if it has not passed yet. + * 4. Handle cases where the day does not exist in a month (e.g., 31st in February). + */ + private function getNextMonthlyReset(User $user, Carbon $from): Carbon + { + $expiredAt = Carbon::createFromTimestamp($user->expired_at, config('app.timezone')); + $resetDay = $expiredAt->day; + $resetTime = [$expiredAt->hour, $expiredAt->minute, $expiredAt->second]; + + $currentMonthTarget = $from->copy()->day($resetDay)->setTime(...$resetTime); + if ($currentMonthTarget->timestamp > $from->timestamp) { + return $currentMonthTarget; + } + + $nextMonthTarget = $from->copy()->startOfMonth()->addMonths(1)->day($resetDay)->setTime(...$resetTime); + + if ($nextMonthTarget->month !== ($from->month % 12) + 1) { + $nextMonth = ($from->month % 12) + 1; + $nextYear = $from->year + ($from->month === 12 ? 1 : 0); + $lastDayOfNextMonth = Carbon::create($nextYear, $nextMonth, 1)->endOfMonth()->day; + $targetDay = min($resetDay, $lastDayOfNextMonth); + $nextMonthTarget = Carbon::create($nextYear, $nextMonth, $targetDay)->setTime(...$resetTime); + } + + return $nextMonthTarget; + } + + /** + * Get the first day of the next year. + */ + private function getNextYearFirstDay(Carbon $from): Carbon + { + return $from->copy()->addYear()->startOfYear(); + } + + /** + * Get the next yearly reset time based on the user's expiration date. + * + * Logic: + * 1. If the user has no expiration date, reset on January 1st of each year. + * 2. If the user has an expiration date, use the month and day of that date as the yearly reset date. + * 3. Prioritize the reset date in the current year if it has not passed yet. + * 4. Handle the case of February 29th in a leap year. + */ + private function getNextYearlyReset(User $user, Carbon $from): Carbon + { + $expiredAt = Carbon::createFromTimestamp($user->expired_at, config('app.timezone')); + $resetMonth = $expiredAt->month; + $resetDay = $expiredAt->day; + $resetTime = [$expiredAt->hour, $expiredAt->minute, $expiredAt->second]; + + $currentYearTarget = $from->copy()->month($resetMonth)->day($resetDay)->setTime(...$resetTime); + if ($currentYearTarget->timestamp > $from->timestamp) { + return $currentYearTarget; + } + + $nextYearTarget = $from->copy()->startOfYear()->addYears(1)->month($resetMonth)->day($resetDay)->setTime(...$resetTime); + + if ($nextYearTarget->month !== $resetMonth) { + $nextYear = $from->year + 1; + $lastDayOfMonth = Carbon::create($nextYear, $resetMonth, 1)->endOfMonth()->day; + $targetDay = min($resetDay, $lastDayOfMonth); + $nextYearTarget = Carbon::create($nextYear, $resetMonth, $targetDay)->setTime(...$resetTime); + } + + return $nextYearTarget; + } + + + /** + * Record the traffic reset log. + */ + private function recordResetLog(User $user, array $data): void + { + TrafficResetLog::create([ + 'user_id' => $user->id, + 'reset_type' => $data['reset_type'], + 'reset_time' => now(), + 'old_upload' => $data['old_upload'], + 'old_download' => $data['old_download'], + 'old_total' => $data['old_total'], + 'new_upload' => $data['new_upload'], + 'new_download' => $data['new_download'], + 'new_total' => $data['new_total'], + 'trigger_source' => $data['trigger_source'], + 'metadata' => $data['metadata'] ?? null, + ]); + } + + /** + * Get the reset type from the user's plan. + */ + private function getResetTypeFromPlan(?Plan $plan): string + { + if (!$plan) { + return TrafficResetLog::TYPE_MANUAL; + } + + $resetMethod = $plan->reset_traffic_method; + + if ($resetMethod === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) { + $resetMethod = (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY); + } + + return match ($resetMethod) { + Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => TrafficResetLog::TYPE_FIRST_DAY_MONTH, + Plan::RESET_TRAFFIC_MONTHLY => TrafficResetLog::TYPE_MONTHLY, + Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => TrafficResetLog::TYPE_FIRST_DAY_YEAR, + Plan::RESET_TRAFFIC_YEARLY => TrafficResetLog::TYPE_YEARLY, + Plan::RESET_TRAFFIC_NEVER => TrafficResetLog::TYPE_MANUAL, + default => TrafficResetLog::TYPE_MANUAL, + }; + } + + /** + * Clear user-related cache. + */ + private function clearUserCache(User $user): void + { + $cacheKeys = [ + "user_traffic_{$user->id}", + "user_reset_status_{$user->id}", + "user_subscription_{$user->token}", + ]; + + foreach ($cacheKeys as $key) { + Cache::forget($key); + } + } + + /** + * Batch check and reset users. Processes all eligible users in batches. + */ + public function batchCheckReset(int $batchSize = 100, ?callable $progressCallback = null): array + { + $startTime = microtime(true); + $totalResetCount = 0; + $totalProcessedCount = 0; + $batchNumber = 1; + $errors = []; + $lastProcessedId = 0; + + try { + do { + $users = User::where('next_reset_at', '<=', time()) + ->whereNotNull('next_reset_at') + ->where('id', '>', $lastProcessedId) + ->where(function ($query) { + $query->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }) + ->where('banned', 0) + ->whereNotNull('plan_id') + ->orderBy('id') + ->limit($batchSize) + ->get(); + + if ($users->isEmpty()) { + break; + } + + $batchResetCount = 0; + + if ($progressCallback) { + $progressCallback([ + 'batch_number' => $batchNumber, + 'batch_size' => $users->count(), + 'total_processed' => $totalProcessedCount, + ]); + } + + foreach ($users as $user) { + try { + if ($this->checkAndReset($user, TrafficResetLog::SOURCE_CRON)) { + $batchResetCount++; + $totalResetCount++; + } + $totalProcessedCount++; + $lastProcessedId = $user->id; + } catch (\Exception $e) { + $error = [ + 'user_id' => $user->id, + 'email' => $user->email, + 'error' => $e->getMessage(), + 'batch' => $batchNumber, + 'timestamp' => now()->toDateTimeString(), + ]; + $batchErrors[] = $error; + $errors[] = $error; + + Log::error('User traffic reset failed', $error); + + $totalProcessedCount++; + $lastProcessedId = $user->id; + } + } + + $batchNumber++; + + if ($batchNumber % 10 === 0) { + gc_collect_cycles(); + } + + if ($batchNumber % 5 === 0) { + usleep(100000); + } + + } while (true); + + } catch (\Exception $e) { + Log::error('Batch traffic reset task failed with an exception', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'total_processed' => $totalProcessedCount, + 'total_reset' => $totalResetCount, + 'last_processed_id' => $lastProcessedId, + ]); + + $errors[] = [ + 'type' => 'system_error', + 'error' => $e->getMessage(), + 'batch' => $batchNumber, + 'last_processed_id' => $lastProcessedId, + 'timestamp' => now()->toDateTimeString(), + ]; + } + + $totalDuration = round(microtime(true) - $startTime, 2); + + $result = [ + 'total_processed' => $totalProcessedCount, + 'total_reset' => $totalResetCount, + 'total_batches' => $batchNumber - 1, + 'error_count' => count($errors), + 'errors' => $errors, + 'duration' => $totalDuration, + 'batch_size' => $batchSize, + 'last_processed_id' => $lastProcessedId, + 'completed_at' => now()->toDateTimeString(), + ]; + + return $result; + } + + /** + * Set the initial reset time for a new user. + */ + public function setInitialResetTime(User $user): void + { + if ($user->next_reset_at !== null) { + return; + } + + $nextResetTime = $this->calculateNextResetTime($user); + + if ($nextResetTime) { + $user->update(['next_reset_at' => $nextResetTime->timestamp]); + } + } + + /** + * Get the user's traffic reset history. + */ + public function getUserResetHistory(User $user, int $limit = 10): \Illuminate\Database\Eloquent\Collection + { + return $user->trafficResetLogs() + ->orderBy('reset_time', 'desc') + ->limit($limit) + ->get(); + } + + /** + * Check if the user is eligible for traffic reset. + */ + public function canReset(User $user): bool + { + return $user->isActive() && $user->plan !== null; + } + + /** + * Manually reset a user's traffic (Admin function). + */ + public function manualReset(User $user, array $metadata = []): bool + { + if (!$this->canReset($user)) { + return false; + } + + return $this->performReset($user, TrafficResetLog::SOURCE_MANUAL); + } +} \ No newline at end of file diff --git a/Xboard/app/Services/UpdateService.php b/Xboard/app/Services/UpdateService.php new file mode 100644 index 0000000..de063cd --- /dev/null +++ b/Xboard/app/Services/UpdateService.php @@ -0,0 +1,458 @@ +getCurrentCommit(); + }); + return $date . '-' . $hash; + } + + /** + * Update version cache + */ + public function updateVersionCache(): void + { + try { + $result = Process::run('git log -1 --format=%cd:%H --date=format:%Y%m%d'); + if ($result->successful()) { + list($date, $hash) = explode(':', trim($result->output())); + Cache::forever(self::CACHE_VERSION_DATE, $date); + Cache::forever(self::CACHE_VERSION, substr($hash, 0, 7)); + // Log::info('Version cache updated: ' . $date . '-' . substr($hash, 0, 7)); + return; + } + } catch (\Exception $e) { + Log::error('Failed to get version with date: ' . $e->getMessage()); + } + + // Fallback + Cache::forever(self::CACHE_VERSION_DATE, date('Ymd')); + $fallbackHash = $this->getCurrentCommit(); + Cache::forever(self::CACHE_VERSION, $fallbackHash); + Log::info('Version cache updated (fallback): ' . date('Ymd') . '-' . $fallbackHash); + } + + public function checkForUpdates(): array + { + try { + // Get current version commit + $currentCommit = $this->getCurrentCommit(); + if ($currentCommit === 'unknown') { + // If unable to get current commit, try to get the first commit + $currentCommit = $this->getFirstCommit(); + } + // Get local git logs + $localLogs = $this->getLocalGitLogs(); + if (empty($localLogs)) { + Log::error('Failed to get local git logs'); + return $this->getCachedUpdateInfo(); + } + + // Get remote latest commits + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'XBoard-Update-Checker' + ])->get(self::GITHUB_API_URL . '?per_page=50'); + + if ($response->successful()) { + $commits = $response->json(); + + if (empty($commits) || !is_array($commits)) { + Log::error('Invalid GitHub response format'); + return $this->getCachedUpdateInfo(); + } + + $latestCommit = $this->formatCommitHash($commits[0]['sha']); + $currentIndex = -1; + $updateLogs = []; + + // First, find the current version position in remote commit history + foreach ($commits as $index => $commit) { + $shortSha = $this->formatCommitHash($commit['sha']); + if ($shortSha === $currentCommit) { + $currentIndex = $index; + break; + } + } + + // Check local version status + $isLocalNewer = false; + if ($currentIndex === -1) { + // Current version not found in remote history, check local commits + foreach ($localLogs as $localCommit) { + $localHash = $this->formatCommitHash($localCommit['hash']); + // If latest remote commit found, local is not newer + if ($localHash === $latestCommit) { + $isLocalNewer = false; + break; + } + // Record additional local commits + $updateLogs[] = [ + 'version' => $localHash, + 'message' => $localCommit['message'], + 'author' => $localCommit['author'], + 'date' => $localCommit['date'], + 'is_local' => true + ]; + $isLocalNewer = true; + } + } + + // If local is not newer, collect commits that need to be updated + if (!$isLocalNewer && $currentIndex > 0) { + $updateLogs = []; + // Collect all commits between current version and latest version + for ($i = 0; $i < $currentIndex; $i++) { + $commit = $commits[$i]; + $updateLogs[] = [ + 'version' => $this->formatCommitHash($commit['sha']), + 'message' => $commit['commit']['message'], + 'author' => $commit['commit']['author']['name'], + 'date' => $commit['commit']['author']['date'], + 'is_local' => false + ]; + } + } + + $hasUpdate = !$isLocalNewer && $currentIndex > 0; + + $updateInfo = [ + 'has_update' => $hasUpdate, + 'is_local_newer' => $isLocalNewer, + 'latest_version' => $isLocalNewer ? $currentCommit : $latestCommit, + 'current_version' => $currentCommit, + 'update_logs' => $updateLogs, + 'download_url' => $commits[0]['html_url'] ?? '', + 'published_at' => $commits[0]['commit']['author']['date'] ?? '', + 'author' => $commits[0]['commit']['author']['name'] ?? '', + ]; + + // Cache check results + $this->setLastCheckTime(); + Cache::put(self::CACHE_UPDATE_INFO, $updateInfo, now()->addHours(24)); + + return $updateInfo; + } + + return $this->getCachedUpdateInfo(); + } catch (\Exception $e) { + Log::error('Update check failed: ' . $e->getMessage()); + return $this->getCachedUpdateInfo(); + } + } + + public function executeUpdate(): array + { + // Check for new version first + $updateInfo = $this->checkForUpdates(); + if ($updateInfo['is_local_newer']) { + return [ + 'success' => false, + 'message' => __('update.local_newer') + ]; + } + if (!$updateInfo['has_update']) { + return [ + 'success' => false, + 'message' => __('update.already_latest') + ]; + } + + // Check for update lock + if (Cache::get(self::CACHE_UPDATE_LOCK)) { + return [ + 'success' => false, + 'message' => __('update.process_running') + ]; + } + + try { + // Set update lock + Cache::put(self::CACHE_UPDATE_LOCK, true, now()->addMinutes(30)); + + // 1. Backup database + $this->backupDatabase(); + + // 2. Pull latest code + $result = $this->pullLatestCode(); + if (!$result['success']) { + throw new \Exception($result['message']); + } + + // 3. Run database migrations + $this->runMigrations(); + + // 4. Clear cache + $this->clearCache(); + + // 5. Create update flag + $this->createUpdateFlag(); + + // 6. Restart Octane if running + $this->restartOctane(); + + // Remove update lock + Cache::forget(self::CACHE_UPDATE_LOCK); + + // Format update logs + $logMessages = array_map(function($log) { + return sprintf("- %s (%s): %s", + $log['version'], + date('Y-m-d H:i', strtotime($log['date'])), + $log['message'] + ); + }, $updateInfo['update_logs']); + + return [ + 'success' => true, + 'message' => __('update.success', [ + 'from' => $updateInfo['current_version'], + 'to' => $updateInfo['latest_version'] + ]), + 'version' => $updateInfo['latest_version'], + 'update_info' => [ + 'from_version' => $updateInfo['current_version'], + 'to_version' => $updateInfo['latest_version'], + 'update_logs' => $logMessages, + 'author' => $updateInfo['author'], + 'published_at' => $updateInfo['published_at'] + ] + ]; + + } catch (\Exception $e) { + Log::error('Update execution failed: ' . $e->getMessage()); + Cache::forget(self::CACHE_UPDATE_LOCK); + + return [ + 'success' => false, + 'message' => __('update.failed', ['error' => $e->getMessage()]) + ]; + } + } + + protected function getCurrentCommit(): string + { + try { + // Ensure git configuration is correct + Process::run(sprintf('git config --global --add safe.directory %s', base_path())); + $result = Process::run('git rev-parse HEAD'); + $fullHash = trim($result->output()); + return $fullHash ? $this->formatCommitHash($fullHash) : 'unknown'; + } catch (\Exception $e) { + Log::error('Failed to get current commit: ' . $e->getMessage()); + return 'unknown'; + } + } + + protected function getFirstCommit(): string + { + try { + // Get first commit hash + $result = Process::run('git rev-list --max-parents=0 HEAD'); + $fullHash = trim($result->output()); + return $fullHash ? $this->formatCommitHash($fullHash) : 'unknown'; + } catch (\Exception $e) { + Log::error('Failed to get first commit: ' . $e->getMessage()); + return 'unknown'; + } + } + + protected function formatCommitHash(string $hash): string + { + // Use 7 characters for commit hash + return substr($hash, 0, 7); + } + + protected function backupDatabase(): void + { + try { + // Use existing backup command + Process::run('php artisan backup:database'); + + if (!Process::result()->successful()) { + throw new \Exception(__('update.backup_failed', ['error' => Process::result()->errorOutput()])); + } + } catch (\Exception $e) { + Log::error('Database backup failed: ' . $e->getMessage()); + throw $e; + } + } + + protected function pullLatestCode(): array + { + try { + // Get current project root directory + $basePath = base_path(); + + // Ensure git configuration is correct + Process::run(sprintf('git config --global --add safe.directory %s', $basePath)); + + // Pull latest code + Process::run('git fetch origin master'); + Process::run('git reset --hard origin/master'); + + // Update dependencies + Process::run('composer install --no-dev --optimize-autoloader'); + + // Update version cache after pulling new code + $this->updateVersionCache(); + + return ['success' => true]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => __('update.code_update_failed', ['error' => $e->getMessage()]) + ]; + } + } + + protected function runMigrations(): void + { + try { + Process::run('php artisan migrate --force'); + } catch (\Exception $e) { + Log::error('Migration failed: ' . $e->getMessage()); + throw new \Exception(__('update.migration_failed', ['error' => $e->getMessage()])); + } + } + + protected function clearCache(): void + { + try { + $commands = [ + 'php artisan config:clear', + 'php artisan cache:clear', + 'php artisan view:clear', + 'php artisan route:clear' + ]; + + foreach ($commands as $command) { + Process::run($command); + } + } catch (\Exception $e) { + Log::error('Cache clearing failed: ' . $e->getMessage()); + throw new \Exception(__('update.cache_clear_failed', ['error' => $e->getMessage()])); + } + } + + protected function createUpdateFlag(): void + { + try { + // Create update flag file for external script to detect and restart container + $flagFile = storage_path('update_pending'); + File::put($flagFile, date('Y-m-d H:i:s')); + } catch (\Exception $e) { + Log::error('Failed to create update flag: ' . $e->getMessage()); + throw new \Exception(__('update.flag_create_failed', ['error' => $e->getMessage()])); + } + } + + protected function restartOctane(): void + { + try { + if (!config('octane.server')) { + return; + } + + // Check Octane running status + $statusResult = Process::run('php artisan octane:status'); + if (!$statusResult->successful()) { + Log::info('Octane is not running, skipping restart.'); + return; + } + + $output = $statusResult->output(); + if (str_contains($output, 'Octane server is running')) { + Log::info('Restarting Octane server after update...'); + // Update version cache before restart + $this->updateVersionCache(); + Process::run('php artisan octane:stop'); + Log::info('Octane server restarted successfully.'); + } else { + Log::info('Octane is not running, skipping restart.'); + } + } catch (\Exception $e) { + Log::error('Failed to restart Octane server: ' . $e->getMessage()); + // Non-fatal error, don't throw exception + } + } + + public function getLastCheckTime() + { + return Cache::get(self::CACHE_LAST_CHECK, null); + } + + protected function setLastCheckTime(): void + { + Cache::put(self::CACHE_LAST_CHECK, now()->timestamp, now()->addDays(30)); + } + + public function getCachedUpdateInfo(): array + { + return Cache::get(self::CACHE_UPDATE_INFO, [ + 'has_update' => false, + 'latest_version' => $this->getCurrentCommit(), + 'current_version' => $this->getCurrentCommit(), + 'update_logs' => [], + 'download_url' => '', + 'published_at' => '', + 'author' => '', + ]); + } + + protected function getLocalGitLogs(int $limit = 50): array + { + try { + // 获取本地git log + $result = Process::run( + sprintf('git log -%d --pretty=format:"%%H||%%s||%%an||%%ai"', $limit) + ); + + if (!$result->successful()) { + return []; + } + + $logs = []; + $lines = explode("\n", trim($result->output())); + foreach ($lines as $line) { + $parts = explode('||', $line); + if (count($parts) === 4) { + $logs[] = [ + 'hash' => $parts[0], + 'message' => $parts[1], + 'author' => $parts[2], + 'date' => $parts[3] + ]; + } + } + return $logs; + } catch (\Exception $e) { + Log::error('Failed to get local git logs: ' . $e->getMessage()); + return []; + } + } +} \ No newline at end of file diff --git a/Xboard/app/Services/UserService.php b/Xboard/app/Services/UserService.php new file mode 100644 index 0000000..c068b67 --- /dev/null +++ b/Xboard/app/Services/UserService.php @@ -0,0 +1,288 @@ +calculateNextResetTime($user); + + if (!$nextResetTime) { + return null; + } + + // Calculate the remaining days from now to the next reset time + $now = time(); + $resetTimestamp = $nextResetTime->timestamp; + + if ($resetTimestamp <= $now) { + return 0; // Reset time has passed or is now + } + + // Calculate the difference in days (rounded up) + $daysDifference = ceil(($resetTimestamp - $now) / 86400); + + return (int) $daysDifference; + } + + public function isAvailable(User $user) + { + if (!$user->banned && $user->transfer_enable && ($user->expired_at > time() || $user->expired_at === NULL)) { + return true; + } + return false; + } + + public function getAvailableUsers() + { + return User::whereRaw('u + d < transfer_enable') + ->where(function ($query) { + $query->where('expired_at', '>=', time()) + ->orWhere('expired_at', NULL); + }) + ->where('banned', 0) + ->get(); + } + + public function getUnAvailbaleUsers() + { + return User::where(function ($query) { + $query->where('expired_at', '<', time()) + ->orWhere('expired_at', 0); + }) + ->where(function ($query) { + $query->where('plan_id', NULL) + ->orWhere('transfer_enable', 0); + }) + ->get(); + } + + public function getUsersByIds($ids) + { + return User::whereIn('id', $ids)->get(); + } + + public function getAllUsers() + { + return User::all(); + } + + public function addBalance(int $userId, int $balance): bool + { + $user = User::lockForUpdate()->find($userId); + if (!$user) { + return false; + } + $user->balance = $user->balance + $balance; + if ($user->balance < 0) { + return false; + } + if (!$user->save()) { + return false; + } + return true; + } + + public function isNotCompleteOrderByUserId(int $userId): bool + { + $order = Order::whereIn('status', [0, 1]) + ->where('user_id', $userId) + ->first(); + if (!$order) { + return false; + } + return true; + } + + public function trafficFetch(Server $server, string $protocol, array $data) + { + $server->rate = $server->getCurrentRate(); + $server = $server->toArray(); + + list($server, $protocol, $data) = HookManager::filter('traffic.process.before', [$server, $protocol, $data]); + // Compatible with legacy hook + list($server, $protocol, $data) = HookManager::filter('traffic.before_process', [$server, $protocol, $data]); + + $timestamp = strtotime(date('Y-m-d')); + collect($data)->chunk(1000)->each(function ($chunk) use ($timestamp, $server, $protocol) { + TrafficFetchJob::dispatch($server, $chunk->toArray(), $protocol, $timestamp); + StatUserJob::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, + 'last_reset_at' => $user->last_reset_at, + 'reset_count' => $user->reset_count, + ]; + } + + /** + * 创建用户 + */ + public function createUser(array $data): User + { + $user = new User(); + + // 基本信息 + $user->email = $data['email']; + $user->password = isset($data['password']) + ? Hash::make($data['password']) + : Hash::make($data['email']); + $user->uuid = Helper::guid(true); + $user->token = Helper::guid(); + + // 默认设置 + $user->remind_expire = admin_setting('default_remind_expire', 1); + $user->remind_traffic = admin_setting('default_remind_traffic', 1); + $user->expired_at = null; + + // 可选字段 + $this->setOptionalFields($user, $data); + + // 处理计划 + if (isset($data['plan_id'])) { + $this->setPlanForUser($user, $data['plan_id'], $data['expired_at'] ?? null); + } else { + $this->setTryOutPlan(user: $user); + } + + return $user; + } + + /** + * 设置可选字段 + */ + private function setOptionalFields(User $user, array $data): void + { + $optionalFields = [ + 'invite_user_id', + 'telegram_id', + 'group_id', + 'speed_limit', + 'expired_at', + 'transfer_enable' + ]; + + foreach ($optionalFields as $field) { + if (array_key_exists($field, $data)) { + $user->{$field} = $data[$field]; + } + } + } + + /** + * 为用户设置计划 + */ + private function setPlanForUser(User $user, int $planId, ?int $expiredAt = null): void + { + $plan = Plan::find($planId); + if (!$plan) + return; + + $user->plan_id = $plan->id; + $user->group_id = $plan->group_id; + $user->transfer_enable = $plan->transfer_enable * 1073741824; + $user->speed_limit = $plan->speed_limit; + + if ($expiredAt) { + $user->expired_at = $expiredAt; + } + } + + /** + * 为用户分配一个新套餐或续费现有套餐 + * + * @param User $user 用户模型 + * @param Plan $plan 套餐模型 + * @param int $validityDays 购买天数 + * @return User 更新后的用户模型 + */ + public function assignPlan(User $user, Plan $plan, int $validityDays): User + { + $user->plan_id = $plan->id; + $user->group_id = $plan->group_id; + $user->transfer_enable = $plan->transfer_enable * 1073741824; + $user->speed_limit = $plan->speed_limit; + $user->device_limit = $plan->device_limit; + + if ($validityDays > 0) { + $user = $this->extendSubscription($user, $validityDays); + } + + $user->save(); + return $user; + } + + /** + * 延长用户的订阅有效期 + * + * @param User $user 用户模型 + * @param int $days 延长天数 + * @return User 更新后的用户模型 + */ + public function extendSubscription(User $user, int $days): User + { + $currentExpired = $user->expired_at ?? time(); + $user->expired_at = max($currentExpired, time()) + ($days * 86400); + + return $user; + } + + /** + * 设置试用计划 + */ + private function setTryOutPlan(User $user): void + { + if (!(int) admin_setting('try_out_plan_id', 0)) + return; + + $plan = Plan::find(admin_setting('try_out_plan_id')); + if (!$plan) + return; + + $user->transfer_enable = $plan->transfer_enable * 1073741824; + $user->plan_id = $plan->id; + $user->group_id = $plan->group_id; + $user->expired_at = time() + (admin_setting('try_out_hour', 1) * 3600); + $user->speed_limit = $plan->speed_limit; + } +} diff --git a/Xboard/app/Support/AbstractProtocol.php b/Xboard/app/Support/AbstractProtocol.php new file mode 100644 index 0000000..78a2c0d --- /dev/null +++ b/Xboard/app/Support/AbstractProtocol.php @@ -0,0 +1,262 @@ +user = $user; + $this->servers = $servers; + $this->clientName = $clientName; + $this->clientVersion = $clientVersion; + $this->userAgent = $userAgent; + $this->protocolRequirements = $this->normalizeProtocolRequirements($this->protocolRequirements); + $this->servers = HookManager::filter('protocol.servers.filtered', $this->filterServersByVersion()); + } + + /** + * 获取协议标识 + * + * @return array + */ + public function getFlags(): array + { + return $this->flags; + } + + /** + * 处理请求 + * + * @return mixed + */ + abstract public function handle(); + + /** + * 根据客户端版本过滤不兼容的服务器 + * + * @return array + */ + protected function filterServersByVersion() + { + $this->filterByAllowedProtocols(); + $hasGlobalConfig = isset($this->protocolRequirements['*']); + $hasClientConfig = isset($this->protocolRequirements[$this->clientName]); + + if ((blank($this->clientName) || blank($this->clientVersion)) && !$hasGlobalConfig) { + return $this->servers; + } + + if (!$hasGlobalConfig && !$hasClientConfig) { + return $this->servers; + } + + return collect($this->servers) + ->filter(fn($server) => $this->isCompatible($server)) + ->values() + ->all(); + } + + /** + * 检查服务器是否与当前客户端兼容 + * + * @param array $server 服务器信息 + * @return bool + */ + protected function isCompatible($server) + { + $serverType = $server['type'] ?? null; + if (isset($this->protocolRequirements['*'][$serverType])) { + $globalRequirements = $this->protocolRequirements['*'][$serverType]; + if (!$this->checkRequirements($globalRequirements, $server)) { + return false; + } + } + + if (!isset($this->protocolRequirements[$this->clientName][$serverType])) { + return true; + } + + $requirements = $this->protocolRequirements[$this->clientName][$serverType]; + return $this->checkRequirements($requirements, $server); + } + + /** + * 检查版本要求 + * + * @param array $requirements 要求配置 + * @param array $server 服务器信息 + * @return bool + */ + private function checkRequirements(array $requirements, array $server): bool + { + foreach ($requirements as $field => $filterRule) { + if (in_array($field, ['base_version', 'incompatible'])) { + continue; + } + + $actualValue = data_get($server, $field); + + if (is_array($filterRule) && isset($filterRule['whitelist'])) { + $allowedValues = $filterRule['whitelist']; + $strict = $filterRule['strict'] ?? false; + if ($strict) { + if ($actualValue === null) { + return false; + } + if (!is_string($actualValue) && !is_int($actualValue)) { + return false; + } + if (!isset($allowedValues[$actualValue])) { + return false; + } + $requiredVersion = $allowedValues[$actualValue]; + if ($requiredVersion !== '0.0.0' && version_compare($this->clientVersion, $requiredVersion, '<')) { + return false; + } + continue; + } + } else { + $allowedValues = $filterRule; + $strict = false; + } + + if ($actualValue === null) { + continue; + } + if (!is_string($actualValue) && !is_int($actualValue)) { + continue; + } + if (!isset($allowedValues[$actualValue])) { + continue; + } + $requiredVersion = $allowedValues[$actualValue]; + if ($requiredVersion !== '0.0.0' && version_compare($this->clientVersion, $requiredVersion, '<')) { + return false; + } + } + + return true; + } + + /** + * 检查当前客户端是否支持特定功能 + * + * @param string $clientName 客户端名称 + * @param string $minVersion 最低版本要求 + * @param array $additionalConditions 额外条件检查 + * @return bool + */ + protected function supportsFeature(string $clientName, string $minVersion, array $additionalConditions = []): bool + { + // 检查客户端名称 + if ($this->clientName !== $clientName) { + return false; + } + + // 检查版本号 + if (empty($this->clientVersion) || version_compare($this->clientVersion, $minVersion, '<')) { + return false; + } + + // 检查额外条件 + foreach ($additionalConditions as $condition) { + if (!$condition) { + return false; + } + } + + return true; + } + + /** + * 根据白名单过滤服务器 + * + * @return void + */ + protected function filterByAllowedProtocols(): void + { + if (!empty($this->allowedProtocols)) { + $this->servers = collect($this->servers) + ->filter(fn($server) => in_array($server['type'], $this->allowedProtocols)) + ->values() + ->all(); + } + } + + /** + * 将平铺的协议需求转换为树形结构 + * + * @param array $flat 平铺的协议需求 + * @return array 树形结构的协议需求 + */ + protected function normalizeProtocolRequirements(array $flat): array + { + $result = []; + foreach ($flat as $key => $value) { + if (!str_contains($key, '.')) { + $result[$key] = $value; + continue; + } + $segments = explode('.', $key, 3); + if (count($segments) < 3) { + $result[$segments[0]][$segments[1] ?? '*'][''] = $value; + continue; + } + [$client, $type, $field] = $segments; + $result[$client][$type][$field] = $value; + } + return $result; + } +} \ No newline at end of file diff --git a/Xboard/app/Support/ProtocolManager.php b/Xboard/app/Support/ProtocolManager.php new file mode 100644 index 0000000..e1fb4cd --- /dev/null +++ b/Xboard/app/Support/ProtocolManager.php @@ -0,0 +1,162 @@ +container = $container; + } + + /** + * 发现并注册所有协议类 + * + * @return self + */ + public function registerAllProtocols() + { + if (empty($this->protocolClasses)) { + $files = glob(app_path('Protocols') . '/*.php'); + + foreach ($files as $file) { + $className = 'App\\Protocols\\' . basename($file, '.php'); + + if (class_exists($className) && is_subclass_of($className, AbstractProtocol::class)) { + $this->protocolClasses[] = $className; + } + } + } + + return $this; + } + + /** + * 获取所有注册的协议类 + * + * @return array + */ + public function getProtocolClasses() + { + if (empty($this->protocolClasses)) { + $this->registerAllProtocols(); + } + + return $this->protocolClasses; + } + + /** + * 获取所有协议的标识 + * + * @return array + */ + public function getAllFlags() + { + return collect($this->getProtocolClasses()) + ->map(function ($class) { + try { + $reflection = new \ReflectionClass($class); + if (!$reflection->isInstantiable()) { + return []; + } + // 'flags' is a public property with a default value in AbstractProtocol + $instanceForFlags = $reflection->newInstanceWithoutConstructor(); + return $instanceForFlags->flags; + } catch (\ReflectionException $e) { + // Log or handle error if a class is problematic + report($e); + return []; + } + }) + ->flatten() + ->unique() + ->values() + ->all(); + } + + /** + * 根据标识匹配合适的协议处理器类名 + * + * @param string $flag 请求标识 + * @return string|null 协议类名或null + */ + public function matchProtocolClassName(string $flag): ?string + { + // 按照相反顺序,使最新定义的协议有更高优先级 + foreach (array_reverse($this->getProtocolClasses()) as $protocolClassString) { + try { + $reflection = new \ReflectionClass($protocolClassString); + + if (!$reflection->isInstantiable() || !$reflection->isSubclassOf(AbstractProtocol::class)) { + continue; + } + + // 'flags' is a public property in AbstractProtocol + $instanceForFlags = $reflection->newInstanceWithoutConstructor(); + $flags = $instanceForFlags->flags; + + if (collect($flags)->contains(fn($f) => stripos($flag, (string) $f) !== false)) { + return $protocolClassString; // 返回类名字符串 + } + } catch (\ReflectionException $e) { + report($e); // Consider logging this error + continue; + } + } + return null; + } + + /** + * 根据标识匹配合适的协议处理器实例 (原有逻辑,如果还需要的话) + * + * @param string $flag 请求标识 + * @param array $user 用户信息 + * @param array $servers 服务器列表 + * @param array $clientInfo 客户端信息 + * @return AbstractProtocol|null + */ + public function matchProtocol($flag, $user, $servers, $clientInfo = []) + { + $protocolClassName = $this->matchProtocolClassName($flag); + if ($protocolClassName) { + return $this->makeProtocolInstance($protocolClassName, [ + 'user' => $user, + 'servers' => $servers, + 'clientName' => $clientInfo['name'] ?? null, + 'clientVersion' => $clientInfo['version'] ?? null + ]); + } + return null; + } + + /** + * 创建协议实例的通用方法,兼容不同版本的Laravel容器 + * + * @param string $class 类名 + * @param array $parameters 构造参数 + * @return object 实例 + */ + protected function makeProtocolInstance($class, array $parameters) + { + // Laravel's make method can accept an array of parameters as its second argument. + // These will be used when resolving the class's dependencies. + return $this->container->make($class, $parameters); + } +} \ No newline at end of file diff --git a/Xboard/app/Support/Setting.php b/Xboard/app/Support/Setting.php new file mode 100644 index 0000000..76adf8f --- /dev/null +++ b/Xboard/app/Support/Setting.php @@ -0,0 +1,140 @@ +cache = Cache::store('redis'); + } + + /** + * 获取配置. + */ + public function get(string $key, mixed $default = null): mixed + { + $this->load(); + return Arr::get($this->loadedSettings, strtolower($key), $default); + } + + /** + * 设置配置信息. + */ + public function set(string $key, mixed $value = null): bool + { + SettingModel::createOrUpdate(strtolower($key), $value); + $this->flush(); + return true; + } + + /** + * 保存配置到数据库. + */ + public function save(array $settings): bool + { + foreach ($settings as $key => $value) { + SettingModel::createOrUpdate(strtolower($key), $value); + } + $this->flush(); + return true; + } + + /** + * 删除配置信息 + */ + public function remove(string $key): bool + { + SettingModel::where('name', $key)->delete(); + $this->flush(); + return true; + } + + /** + * 更新单个设置项 + */ + public function update(string $key, $value): bool + { + return $this->set($key, $value); + } + + /** + * 批量获取配置项 + */ + public function getBatch(array $keys): array + { + $this->load(); + $result = []; + + foreach ($keys as $index => $item) { + $isNumericIndex = is_numeric($index); + $key = strtolower($isNumericIndex ? $item : $index); + $default = $isNumericIndex ? config('v2board.' . $item) : (config('v2board.' . $key) ?? $item); + + $result[$item] = Arr::get($this->loadedSettings, $key, $default); + } + + return $result; + } + + /** + * 将所有设置转换为数组 + */ + public function toArray(): array + { + $this->load(); + return $this->loadedSettings; + } + + /** + * 加载配置到请求内缓存 + */ + private function load(): void + { + if ($this->loadedSettings !== null) { + return; + } + + try { + $settings = $this->cache->rememberForever(self::CACHE_KEY, function (): array { + return array_change_key_case( + SettingModel::pluck('value', 'name')->toArray(), + CASE_LOWER + ); + }); + + // 处理JSON格式的值 + foreach ($settings as $key => $value) { + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $settings[$key] = $decoded; + } + } + } + + $this->loadedSettings = $settings; + } catch (\Throwable) { + $this->loadedSettings = []; + } + } + + /** + * 清空缓存 + */ + private function flush(): void + { + $this->cache->forget(self::CACHE_KEY); + $this->loadedSettings = null; + } +} diff --git a/Xboard/app/Traits/HasPluginConfig.php b/Xboard/app/Traits/HasPluginConfig.php new file mode 100644 index 0000000..a44e2fc --- /dev/null +++ b/Xboard/app/Traits/HasPluginConfig.php @@ -0,0 +1,144 @@ +getPluginConfig(); + + if ($key) { + return $config[$key] ?? $default; + } + + return $config; + } + + /** + * 获取完整的插件配置 + */ + protected function getPluginConfig(): array + { + if ($this->pluginConfig === null) { + $pluginCode = $this->getPluginCode(); + + \Log::channel('daily')->info('Telegram Login: 获取插件配置', [ + 'plugin_code' => $pluginCode + ]); + + $this->pluginConfig = Cache::remember( + "plugin_config_{$pluginCode}", + 3600, + function () use ($pluginCode) { + $plugin = Plugin::where('code', $pluginCode) + ->where('is_enabled', true) + ->first(); + + if (!$plugin || !$plugin->config) { + return []; + } + + return json_decode($plugin->config, true) ?? []; + } + ); + } + + return $this->pluginConfig; + } + + /** + * 获取插件代码 + */ + public function getPluginCode(): string + { + if ($this->pluginCode === null) { + $this->pluginCode = $this->autoDetectPluginCode(); + } + + return $this->pluginCode; + } + + /** + * 设置插件代码(如果自动检测不准确可以手动设置) + */ + public function setPluginCode(string $pluginCode): void + { + $this->pluginCode = $pluginCode; + $this->pluginConfig = null; // 重置配置缓存 + $this->pluginEnabled = null; + } + + /** + * 自动检测插件代码 + */ + protected function autoDetectPluginCode(): string + { + $reflection = new \ReflectionClass($this); + $namespace = $reflection->getNamespaceName(); + + // 从命名空间提取插件代码 + // 例如: Plugin\TelegramLogin\Controllers => telegram_login + if (preg_match('/^Plugin\\\\(.+?)\\\\/', $namespace, $matches)) { + return $this->convertToKebabCase($matches[1]); + } + + throw new \RuntimeException('Unable to detect plugin code from namespace: ' . $namespace); + } + + /** + * 将 StudlyCase 转换为 kebab-case + */ + protected function convertToKebabCase(string $string): string + { + return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $string)); + } + + /** + * 检查插件是否启用 + */ + public function isPluginEnabled(): bool + { + if ($this->pluginEnabled !== null) { + return $this->pluginEnabled; + } + + $pluginCode = $this->getPluginCode(); + $isEnabled = Plugin::where('code', $pluginCode)->value('is_enabled'); + $this->pluginEnabled = (bool) $isEnabled; + + return $this->pluginEnabled; + } + + /** + * 清除插件配置缓存 + */ + public function clearConfigCache(): void + { + $pluginCode = $this->getPluginCode(); + Cache::forget("plugin_config_{$pluginCode}"); + $this->pluginConfig = null; + $this->pluginEnabled = null; + } +} \ No newline at end of file diff --git a/Xboard/app/Traits/QueryOperators.php b/Xboard/app/Traits/QueryOperators.php new file mode 100644 index 0000000..706d5c5 --- /dev/null +++ b/Xboard/app/Traits/QueryOperators.php @@ -0,0 +1,68 @@ + '=', + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + 'like' => 'like', + 'notlike' => 'not like', + 'null' => 'null', + 'notnull' => 'notnull', + default => 'like' + }; + } + + /** + * 获取查询值格式化 + * + * @param string $operator + * @param mixed $value + * @return mixed + */ + protected function formatQueryValue(string $operator, mixed $value): mixed + { + return match (strtolower($operator)) { + 'like', 'notlike' => "%{$value}%", + 'null', 'notnull' => null, + default => $value + }; + } + + /** + * 应用查询条件 + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $field + * @param string $operator + * @param mixed $value + * @return void + */ + protected function applyQueryCondition($query, array|Expression|string $field, string $operator, mixed $value): void + { + $queryOperator = $this->getQueryOperator($operator); + + if ($queryOperator === 'null') { + $query->whereNull($field); + } elseif ($queryOperator === 'notnull') { + $query->whereNotNull($field); + } else { + $query->where($field, $queryOperator, $this->formatQueryValue($operator, $value)); + } + } +} \ No newline at end of file diff --git a/Xboard/app/Utils/CacheKey.php b/Xboard/app/Utils/CacheKey.php new file mode 100644 index 0000000..6795c6c --- /dev/null +++ b/Xboard/app/Utils/CacheKey.php @@ -0,0 +1,69 @@ + '邮箱验证码', + 'LAST_SEND_EMAIL_VERIFY_TIMESTAMP' => '最后一次发送邮箱验证码时间', + 'TEMP_TOKEN' => '临时令牌', + 'LAST_SEND_EMAIL_REMIND_TRAFFIC' => '最后发送流量邮件提醒', + 'SCHEDULE_LAST_CHECK_AT' => '计划任务最后检查时间', + 'REGISTER_IP_RATE_LIMIT' => '注册频率限制', + 'LAST_SEND_LOGIN_WITH_MAIL_LINK_TIMESTAMP' => '最后一次发送登入链接时间', + 'PASSWORD_ERROR_LIMIT' => '密码错误次数限制', + 'USER_SESSIONS' => '用户session', + 'FORGET_REQUEST_LIMIT' => '找回密码次数限制' + ]; + + // 允许的缓存键模式(支持通配符) + const ALLOWED_PATTERNS = [ + 'SERVER_*_ONLINE_USER', // 节点在线用户 + 'MULTI_SERVER_*_ONLINE_USER', // 多服务器在线用户 + 'SERVER_*_LAST_CHECK_AT', // 节点最后检查时间 + 'SERVER_*_LAST_PUSH_AT', // 节点最后推送时间 + 'SERVER_*_LOAD_STATUS', // 节点负载状态 + 'SERVER_*_LAST_LOAD_AT', // 节点最后负载提交时间 + 'SERVER_*_METRICS', // 节点指标数据 + 'USER_ONLINE_CONN_*_*', // 用户在线连接数 (特定节点类型_ID) + ]; + + /** + * 生成缓存键 + */ + public static function get(string $key, mixed $uniqueValue = null): string + { + // 检查是否为核心键 + if (array_key_exists($key, self::CORE_KEYS)) { + return $uniqueValue ? $key . '_' . $uniqueValue : $key; + } + + // 检查是否匹配允许的模式 + if (self::matchesPattern($key)) { + return $uniqueValue ? $key . '_' . $uniqueValue : $key; + } + + // 开发环境下记录警告,生产环境允许通过 + if (app()->environment('local', 'development')) { + logger()->warning("Unknown cache key used: {$key}"); + } + + return $uniqueValue ? $key . '_' . $uniqueValue : $key; + } + + /** + * 检查键名是否匹配允许的模式 + */ + private static function matchesPattern(string $key): bool + { + foreach (self::ALLOWED_PATTERNS as $pattern) { + $regex = '/^' . str_replace('*', '[A-Za-z0-9_]+', $pattern) . '$/'; + if (preg_match($regex, $key)) { + return true; + } + } + return false; + } +} diff --git a/Xboard/app/Utils/Dict.php b/Xboard/app/Utils/Dict.php new file mode 100644 index 0000000..e0e6fe6 --- /dev/null +++ b/Xboard/app/Utils/Dict.php @@ -0,0 +1,23 @@ +", "~", "+", "=", ",", "." + )); + } + + $charsLen = count($chars) - 1; + shuffle($chars); + $str = ''; + for ($i = 0; $i < $len; $i++) { + $str .= $chars[mt_rand(0, $charsLen)]; + } + return $str; + } + + public static function wrapIPv6($addr) { + if (filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return "[$addr]"; + } else { + return $addr; + } + } + + public static function multiPasswordVerify($algo, $salt, $password, $hash) + { + switch($algo) { + case 'md5': return md5($password) === $hash; + case 'sha256': return hash('sha256', $password) === $hash; + case 'md5salt': return md5($password . $salt) === $hash; + case 'sha256salt': return hash('sha256', $password . $salt) === $hash; + default: return password_verify($password, $hash); + } + } + + public static function emailSuffixVerify($email, $suffixs) + { + $suffix = preg_split('/@/', $email)[1]; + if (!$suffix) return false; + if (!is_array($suffixs)) { + $suffixs = preg_split('/,/', $suffixs); + } + if (!in_array($suffix, $suffixs)) return false; + return true; + } + + public static function trafficConvert(float $byte) + { + $kb = 1024; + $mb = 1048576; + $gb = 1073741824; + if ($byte > $gb) { + return round($byte / $gb, 2) . ' GB'; + } else if ($byte > $mb) { + return round($byte / $mb, 2) . ' MB'; + } else if ($byte > $kb) { + return round($byte / $kb, 2) . ' KB'; + } else if ($byte < 0) { + return 0; + } else { + return round($byte, 2) . ' B'; + } + } + + public static function getSubscribeUrl(string $token, $subscribeUrl = null) + { + $path = route('client.subscribe', ['token' => $token], false); + + if ($subscribeUrl) { + $finalUrl = rtrim($subscribeUrl, '/') . $path; + return HookManager::filter('subscribe.url', $finalUrl); + } + + $urlString = (string)admin_setting('subscribe_url', ''); + $subscribeUrlList = $urlString ? explode(',', $urlString) : []; + + if (empty($subscribeUrlList)) { + return HookManager::filter('subscribe.url', url($path)); + } + + $selectedUrl = self::replaceByPattern(Arr::random($subscribeUrlList)); + $finalUrl = rtrim($selectedUrl, '/') . $path; + + return HookManager::filter('subscribe.url', $finalUrl); + } + + public static function randomPort($range): int { + $portRange = explode('-', (string) $range, 2); + $min = (int) ($portRange[0] ?? 0); + $max = (int) ($portRange[1] ?? $portRange[0] ?? 0); + if ($min > $max) { + [$min, $max] = [$max, $min]; + } + return random_int($min, $max); + } + + public static function base64EncodeUrlSafe($data) + { + $encoded = base64_encode($data); + return str_replace(['+', '/', '='], ['-', '_', ''], $encoded); + } + + /** + * 根据规则替换域名中对应的字符串 + * + * @param string $input 用户输入的字符串 + * @return string 替换后的字符串 + */ + public static function replaceByPattern($input) + { + $patterns = [ + '/\[(\d+)-(\d+)\]/' => function ($matches) { + $min = intval($matches[1]); + $max = intval($matches[2]); + if ($min > $max) { + list($min, $max) = [$max, $min]; + } + $randomNumber = rand($min, $max); + return $randomNumber; + }, + '/\[uuid\]/' => function () { + return self::guid(true); + } + ]; + foreach ($patterns as $pattern => $callback) { + $input = preg_replace_callback($pattern, $callback, $input); + } + return $input; + } + + public static function getIpByDomainName($domain) { + return gethostbynamel($domain) ?: []; + } + + public static function getTlsFingerprint($utls = null) + { + + if (is_array($utls) || is_object($utls)) { + if (!data_get($utls, 'enabled')) { + return null; + } + $fingerprint = data_get($utls, 'fingerprint', 'chrome'); + if ($fingerprint !== 'random') { + return $fingerprint; + } + } + + $fingerprints = ['chrome', 'firefox', 'safari', 'ios', 'edge', 'qq']; + return Arr::random($fingerprints); + } + + public static function encodeURIComponent($str) { + $revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')'); + return strtr(rawurlencode($str), $revert); + } + + public static function getEmailSuffix(): array|bool + { + $suffix = admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT); + if (!is_array($suffix)) { + return preg_split('/,/', $suffix); + } + return $suffix; + } + + /** + * convert the transfer_enable to GB + * @param float $transfer_enable + * @return float + */ + public static function transferToGB(float $transfer_enable): float + { + return $transfer_enable / 1073741824; + } + + /** + * 转义 Telegram Markdown 特殊字符 + * @param string $text + * @return string + */ + public static function escapeMarkdown(string $text): string + { + return str_replace(['_', '*', '`', '['], ['\_', '\*', '\`', '\['], $text); + } +} diff --git a/Xboard/app/WebSocket/NodeEventHandlers.php b/Xboard/app/WebSocket/NodeEventHandlers.php new file mode 100644 index 0000000..75521f8 --- /dev/null +++ b/Xboard/app/WebSocket/NodeEventHandlers.php @@ -0,0 +1,144 @@ +type); + Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600); + ServerService::updateMetrics($node, $data); + + Log::debug("[WS] Node#{$nodeId} status updated"); + } + + /** + * Handle device report from node + * + * 数据格式: {"event": "report.devices", "data": {userId: [ip1, ip2, ...], ...}} + */ + public static function handleDeviceReport(TcpConnection $conn, int $nodeId, array $data): void + { + $service = app(DeviceStateService::class); + + // Get old data + $oldDevices = $service->getNodeDevices($nodeId); + + // Calculate diff + $removedUsers = array_diff_key($oldDevices, $data); + $newDevices = []; + + foreach ($data as $userId => $ips) { + if (is_numeric($userId) && is_array($ips)) { + $newDevices[(int) $userId] = $ips; + } + } + + // Handle removed users + foreach ($removedUsers as $userId => $ips) { + $service->removeNodeDevices($nodeId, $userId); + $service->notifyUpdate($userId); + } + + // Handle new/updated users + foreach ($newDevices as $userId => $ips) { + $service->setDevices($userId, $nodeId, $ips); + } + + // Mark for push + Redis::sadd('device:push_pending_nodes', $nodeId); + + Log::debug("[WS] Node#{$nodeId} synced " . count($newDevices) . " users, removed " . count($removedUsers)); + } + + /** + * Handle device state request from node + */ + public static function handleDeviceRequest(TcpConnection $conn, int $nodeId, array $data = []): void + { + $node = Server::find($nodeId); + if (!$node) return; + + $users = ServerService::getAvailableUsers($node); + $userIds = $users->pluck('id')->toArray(); + + $service = app(DeviceStateService::class); + $devices = $service->getUsersDevices($userIds); + + $conn->send(json_encode([ + 'event' => 'sync.devices', + 'data' => ['users' => $devices], + ])); + + Log::debug("[WS] Node#{$nodeId} requested devices, sent " . count($devices) . " users"); + } + + /** + * Push device state to node + */ + public static function pushDeviceStateToNode(int $nodeId, DeviceStateService $service): void + { + $node = Server::find($nodeId); + if (!$node) return; + + $users = ServerService::getAvailableUsers($node); + $userIds = $users->pluck('id')->toArray(); + $devices = $service->getUsersDevices($userIds); + + NodeRegistry::send($nodeId, 'sync.devices', [ + 'users' => $devices + ]); + + Log::debug("[WS] Pushed device state to node#{$nodeId}: " . count($devices) . " users"); + } + + /** + * Push full config + users to newly connected node + */ + public static function pushFullSync(TcpConnection $conn, Server $node): void + { + $nodeId = $conn->nodeId; + + // Push config + $config = ServerService::buildNodeConfig($node); + $conn->send(json_encode([ + 'event' => 'sync.config', + 'data' => ['config' => $config] + ])); + + // Push users + $users = ServerService::getAvailableUsers($node)->toArray(); + $conn->send(json_encode([ + 'event' => 'sync.users', + 'data' => ['users' => $users] + ])); + + Log::info("[WS] Full sync pushed to node#{$nodeId}", [ + 'users' => count($users), + ]); + } +} diff --git a/Xboard/app/WebSocket/NodeWorker.php b/Xboard/app/WebSocket/NodeWorker.php new file mode 100644 index 0000000..d45dd22 --- /dev/null +++ b/Xboard/app/WebSocket/NodeWorker.php @@ -0,0 +1,249 @@ + [NodeEventHandlers::class, 'handlePong'], + 'node.status' => [NodeEventHandlers::class, 'handleNodeStatus'], + 'report.devices' => [NodeEventHandlers::class, 'handleDeviceReport'], + 'request.devices' => [NodeEventHandlers::class, 'handleDeviceRequest'], + ]; + + public function __construct(string $host, int $port) + { + $this->worker = new Worker("websocket://{$host}:{$port}"); + $this->worker->count = 1; + $this->worker->name = 'xboard-ws-server'; + } + + public function run(): void + { + $this->setupLogging(); + $this->setupCallbacks(); + Worker::runAll(); + } + + private function setupLogging(): void + { + $logPath = storage_path('logs'); + if (!is_dir($logPath)) { + mkdir($logPath, 0777, true); + } + Worker::$logFile = $logPath . '/xboard-ws-server.log'; + Worker::$pidFile = $logPath . '/xboard-ws-server.pid'; + } + + private function setupCallbacks(): void + { + $this->worker->onWorkerStart = [$this, 'onWorkerStart']; + $this->worker->onConnect = [$this, 'onConnect']; + $this->worker->onWebSocketConnect = [$this, 'onWebSocketConnect']; + $this->worker->onMessage = [$this, 'onMessage']; + $this->worker->onClose = [$this, 'onClose']; + } + + public function onWorkerStart(Worker $worker): void + { + Log::info("[WS] Worker started, pid={$worker->id}"); + $this->subscribeRedis(); + $this->setupTimers(); + } + + private function setupTimers(): void + { + // Ping timer + Timer::add(self::PING_INTERVAL, function () { + foreach (NodeRegistry::getConnectedNodeIds() as $nodeId) { + $conn = NodeRegistry::get($nodeId); + if ($conn) { + $conn->send(json_encode(['event' => 'ping'])); + } + } + }); + + // Device state push timer + Timer::add(10, function () { + $pendingNodeIds = Redis::spop('device:push_pending_nodes', 100); + if (empty($pendingNodeIds)) { + return; + } + + $service = app(DeviceStateService::class); + foreach ($pendingNodeIds as $nodeId) { + $nodeId = (int) $nodeId; + if (NodeRegistry::get($nodeId) !== null) { + NodeEventHandlers::pushDeviceStateToNode($nodeId, $service); + } + } + }); + } + + public function onConnect(TcpConnection $conn): void + { + $conn->authTimer = Timer::add(self::AUTH_TIMEOUT, function () use ($conn) { + if (empty($conn->nodeId)) { + $conn->close(json_encode([ + 'event' => 'error', + 'data' => ['message' => 'auth timeout'], + ])); + } + }, [], false); + } + + public function onWebSocketConnect(TcpConnection $conn, $httpMessage): void + { + $queryString = ''; + if (is_string($httpMessage)) { + $queryString = parse_url($httpMessage, PHP_URL_QUERY) ?? ''; + } elseif ($httpMessage instanceof \Workerman\Protocols\Http\Request) { + $queryString = $httpMessage->queryString(); + } + + parse_str($queryString, $params); + $token = $params['token'] ?? ''; + $nodeId = (int) ($params['node_id'] ?? 0); + + // Authenticate + $serverToken = admin_setting('server_token', ''); + if ($token === '' || $serverToken === '' || !hash_equals($serverToken, $token)) { + $conn->close(json_encode([ + 'event' => 'error', + 'data' => ['message' => 'invalid token'], + ])); + return; + } + + $node = ServerService::getServer($nodeId, null); + if (!$node) { + $conn->close(json_encode([ + 'event' => 'error', + 'data' => ['message' => 'node not found'], + ])); + return; + } + + // Auth passed + if (isset($conn->authTimer)) { + Timer::del($conn->authTimer); + } + + $conn->nodeId = $nodeId; + NodeRegistry::add($nodeId, $conn); + Cache::put("node_ws_alive:{$nodeId}", true, 86400); + + // Clear old device data + app(DeviceStateService::class)->clearAllNodeDevices($nodeId); + + Log::debug("[WS] Node#{$nodeId} connected", [ + 'remote' => $conn->getRemoteIp(), + 'total' => NodeRegistry::count(), + ]); + + // Send auth success + $conn->send(json_encode([ + 'event' => 'auth.success', + 'data' => ['node_id' => $nodeId], + ])); + + // Push full sync + NodeEventHandlers::pushFullSync($conn, $node); + } + + public function onMessage(TcpConnection $conn, $data): void + { + $msg = json_decode($data, true); + if (!is_array($msg)) { + return; + } + + $event = $msg['event'] ?? ''; + $nodeId = $conn->nodeId ?? null; + + if (isset($this->handlers[$event]) && $nodeId) { + $handler = $this->handlers[$event]; + $handler($conn, $nodeId, $msg['data'] ?? []); + } + } + + public function onClose(TcpConnection $conn): void + { + if (!empty($conn->nodeId)) { + $nodeId = $conn->nodeId; + NodeRegistry::remove($nodeId); + Cache::forget("node_ws_alive:{$nodeId}"); + + $service = app(DeviceStateService::class); + $affectedUserIds = $service->clearAllNodeDevices($nodeId); + foreach ($affectedUserIds as $userId) { + $service->notifyUpdate($userId); + } + + Log::debug("[WS] Node#{$nodeId} disconnected", [ + 'total' => NodeRegistry::count(), + 'affected_users' => count($affectedUserIds), + ]); + } + } + + private function subscribeRedis(): void + { + $host = config('database.redis.default.host', '127.0.0.1'); + $port = config('database.redis.default.port', 6379); + + if (str_starts_with($host, '/')) { + $redisUri = "unix://{$host}"; + } else { + $redisUri = "redis://{$host}:{$port}"; + } + + $redis = new \Workerman\Redis\Client($redisUri); + + $password = config('database.redis.default.password'); + if ($password) { + $redis->auth($password); + } + + $prefix = config('database.redis.options.prefix', ''); + $channel = $prefix . 'node:push'; + + $redis->subscribe([$channel], function ($chan, $message) { + $payload = json_decode($message, true); + if (!is_array($payload)) { + return; + } + + $nodeId = $payload['node_id'] ?? null; + $event = $payload['event'] ?? ''; + $data = $payload['data'] ?? []; + + if (!$nodeId || !$event) { + return; + } + + $sent = NodeRegistry::send((int) $nodeId, $event, $data); + if ($sent) { + Log::debug("[WS] Pushed {$event} to node#{$nodeId}"); + } + }); + + Log::info("[WS] Subscribed to Redis channel: {$channel}"); + } +} diff --git a/Xboard/artisan b/Xboard/artisan new file mode 100644 index 0000000..5c23e2e --- /dev/null +++ b/Xboard/artisan @@ -0,0 +1,53 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +/* +|-------------------------------------------------------------------------- +| Shutdown The Application +|-------------------------------------------------------------------------- +| +| Once Artisan has finished running, we will fire off the shutdown events +| so that any final work may be done by the application before we shut +| down the process. This is the last thing to happen to the request. +| +*/ + +$kernel->terminate($input, $status); + +exit($status); diff --git a/Xboard/bootstrap/app.php b/Xboard/bootstrap/app.php new file mode 100644 index 0000000..037e17d --- /dev/null +++ b/Xboard/bootstrap/app.php @@ -0,0 +1,55 @@ +singleton( + Illuminate\Contracts\Http\Kernel::class, + App\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + App\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + App\Exceptions\Handler::class +); + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + +return $app; diff --git a/Xboard/bootstrap/cache/.gitignore b/Xboard/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/Xboard/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/Xboard/compose.sample.yaml b/Xboard/compose.sample.yaml new file mode 100644 index 0000000..b955027 --- /dev/null +++ b/Xboard/compose.sample.yaml @@ -0,0 +1,44 @@ +services: + web: + image: ghcr.io/cedar2025/xboard:new + volumes: + - redis-data:/data + - ./:/www/ + environment: + - docker=true + depends_on: + - redis + network_mode: host + command: php artisan octane:start --port=7001 --host=0.0.0.0 + restart: always + horizon: + image: ghcr.io/cedar2025/xboard:new + volumes: + - redis-data:/data + - ./:/www/ + restart: always + network_mode: host + command: php artisan horizon + depends_on: + - redis + ws-server: + image: ghcr.io/cedar2025/xboard:new + volumes: + - redis-data:/data + - ./:/www/ + restart: always + network_mode: host + command: php artisan ws-server start + depends_on: + - redis + redis: + image: redis:7-alpine + command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 + restart: unless-stopped + volumes: + - redis-data:/data + sysctls: + net.core.somaxconn: 1024 + +volumes: + redis-data: diff --git a/Xboard/composer.json b/Xboard/composer.json new file mode 100644 index 0000000..ce7762d --- /dev/null +++ b/Xboard/composer.json @@ -0,0 +1,98 @@ +{ + "name": "xboard/xboard", + "type": "project", + "description": "xboard is a proxy protocol manage.", + "keywords": [ + "xboard", + "v2ray", + "shadowsocks", + "trojan", + "laravel" + ], + "license": "MIT", + "require": { + "php": "^8.2", + "bacon/bacon-qr-code": "^2.0", + "doctrine/dbal": "^4.0", + "google/cloud-storage": "^1.35", + "google/recaptcha": "^1.2", + "guzzlehttp/guzzle": "^7.8", + "laravel/framework": "^12.0", + "laravel/horizon": "^5.30", + "laravel/octane": "2.11.*", + "laravel/prompts": "^0.3", + "laravel/sanctum": "^4.0", + "laravel/tinker": "^2.10", + "linfo/linfo": "^4.0", + "paragonie/sodium_compat": "^1.20", + "php-curl-class/php-curl-class": "^8.6", + "spatie/db-dumper": "^3.4", + "stripe/stripe-php": "^7.36.1", + "symfony/http-client": "^7.0", + "symfony/mailgun-mailer": "^7.0", + "symfony/yaml": "*", + "webmozart/assert": "*", + "workerman/redis": "^2.0", + "workerman/workerman": "^5.1", + "zoujingli/ip2region": "^2.0" + }, + "require-dev": { + "barryvdh/laravel-debugbar": "^3.9", + "fakerphp/faker": "^1.9.1", + "larastan/larastan": "^3.0", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.0", + "orangehill/iseed": "^3.0", + "phpunit/phpunit": "^11.0", + "spatie/laravel-ignition": "^2.4" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Library\\": "library/", + "Plugin\\": "plugins/" + }, + "classmap": [ + "database/seeders", + "database/factories" + ], + "files": [ + "app/Helpers/Functions.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "prefer-stable": true, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi" + ], + "post-root-package-install": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi" + ] + }, + "repositories": [ + { + "type": "composer", + "url": "https://packagist.org" + } + ] +} diff --git a/Xboard/config/app.php b/Xboard/config/app.php new file mode 100644 index 0000000..3ed7fe4 --- /dev/null +++ b/Xboard/config/app.php @@ -0,0 +1,193 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | your application so that it is used when running Artisan tasks. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + 'asset_url' => env('ASSET_URL', null), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. We have gone + | ahead and set this to a sensible default for you out of the box. + | + */ + + 'timezone' => 'Asia/Shanghai', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by the translation service provider. You are free to set this value + | to any of the locales which will be supported by the application. + | + */ + + 'locale' => 'zh-CN', + + /* + |-------------------------------------------------------------------------- + | Application Fallback Locale + |-------------------------------------------------------------------------- + | + | The fallback locale determines the locale to use when the current one + | is not available. You may change the value to correspond to any of + | the language folders that are provided through your application. + | + */ + + 'fallback_locale' => 'zh-CN', + + /* + |-------------------------------------------------------------------------- + | Faker Locale + |-------------------------------------------------------------------------- + | + | This locale will be used by the Faker PHP library when generating fake + | data for your database seeds. For example, this will be used to get + | localized telephone numbers, street address information and more. + | + */ + + 'faker_locale' => 'zh-CN', + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is used by the Illuminate encrypter service and should be set + | to a random, 32 character string, otherwise these encrypted strings + | will not be safe. Please do this before deploying an application! + | + */ + + 'key' => env('APP_KEY'), + + 'cipher' => 'AES-256-CBC', + + /* + |-------------------------------------------------------------------------- + | Autoloaded Service Providers + |-------------------------------------------------------------------------- + | + | The service providers listed here will be automatically loaded on the + | request to your application. Feel free to add your own services to + | this array to grant expanded functionality to your applications. + | + */ + + 'providers' => [ + + /* + * Laravel Framework Service Providers... + */ + Illuminate\Auth\AuthServiceProvider::class, + Illuminate\Broadcasting\BroadcastServiceProvider::class, + Illuminate\Bus\BusServiceProvider::class, + Illuminate\Cache\CacheServiceProvider::class, + Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, + Illuminate\Cookie\CookieServiceProvider::class, + Illuminate\Database\DatabaseServiceProvider::class, + Illuminate\Encryption\EncryptionServiceProvider::class, + Illuminate\Filesystem\FilesystemServiceProvider::class, + Illuminate\Foundation\Providers\FoundationServiceProvider::class, + Illuminate\Hashing\HashServiceProvider::class, + Illuminate\Mail\MailServiceProvider::class, + Illuminate\Notifications\NotificationServiceProvider::class, + Illuminate\Pagination\PaginationServiceProvider::class, + Illuminate\Pipeline\PipelineServiceProvider::class, + Illuminate\Queue\QueueServiceProvider::class, + Illuminate\Redis\RedisServiceProvider::class, + Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, + Illuminate\Session\SessionServiceProvider::class, + Illuminate\Translation\TranslationServiceProvider::class, + Illuminate\Validation\ValidationServiceProvider::class, + Illuminate\View\ViewServiceProvider::class, + + /* + * Package Service Providers... + */ + + /* + * Application Service Providers... + */ + App\Providers\AuthServiceProvider::class, + // App\Providers\BroadcastServiceProvider::class, + App\Providers\EventServiceProvider::class, + App\Providers\HorizonServiceProvider::class, + App\Providers\RouteServiceProvider::class, + App\Providers\SettingServiceProvider::class, + App\Providers\OctaneServiceProvider::class, + App\Providers\PluginServiceProvider::class, + App\Providers\ProtocolServiceProvider::class, + + ], + + /* + |-------------------------------------------------------------------------- + | V2board version + |-------------------------------------------------------------------------- + | + | The only modification by laravel config + | + */ + 'version' => '1.0.0' +]; diff --git a/Xboard/config/auth.php b/Xboard/config/auth.php new file mode 100644 index 0000000..c75105f --- /dev/null +++ b/Xboard/config/auth.php @@ -0,0 +1,103 @@ + [ + 'guard' => 'api', + 'passwords' => 'users', + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | here which uses session storage and the Eloquent user provider. + | + | All authentication drivers have a user provider. This defines how the + | users are actually retrieved out of your database or other storage + | mechanisms used by this application to persist your user's data. + | + | Supported: "session", "token" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + 'api' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + 'hash' => false, + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication drivers have a user provider. This defines how the + | users are actually retrieved out of your database or other storage + | mechanisms used by this application to persist your user's data. + | + | If you have multiple user tables or models you may configure multiple + | sources which represent each model / table. These sources may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | You may specify multiple password reset configurations if you have more + | than one user table or model in the application and you want to have + | separate password reset settings based on the specific user types. + | + | The expire time is the number of minutes that the reset token should be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => 'password_resets', + 'expire' => 60, + ], + ], + +]; diff --git a/Xboard/config/broadcasting.php b/Xboard/config/broadcasting.php new file mode 100644 index 0000000..3bba110 --- /dev/null +++ b/Xboard/config/broadcasting.php @@ -0,0 +1,59 @@ + env('BROADCAST_DRIVER', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over websockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'useTLS' => true, + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/Xboard/config/cache.php b/Xboard/config/cache.php new file mode 100644 index 0000000..b97535c --- /dev/null +++ b/Xboard/config/cache.php @@ -0,0 +1,107 @@ + env('CACHE_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + */ + + 'stores' => [ + + 'apc' => [ + 'driver' => 'apc', + ], + + 'array' => [ + 'driver' => 'array', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'cache', + 'connection' => null, + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'cache', + ], + + 'octane' => [ + 'driver' => 'octane' + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_V2BOARD_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing a RAM based store such as APC or Memcached, there might + | be other applications utilizing the same cache. So, we'll specify a + | value to get prefixed to all our keys so we can avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'), + +]; diff --git a/Xboard/config/cloud_storage.php b/Xboard/config/cloud_storage.php new file mode 100644 index 0000000..7ba0747 --- /dev/null +++ b/Xboard/config/cloud_storage.php @@ -0,0 +1,10 @@ + [ + 'key_file' => env('GOOGLE_CLOUD_KEY_FILE') ? base_path(env('GOOGLE_CLOUD_KEY_FILE')) : null, + 'storage_bucket' => env('GOOGLE_CLOUD_STORAGE_BUCKET'), + ], + +]; \ No newline at end of file diff --git a/Xboard/config/cors.php b/Xboard/config/cors.php new file mode 100644 index 0000000..558369d --- /dev/null +++ b/Xboard/config/cors.php @@ -0,0 +1,34 @@ + ['api/*'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => ['*'], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, + +]; diff --git a/Xboard/config/database.php b/Xboard/config/database.php new file mode 100644 index 0000000..1929d5e --- /dev/null +++ b/Xboard/config/database.php @@ -0,0 +1,157 @@ + env('DB_CONNECTION', 'mysql'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Here are each of the database connections setup for your application. + | Of course, examples of configuring each database platform that is + | supported by Laravel is shown below to make development simple. + | + | + | All database work in Laravel is done through the PHP PDO facilities + | so make sure you have the driver for your particular database of + | choice installed on your machine before you begin development. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DATABASE_URL'), + 'database' => env('DB_DATABASE') ? base_path(env('DB_DATABASE')) : database_path('database.sqlite'), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => env('DB_BUSY_TIMEOUT', 30000), + 'journal_mode' => env('DB_JOURNAL_MODE', 'wal'), + 'synchronous' => env('DB_SYNCHRONOUS', 'normal'), + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => (extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + PDO::ATTR_PERSISTENT => false, + ]) : []), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'idle_timeout' => 60, + ], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run in the database. + | + */ + + 'migrations' => 'migrations', + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as APC or Memcached. Laravel makes it easy to dig right in. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => env('REDIS_DB', 0), + 'persistent' => true, // 开启持久连接 + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => env('REDIS_CACHE_DB', 1), + ], + + ], + +]; diff --git a/Xboard/config/debugbar.php b/Xboard/config/debugbar.php new file mode 100644 index 0000000..fe3b192 --- /dev/null +++ b/Xboard/config/debugbar.php @@ -0,0 +1,275 @@ + env('DEBUGBAR_ENABLED', null), + 'except' => [ + 'telescope*', + 'horizon*', + ], + + /* + |-------------------------------------------------------------------------- + | Storage settings + |-------------------------------------------------------------------------- + | + | DebugBar stores data for session/ajax requests. + | You can disable this, so the debugbar stores data in headers/session, + | but this can cause problems with large data collectors. + | By default, file storage (in the storage folder) is used. Redis and PDO + | can also be used. For PDO, run the package migrations first. + | + */ + 'storage' => [ + 'enabled' => true, + 'driver' => 'file', // redis, file, pdo, socket, custom + 'path' => storage_path('debugbar'), // For file driver + 'connection' => null, // Leave null for default connection (Redis/PDO) + 'provider' => '', // Instance of StorageInterface for custom driver + 'hostname' => '127.0.0.1', // Hostname to use with the "socket" driver + 'port' => 2304, // Port to use with the "socket" driver + ], + + /* + |-------------------------------------------------------------------------- + | Editor + |-------------------------------------------------------------------------- + | + | Choose your preferred editor to use when clicking file name. + | + | Supported: "phpstorm", "vscode", "vscode-insiders", "vscode-remote", + | "vscode-insiders-remote", "vscodium", "textmate", "emacs", + | "sublime", "atom", "nova", "macvim", "idea", "netbeans", + | "xdebug", "espresso" + | + */ + + 'editor' => env('DEBUGBAR_EDITOR', 'phpstorm'), + + /* + |-------------------------------------------------------------------------- + | Remote Path Mapping + |-------------------------------------------------------------------------- + | + | If you are using a remote dev server, like Laravel Homestead, Docker, or + | even a remote VPS, it will be necessary to specify your path mapping. + | + | Leaving one, or both of these, empty or null will not trigger the remote + | URL changes and Debugbar will treat your editor links as local files. + | + | "remote_sites_path" is an absolute base path for your sites or projects + | in Homestead, Vagrant, Docker, or another remote development server. + | + | Example value: "/home/vagrant/Code" + | + | "local_sites_path" is an absolute base path for your sites or projects + | on your local computer where your IDE or code editor is running on. + | + | Example values: "/Users//Code", "C:\Users\\Documents\Code" + | + */ + + 'remote_sites_path' => env('DEBUGBAR_REMOTE_SITES_PATH', ''), + 'local_sites_path' => env('DEBUGBAR_LOCAL_SITES_PATH', ''), + + /* + |-------------------------------------------------------------------------- + | Vendors + |-------------------------------------------------------------------------- + | + | Vendor files are included by default, but can be set to false. + | This can also be set to 'js' or 'css', to only include javascript or css vendor files. + | Vendor files are for css: font-awesome (including fonts) and highlight.js (css files) + | and for js: jquery and and highlight.js + | So if you want syntax highlighting, set it to true. + | jQuery is set to not conflict with existing jQuery scripts. + | + */ + + 'include_vendors' => true, + + /* + |-------------------------------------------------------------------------- + | Capture Ajax Requests + |-------------------------------------------------------------------------- + | + | The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors), + | you can use this option to disable sending the data through the headers. + | + | Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools. + | + | Note for your request to be identified as ajax requests they must either send the header + | X-Requested-With with the value XMLHttpRequest (most JS libraries send this), or have application/json as a Accept header. + */ + + 'capture_ajax' => true, + 'add_ajax_timing' => false, + + /* + |-------------------------------------------------------------------------- + | Custom Error Handler for Deprecated warnings + |-------------------------------------------------------------------------- + | + | When enabled, the Debugbar shows deprecated warnings for Symfony components + | in the Messages tab. + | + */ + 'error_handler' => false, + + /* + |-------------------------------------------------------------------------- + | Clockwork integration + |-------------------------------------------------------------------------- + | + | The Debugbar can emulate the Clockwork headers, so you can use the Chrome + | Extension, without the server-side code. It uses Debugbar collectors instead. + | + */ + 'clockwork' => false, + + /* + |-------------------------------------------------------------------------- + | DataCollectors + |-------------------------------------------------------------------------- + | + | Enable/disable DataCollectors + | + */ + + 'collectors' => [ + 'phpinfo' => true, // Php version + 'messages' => true, // Messages + 'time' => true, // Time Datalogger + 'memory' => true, // Memory usage + 'exceptions' => true, // Exception displayer + 'log' => true, // Logs from Monolog (merged in messages if enabled) + 'db' => true, // Show database (PDO) queries and bindings + 'views' => true, // Views with their data + 'route' => true, // Current route information + 'auth' => false, // Display Laravel authentication status + 'gate' => true, // Display Laravel Gate checks + 'session' => true, // Display session data + 'symfony_request' => true, // Only one can be enabled.. + 'mail' => true, // Catch mail messages + 'laravel' => false, // Laravel version and environment + 'events' => false, // All events fired + 'default_request' => false, // Regular or special Symfony request logger + 'logs' => false, // Add the latest log messages + 'files' => false, // Show the included files + 'config' => false, // Display config settings + 'cache' => false, // Display cache events + 'models' => true, // Display models + 'livewire' => true, // Display Livewire (when available) + ], + + /* + |-------------------------------------------------------------------------- + | Extra options + |-------------------------------------------------------------------------- + | + | Configure some DataCollectors + | + */ + + 'options' => [ + 'auth' => [ + 'show_name' => true, // Also show the users name/email in the debugbar + ], + 'db' => [ + 'with_params' => true, // Render SQL with the parameters substituted + 'backtrace' => true, // Use a backtrace to find the origin of the query in your files. + 'backtrace_exclude_paths' => [], // Paths to exclude from backtrace. (in addition to defaults) + 'timeline' => false, // Add the queries to the timeline + 'duration_background' => true, // Show shaded background on each query relative to how long it took to execute. + 'explain' => [ // Show EXPLAIN output on queries + 'enabled' => false, + 'types' => ['SELECT'], // Deprecated setting, is always only SELECT + ], + 'hints' => false, // Show hints for common mistakes + 'show_copy' => false, // Show copy button next to the query + ], + 'mail' => [ + 'full_log' => false, + ], + 'views' => [ + 'timeline' => false, // Add the views to the timeline (Experimental) + 'data' => false, //Note: Can slow down the application, because the data can be quite large.. + ], + 'route' => [ + 'label' => true, // show complete route on bar + ], + 'logs' => [ + 'file' => null, + ], + 'cache' => [ + 'values' => true, // collect cache values + ], + ], + + /* + |-------------------------------------------------------------------------- + | Inject Debugbar in Response + |-------------------------------------------------------------------------- + | + | Usually, the debugbar is added just before , by listening to the + | Response after the App is done. If you disable this, you have to add them + | in your template yourself. See http://phpdebugbar.com/docs/rendering.html + | + */ + + 'inject' => true, + + /* + |-------------------------------------------------------------------------- + | DebugBar route prefix + |-------------------------------------------------------------------------- + | + | Sometimes you want to set route prefix to be used by DebugBar to load + | its resources from. Usually the need comes from misconfigured web server or + | from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97 + | + */ + 'route_prefix' => '_debugbar', + + /* + |-------------------------------------------------------------------------- + | DebugBar route domain + |-------------------------------------------------------------------------- + | + | By default DebugBar route served from the same domain that request served. + | To override default domain, specify it as a non-empty value. + */ + 'route_domain' => null, + + /* + |-------------------------------------------------------------------------- + | DebugBar theme + |-------------------------------------------------------------------------- + | + | Switches between light and dark theme. If set to auto it will respect system preferences + | Possible values: auto, light, dark + */ + 'theme' => env('DEBUGBAR_THEME', 'auto'), + + /* + |-------------------------------------------------------------------------- + | Backtrace stack limit + |-------------------------------------------------------------------------- + | + | By default, the DebugBar limits the number of frames returned by the 'debug_backtrace()' function. + | If you need larger stacktraces, you can increase this number. Setting it to 0 will result in no limit. + */ + 'debug_backtrace_limit' => 50, +]; diff --git a/Xboard/config/filesystems.php b/Xboard/config/filesystems.php new file mode 100644 index 0000000..925b69d --- /dev/null +++ b/Xboard/config/filesystems.php @@ -0,0 +1,69 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Default Cloud Filesystem Disk + |-------------------------------------------------------------------------- + | + | Many applications store files both locally and in the cloud. For this + | reason, you may specify a default "cloud" driver here. This driver + | will be bound as the Cloud disk implementation in the container. + | + */ + + 'cloud' => env('FILESYSTEM_CLOUD', 's3'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Here you may configure as many filesystem "disks" as you wish, and you + | may even configure multiple disks of the same driver. Defaults have + | been setup for each driver as an example of the required options. + | + | Supported Drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL') . '/storage', + 'visibility' => 'public', + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_V2BOARD_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + ], + + ], + +]; diff --git a/Xboard/config/hashing.php b/Xboard/config/hashing.php new file mode 100644 index 0000000..9146bfd --- /dev/null +++ b/Xboard/config/hashing.php @@ -0,0 +1,52 @@ + 'bcrypt', + + /* + |-------------------------------------------------------------------------- + | Bcrypt Options + |-------------------------------------------------------------------------- + | + | Here you may specify the configuration options that should be used when + | passwords are hashed using the Bcrypt algorithm. This will allow you + | to control the amount of time it takes to hash the given password. + | + */ + + 'bcrypt' => [ + 'rounds' => env('BCRYPT_ROUNDS', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Argon Options + |-------------------------------------------------------------------------- + | + | Here you may specify the configuration options that should be used when + | passwords are hashed using the Argon algorithm. These will allow you + | to control the amount of time it takes to hash the given password. + | + */ + + 'argon' => [ + 'memory' => 8192, + 'threads' => 2, + 'time' => 2, + ], + +]; diff --git a/Xboard/config/hidden_features.php b/Xboard/config/hidden_features.php new file mode 100644 index 0000000..b676bed --- /dev/null +++ b/Xboard/config/hidden_features.php @@ -0,0 +1,5 @@ + (env('ENABLE_EXPOSED_USER_COUNT_FIX') === base64_decode('M2YwNmYxODI=')) +]; \ No newline at end of file diff --git a/Xboard/config/horizon.php b/Xboard/config/horizon.php new file mode 100644 index 0000000..fde7487 --- /dev/null +++ b/Xboard/config/horizon.php @@ -0,0 +1,228 @@ +getParser(); + +return [ + + /* + |-------------------------------------------------------------------------- + | Horizon Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Horizon will be accessible from. If this + | setting is null, Horizon will reside under the same domain as the + | application. Otherwise, this value will serve as the subdomain. + | + */ + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => 'monitor', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env( + 'HORIZON_PREFIX', + Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:' + ), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['admin'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:default' => 60, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Metrics + |-------------------------------------------------------------------------- + | + | Here you can configure how many snapshots should be kept to display in + | the metrics graph. This will get used in combination with Horizon's + | `horizon:snapshot` schedule to define how long to retain metrics. + | + */ + + 'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon worker + | may consume before it is terminated and restarted. You should set + | this value according to the resources available to your server. + | + */ + + 'memory_limit' => 256, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'environments' => [ + 'production' => [ + 'data-pipeline' => [ + 'connection' => 'redis', + 'queue' => ['traffic_fetch', 'stat', 'user_alive_sync'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 8, + 'balanceCooldown' => 1, + 'tries' => 3, + 'timeout' => 30, + ], + 'business' => [ + 'connection' => 'redis', + 'queue' => ['default', 'order_handle'], + 'balance' => 'simple', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'tries' => 3, + 'timeout' => 30, + ], + 'notification' => [ + 'connection' => 'redis', + 'queue' => ['send_email', 'send_telegram', 'send_email_mass', 'node_sync'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'size', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'tries' => 3, + 'timeout' => 60, + 'backoff' => [3, 10, 30], + ], + ], + 'local' => [ + 'Xboard' => [ + 'connection' => 'redis', + 'queue' => [ + 'default', + 'order_handle', + 'traffic_fetch', + 'stat', + 'send_email', + 'send_email_mass', + 'send_telegram', + 'user_alive_sync', + 'node_sync' + ], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 5, + 'tries' => 1, + 'timeout' => 60, + 'balanceCooldown' => 3, + ], + ], + ], +]; diff --git a/Xboard/config/logging.php b/Xboard/config/logging.php new file mode 100644 index 0000000..7ec1268 --- /dev/null +++ b/Xboard/config/logging.php @@ -0,0 +1,64 @@ + env('LOG_CHANNEL', 'daily'), + + 'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['daily'], + 'ignore_exceptions' => false, + ], + + 'backup' => [ + 'driver' => 'single', + 'path' => storage_path('logs/backup.log'), + 'level' => 'debug', + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 14, + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'deprecations' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/deprecations.log'), + 'level' => 'debug', + 'days' => 14, + ], + ], + +]; diff --git a/Xboard/config/mail.php b/Xboard/config/mail.php new file mode 100644 index 0000000..3c65eb3 --- /dev/null +++ b/Xboard/config/mail.php @@ -0,0 +1,136 @@ + env('MAIL_DRIVER', 'smtp'), + + /* + |-------------------------------------------------------------------------- + | SMTP Host Address + |-------------------------------------------------------------------------- + | + | Here you may provide the host address of the SMTP server used by your + | applications. A default option is provided that is compatible with + | the Mailgun mail service which will provide reliable deliveries. + | + */ + + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + + /* + |-------------------------------------------------------------------------- + | SMTP Host Port + |-------------------------------------------------------------------------- + | + | This is the SMTP port used by your application to deliver e-mails to + | users of the application. Like the host we have set this value to + | stay compatible with the Mailgun e-mail application by default. + | + */ + + 'port' => env('MAIL_PORT', 587), + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all e-mails sent by your application to be sent from + | the same address. Here, you may specify a name and address that is + | used globally for all e-mails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + + /* + |-------------------------------------------------------------------------- + | E-Mail Encryption Protocol + |-------------------------------------------------------------------------- + | + | Here you may specify the encryption protocol that should be used when + | the application send e-mail messages. A sensible default using the + | transport layer security protocol should provide great security. + | + */ + + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + + /* + |-------------------------------------------------------------------------- + | SMTP Server Username + |-------------------------------------------------------------------------- + | + | If your SMTP server requires a username for authentication, you should + | set it here. This will get used to authenticate with your server on + | connection. You may also set the "password" value below this one. + | + */ + + 'username' => env('MAIL_USERNAME'), + + 'password' => env('MAIL_PASSWORD'), + + /* + |-------------------------------------------------------------------------- + | Sendmail System Path + |-------------------------------------------------------------------------- + | + | When using the "sendmail" driver to send e-mails, we will need to know + | the path to where Sendmail lives on this server. A default path has + | been provided here, which will work well on most of your systems. + | + */ + + 'sendmail' => '/usr/sbin/sendmail -bs', + + /* + |-------------------------------------------------------------------------- + | Markdown Mail Settings + |-------------------------------------------------------------------------- + | + | If you are using Markdown based email rendering, you may configure your + | theme and component paths here, allowing you to customize the design + | of the emails. Or, you may simply stick with the Laravel defaults! + | + */ + + 'markdown' => [ + 'theme' => 'default', + + 'paths' => [ + resource_path('views/vendor/mail'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Log Channel + |-------------------------------------------------------------------------- + | + | If you are using the "log" driver, you may specify the logging channel + | if you prefer to keep mail messages separate from other log entries + | for simpler reading. Otherwise, the default channel will be used. + | + */ + + 'log_channel' => env('MAIL_LOG_CHANNEL'), + +]; diff --git a/Xboard/config/octane.php b/Xboard/config/octane.php new file mode 100644 index 0000000..01190fb --- /dev/null +++ b/Xboard/config/octane.php @@ -0,0 +1,221 @@ + env('OCTANE_SERVER', 'swoole'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', false), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushTemporaryContainerInstances::class, + DisconnectFromDatabases::class, + CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + // + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + ], + + 'flush' => [ + \App\Services\Plugin\HookManager::class, + ], + + /* + |-------------------------------------------------------------------------- + | Octane Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 5000, + 'bytes' => 20000, + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config', + 'database', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 128, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => 60, + +]; diff --git a/Xboard/config/queue.php b/Xboard/config/queue.php new file mode 100644 index 0000000..495c858 --- /dev/null +++ b/Xboard/config/queue.php @@ -0,0 +1,88 @@ + env('QUEUE_CONNECTION', 'sync'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection information for each server that + | is used by your application. A default configuration has been added + | for each back-end shipped with Laravel. You are free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => 'localhost', + 'queue' => 'default', + 'retry_after' => 90, + 'block_for' => 0, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'your-queue-name'), + 'region' => env('AWS_V2BOARD_REGION', 'us-east-1'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control which database and table are used to store the jobs that + | have failed. You may change them to any database / table you wish. + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database'), + 'database' => env('DB_CONNECTION', 'mysql'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/Xboard/config/sanctum.php b/Xboard/config/sanctum.php new file mode 100644 index 0000000..35d75b3 --- /dev/null +++ b/Xboard/config/sanctum.php @@ -0,0 +1,83 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort() + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, + 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, + ], + +]; diff --git a/Xboard/config/scribe.php b/Xboard/config/scribe.php new file mode 100644 index 0000000..b2e0c03 --- /dev/null +++ b/Xboard/config/scribe.php @@ -0,0 +1,270 @@ + for the generated documentation. If this is empty, Scribe will infer it from config('app.name'). + 'title' => null, + + // A short description of your API. Will be included in the docs webpage, Postman collection and OpenAPI spec. + 'description' => '', + + // The base URL displayed in the docs. If this is empty, Scribe will use the value of config('app.url') at generation time. + // If you're using `laravel` type, you can set this to a dynamic string, like '{{ config("app.tenant_url") }}' to get a dynamic base URL. + 'base_url' => null, + + 'routes' => [ + [ + // Routes that match these conditions will be included in the docs + 'match' => [ + // Match only routes whose paths match this pattern (use * as a wildcard to match any characters). Example: 'users/*'. + 'prefixes' => ['api/*'], + + // Match only routes whose domains match this pattern (use * as a wildcard to match any characters). Example: 'api.*'. + 'domains' => ['*'], + + // [Dingo router only] Match only routes registered under this version. Wildcards are NOT supported. + 'versions' => ['v1'], + ], + + // Include these routes even if they did not match the rules above. + 'include' => [ + // 'users.index', 'POST /new', '/auth/*' + ], + + // Exclude these routes even if they matched the rules above. + 'exclude' => [ + // 'GET /health', 'admin.*' + ], + ], + ], + + // The type of documentation output to generate. + // - "static" will generate a static HTMl page in the /public/docs folder, + // - "laravel" will generate the documentation as a Blade view, so you can add routing and authentication. + // - "external_static" and "external_laravel" do the same as above, but generate a basic template, + // passing the OpenAPI spec as a URL, allowing you to easily use the docs with an external generator + 'type' => 'static', + + // See https://scribe.knuckles.wtf/laravel/reference/config#theme for supported options + 'theme' => 'default', + + 'static' => [ + // HTML documentation, assets and Postman collection will be generated to this folder. + // Source Markdown will still be in resources/docs. + 'output_path' => 'public/docs', + ], + + 'laravel' => [ + // Whether to automatically create a docs endpoint for you to view your generated docs. + // If this is false, you can still set up routing manually. + 'add_routes' => true, + + // URL path to use for the docs endpoint (if `add_routes` is true). + // By default, `/docs` opens the HTML page, `/docs.postman` opens the Postman collection, and `/docs.openapi` the OpenAPI spec. + 'docs_url' => '/docs', + + // Directory within `public` in which to store CSS and JS assets. + // By default, assets are stored in `public/vendor/scribe`. + // If set, assets will be stored in `public/{{assets_directory}}` + 'assets_directory' => null, + + // Middleware to attach to the docs endpoint (if `add_routes` is true). + 'middleware' => [], + ], + + 'external' => [ + 'html_attributes' => [] + ], + + 'try_it_out' => [ + // Add a Try It Out button to your endpoints so consumers can test endpoints right from their browser. + // Don't forget to enable CORS headers for your endpoints. + 'enabled' => true, + + // The base URL for the API tester to use (for example, you can set this to your staging URL). + // Leave as null to use the current app URL when generating (config("app.url")). + 'base_url' => null, + + // [Laravel Sanctum] Fetch a CSRF token before each request, and add it as an X-XSRF-TOKEN header. + 'use_csrf' => false, + + // The URL to fetch the CSRF token from (if `use_csrf` is true). + 'csrf_url' => '/sanctum/csrf-cookie', + ], + + // How is your API authenticated? This information will be used in the displayed docs, generated examples and response calls. + 'auth' => [ + // Set this to true if ANY endpoints in your API use authentication. + 'enabled' => false, + + // Set this to true if your API should be authenticated by default. If so, you must also set `enabled` (above) to true. + // You can then use @unauthenticated or @authenticated on individual endpoints to change their status from the default. + 'default' => false, + + // Where is the auth value meant to be sent in a request? + // Options: query, body, basic, bearer, header (for custom header) + 'in' => 'bearer', + + // The name of the auth parameter (e.g. token, key, apiKey) or header (e.g. Authorization, Api-Key). + 'name' => 'key', + + // The value of the parameter to be used by Scribe to authenticate response calls. + // This will NOT be included in the generated documentation. If empty, Scribe will use a random value. + 'use_value' => env('SCRIBE_AUTH_KEY'), + + // Placeholder your users will see for the auth parameter in the example requests. + // Set this to null if you want Scribe to use a random value as placeholder instead. + 'placeholder' => '{YOUR_AUTH_KEY}', + + // Any extra authentication-related info for your users. Markdown and HTML are supported. + 'extra_info' => 'You can retrieve your token by visiting your dashboard and clicking Generate API token.', + ], + + // Text to place in the "Introduction" section, right after the `description`. Markdown and HTML are supported. + 'intro_text' => <<As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile). +You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile). +INTRO + , + + // Example requests for each endpoint will be shown in each of these languages. + // Supported options are: bash, javascript, php, python + // To add a language of your own, see https://scribe.knuckles.wtf/laravel/advanced/example-requests + 'example_languages' => [ + 'bash', + 'javascript', + ], + + // Generate a Postman collection (v2.1.0) in addition to HTML docs. + // For 'static' docs, the collection will be generated to public/docs/collection.json. + // For 'laravel' docs, it will be generated to storage/app/scribe/collection.json. + // Setting `laravel.add_routes` to true (above) will also add a route for the collection. + 'postman' => [ + 'enabled' => true, + + 'overrides' => [ + // 'info.version' => '2.0.0', + ], + ], + + // Generate an OpenAPI spec (v3.0.1) in addition to docs webpage. + // For 'static' docs, the collection will be generated to public/docs/openapi.yaml. + // For 'laravel' docs, it will be generated to storage/app/scribe/openapi.yaml. + // Setting `laravel.add_routes` to true (above) will also add a route for the spec. + 'openapi' => [ + 'enabled' => true, + + 'overrides' => [ + // 'info.version' => '2.0.0', + ], + ], + + 'groups' => [ + // Endpoints which don't have a @group will be placed in this default group. + 'default' => 'Endpoints', + + // By default, Scribe will sort groups alphabetically, and endpoints in the order their routes are defined. + // You can override this by listing the groups, subgroups and endpoints here in the order you want them. + // See https://scribe.knuckles.wtf/blog/laravel-v4#easier-sorting and https://scribe.knuckles.wtf/laravel/reference/config#order for details + 'order' => [], + ], + + // Custom logo path. This will be used as the value of the src attribute for the tag, + // so make sure it points to an accessible URL or path. Set to false to not use a logo. + // For example, if your logo is in public/img: + // - 'logo' => '../img/logo.png' // for `static` type (output folder is public/docs) + // - 'logo' => 'img/logo.png' // for `laravel` type + 'logo' => false, + + // Customize the "Last updated" value displayed in the docs by specifying tokens and formats. + // Examples: + // - {date:F j Y} => March 28, 2022 + // - {git:short} => Short hash of the last Git commit + // Available tokens are `{date:}` and `{git:}`. + // The format you pass to `date` will be passed to PHP's `date()` function. + // The format you pass to `git` can be either "short" or "long". + 'last_updated' => 'Last updated: {date:F j, Y}', + + 'examples' => [ + // Set this to any number (e.g. 1234) to generate the same example values for parameters on each run, + 'faker_seed' => null, + + // With API resources and transformers, Scribe tries to generate example models to use in your API responses. + // By default, Scribe will try the model's factory, and if that fails, try fetching the first from the database. + // You can reorder or remove strategies here. + 'models_source' => ['factoryCreate', 'factoryMake', 'databaseFirst'], + ], + + // The strategies Scribe will use to extract information about your routes at each stage. + // If you create or install a custom strategy, add it here. + 'strategies' => [ + 'metadata' => [ + Strategies\Metadata\GetFromDocBlocks::class, + Strategies\Metadata\GetFromMetadataAttributes::class, + ], + 'urlParameters' => [ + Strategies\UrlParameters\GetFromLaravelAPI::class, + Strategies\UrlParameters\GetFromUrlParamAttribute::class, + Strategies\UrlParameters\GetFromUrlParamTag::class, + ], + 'queryParameters' => [ + Strategies\QueryParameters\GetFromFormRequest::class, + Strategies\QueryParameters\GetFromInlineValidator::class, + Strategies\QueryParameters\GetFromQueryParamAttribute::class, + Strategies\QueryParameters\GetFromQueryParamTag::class, + ], + 'headers' => [ + Strategies\Headers\GetFromHeaderAttribute::class, + Strategies\Headers\GetFromHeaderTag::class, + [ + 'override', + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ] + ] + ], + 'bodyParameters' => [ + Strategies\BodyParameters\GetFromFormRequest::class, + Strategies\BodyParameters\GetFromInlineValidator::class, + Strategies\BodyParameters\GetFromBodyParamAttribute::class, + Strategies\BodyParameters\GetFromBodyParamTag::class, + ], + 'responses' => [ + Strategies\Responses\UseResponseAttributes::class, + Strategies\Responses\UseTransformerTags::class, + Strategies\Responses\UseApiResourceTags::class, + Strategies\Responses\UseResponseTag::class, + Strategies\Responses\UseResponseFileTag::class, + [ + Strategies\Responses\ResponseCalls::class, + [ + 'only' => ['GET *'], + // Disable debug mode when generating response calls to avoid error stack traces in responses + 'config' => [ + 'app.debug' => false, + ], + ] + ] + ], + 'responseFields' => [ + Strategies\ResponseFields\GetFromResponseFieldAttribute::class, + Strategies\ResponseFields\GetFromResponseFieldTag::class, + ], + ], + + // For response calls, API resource responses and transformer responses, + // Scribe will try to start database transactions, so no changes are persisted to your database. + // Tell Scribe which connections should be transacted here. If you only use one db connection, you can leave this as is. + 'database_connections_to_transact' => [config('database.default')], + + 'fractal' => [ + // If you are using a custom serializer with league/fractal, you can specify it here. + 'serializer' => null, + ], + + 'routeMatcher' => \Knuckles\Scribe\Matching\RouteMatcher::class, +]; diff --git a/Xboard/config/services.php b/Xboard/config/services.php new file mode 100644 index 0000000..950dc99 --- /dev/null +++ b/Xboard/config/services.php @@ -0,0 +1,33 @@ + [ + 'domain' => env('MAILGUN_DOMAIN'), + 'secret' => env('MAILGUN_SECRET'), + 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), + ], + + 'postmark' => [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_V2BOARD_REGION', 'us-east-1'), + ], + +]; diff --git a/Xboard/config/session.php b/Xboard/config/session.php new file mode 100644 index 0000000..406d50e --- /dev/null +++ b/Xboard/config/session.php @@ -0,0 +1,199 @@ + env('SESSION_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to immediately expire on the browser closing, set that option. + | + */ + + 'lifetime' => env('SESSION_LIFETIME', 120), + + 'expire_on_close' => false, + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it is stored. All encryption will be run + | automatically by Laravel and you can use the Session like normal. + | + */ + + 'encrypt' => false, + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When using the native session driver, we need a location where session + | files may be stored. A default has been set for you but a different + | location may be specified. This is only needed for file sessions. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION', null), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table we + | should use to manage the sessions. Of course, a sensible default is + | provided for you; however, you are free to change this as needed. + | + */ + + 'table' => 'sessions', + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using the "apc", "memcached", or "dynamodb" session drivers you may + | list a cache store that should be used for these sessions. This value + | must match with one of the application's configured cache "stores". + | + */ + + 'store' => env('SESSION_STORE', null), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the cookie used to identify a session + | instance by ID. The name specified here will get used every time a + | new session cookie is created by the framework for every driver. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug(env('APP_NAME', 'laravel'), '_') . '_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application but you are free to change this when necessary. + | + */ + + 'path' => '/', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | Here you may change the domain of the cookie used to identify a session + | in your application. This will determine which domains the cookie is + | available to in your application. A sensible default has been set. + | + */ + + 'domain' => env('SESSION_DOMAIN', null), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you if it can not be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. You are free to modify this option if needed. + | + */ + + 'http_only' => true, + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | do not enable this as other CSRF protection services are in place. + | + | Supported: "lax", "strict" + | + */ + + 'same_site' => null, + +]; diff --git a/Xboard/config/theme/.gitignore b/Xboard/config/theme/.gitignore new file mode 100644 index 0000000..9b8775c --- /dev/null +++ b/Xboard/config/theme/.gitignore @@ -0,0 +1,2 @@ +*.php +!.gitignore diff --git a/Xboard/config/view.php b/Xboard/config/view.php new file mode 100644 index 0000000..22b8a18 --- /dev/null +++ b/Xboard/config/view.php @@ -0,0 +1,36 @@ + [ + resource_path('views'), + ], + + /* + |-------------------------------------------------------------------------- + | Compiled View Path + |-------------------------------------------------------------------------- + | + | This option determines where all the compiled Blade templates will be + | stored for your application. Typically, this is within the storage + | directory. However, as usual, you are free to change this value. + | + */ + + 'compiled' => env( + 'VIEW_COMPILED_PATH', + realpath(storage_path('framework/views')) + ), + +]; diff --git a/Xboard/database/.gitignore b/Xboard/database/.gitignore new file mode 100644 index 0000000..97fc976 --- /dev/null +++ b/Xboard/database/.gitignore @@ -0,0 +1,2 @@ +*.sqlite +*.sqlite-journal diff --git a/Xboard/database/factories/UserFactory.php b/Xboard/database/factories/UserFactory.php new file mode 100644 index 0000000..741edea --- /dev/null +++ b/Xboard/database/factories/UserFactory.php @@ -0,0 +1,28 @@ +define(User::class, function (Faker $faker) { + return [ + 'name' => $faker->name, + 'email' => $faker->unique()->safeEmail, + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]; +}); diff --git a/Xboard/database/migrations/2019_08_19_000000_create_failed_jobs_table.php b/Xboard/database/migrations/2019_08_19_000000_create_failed_jobs_table.php new file mode 100644 index 0000000..510e6e7 --- /dev/null +++ b/Xboard/database/migrations/2019_08_19_000000_create_failed_jobs_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('failed_jobs'); + } +} diff --git a/Xboard/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/Xboard/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php new file mode 100644 index 0000000..542f19b --- /dev/null +++ b/Xboard/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -0,0 +1,34 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/Xboard/database/migrations/2023_03_19_000000_create_v2_tables.php b/Xboard/database/migrations/2023_03_19_000000_create_v2_tables.php new file mode 100644 index 0000000..6fa7080 --- /dev/null +++ b/Xboard/database/migrations/2023_03_19_000000_create_v2_tables.php @@ -0,0 +1,496 @@ +integer('id', true); + $table->integer('invite_user_id'); + $table->integer('user_id'); + $table->char('trade_no', 36); + $table->integer('order_amount'); + $table->integer('get_amount'); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Invite Code + if (!Schema::hasTable('v2_invite_code')) { + Schema::create('v2_invite_code', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('user_id'); + $table->char('code', 32); + $table->boolean('status')->default(false); + $table->integer('pv')->default(0); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Knowledge + if (!Schema::hasTable('v2_knowledge')) { + Schema::create('v2_knowledge', function (Blueprint $table) { + $table->integer('id', true); + $table->char('language', 5)->comment('語言'); + $table->string('category')->comment('分類名'); + $table->string('title')->comment('標題'); + $table->text('body')->comment('內容'); + $table->integer('sort')->nullable()->comment('排序'); + $table->boolean('show')->default(false)->comment('顯示'); + $table->integer('created_at')->comment('創建時間'); + $table->integer('updated_at')->comment('更新時間'); + }); + } + + // Plan + if (!Schema::hasTable('v2_plan')) { + Schema::create('v2_plan', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('group_id'); + $table->integer('transfer_enable'); + $table->string('name'); + $table->integer('speed_limit')->nullable(); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->boolean('renew')->default(true); + $table->text('content')->nullable(); + $table->integer('month_price')->nullable(); + $table->integer('quarter_price')->nullable(); + $table->integer('half_year_price')->nullable(); + $table->integer('year_price')->nullable(); + $table->integer('two_year_price')->nullable(); + $table->integer('three_year_price')->nullable(); + $table->integer('onetime_price')->nullable(); + $table->integer('reset_price')->nullable(); + $table->integer('reset_traffic_method')->nullable()->comment('重置流量方式:null跟随系统设置、0每月1号、1按月重置、2不重置、3每年1月1日、4按年重置'); + $table->integer('capacity_limit')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Server Group + if (!Schema::hasTable('v2_server_group')) { + Schema::create('v2_server_group', function (Blueprint $table) { + $table->integer('id', true); + $table->string('name'); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Server Route + if (!Schema::hasTable('v2_server_route')) { + Schema::create('v2_server_route', function (Blueprint $table) { + $table->integer('id', true); + $table->string('remarks'); + $table->text('match'); + $table->string('action', 11); + $table->string('action_value')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // stat server + if (!Schema::hasTable('v2_stat_server')) { + Schema::create('v2_stat_server', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('server_id')->index('server_id')->comment('节点id'); + $table->char('server_type', 11)->comment('节点类型'); + $table->bigInteger('u'); + $table->bigInteger('d'); + $table->char('record_type', 1)->comment('d day m month'); + $table->integer('record_at')->index('record_at')->comment('记录时间'); + $table->integer('created_at'); + $table->integer('updated_at'); + + $table->unique(['server_id', 'server_type', 'record_at'], 'server_id_server_type_record_at'); + }); + } + + // User + if (!Schema::hasTable('v2_user')) { + Schema::create('v2_user', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('invite_user_id')->nullable(); + $table->bigInteger('telegram_id')->nullable(); + $table->string('email', 64)->unique('email'); + $table->string('password', 64); + $table->char('password_algo', 10)->nullable(); + $table->char('password_salt', 10)->nullable(); + $table->integer('balance')->default(0); + $table->integer('discount')->nullable(); + $table->tinyInteger('commission_type')->default(0)->comment('0: system 1: period 2: onetime'); + $table->integer('commission_rate')->nullable(); + $table->integer('commission_balance')->default(0); + $table->integer('t')->default(0); + $table->bigInteger('u')->default(0); + $table->bigInteger('d')->default(0); + $table->bigInteger('transfer_enable')->default(0); + $table->boolean('banned')->default(false); + $table->boolean('is_admin')->default(false); + $table->integer('last_login_at')->nullable(); + $table->boolean('is_staff')->default(false); + $table->integer('last_login_ip')->nullable(); + $table->string('uuid', 36); + $table->integer('group_id')->nullable(); + $table->integer('plan_id')->nullable(); + $table->integer('speed_limit')->nullable(); + $table->tinyInteger('remind_expire')->nullable()->default(1); + $table->tinyInteger('remind_traffic')->nullable()->default(1); + $table->char('token', 32); + $table->bigInteger('expired_at')->nullable()->default(0); + $table->text('remarks')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Mail Log + if (!Schema::hasTable('v2_mail_log')) { + Schema::create('v2_mail_log', function (Blueprint $table) { + $table->integer('id', true); + $table->string('email', 64); + $table->string('subject'); + $table->string('template_name'); + $table->text('error')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Log + if (!Schema::hasTable('v2_log')) { + Schema::create('v2_log', function (Blueprint $table) { + $table->integer('id', true); + $table->text('title'); + $table->string('level', 11)->nullable(); + $table->string('host')->nullable(); + $table->string('uri'); + $table->string('method', 11); + $table->text('data')->nullable(); + $table->string('ip', 128)->nullable(); + $table->text('context')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Stat + if (!Schema::hasTable('v2_stat')) { + Schema::create('v2_stat', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('record_at'); + $table->char('record_type', 1); + $table->integer('order_count')->comment('订单数量'); + $table->integer('order_total')->comment('订单合计'); + $table->integer('commission_count'); + $table->integer('commission_total')->comment('佣金合计'); + $table->integer('paid_count'); + $table->integer('paid_total'); + $table->integer('register_count'); + $table->integer('invite_count'); + $table->string('transfer_used_total', 32); + $table->integer('created_at'); + $table->integer('updated_at'); + + if (config('database.default') !== 'sqlite') { + $table->unique(['record_at']); + } + }); + } + + // stat user + if (!Schema::hasTable('v2_stat_user')) { + Schema::create('v2_stat_user', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('user_id'); + $table->decimal('server_rate', 10); + $table->bigInteger('u'); + $table->bigInteger('d'); + $table->char('record_type', 2); + $table->integer('record_at'); + $table->integer('created_at'); + $table->integer('updated_at'); + + // 如果是不是sqlite才添加多个索引 + if (config('database.default') !== 'sqlite') { + $table->index(['user_id', 'server_rate', 'record_at']); + $table->unique(['server_rate', 'user_id', 'record_at'], 'server_rate_user_id_record_at'); + } + }); + } + + // ticket message + if (!Schema::hasTable('v2_ticket_message')) { + Schema::create('v2_ticket_message', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('user_id'); + $table->integer('ticket_id'); + $table->text('message'); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Order + if (!Schema::hasTable('v2_order')) { + Schema::create('v2_order', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('invite_user_id')->nullable(); + $table->integer('user_id'); + $table->integer('plan_id'); + $table->integer('coupon_id')->nullable(); + $table->integer('payment_id')->nullable(); + $table->integer('type')->comment('1新购2续费3升级'); + $table->string('period'); + $table->string('trade_no', 36)->unique('trade_no'); + $table->string('callback_no')->nullable(); + $table->integer('total_amount'); + $table->integer('handling_amount')->nullable(); + $table->integer('discount_amount')->nullable(); + $table->integer('surplus_amount')->nullable()->comment('剩余价值'); + $table->integer('refund_amount')->nullable()->comment('退款金额'); + $table->integer('balance_amount')->nullable()->comment('使用余额'); + $table->text('surplus_order_ids')->nullable()->comment('折抵订单'); + $table->integer('status')->default(0)->comment('0待支付1开通中2已取消3已完成4已折抵'); + $table->integer('commission_status')->default(false)->comment('0待确认1发放中2有效3无效'); + $table->integer('commission_balance')->default(0); + $table->integer('actual_commission_balance')->nullable()->comment('实际支付佣金'); + $table->integer('paid_at')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Payment + if (!Schema::hasTable('v2_payment')) { + Schema::create('v2_payment', function (Blueprint $table) { + $table->integer('id', true); + $table->char('uuid', 32); + $table->string('payment', 16); + $table->string('name'); + $table->string('icon')->nullable(); + $table->text('config'); + $table->string('notify_domain', 128)->nullable(); + $table->integer('handling_fee_fixed')->nullable(); + $table->decimal('handling_fee_percent', 5)->nullable(); + $table->boolean('enable')->default(false); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Coupon + if (!Schema::hasTable('v2_coupon')) { + Schema::create('v2_coupon', function (Blueprint $table) { + $table->integer('id', true); + $table->string('code'); + $table->string('name'); + $table->integer('type'); + $table->integer('value'); + $table->boolean('show')->default(false); + $table->integer('limit_use')->nullable(); + $table->integer('limit_use_with_user')->nullable(); + $table->string('limit_plan_ids')->nullable(); + $table->string('limit_period')->nullable(); + $table->integer('started_at'); + $table->integer('ended_at'); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Notice + if (!Schema::hasTable('v2_notice')) { + Schema::create('v2_notice', function (Blueprint $table) { + $table->integer('id', true); + $table->string('title'); + $table->text('content'); + $table->boolean('show')->default(false); + $table->string('img_url')->nullable(); + $table->string('tags')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Ticket + if (!Schema::hasTable('v2_ticket')) { + Schema::create('v2_ticket', function (Blueprint $table) { + $table->integer('id', true); + $table->integer('user_id'); + $table->string('subject'); + $table->integer('level'); + $table->integer('status')->default(0)->comment('0:已开启 1:已关闭'); + $table->integer('reply_status')->default(1)->comment('0:待回复 1:已回复'); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Server Hysteria + if (!Schema::hasTable('v2_server_hysteria')) { + Schema::create('v2_server_hysteria', function (Blueprint $table) { + $table->integer('id', true); + $table->string('group_id'); + $table->string('route_id')->nullable(); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->string('host'); + $table->string('port', 11); + $table->integer('server_port'); + $table->string('tags')->nullable(); + $table->string('rate', 11); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->integer('up_mbps'); + $table->integer('down_mbps'); + $table->string('server_name', 64)->nullable(); + $table->boolean('insecure')->default(false); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Server Shadowsocks + if (!Schema::hasTable('v2_server_shadowsocks')) { + autoIncrement: + Schema::create('v2_server_shadowsocks', function (Blueprint $table) { + $table->integer('id', true); + $table->string('group_id'); + $table->string('route_id')->nullable(); + $table->integer('parent_id')->nullable(); + $table->string('tags')->nullable(); + $table->string('name'); + $table->string('rate', 11); + $table->string('host'); + $table->string('port', 11); + $table->integer('server_port'); + $table->string('cipher'); + $table->char('obfs', 11)->nullable(); + $table->string('obfs_settings')->nullable(); + $table->tinyInteger('show')->default(0); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + // Server Trojan + if (!Schema::hasTable('v2_server_trojan')) { + Schema::create('v2_server_trojan', function (Blueprint $table) { + $table->integer('id', true)->comment('节点ID'); + $table->string('group_id')->comment('节点组'); + $table->string('route_id')->nullable(); + $table->integer('parent_id')->nullable()->comment('父节点'); + $table->string('tags')->nullable()->comment('节点标签'); + $table->string('name')->comment('节点名称'); + $table->string('rate', 11)->comment('倍率'); + $table->string('host')->comment('主机名'); + $table->string('port', 11)->comment('连接端口'); + $table->integer('server_port')->comment('服务端口'); + $table->boolean('allow_insecure')->default(false)->comment('是否允许不安全'); + $table->string('server_name')->nullable(); + $table->boolean('show')->default(false)->comment('是否显示'); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Server Vless + if (!Schema::hasTable('v2_server_vless')) { + Schema::create('v2_server_vless', function (Blueprint $table) { + $table->integer('id', true); + $table->text('group_id'); + $table->text('route_id')->nullable(); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->string('host'); + $table->integer('port'); + $table->integer('server_port'); + $table->integer('tls'); + $table->text('tls_settings')->nullable(); + $table->string('flow', 64)->nullable(); + $table->string('network', 11); + $table->text('network_settings')->nullable(); + $table->text('tags')->nullable(); + $table->string('rate', 11); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + // Server Vmess + if (!Schema::hasTable('v2_server_vmess')) { + Schema::create('v2_server_vmess', function (Blueprint $table) { + $table->integer('id', true); + $table->string('group_id'); + $table->string('route_id')->nullable(); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->string('host'); + $table->string('port', 11); + $table->integer('server_port'); + $table->tinyInteger('tls')->default(0); + $table->string('tags')->nullable(); + $table->string('rate', 11); + $table->string('network', 11); + $table->text('rules')->nullable(); + $table->text('networkSettings')->nullable(); + $table->text('tlsSettings')->nullable(); + $table->text('ruleSettings')->nullable(); + $table->text('dnsSettings')->nullable(); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + } + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('v2_commission_log'); + Schema::dropIfExists('v2_plan'); + Schema::dropIfExists('v2_user'); + Schema::dropIfExists('v2_mail_log'); + Schema::dropIfExists('v2_log'); + Schema::dropIfExists('v2_stat'); + Schema::dropIfExists('v2_order'); + Schema::dropIfExists('v2_coupon'); + Schema::dropIfExists('v2_notice'); + Schema::dropIfExists('v2_ticket'); + Schema::dropIfExists('v2_settings'); + Schema::dropIfExists('v2_ticket_message'); + Schema::dropIfExists('v2_invite_code'); + Schema::dropIfExists('v2_knowledge'); + Schema::dropIfExists('v2_server_group'); + Schema::dropIfExists('v2_server_route'); + Schema::dropIfExists('v2_stat_server'); + Schema::dropIfExists('v2_stat_user'); + Schema::dropIfExists('v2_server_hysteria'); + Schema::dropIfExists('v2_server_shadowsocks'); + Schema::dropIfExists('v2_server_trojan'); + Schema::dropIfExists('v2_server_vless'); + Schema::dropIfExists('v2_server_vmess'); + } +}; diff --git a/Xboard/database/migrations/2023_08_14_221234_create_v2_settings_table.php b/Xboard/database/migrations/2023_08_14_221234_create_v2_settings_table.php new file mode 100644 index 0000000..b6e788b --- /dev/null +++ b/Xboard/database/migrations/2023_08_14_221234_create_v2_settings_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('group')->comment('设置分组')->nullable(); + $table->string('type')->comment('设置类型')->nullable(); + $table->string('name')->comment('设置名称')->unique(); + $table->string('value')->comment('设置值')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('v2_settings'); + } +} diff --git a/Xboard/database/migrations/2023_09_04_190923_add_column_excludes_to_server_table.php b/Xboard/database/migrations/2023_09_04_190923_add_column_excludes_to_server_table.php new file mode 100644 index 0000000..ef1038e --- /dev/null +++ b/Xboard/database/migrations/2023_09_04_190923_add_column_excludes_to_server_table.php @@ -0,0 +1,56 @@ +text("excludes")->nullable()->after('tags'); + }); + Schema::table('v2_server_shadowsocks', function (Blueprint $table) { + $table->text("excludes")->nullable()->after('tags'); + }); + Schema::table('v2_server_trojan', function (Blueprint $table) { + $table->text("excludes")->nullable()->after('tags'); + }); + Schema::table('v2_server_vless', function (Blueprint $table) { + $table->text("excludes")->nullable()->after('tags'); + }); + Schema::table('v2_server_vmess', function (Blueprint $table) { + $table->text("excludes")->nullable()->after('tags'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('v2_server_hysteria', function (Blueprint $table) { + $table->dropColumn('excludes'); + }); + Schema::table('v2_server_shadowsocks', function (Blueprint $table) { + $table->dropColumn('excludes'); + }); + Schema::table('v2_server_trojan', function (Blueprint $table) { + $table->dropColumn('excludes'); + }); + Schema::table('v2_server_vless', function (Blueprint $table) { + $table->dropColumn('excludes'); + }); + Schema::table('v2_server_vmess', function (Blueprint $table) { + $table->dropColumn('excludes'); + }); + } +} diff --git a/Xboard/database/migrations/2023_09_06_195956_add_column_ips_to_server_table.php b/Xboard/database/migrations/2023_09_06_195956_add_column_ips_to_server_table.php new file mode 100644 index 0000000..be71dc9 --- /dev/null +++ b/Xboard/database/migrations/2023_09_06_195956_add_column_ips_to_server_table.php @@ -0,0 +1,56 @@ +string("ips")->nullable()->after('excludes'); + }); + Schema::table('v2_server_shadowsocks', function (Blueprint $table) { + $table->string("ips")->nullable()->after('excludes'); + }); + Schema::table('v2_server_trojan', function (Blueprint $table) { + $table->string("ips")->nullable()->after('excludes'); + }); + Schema::table('v2_server_vless', function (Blueprint $table) { + $table->string("ips")->nullable()->after('excludes'); + }); + Schema::table('v2_server_vmess', function (Blueprint $table) { + $table->string("ips")->nullable()->after('excludes'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('v2_server_hysteria', function (Blueprint $table) { + $table->dropColumn('ips'); + }); + Schema::table('v2_server_shadowsocks', function (Blueprint $table) { + $table->dropColumn('ips'); + }); + Schema::table('v2_server_trojan', function (Blueprint $table) { + $table->dropColumn('ips'); + }); + Schema::table('v2_server_vless', function (Blueprint $table) { + $table->dropColumn('ips'); + }); + Schema::table('v2_server_vmess', function (Blueprint $table) { + $table->dropColumn('ips'); + }); + } +} diff --git a/Xboard/database/migrations/2023_09_14_013244_add_column_alpn_to_server_hysteria_table.php b/Xboard/database/migrations/2023_09_14_013244_add_column_alpn_to_server_hysteria_table.php new file mode 100644 index 0000000..332666f --- /dev/null +++ b/Xboard/database/migrations/2023_09_14_013244_add_column_alpn_to_server_hysteria_table.php @@ -0,0 +1,32 @@ +tinyInteger('alpn',false,true)->default(0)->comment('ALPN,0:hysteria、1:http/1.1、2:h2、3:h3'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('v2_server_hysteria', function (Blueprint $table) { + $table->dropColumn('alpn'); + }); + } +} diff --git a/Xboard/database/migrations/2023_09_24_040317_add_column_network_and_network_settings_to_v2_server_trojan.php b/Xboard/database/migrations/2023_09_24_040317_add_column_network_and_network_settings_to_v2_server_trojan.php new file mode 100644 index 0000000..529cf68 --- /dev/null +++ b/Xboard/database/migrations/2023_09_24_040317_add_column_network_and_network_settings_to_v2_server_trojan.php @@ -0,0 +1,33 @@ +string('network', 11)->default('tcp')->after('server_name')->comment('传输协议'); + $table->text('networkSettings')->nullable()->after('network')->comment('传输协议配置'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('v2_server_trojan', function (Blueprint $table) { + $table->dropColumn(["network","networkSettings"]); + }); + } +} diff --git a/Xboard/database/migrations/2023_09_29_044957_add_column_version_and_is_obfs_to_server_hysteria_table.php b/Xboard/database/migrations/2023_09_29_044957_add_column_version_and_is_obfs_to_server_hysteria_table.php new file mode 100644 index 0000000..c2f7762 --- /dev/null +++ b/Xboard/database/migrations/2023_09_29_044957_add_column_version_and_is_obfs_to_server_hysteria_table.php @@ -0,0 +1,33 @@ +tinyInteger('version',false,true)->default(1)->comment('hysteria版本,Version:1\2'); + $table->boolean('is_obfs')->default(true)->comment('是否开启obfs'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('v2_server_hysteria', function (Blueprint $table) { + $table->dropColumn('version','is_obfs'); + }); + } +} diff --git a/Xboard/database/migrations/2023_11_19_205026_change_column_value_to_v2_settings_table.php b/Xboard/database/migrations/2023_11_19_205026_change_column_value_to_v2_settings_table.php new file mode 100644 index 0000000..97ad5d6 --- /dev/null +++ b/Xboard/database/migrations/2023_11_19_205026_change_column_value_to_v2_settings_table.php @@ -0,0 +1,28 @@ +text('value')->comment('设置值')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_settings', function (Blueprint $table) { + $table->string('value')->comment('设置值')->nullable()->change(); + }); + } +}; diff --git a/Xboard/database/migrations/2023_12_12_212239_add_index_to_v2_user_table.php b/Xboard/database/migrations/2023_12_12_212239_add_index_to_v2_user_table.php new file mode 100644 index 0000000..b06cbd3 --- /dev/null +++ b/Xboard/database/migrations/2023_12_12_212239_add_index_to_v2_user_table.php @@ -0,0 +1,28 @@ +index(['u','d','expired_at','group_id','banned','transfer_enable']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_user', function (Blueprint $table) { + $table->dropIndex(['u','d','expired_at','group_id','banned','transfer_enable']); + }); + } +}; diff --git a/Xboard/database/migrations/2024_03_19_103149_modify_icon_column_to_v2_payment_table.php b/Xboard/database/migrations/2024_03_19_103149_modify_icon_column_to_v2_payment_table.php new file mode 100644 index 0000000..3d129e7 --- /dev/null +++ b/Xboard/database/migrations/2024_03_19_103149_modify_icon_column_to_v2_payment_table.php @@ -0,0 +1,28 @@ +text('icon')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_payment', function (Blueprint $table) { + $table->string('icon')->nullable()->change(); + }); + } +}; diff --git a/Xboard/database/migrations/2025_01_01_130644_modify_commission_status_in_v2_order_table.php b/Xboard/database/migrations/2025_01_01_130644_modify_commission_status_in_v2_order_table.php new file mode 100644 index 0000000..916e226 --- /dev/null +++ b/Xboard/database/migrations/2025_01_01_130644_modify_commission_status_in_v2_order_table.php @@ -0,0 +1,28 @@ +integer('commission_status')->nullable()->default(null)->comment('0待确认1发放中2有效3无效')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_order', function (Blueprint $table) { + $table->integer('commission_status')->default(false)->comment('0待确认1发放中2有效3无效')->change(); + }); + } +}; diff --git a/Xboard/database/migrations/2025_01_04_optimize_plan_table.php b/Xboard/database/migrations/2025_01_04_optimize_plan_table.php new file mode 100644 index 0000000..d6b1f47 --- /dev/null +++ b/Xboard/database/migrations/2025_01_04_optimize_plan_table.php @@ -0,0 +1,129 @@ +json('prices')->nullable()->after('name') + ->comment('Store different duration prices and reset traffic price'); + $table->boolean('sell')->default(false)->after('prices')->comment('is sell'); + }); + + // Step 2: Migrate data to new format + DB::table('v2_plan')->orderBy('id')->chunk(100, function ($plans) { + foreach ($plans as $plan) { + $prices = array_filter([ + 'monthly' => $plan->month_price !== null ? $plan->month_price / 100 : null, + 'quarterly' => $plan->quarter_price !== null ? $plan->quarter_price / 100 : null, + 'half_yearly' => $plan->half_year_price !== null ? $plan->half_year_price / 100 : null, + 'yearly' => $plan->year_price !== null ? $plan->year_price / 100 : null, + 'two_yearly' => $plan->two_year_price !== null ? $plan->two_year_price / 100 : null, + 'three_yearly' => $plan->three_year_price !== null ? $plan->three_year_price / 100 : null, + 'onetime' => $plan->onetime_price !== null ? $plan->onetime_price / 100 : null, + 'reset_traffic' => $plan->reset_price !== null ? $plan->reset_price / 100 : null + ], function ($price) { + return $price !== null; + }); + + DB::table('v2_plan') + ->where('id', $plan->id) + ->update([ + 'prices' => json_encode($prices), + 'sell' => $plan->show + ]); + } + }); + + // Step 3: Optimize existing columns + Schema::table('v2_plan', function (Blueprint $table) { + // Modify existing columns to be more efficient + $table->unsignedInteger('group_id')->nullable()->change(); + $table->unsignedBigInteger('transfer_enable')->nullable() + ->comment('Transfer limit in bytes')->change(); + $table->unsignedInteger('speed_limit')->nullable() + ->comment('Speed limit in Mbps, 0 for unlimited')->change(); + $table->integer('reset_traffic_method')->nullable()->default(0) + ->comment('重置流量方式:null跟随系统设置、0每月1号、1按月重置、2不重置、3每年1月1日、4按年重置')->change(); + $table->unsignedInteger('capacity_limit')->nullable()->default(0) + ->comment('0 for unlimited')->change(); + }); + + // Step 4: Drop old columns + Schema::table('v2_plan', function (Blueprint $table) { + $table->dropColumn([ + 'month_price', + 'quarter_price', + 'half_year_price', + 'year_price', + 'two_year_price', + 'three_year_price', + 'onetime_price', + 'reset_price', + ]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Step 1: Add back old columns + Schema::table('v2_plan', function (Blueprint $table) { + $table->integer('month_price')->nullable(); + $table->integer('quarter_price')->nullable(); + $table->integer('half_year_price')->nullable(); + $table->integer('year_price')->nullable(); + $table->integer('two_year_price')->nullable(); + $table->integer('three_year_price')->nullable(); + $table->integer('onetime_price')->nullable(); + $table->integer('reset_price')->nullable(); + }); + + // Step 2: Restore data from new format to old format + DB::table('v2_plan')->orderBy('id')->chunk(100, function ($plans) { + foreach ($plans as $plan) { + $prices = json_decode($plan->prices, true) ?? []; + + DB::table('v2_plan') + ->where('id', $plan->id) + ->update([ + 'month_price' => $prices['monthly'] * 100 ?? null, + 'quarter_price' => $prices['quarterly'] * 100 ?? null, + 'half_year_price' => $prices['half_yearly'] * 100 ?? null, + 'year_price' => $prices['yearly'] * 100 ?? null, + 'two_year_price' => $prices['two_yearly'] * 100 ?? null, + 'three_year_price' => $prices['three_yearly'] * 100 ?? null, + 'onetime_price' => $prices['onetime'] * 100 ?? null, + 'reset_price' => $prices['reset_traffic'] * 100 ?? null, + ]); + } + }); + + // Step 3: Drop new columns + Schema::table('v2_plan', function (Blueprint $table) { + $table->dropColumn([ + 'prices', + 'sell' + ]); + }); + + // Step 4: Restore column types to original + Schema::table('v2_plan', function (Blueprint $table) { + $table->integer('group_id')->change(); + $table->integer('transfer_enable')->change(); + $table->integer('speed_limit')->nullable()->change(); + $table->integer('reset_traffic_method')->nullable()->change(); + $table->integer('capacity_limit')->nullable()->change(); + }); + } +}; \ No newline at end of file diff --git a/Xboard/database/migrations/2025_01_05_131425_create_v2_server_table.php b/Xboard/database/migrations/2025_01_05_131425_create_v2_server_table.php new file mode 100644 index 0000000..f8fb84b --- /dev/null +++ b/Xboard/database/migrations/2025_01_05_131425_create_v2_server_table.php @@ -0,0 +1,523 @@ +id('id'); + $table->string('type')->comment('Server Type'); + $table->string('code')->nullable()->comment('Server Spectific Key'); + $table->unsignedInteger('parent_id')->nullable()->comment('Parent Server ID'); + $table->json('group_ids')->nullable()->comment('Group ID'); + $table->json('route_ids')->nullable()->comment('Route ID'); + $table->string('name')->comment('Server Name'); + $table->decimal('rate', 8, 2)->comment('Traffic Rate'); + $table->json('tags')->nullable()->comment('Server Tags'); + $table->string('host')->comment('Server Host'); + $table->string('port')->comment('Client Port'); + $table->integer('server_port')->comment('Server Port'); + $table->json('protocol_settings')->nullable(); + $table->boolean('show')->default(false)->comment('Show in List'); + $table->integer('sort')->nullable()->unsigned()->index(); + $table->timestamps(); + $table->unique(['type', 'code']); + }); + + // Migrate Trojan servers + $trojanServers = DB::table('v2_server_trojan')->get(); + foreach ($trojanServers as $server) { + DB::table('v2_server')->insert([ + 'type' => 'trojan', + 'code' => (string) $server->id, + 'parent_id' => $server->parent_id, + 'group_ids' => $server->group_id ?: "[]", + 'route_ids' => $server->route_id ?: "[]", + 'name' => $server->name, + 'rate' => $server->rate, + 'tags' => $server->tags ?: "[]", + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'protocol_settings' => json_encode([ + 'allow_insecure' => $server->allow_insecure, + 'server_name' => $server->server_name, + 'network' => $server->network, + 'network_settings' => $server->networkSettings + ]), + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => date('Y-m-d H:i:s', $server->created_at), + 'updated_at' => date('Y-m-d H:i:s', $server->updated_at) + ]); + } + + // Migrate VMess servers + $vmessServers = DB::table('v2_server_vmess')->get(); + foreach ($vmessServers as $server) { + DB::table('v2_server')->insert([ + 'type' => 'vmess', + 'code' => (string) $server->id, + 'parent_id' => $server->parent_id, + 'group_ids' => $server->group_id ?: "[]", + 'route_ids' => $server->route_id ?: "[]", + 'name' => $server->name, + 'rate' => $server->rate, + 'tags' => $server->tags ?: "[]", + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'protocol_settings' => json_encode([ + 'tls' => $server->tls, + 'network' => $server->network, + 'rules' => json_decode($server->rules), + 'network_settings' => json_decode($server->networkSettings), + 'tls_settings' => json_decode($server->tlsSettings), + ]), + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => date('Y-m-d H:i:s', $server->created_at), + 'updated_at' => date('Y-m-d H:i:s', $server->updated_at) + ]); + } + + // Migrate VLESS servers + $vlessServers = DB::table('v2_server_vless')->get(); + foreach ($vlessServers as $server) { + $tlsSettings = optional(json_decode($server->tls_settings)); + DB::table('v2_server')->insert([ + 'type' => 'vless', + 'code' => (string) $server->id, + 'parent_id' => $server->parent_id, + 'group_ids' => $server->group_id ?: "[]", + 'route_ids' => $server->route_id ?: "[]", + 'name' => $server->name, + 'rate' => $server->rate, + 'tags' => $server->tags ?: "[]", + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'protocol_settings' => json_encode([ + 'tls' => $server->tls, + 'tls_settings' => $tlsSettings, + 'flow' => $server->flow, + 'network' => $server->network, + 'network_settings' => json_decode($server->network_settings), + 'reality_settings' => ($tlsSettings && $tlsSettings->public_key && $tlsSettings->short_id && $tlsSettings->server_name) ? [ + 'public_key' => $tlsSettings->public_key, + 'short_id' => $tlsSettings->short_id, + 'server_name' => $tlsSettings->server_name, + 'server_port' => $tlsSettings->server_port, + 'private_key' => $tlsSettings->private_key, + ] : null + ]), + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => date('Y-m-d H:i:s', $server->created_at), + 'updated_at' => date('Y-m-d H:i:s', $server->updated_at) + ]); + } + + // Migrate Shadowsocks servers + $ssServers = DB::table('v2_server_shadowsocks')->get(); + foreach ($ssServers as $server) { + DB::table('v2_server')->insert([ + 'type' => 'shadowsocks', + 'code' => (string) $server->id, + 'parent_id' => $server->parent_id, + 'group_ids' => $server->group_id ?: "[]", + 'route_ids' => $server->route_id ?: "[]", + 'name' => $server->name, + 'rate' => $server->rate, + 'tags' => $server->tags ?: "[]", + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'protocol_settings' => json_encode([ + 'cipher' => $server->cipher, + 'obfs' => $server->obfs, + 'obfs_settings' => json_decode($server->obfs_settings) + ]), + 'show' => (bool) $server->show, + 'sort' => $server->sort, + 'created_at' => date('Y-m-d H:i:s', $server->created_at), + 'updated_at' => date('Y-m-d H:i:s', $server->updated_at) + ]); + } + + // Migrate Hysteria servers + $hysteriaServers = DB::table(table: 'v2_server_hysteria')->get(); + foreach ($hysteriaServers as $server) { + DB::table('v2_server')->insert([ + 'type' => 'hysteria', + 'code' => (string) $server->id, + 'parent_id' => $server->parent_id, + 'group_ids' => $server->group_id ?: "[]", + 'route_ids' => $server->route_id ?: "[]", + 'name' => $server->name, + 'rate' => $server->rate, + 'tags' => $server->tags ?: "[]", + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'protocol_settings' => json_encode([ + 'version' => $server->version, + 'bandwidth' => [ + 'up' => $server->up_mbps, + 'down' => $server->down_mbps, + ], + 'obfs' => [ + 'open' => $server->is_obfs, + 'type' => 'salamander', + 'password' => Helper::getServerKey($server->created_at, 16), + ], + 'tls' => [ + 'server_name' => $server->server_name, + 'allow_insecure' => $server->insecure + ] + ]), + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => date('Y-m-d H:i:s', $server->created_at), + 'updated_at' => date('Y-m-d H:i:s', $server->updated_at) + ]); + } + + // Update parent_id for all servers + $this->updateParentIds(); + + // Drop old tables + Schema::dropIfExists('v2_server_trojan'); + Schema::dropIfExists('v2_server_vmess'); + Schema::dropIfExists('v2_server_vless'); + Schema::dropIfExists('v2_server_shadowsocks'); + Schema::dropIfExists('v2_server_hysteria'); + } + + /** + * Update parent_id references for all servers + */ + private function updateParentIds(): void + { + // Get all servers that have a parent_id + $servers = DB::table('v2_server') + ->whereNotNull('parent_id') + ->get(); + + // Update each server's parent_id to reference the new table's id + foreach ($servers as $server) { + $parentId = DB::table('v2_server') + ->where('type', $server->type) + ->where('code', $server->parent_id) + ->value('id'); + + if ($parentId) { + DB::table('v2_server') + ->where('id', $server->id) + ->update(['parent_id' => $parentId]); + } + } + } + + /** + * Restore parent_id references when rolling back + */ + private function restoreParentIds(string $type, string $table): void + { + // Get all servers of the specified type that have a parent_id + $servers = DB::table($table) + ->whereNotNull('parent_id') + ->get(); + + // Update each server's parent_id to reference back to the original id + foreach ($servers as $server) { + $originalParentId = DB::table('v2_server') + ->where('type', $type) + ->where('id', $server->parent_id) + ->value('code'); + + if ($originalParentId) { + DB::table($table) + ->where('id', $server->id) + ->update(['parent_id' => $originalParentId]); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Recreate old tables + Schema::create('v2_server_trojan', function (Blueprint $table) { + $table->integer('id', true)->comment('节点ID'); + $table->string('group_id')->comment('节点组'); + $table->string('route_id')->nullable(); + $table->string('ips')->nullable(); + $table->string('excludes')->nullable(); + $table->integer('parent_id')->nullable()->comment('父节点'); + $table->string('tags')->nullable()->comment('节点标签'); + $table->string('name')->comment('节点名称'); + $table->string('rate', 11)->comment('倍率'); + $table->string('host')->comment('主机名'); + $table->string('port', 11)->comment('连接端口'); + $table->integer('server_port')->comment('服务端口'); + $table->boolean('allow_insecure')->default(false)->comment('是否允许不安全'); + $table->string('server_name')->nullable(); + $table->string('network')->nullable(); + $table->text('networkSettings')->nullable(); + $table->boolean('show')->default(false)->comment('是否显示'); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + Schema::create('v2_server_vmess', function (Blueprint $table) { + $table->integer('id', true); + $table->string('group_id'); + $table->string('route_id')->nullable(); + $table->string('ips')->nullable(); + $table->string('excludes')->nullable(); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->string('host'); + $table->string('port', 11); + $table->integer('server_port'); + $table->tinyInteger('tls')->default(0); + $table->string('tags')->nullable(); + $table->string('rate', 11); + $table->string('network', 11); + $table->text('rules')->nullable(); + $table->text('networkSettings')->nullable(); + $table->text('tlsSettings')->nullable(); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + Schema::create('v2_server_vless', function (Blueprint $table) { + $table->integer('id', true); + $table->text('group_id'); + $table->text('route_id')->nullable(); + $table->string('ips')->nullable(); + $table->string('excludes')->nullable(); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->string('host'); + $table->integer('port'); + $table->integer('server_port'); + $table->boolean('tls'); + $table->text('tls_settings')->nullable(); + $table->string('flow', 64)->nullable(); + $table->string('network', 11); + $table->text('network_settings')->nullable(); + $table->text('tags')->nullable(); + $table->string('rate', 11); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + Schema::create('v2_server_shadowsocks', function (Blueprint $table) { + $table->integer('id', true); + $table->string('group_id'); + $table->string('route_id')->nullable(); + $table->string('ips')->nullable(); + $table->string('excludes')->nullable(); + $table->integer('parent_id')->nullable(); + $table->string('tags')->nullable(); + $table->string('name'); + $table->string('rate', 11); + $table->string('host'); + $table->string('port', 11); + $table->integer('server_port'); + $table->string('cipher'); + $table->char('obfs', 11)->nullable(); + $table->string('obfs_settings')->nullable(); + $table->tinyInteger('show')->default(0); + $table->integer('sort')->nullable(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + Schema::create('v2_server_hysteria', function (Blueprint $table) { + $table->integer('id', true); + $table->string('group_id'); + $table->string('route_id')->nullable(); + $table->string('ips')->nullable(); + $table->string('excludes')->nullable(); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->string('host'); + $table->string('port', 11); + $table->integer('server_port'); + $table->string('tags')->nullable(); + $table->string('rate', 11); + $table->boolean('show')->default(false); + $table->integer('sort')->nullable(); + $table->tinyInteger('version', false, true)->default(1)->comment('hysteria版本,Version:1\2'); + $table->boolean('is_obfs')->default(true)->comment('是否开启obfs'); + $table->string('alpn')->nullable(); + $table->integer('up_mbps'); + $table->integer('down_mbps'); + $table->string('server_name', 64)->nullable(); + $table->boolean('insecure')->default(false); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + // Migrate data back to old tables + $servers = DB::table('v2_server')->get(); + foreach ($servers as $server) { + $settings = json_decode($server->protocol_settings, true); + $timestamp = strtotime($server->created_at); + $updated = strtotime($server->updated_at); + switch ($server->type) { + case 'trojan': + DB::table('v2_server_trojan')->insert([ + 'id' => (int) $server->code, + 'group_id' => $server->group_ids, + 'route_id' => $server->route_ids, + 'parent_id' => $server->parent_id, + 'tags' => $server->tags, + 'name' => $server->name, + 'rate' => (string) $server->rate, + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'allow_insecure' => $settings['allow_insecure'], + 'server_name' => $settings['server_name'], + 'network' => $settings['network'] ?? null, + 'networkSettings' => $settings['network_settings'] ?? null, + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => $timestamp, + 'updated_at' => $updated + ]); + break; + case 'vmess': + DB::table('v2_server_vmess')->insert([ + 'id' => (int) $server->code, + 'group_id' => $server->group_ids, + 'route_id' => $server->route_ids, + 'name' => $server->name, + 'parent_id' => $server->parent_id, + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'tls' => $settings['tls'], + 'tags' => $server->tags, + 'rate' => (string) $server->rate, + 'network' => $settings['network'], + 'rules' => json_encode($settings['rules']), + 'networkSettings' => json_encode($settings['network_settings']), + 'tlsSettings' => json_encode($settings['tls_settings']), + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => $timestamp, + 'updated_at' => $updated + ]); + break; + case 'vless': + // 处理 reality settings + $tlsSettings = $settings['tls_settings'] ?? new \stdClass(); + if (isset($settings['reality_settings'])) { + $tlsSettings = array_merge((array) $tlsSettings, [ + 'public_key' => $settings['reality_settings']['public_key'], + 'short_id' => $settings['reality_settings']['short_id'], + 'server_name' => explode(':', $settings['reality_settings']['dest'])[0], + 'server_port' => explode(':', $settings['reality_settings']['dest'])[1] ?? null, + 'private_key' => $settings['reality_settings']['private_key'] + ]); + } + + DB::table('v2_server_vless')->insert([ + 'id' => (int) $server->code, + 'group_id' => $server->group_ids, + 'route_id' => $server->route_ids, + 'name' => $server->name, + 'parent_id' => $server->parent_id, + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'tls' => $settings['tls'], + 'tls_settings' => json_encode($tlsSettings), + 'flow' => $settings['flow'], + 'network' => $settings['network'], + 'network_settings' => json_encode($settings['network_settings']), + 'tags' => $server->tags, + 'rate' => (string) $server->rate, + 'show' => $server->show, + 'sort' => $server->sort, + 'created_at' => $timestamp, + 'updated_at' => $updated + ]); + break; + case 'shadowsocks': + DB::table('v2_server_shadowsocks')->insert([ + 'id' => (int) $server->code, + 'group_id' => $server->group_ids, + 'route_id' => $server->route_ids, + 'parent_id' => $server->parent_id, + 'tags' => $server->tags, + 'name' => $server->name, + 'rate' => (string) $server->rate, + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'cipher' => $settings['cipher'], + 'obfs' => $settings['obfs'], + 'obfs_settings' => json_encode($settings['obfs_settings']), + 'show' => (int) $server->show, + 'sort' => $server->sort, + 'created_at' => $timestamp, + 'updated_at' => $updated + ]); + break; + case 'hysteria': + DB::table('v2_server_hysteria')->insert([ + 'id' => (int) $server->code, + 'group_id' => $server->group_ids, + 'route_id' => $server->route_ids, + 'name' => $server->name, + 'parent_id' => $server->parent_id, + 'host' => $server->host, + 'port' => $server->port, + 'server_port' => $server->server_port, + 'tags' => $server->tags, + 'rate' => (string) $server->rate, + 'show' => $server->show, + 'sort' => $server->sort, + 'up_mbps' => $settings['bandwidth']['up'], + 'down_mbps' => $settings['bandwidth']['down'], + 'server_name' => $settings['tls']['server_name'], + 'insecure' => $settings['tls']['allow_insecure'], + 'created_at' => $timestamp, + 'updated_at' => $updated + ]); + break; + } + } + + // Restore parent_id references for each server type + $this->restoreParentIds('trojan', 'v2_server_trojan'); + $this->restoreParentIds('vmess', 'v2_server_vmess'); + $this->restoreParentIds('vless', 'v2_server_vless'); + $this->restoreParentIds('shadowsocks', 'v2_server_shadowsocks'); + $this->restoreParentIds('hysteria', 'v2_server_hysteria'); + + // Drop new table + Schema::dropIfExists('v2_server'); + } +}; diff --git a/Xboard/database/migrations/2025_01_10_152139_add_device_limit_column.php b/Xboard/database/migrations/2025_01_10_152139_add_device_limit_column.php new file mode 100644 index 0000000..8015d10 --- /dev/null +++ b/Xboard/database/migrations/2025_01_10_152139_add_device_limit_column.php @@ -0,0 +1,31 @@ +unsignedInteger('device_limit')->nullable()->after('speed_limit'); + }); + Schema::table('v2_user', function (Blueprint $table) { + $table->integer('device_limit')->nullable()->after('expired_at'); + $table->integer('online_count')->nullable()->after('device_limit'); + $table->timestamp('last_online_at')->nullable()->after('online_count'); + }); + } + + public function down(): void + { + Schema::table('v2_user', function (Blueprint $table) { + $table->dropColumn('device_limit'); + $table->dropColumn('online_count'); + $table->dropColumn('last_online_at'); + }); + Schema::table('v2_plan', function (Blueprint $table) { + $table->dropColumn('device_limit'); + }); + } +}; diff --git a/Xboard/database/migrations/2025_01_12_190315_add_sort_to_v2_notice_table.php b/Xboard/database/migrations/2025_01_12_190315_add_sort_to_v2_notice_table.php new file mode 100644 index 0000000..9cbef66 --- /dev/null +++ b/Xboard/database/migrations/2025_01_12_190315_add_sort_to_v2_notice_table.php @@ -0,0 +1,30 @@ +integer('sort')->nullable()->after('id')->index(); + }); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_notice', function (Blueprint $table) { + $table->dropColumn('sort'); + }); + } +}; diff --git a/Xboard/database/migrations/2025_01_12_200936_modify_commission_status_in_v2_order_table.php b/Xboard/database/migrations/2025_01_12_200936_modify_commission_status_in_v2_order_table.php new file mode 100644 index 0000000..668ef17 --- /dev/null +++ b/Xboard/database/migrations/2025_01_12_200936_modify_commission_status_in_v2_order_table.php @@ -0,0 +1,33 @@ +where('commission_status', null)->update([ + 'commission_status' => 0 + ]); + Schema::table('v2_order', function (Blueprint $table) { + $table->integer('commission_status')->default(value: 0)->comment('0待确认1发放中2有效3无效')->change(); + }); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_order', function (Blueprint $table) { + $table->integer('commission_status')->nullable()->comment('0待确认1发放中2有效3无效')->change(); + }); + } +}; diff --git a/Xboard/database/migrations/2025_01_13_000000_convert_order_period_fields.php b/Xboard/database/migrations/2025_01_13_000000_convert_order_period_fields.php new file mode 100644 index 0000000..dc6718c --- /dev/null +++ b/Xboard/database/migrations/2025_01_13_000000_convert_order_period_fields.php @@ -0,0 +1,56 @@ + 'monthly', + 'quarter_price' => 'quarterly', + 'half_year_price' => 'half_yearly', + 'year_price' => 'yearly', + 'two_year_price' => 'two_yearly', + 'three_year_price' => 'three_yearly', + 'onetime_price' => 'onetime', + 'reset_price' => 'reset_traffic' + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + // 批量更新订单的周期字段 + foreach (self::PERIOD_MAPPING as $oldPeriod => $newPeriod) { + DB::table('v2_order') + ->where('period', $oldPeriod) + ->update(['period' => $newPeriod]); + } + + // 检查是否还有未转换的记录 + $unconvertedCount = DB::table('v2_order') + ->whereNotIn('period', array_values(self::PERIOD_MAPPING)) + ->count(); + + if ($unconvertedCount > 0) { + Log::warning("Found {$unconvertedCount} orders with unconverted period values"); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 回滚操作 - 将新的周期值转换回旧的价格字段名 + foreach (self::PERIOD_MAPPING as $oldPeriod => $newPeriod) { + DB::table('v2_order') + ->where('period', $newPeriod) + ->update(['period' => $oldPeriod]); + } + } +}; \ No newline at end of file diff --git a/Xboard/database/migrations/2025_01_15_000002_add_stat_performance_indexes.php b/Xboard/database/migrations/2025_01_15_000002_add_stat_performance_indexes.php new file mode 100644 index 0000000..4640e7e --- /dev/null +++ b/Xboard/database/migrations/2025_01_15_000002_add_stat_performance_indexes.php @@ -0,0 +1,93 @@ +index('t'); + $table->index('online_count'); + $table->index('created_at'); + }); + + Schema::table('v2_order', function (Blueprint $table) { + $table->index('created_at'); + $table->index('status'); + $table->index('total_amount'); + $table->index('commission_status'); + $table->index('invite_user_id'); + $table->index('commission_balance'); + }); + + Schema::table('v2_stat_server', function (Blueprint $table) { + $table->index('server_id'); + $table->index('record_at'); + $table->index('u'); + $table->index('d'); + }); + + Schema::table('v2_stat_user', function (Blueprint $table) { + $table->index('u'); + $table->index('d'); + }); + + Schema::table('v2_commission_log', function (Blueprint $table) { + $table->index('created_at'); + $table->index('get_amount'); + }); + + Schema::table('v2_ticket', function (Blueprint $table) { + $table->index('status'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_user', function (Blueprint $table) { + $table->dropIndex(['t']); + $table->dropIndex(['online_count']); + $table->dropIndex(['created_at']); + }); + + Schema::table('v2_order', function (Blueprint $table) { + $table->dropIndex(['created_at']); + $table->dropIndex(['status']); + $table->dropIndex(['total_amount']); + $table->dropIndex(['commission_status']); + $table->dropIndex(['invite_user_id']); + $table->dropIndex(['commission_balance']); + }); + + Schema::table('v2_stat_server', function (Blueprint $table) { + $table->dropIndex(['server_id']); + $table->dropIndex(['record_at']); + $table->dropIndex(['u']); + $table->dropIndex(['d']); + }); + + Schema::table('v2_stat_user', function (Blueprint $table) { + $table->dropIndex(['u']); + $table->dropIndex(['d']); + }); + + Schema::table('v2_commission_log', function (Blueprint $table) { + $table->dropIndex(['created_at']); + $table->dropIndex(['get_amount']); + }); + + Schema::table('v2_ticket', function (Blueprint $table) { + $table->dropIndex(['status']); + $table->dropIndex(['created_at']); + }); + } +}; \ No newline at end of file diff --git a/Xboard/database/migrations/2025_01_16_142320_add_updated_at_index_to_v2_order_table.php b/Xboard/database/migrations/2025_01_16_142320_add_updated_at_index_to_v2_order_table.php new file mode 100644 index 0000000..5278bbf --- /dev/null +++ b/Xboard/database/migrations/2025_01_16_142320_add_updated_at_index_to_v2_order_table.php @@ -0,0 +1,28 @@ +index('updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_order', function (Blueprint $table) { + $table->dropIndex(['updated_at']); + }); + } +}; diff --git a/Xboard/database/migrations/2025_01_18_140511_create_plugins_table.php b/Xboard/database/migrations/2025_01_18_140511_create_plugins_table.php new file mode 100644 index 0000000..be3d070 --- /dev/null +++ b/Xboard/database/migrations/2025_01_18_140511_create_plugins_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('code')->unique(); + $table->string('version', 50); + $table->boolean('is_enabled')->default(false); + $table->json('config')->nullable(); + $table->timestamp('installed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('v2_plugins'); + } +}; diff --git a/Xboard/database/migrations/2025_06_21_000001_optimize_v2_settings_table.php b/Xboard/database/migrations/2025_06_21_000001_optimize_v2_settings_table.php new file mode 100644 index 0000000..affce15 --- /dev/null +++ b/Xboard/database/migrations/2025_06_21_000001_optimize_v2_settings_table.php @@ -0,0 +1,37 @@ +mediumText('value')->nullable()->change(); + // 添加优化索引 + $table->index('name', 'idx_setting_name'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('v2_settings', function (Blueprint $table) { + $table->string('value')->nullable()->change(); + $table->dropIndex('idx_setting_name'); + }); + } +} \ No newline at end of file diff --git a/Xboard/database/migrations/2025_06_21_000002_create_traffic_reset_logs_table.php b/Xboard/database/migrations/2025_06_21_000002_create_traffic_reset_logs_table.php new file mode 100644 index 0000000..2d12fc4 --- /dev/null +++ b/Xboard/database/migrations/2025_06_21_000002_create_traffic_reset_logs_table.php @@ -0,0 +1,43 @@ +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'); + } +} \ No newline at end of file diff --git a/Xboard/database/migrations/2025_06_21_000003_add_traffic_reset_fields_to_users.php b/Xboard/database/migrations/2025_06_21_000003_add_traffic_reset_fields_to_users.php new file mode 100644 index 0000000..004c666 --- /dev/null +++ b/Xboard/database/migrations/2025_06_21_000003_add_traffic_reset_fields_to_users.php @@ -0,0 +1,39 @@ +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'); + }); + } + + // Set initial reset time for existing users + Artisan::call('reset:traffic', ['--fix-null' => true]); + } + + /** + * 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']); + }); + } +} \ No newline at end of file diff --git a/Xboard/database/migrations/2025_07_01_081556_add_tags_to_v2_plan_table.php b/Xboard/database/migrations/2025_07_01_081556_add_tags_to_v2_plan_table.php new file mode 100644 index 0000000..7edf6d7 --- /dev/null +++ b/Xboard/database/migrations/2025_07_01_081556_add_tags_to_v2_plan_table.php @@ -0,0 +1,28 @@ +json('tags')->nullable()->after('content'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_plan', function (Blueprint $table) { + $table->dropColumn('tags'); + }); + } +}; diff --git a/Xboard/database/migrations/2025_07_01_122908_create_gift_card_tables.php b/Xboard/database/migrations/2025_07_01_122908_create_gift_card_tables.php new file mode 100644 index 0000000..290ce00 --- /dev/null +++ b/Xboard/database/migrations/2025_07_01_122908_create_gift_card_tables.php @@ -0,0 +1,98 @@ +id(); + $table->string('name')->comment('礼品卡名称'); + $table->text('description')->nullable()->comment('礼品卡描述'); + $table->tinyInteger('type')->comment('卡片类型:1余额 2有效期 3流量 4重置包 5套餐 6组合 7盲盒 8任务 9等级 10节日'); + $table->tinyInteger('status')->default(1)->comment('状态:0禁用 1启用'); + $table->json('conditions')->nullable()->comment('使用条件配置'); + $table->json('rewards')->comment('奖励配置'); + $table->json('limits')->nullable()->comment('限制条件'); + $table->json('special_config')->nullable()->comment('特殊配置(节日时间、等级倍率等)'); + $table->string('icon')->nullable()->comment('卡片图标'); + $table->string('background_image')->nullable()->comment('背景图片URL'); + $table->string('theme_color', 7)->default('#1890ff')->comment('主题色'); + $table->integer('sort')->default(0)->comment('排序'); + $table->integer('admin_id')->comment('创建管理员ID'); + $table->integer('created_at'); + $table->integer('updated_at'); + + $table->index(['type', 'status'], 'idx_gift_template_type_status'); + $table->index('created_at', 'idx_gift_template_created_at'); + }); + + // 礼品卡兑换码表 + Schema::create('v2_gift_card_code', function (Blueprint $table) { + $table->id(); + $table->integer('template_id')->comment('模板ID'); + $table->string('code', 32)->unique()->comment('兑换码'); + $table->string('batch_id', 32)->nullable()->comment('批次ID'); + $table->tinyInteger('status')->default(0)->comment('状态:0未使用 1已使用 2已过期 3已禁用'); + $table->integer('user_id')->nullable()->comment('使用用户ID'); + $table->integer('used_at')->nullable()->comment('使用时间'); + $table->integer('expires_at')->nullable()->comment('过期时间'); + $table->json('actual_rewards')->nullable()->comment('实际获得的奖励(用于盲盒等)'); + $table->integer('usage_count')->default(0)->comment('使用次数(分享卡)'); + $table->integer('max_usage')->default(1)->comment('最大使用次数'); + $table->json('metadata')->nullable()->comment('额外数据'); + $table->integer('created_at'); + $table->integer('updated_at'); + + $table->index('template_id', 'idx_gift_code_template_id'); + $table->index('status', 'idx_gift_code_status'); + $table->index('user_id', 'idx_gift_code_user_id'); + $table->index('batch_id', 'idx_gift_code_batch_id'); + $table->index('expires_at', 'idx_gift_code_expires_at'); + $table->index(['code', 'status', 'expires_at'], 'idx_gift_code_lookup'); + }); + + // 礼品卡使用记录表 + Schema::create('v2_gift_card_usage', function (Blueprint $table) { + $table->id(); + $table->integer('code_id')->comment('兑换码ID'); + $table->integer('template_id')->comment('模板ID'); + $table->integer('user_id')->comment('使用用户ID'); + $table->integer('invite_user_id')->nullable()->comment('邀请人ID'); + $table->json('rewards_given')->comment('实际发放的奖励'); + $table->json('invite_rewards')->nullable()->comment('邀请人获得的奖励'); + $table->integer('user_level_at_use')->nullable()->comment('使用时用户等级'); + $table->integer('plan_id_at_use')->nullable()->comment('使用时用户套餐ID'); + $table->decimal('multiplier_applied', 3, 2)->default(1.00)->comment('应用的倍率'); + $table->string('ip_address', 45)->nullable()->comment('使用IP地址'); + $table->text('user_agent')->nullable()->comment('用户代理'); + $table->text('notes')->nullable()->comment('备注'); + $table->integer('created_at'); + + $table->index('code_id', 'idx_gift_usage_code_id'); + $table->index('template_id', 'idx_gift_usage_template_id'); + $table->index('user_id', 'idx_gift_usage_user_id'); + $table->index('invite_user_id', 'idx_gift_usage_invite_user_id'); + $table->index('created_at', 'idx_gift_usage_created_at'); + $table->index(['user_id', 'created_at'], 'idx_gift_usage_user_usage'); + $table->index(['template_id', 'created_at'], 'idx_gift_usage_template_stats'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('v2_gift_card_usage'); + Schema::dropIfExists('v2_gift_card_code'); + Schema::dropIfExists('v2_gift_card_template'); + } +}; diff --git a/Xboard/database/migrations/2025_07_13_224539_add_column_rate_time_ranges_to_v2_server_table.php b/Xboard/database/migrations/2025_07_13_224539_add_column_rate_time_ranges_to_v2_server_table.php new file mode 100644 index 0000000..aa5ad62 --- /dev/null +++ b/Xboard/database/migrations/2025_07_13_224539_add_column_rate_time_ranges_to_v2_server_table.php @@ -0,0 +1,29 @@ +boolean('rate_time_enable')->default(false)->comment('是否启用动态倍率')->after('rate'); + $table->json('rate_time_ranges')->nullable()->comment('动态倍率规则')->after('rate_time_enable'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_server', function (Blueprint $table) { + $table->dropColumn('rate_time_enable'); + $table->dropColumn('rate_time_ranges'); + }); + } +}; diff --git a/Xboard/database/migrations/2025_07_26_000001_add_type_to_plugins_table.php b/Xboard/database/migrations/2025_07_26_000001_add_type_to_plugins_table.php new file mode 100644 index 0000000..b372621 --- /dev/null +++ b/Xboard/database/migrations/2025_07_26_000001_add_type_to_plugins_table.php @@ -0,0 +1,24 @@ +string('type', 20)->default('feature')->after('code')->comment('插件类型:feature功能性,payment支付型'); + $table->index(['type', 'is_enabled']); + }); + } + + public function down(): void + { + Schema::table('v2_plugins', function (Blueprint $table) { + $table->dropIndex(['type', 'is_enabled']); + $table->dropColumn('type'); + }); + } +}; \ No newline at end of file diff --git a/Xboard/database/migrations/2025_07_27_000001_create_v2_subscribe_templates_table.php b/Xboard/database/migrations/2025_07_27_000001_create_v2_subscribe_templates_table.php new file mode 100644 index 0000000..873a3df --- /dev/null +++ b/Xboard/database/migrations/2025_07_27_000001_create_v2_subscribe_templates_table.php @@ -0,0 +1,91 @@ +id(); + $table->string('name')->unique()->comment('Template key, e.g. singbox, clash'); + $table->mediumText('content')->nullable()->comment('Template content'); + $table->timestamps(); + }); + + $this->seedDefaults(); + } + + public function down(): void + { + Schema::dropIfExists('v2_subscribe_templates'); + } + + private function seedDefaults(): void + { + // Fallback order matches original protocol class behavior + $protocols = [ + 'singbox' => [ + 'resources/rules/custom.sing-box.json', + 'resources/rules/default.sing-box.json', + ], + 'clash' => [ + 'resources/rules/custom.clash.yaml', + 'resources/rules/default.clash.yaml', + ], + 'clashmeta' => [ + 'resources/rules/custom.clashmeta.yaml', + 'resources/rules/custom.clash.yaml', + 'resources/rules/default.clash.yaml', + ], + 'stash' => [ + 'resources/rules/custom.stash.yaml', + 'resources/rules/custom.clash.yaml', + 'resources/rules/default.clash.yaml', + ], + 'surge' => [ + 'resources/rules/custom.surge.conf', + 'resources/rules/default.surge.conf', + ], + 'surfboard' => [ + 'resources/rules/custom.surfboard.conf', + 'resources/rules/default.surfboard.conf', + ], + ]; + + foreach ($protocols as $name => $fileFallbacks) { + $existing = DB::table('v2_settings') + ->where('name', "subscribe_template_{$name}") + ->value('value'); + + if ($existing !== null && $existing !== '') { + $content = $existing; + } else { + $content = ''; + foreach ($fileFallbacks as $file) { + $path = base_path($file); + if (File::exists($path)) { + $content = File::get($path); + break; + } + } + } + + DB::table('v2_subscribe_templates')->insert([ + 'name' => $name, + 'content' => $content, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // Clean up old entries from v2_settings + DB::table('v2_settings') + ->where('name', 'like', 'subscribe_template_%') + ->delete(); + } +}; diff --git a/Xboard/database/migrations/2026_03_11_000001_replace_v2_log_with_admin_audit_log.php b/Xboard/database/migrations/2026_03_11_000001_replace_v2_log_with_admin_audit_log.php new file mode 100644 index 0000000..7f7b587 --- /dev/null +++ b/Xboard/database/migrations/2026_03_11_000001_replace_v2_log_with_admin_audit_log.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('admin_id')->index(); + $table->string('action', 64)->index()->comment('Action identifier e.g. user.update'); + $table->string('method', 10); + $table->string('uri', 512); + $table->text('request_data')->nullable(); + $table->string('ip', 128)->nullable(); + $table->unsignedInteger('created_at'); + $table->unsignedInteger('updated_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('v2_admin_audit_log'); + } +}; diff --git a/Xboard/database/migrations/2026_03_11_000002_add_stat_user_record_at_index.php b/Xboard/database/migrations/2026_03_11_000002_add_stat_user_record_at_index.php new file mode 100644 index 0000000..2438855 --- /dev/null +++ b/Xboard/database/migrations/2026_03_11_000002_add_stat_user_record_at_index.php @@ -0,0 +1,22 @@ +index(['record_at', 'user_id'], 'idx_stat_user_record_user'); + }); + } + + public function down(): void + { + Schema::table('v2_stat_user', function (Blueprint $table) { + $table->dropIndex('idx_stat_user_record_user'); + }); + } +}; diff --git a/Xboard/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php b/Xboard/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php new file mode 100644 index 0000000..6f67ff7 --- /dev/null +++ b/Xboard/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php @@ -0,0 +1,30 @@ +json('custom_outbounds')->nullable()->after('protocol_settings'); + $table->json('custom_routes')->nullable()->after('custom_outbounds'); + $table->json('cert_config')->nullable()->after('custom_routes'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_server', function (Blueprint $table) { + $table->dropColumn(['custom_outbounds', 'custom_routes', 'cert_config']); + }); + } +}; diff --git a/Xboard/database/migrations/2026_03_28_161536_add_traffic_fields_to_servers.php b/Xboard/database/migrations/2026_03_28_161536_add_traffic_fields_to_servers.php new file mode 100644 index 0000000..ca585b8 --- /dev/null +++ b/Xboard/database/migrations/2026_03_28_161536_add_traffic_fields_to_servers.php @@ -0,0 +1,46 @@ +bigInteger('transfer_enable') + ->default(null) + ->nullable() + ->after('rate') + ->comment('Traffic limit , 0 or null=no limit'); + } + if (!Schema::hasColumn('v2_server', 'u')) { + $table->bigInteger('u') + ->default(0) + ->after('transfer_enable') + ->comment('upload traffic'); + } + if (!Schema::hasColumn('v2_server', 'd')) { + $table->bigInteger('d') + ->default(0) + ->after('u') + ->comment('donwload traffic'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_server', function (Blueprint $table) { + $table->dropColumn(['transfer_enable', 'u', 'd']); + }); + } +}; diff --git a/Xboard/database/seeders/DatabaseSeeder.php b/Xboard/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..34b89fa --- /dev/null +++ b/Xboard/database/seeders/DatabaseSeeder.php @@ -0,0 +1,17 @@ +call(UsersTableSeeder::class) + } +} diff --git a/Xboard/database/seeders/OriginV2bMigrationsTableSeeder.php b/Xboard/database/seeders/OriginV2bMigrationsTableSeeder.php new file mode 100644 index 0000000..5643bb5 --- /dev/null +++ b/Xboard/database/seeders/OriginV2bMigrationsTableSeeder.php @@ -0,0 +1,170 @@ +insert(array ( + 0 => + array ( + 'id' => 1, + 'migration' => '2019_08_19_000000_create_failed_jobs_table', + 'batch' => 1, + ), + 1 => + array ( + 'id' => 2, + 'migration' => '2023_08_07_205816_create_v2_commission_log_table', + 'batch' => 1, + ), + 2 => + array ( + 'id' => 3, + 'migration' => '2023_08_07_205816_create_v2_coupon_table', + 'batch' => 1, + ), + 3 => + array ( + 'id' => 4, + 'migration' => '2023_08_07_205816_create_v2_invite_code_table', + 'batch' => 1, + ), + 4 => + array ( + 'id' => 5, + 'migration' => '2023_08_07_205816_create_v2_knowledge_table', + 'batch' => 1, + ), + 5 => + array ( + 'id' => 6, + 'migration' => '2023_08_07_205816_create_v2_log_table', + 'batch' => 1, + ), + 6 => + array ( + 'id' => 7, + 'migration' => '2023_08_07_205816_create_v2_mail_log_table', + 'batch' => 1, + ), + 7 => + array ( + 'id' => 8, + 'migration' => '2023_08_07_205816_create_v2_notice_table', + 'batch' => 1, + ), + 8 => + array ( + 'id' => 9, + 'migration' => '2023_08_07_205816_create_v2_order_table', + 'batch' => 1, + ), + 9 => + array ( + 'id' => 10, + 'migration' => '2023_08_07_205816_create_v2_payment_table', + 'batch' => 1, + ), + 10 => + array ( + 'id' => 11, + 'migration' => '2023_08_07_205816_create_v2_plan_table', + 'batch' => 1, + ), + 11 => + array ( + 'id' => 12, + 'migration' => '2023_08_07_205816_create_v2_server_group_table', + 'batch' => 1, + ), + 12 => + array ( + 'id' => 13, + 'migration' => '2023_08_07_205816_create_v2_server_hysteria_table', + 'batch' => 1, + ), + 13 => + array ( + 'id' => 14, + 'migration' => '2023_08_07_205816_create_v2_server_route_table', + 'batch' => 1, + ), + 14 => + array ( + 'id' => 15, + 'migration' => '2023_08_07_205816_create_v2_server_shadowsocks_table', + 'batch' => 1, + ), + 15 => + array ( + 'id' => 16, + 'migration' => '2023_08_07_205816_create_v2_server_trojan_table', + 'batch' => 1, + ), + 16 => + array ( + 'id' => 17, + 'migration' => '2023_08_07_205816_create_v2_server_vless_table', + 'batch' => 1, + ), + 17 => + array ( + 'id' => 18, + 'migration' => '2023_08_07_205816_create_v2_server_vmess_table', + 'batch' => 1, + ), + 18 => + array ( + 'id' => 19, + 'migration' => '2023_08_07_205816_create_v2_stat_server_table', + 'batch' => 1, + ), + 19 => + array ( + 'id' => 20, + 'migration' => '2023_08_07_205816_create_v2_stat_table', + 'batch' => 1, + ), + 20 => + array ( + 'id' => 21, + 'migration' => '2023_08_07_205816_create_v2_stat_user_table', + 'batch' => 1, + ), + 21 => + array ( + 'id' => 22, + 'migration' => '2023_08_07_205816_create_v2_ticket_message_table', + 'batch' => 1, + ), + 22 => + array ( + 'id' => 23, + 'migration' => '2023_08_07_205816_create_v2_ticket_table', + 'batch' => 1, + ), + 23 => + array ( + 'id' => 24, + 'migration' => '2023_08_07_205816_create_v2_user_table', + 'batch' => 1, + ) + )); + } +} \ No newline at end of file diff --git a/Xboard/docs/en/development/device-limit.md b/Xboard/docs/en/development/device-limit.md new file mode 100644 index 0000000..f56b9e7 --- /dev/null +++ b/Xboard/docs/en/development/device-limit.md @@ -0,0 +1,176 @@ +# Online Device Limit Design + +## Overview + +This document describes the design and implementation of the online device limit feature in Xboard. + +## Design Goals + +1. Accurate Control + - Precise counting of online devices + - Real-time monitoring of device status + - Accurate device identification + +2. Performance Optimization + - Minimal impact on system performance + - Efficient device tracking + - Optimized resource usage + +3. User Experience + - Smooth connection experience + - Clear error messages + - Graceful handling of limit exceeded cases + +## Implementation Details + +### 1. Device Identification + +#### Device ID Generation +```php +public function generateDeviceId($user, $request) { + return md5( + $user->id . + $request->header('User-Agent') . + $request->ip() + ); +} +``` + +#### Device Information Storage +```php +[ + 'device_id' => 'unique_device_hash', + 'user_id' => 123, + 'ip' => '192.168.1.1', + 'user_agent' => 'Mozilla/5.0...', + 'last_active' => '2024-03-21 10:00:00' +] +``` + +### 2. Connection Management + +#### Connection Check +```php +public function checkDeviceLimit($user, $deviceId) { + $onlineDevices = $this->getOnlineDevices($user->id); + + if (count($onlineDevices) >= $user->device_limit) { + if (!in_array($deviceId, $onlineDevices)) { + throw new DeviceLimitExceededException(); + } + } + + return true; +} +``` + +#### Device Status Update +```php +public function updateDeviceStatus($userId, $deviceId) { + Redis::hset( + "user:{$userId}:devices", + $deviceId, + json_encode([ + 'last_active' => now(), + 'status' => 'online' + ]) + ); +} +``` + +### 3. Cleanup Mechanism + +#### Inactive Device Cleanup +```php +public function cleanupInactiveDevices() { + $inactiveThreshold = now()->subMinutes(30); + + foreach ($this->getUsers() as $user) { + $devices = $this->getOnlineDevices($user->id); + + foreach ($devices as $deviceId => $info) { + if ($info['last_active'] < $inactiveThreshold) { + $this->removeDevice($user->id, $deviceId); + } + } + } +} +``` + +## Error Handling + +### Error Types +1. Device Limit Exceeded + ```php + class DeviceLimitExceededException extends Exception { + protected $message = 'Device limit exceeded'; + protected $code = 4001; + } + ``` + +2. Invalid Device + ```php + class InvalidDeviceException extends Exception { + protected $message = 'Invalid device'; + protected $code = 4002; + } + ``` + +### Error Messages +```php +return [ + 'device_limit_exceeded' => 'Maximum number of devices reached', + 'invalid_device' => 'Device not recognized', + 'device_expired' => 'Device session expired' +]; +``` + +## Performance Considerations + +1. Cache Strategy + - Use Redis for device tracking + - Implement cache expiration + - Optimize cache structure + +2. Database Operations + - Minimize database queries + - Use batch operations + - Implement query optimization + +3. Memory Management + - Efficient data structure + - Regular cleanup of expired data + - Memory usage monitoring + +## Security Measures + +1. Device Verification + - Validate device information + - Check for suspicious patterns + - Implement rate limiting + +2. Data Protection + - Encrypt sensitive information + - Implement access control + - Regular security audits + +## Future Improvements + +1. Enhanced Features + - Device management interface + - Device activity history + - Custom device names + +2. Performance Optimization + - Improved caching strategy + - Better cleanup mechanism + - Reduced memory usage + +3. Security Enhancements + - Advanced device fingerprinting + - Fraud detection + - Improved encryption + +## Conclusion + +This design provides a robust and efficient solution for managing online device limits while maintaining good performance and user experience. Regular monitoring and updates will ensure the system remains effective and secure. \ No newline at end of file diff --git a/Xboard/docs/en/development/performance.md b/Xboard/docs/en/development/performance.md new file mode 100644 index 0000000..3741d49 --- /dev/null +++ b/Xboard/docs/en/development/performance.md @@ -0,0 +1,100 @@ +# Performance Comparison Report + +## Test Environment + +### Hardware Configuration +- CPU: AMD EPYC 7K62 48-Core Processor +- Memory: 4GB +- Disk: NVMe SSD +- Network: 1Gbps + +### Software Environment +- OS: Ubuntu 22.04 LTS +- PHP: 8.2 +- MySQL: 5.7 +- Redis: 7.0 +- Docker: Latest stable version + +## Test Scenarios + +### 1. User Login Performance +- Concurrent users: 100 +- Test duration: 60 seconds +- Request type: POST +- Target endpoint: `/api/v1/passport/auth/login` + +Results: +- Average response time: 156ms +- 95th percentile: 245ms +- Maximum response time: 412ms +- Requests per second: 642 + +### 2. User Dashboard Loading +- Concurrent users: 100 +- Test duration: 60 seconds +- Request type: GET +- Target endpoint: `/api/v1/user/dashboard` + +Results: +- Average response time: 89ms +- 95th percentile: 167ms +- Maximum response time: 289ms +- Requests per second: 1121 + +### 3. Node List Query +- Concurrent users: 100 +- Test duration: 60 seconds +- Request type: GET +- Target endpoint: `/api/v1/user/server/nodes` + +Results: +- Average response time: 134ms +- 95th percentile: 223ms +- Maximum response time: 378ms +- Requests per second: 745 + +## Performance Optimization Measures + +1. Database Optimization + - Added indexes for frequently queried fields + - Optimized slow queries + - Implemented query caching + +2. Cache Strategy + - Using Redis for session storage + - Caching frequently accessed data + - Implementing cache warming + +3. Code Optimization + - Reduced database queries + - Optimized database connection pool + - Improved error handling + +## Comparison with Previous Version + +| Metric | Previous Version | Current Version | Improvement | +|--------|-----------------|-----------------|-------------| +| Login Response | 289ms | 156ms | 46% | +| Dashboard Loading | 178ms | 89ms | 50% | +| Node List Query | 256ms | 134ms | 48% | + +## Future Optimization Plans + +1. Infrastructure Level + - Implement horizontal scaling + - Add load balancing + - Optimize network configuration + +2. Application Level + - Further optimize database queries + - Implement more efficient caching strategies + - Reduce memory usage + +3. Monitoring and Maintenance + - Add performance monitoring + - Implement automatic scaling + - Regular performance testing + +## Conclusion + +The current version shows significant performance improvements compared to the previous version, with an average improvement of 48% in response times. The optimization measures implemented have effectively enhanced the system's performance and stability. \ No newline at end of file diff --git a/Xboard/docs/en/development/plugin-development-guide.md b/Xboard/docs/en/development/plugin-development-guide.md new file mode 100644 index 0000000..a5163c5 --- /dev/null +++ b/Xboard/docs/en/development/plugin-development-guide.md @@ -0,0 +1,691 @@ +# XBoard Plugin Development Guide + +## 📦 Plugin Structure + +Each plugin is an independent directory with the following structure: + +``` +plugins/ +└── YourPlugin/ # Plugin directory (PascalCase naming) + ├── Plugin.php # Main plugin class (required) + ├── config.json # Plugin configuration (required) + ├── routes/ + │ └── api.php # API routes + ├── Controllers/ # Controllers directory + │ └── YourController.php + ├── Commands/ # Artisan commands directory + │ └── YourCommand.php + └── README.md # Documentation +``` + +## 🚀 Quick Start + +### 1. Create Configuration File `config.json` + +```json +{ + "name": "My Plugin", + "code": "my_plugin", // Corresponds to plugin directory (lowercase + underscore) + "version": "1.0.0", + "description": "Plugin functionality description", + "author": "Author Name", + "require": { + "xboard": ">=1.0.0" // Version not fully implemented yet + }, + "config": { + "api_key": { + "type": "string", + "default": "", + "label": "API Key", + "description": "API Key" + }, + "timeout": { + "type": "number", + "default": 300, + "label": "Timeout (seconds)", + "description": "Timeout in seconds" + } + } +} +``` + +### 2. Create Main Plugin Class `Plugin.php` + +```php +filter('guest_comm_config', function ($config) { + $config['my_plugin_enable'] = true; + $config['my_plugin_setting'] = $this->getConfig('api_key', ''); + return $config; + }); + } +} +``` + +### 3. Create Controller + +**Recommended approach: Extend PluginController** + +```php +getConfig('api_key'); + $timeout = $this->getConfig('timeout', 300); + + // Your business logic... + + return $this->success(['message' => 'Success']); + } +} +``` + +### 4. Create Routes `routes/api.php` + +```php + 'api/v1/your-plugin' +], function () { + Route::post('/handle', [YourController::class, 'handle']); +}); +``` + +## 🔧 Configuration Access + +In controllers, you can easily access plugin configuration: + +```php +// Get single configuration +$value = $this->getConfig('key', 'default_value'); + +// Get all configurations +$allConfig = $this->getConfig(); + +// Check if plugin is enabled +$enabled = $this->isPluginEnabled(); +``` + +## 🎣 Hook System + +### Popular Hooks (Recommended to follow) + +XBoard has built-in hooks for many business-critical nodes. Plugin developers can flexibly extend through `filter` or `listen` methods. Here are the most commonly used and valuable hooks: + +| Hook Name | Type | Typical Parameters | Description | +| ------------------------- | ------ | ----------------------- | ---------------- | +| user.register.before | action | Request | Before user registration | +| user.register.after | action | User | After user registration | +| user.login.after | action | User | After user login | +| user.password.reset.after | action | User | After password reset | +| order.cancel.before | action | Order | Before order cancellation | +| order.cancel.after | action | Order | After order cancellation | +| payment.notify.before | action | method, uuid, request | Before payment callback | +| payment.notify.verified | action | array | Payment callback verification successful | +| payment.notify.failed | action | method, uuid, request | Payment callback verification failed | +| traffic.reset.after | action | User | After traffic reset | +| ticket.create.after | action | Ticket | After ticket creation | +| ticket.reply.user.after | action | Ticket | After user replies to ticket | +| ticket.close.after | action | Ticket | After ticket closure | + +> ⚡️ The hook system will continue to expand. Developers can always follow this documentation and the `php artisan hook:list` command to get the latest supported hooks. + +### Filter Hooks + +Used to modify data: + +```php +// In Plugin.php boot() method +$this->filter('guest_comm_config', function ($config) { + // Add configuration for frontend + $config['my_setting'] = $this->getConfig('setting'); + return $config; +}); +``` + +### Action Hooks + +Used to execute operations: + +```php +$this->listen('user.created', function ($user) { + // Operations after user creation + $this->doSomething($user); +}); +``` + +## 📝 Real Example: Telegram Login Plugin + +Using TelegramLogin plugin as an example to demonstrate complete implementation: + +**Main Plugin Class** (23 lines): + +```php +filter('guest_comm_config', function ($config) { + $config['telegram_login_enable'] = true; + $config['telegram_login_domain'] = $this->getConfig('domain', ''); + $config['telegram_bot_username'] = $this->getConfig('bot_username', ''); + return $config; + }); + } +} +``` + +**Controller** (extends PluginController): + +```php +class TelegramLoginController extends PluginController +{ + public function telegramLogin(Request $request) + { + // Check plugin status + if ($error = $this->beforePluginAction()) { + return $error[1]; + } + + // Get configuration + $botToken = $this->getConfig('bot_token'); + $timeout = $this->getConfig('auth_timeout', 300); + + // Business logic... + + return $this->success($result); + } +} +``` + +## ⏰ Plugin Scheduled Tasks (Scheduler) + +Plugins can register their own scheduled tasks by implementing the `schedule(Schedule $schedule)` method in the main class. + +**Example:** + +```php +use Illuminate\Console\Scheduling\Schedule; + +class Plugin extends AbstractPlugin +{ + public function schedule(Schedule $schedule): void + { + // Execute every hour + $schedule->call(function () { + // Your scheduled task logic + \Log::info('Plugin scheduled task executed'); + })->hourly(); + } +} +``` + +- Just implement the `schedule()` method in Plugin.php. +- All plugin scheduled tasks will be automatically scheduled by the main program. +- Supports all Laravel scheduler usage. + +## 🖥️ Plugin Artisan Commands + +Plugins can automatically register Artisan commands by creating command classes in the `Commands/` directory. + +### Command Directory Structure + +``` +plugins/YourPlugin/ +├── Commands/ +│ ├── TestCommand.php # Test command +│ ├── BackupCommand.php # Backup command +│ └── CleanupCommand.php # Cleanup command +``` + +### Create Command Class + +**Example: TestCommand.php** + +```php +argument('action'); + $message = $this->option('message'); + + try { + return match ($action) { + 'ping' => $this->ping($message), + 'info' => $this->showInfo(), + default => $this->showHelp() + }; + } catch (\Exception $e) { + $this->error('Operation failed: ' . $e->getMessage()); + return 1; + } + } + + protected function ping(string $message): int + { + $this->info("✅ {$message}"); + return 0; + } + + protected function showInfo(): int + { + $this->info('Plugin Information:'); + $this->table( + ['Property', 'Value'], + [ + ['Plugin Name', 'YourPlugin'], + ['Version', '1.0.0'], + ['Status', 'Enabled'], + ] + ); + return 0; + } + + protected function showHelp(): int + { + $this->info('Usage:'); + $this->line(' php artisan your-plugin:test ping --message="Hello" # Test'); + $this->line(' php artisan your-plugin:test info # Show info'); + return 0; + } +} +``` + +### Automatic Command Registration + +- ✅ Automatically register all commands in `Commands/` directory when plugin is enabled +- ✅ Command namespace automatically set to `Plugin\YourPlugin\Commands` +- ✅ Supports all Laravel command features (arguments, options, interaction, etc.) + +### Usage Examples + +```bash +# Test command +php artisan your-plugin:test ping --message="Hello World" + +# Show information +php artisan your-plugin:test info + +# View help +php artisan your-plugin:test --help +``` + +### Best Practices + +1. **Command Naming**: Use `plugin-name:action` format, e.g., `telegram:test` +2. **Error Handling**: Wrap main logic with try-catch +3. **Return Values**: Return 0 for success, 1 for failure +4. **User Friendly**: Provide clear help information and error messages +5. **Type Declarations**: Use PHP 8.2 type declarations + +## 🛠️ Development Tools + +### Controller Base Class Selection + +**Method 1: Extend PluginController (Recommended)** + +- Automatic configuration access: `$this->getConfig()` +- Automatic status checking: `$this->beforePluginAction()` +- Unified error handling + +**Method 2: Use HasPluginConfig Trait** + +```php +use App\Http\Controllers\Controller; +use App\Traits\HasPluginConfig; + +class YourController extends Controller +{ + use HasPluginConfig; + + public function handle() + { + $config = $this->getConfig('key'); + // ... + } +} +``` + +### Configuration Types + +Supported configuration types: + +- `string` - String +- `number` - Number +- `boolean` - Boolean +- `json` - Array +- `yaml` + +## 🎯 Best Practices + +### 1. Concise Main Class + +- Plugin main class should be as concise as possible +- Mainly used for registering hooks and routes +- Complex logic should be placed in controllers or services + +### 2. Configuration Management + +- Define all configuration items in `config.json` +- Use `$this->getConfig()` to access configuration +- Provide default values for all configurations + +### 3. Route Design + +- Use semantic route prefixes +- Place API routes in `routes/api.php` +- Place Web routes in `routes/web.php` + +### 4. Error Handling + +```php +public function handle(Request $request) +{ + // Check plugin status + if ($error = $this->beforePluginAction()) { + return $error[1]; + } + + try { + // Business logic + return $this->success($result); + } catch (\Exception $e) { + return $this->fail([500, $e->getMessage()]); + } +} +``` + +## 🔍 Debugging Tips + +### 1. Logging + +```php +\Log::info('Plugin operation', ['data' => $data]); +\Log::error('Plugin error', ['error' => $e->getMessage()]); +``` + +### 2. Configuration Checking + +```php +// Check required configuration +if (!$this->getConfig('required_key')) { + return $this->fail([400, 'Missing configuration']); +} +``` + +### 3. Development Mode + +```php +if (config('app.debug')) { + // Detailed debug information for development environment +} +``` + +## 📋 Plugin Lifecycle + +1. **Installation**: Validate configuration, register to database +2. **Enable**: Load plugin, register hooks and routes +3. **Running**: Handle requests, execute business logic + +## 🎉 Summary + +Based on TelegramLogin plugin practical experience: + +- **Simplicity**: Main class only 23 lines, focused on core functionality +- **Practicality**: Extends PluginController, convenient configuration access +- **Maintainability**: Clear directory structure, standard development patterns +- **Extensibility**: Hook-based architecture, easy to extend functionality + +Following this guide, you can quickly develop plugins with complete functionality and concise code! 🚀 + +## 🖥️ Complete Plugin Artisan Commands Guide + +### Feature Highlights + +✅ **Auto Registration**: Automatically register all commands in `Commands/` directory when plugin is enabled +✅ **Namespace Isolation**: Each plugin's commands use independent namespaces +✅ **Type Safety**: Support PHP 8.2 type declarations +✅ **Error Handling**: Comprehensive exception handling and error messages +✅ **Configuration Integration**: Commands can access plugin configuration +✅ **Interaction Support**: Support user input and confirmation operations + +### Real Case Demonstrations + +#### 1. Telegram Plugin Commands + +```bash +# Test Bot connection +php artisan telegram:test ping + +# Send message +php artisan telegram:test send --message="Hello World" + +# Get Bot information +php artisan telegram:test info +``` + +#### 2. TelegramExtra Plugin Commands + +```bash +# Show all statistics +php artisan telegram-extra:stats all + +# User statistics +php artisan telegram-extra:stats users + +# JSON format output +php artisan telegram-extra:stats users --format=json +``` + +#### 3. Example Plugin Commands + +```bash +# Basic usage +php artisan example:hello + +# With arguments and options +php artisan example:hello Bear --message="Welcome!" +``` + +### Development Best Practices + +#### 1. Command Naming Conventions + +```php +// ✅ Recommended: Use plugin name as prefix +protected $signature = 'telegram:test {action}'; +protected $signature = 'telegram-extra:stats {type}'; +protected $signature = 'example:hello {name}'; + +// ❌ Avoid: Use generic names +protected $signature = 'test {action}'; +protected $signature = 'stats {type}'; +``` + +#### 2. Error Handling Pattern + +```php +public function handle(): int +{ + try { + // Main logic + return $this->executeAction(); + } catch (\Exception $e) { + $this->error('Operation failed: ' . $e->getMessage()); + return 1; + } +} +``` + +#### 3. User Interaction + +```php +// Get user input +$chatId = $this->ask('Please enter chat ID'); + +// Confirm operation +if (!$this->confirm('Are you sure you want to execute this operation?')) { + $this->info('Operation cancelled'); + return 0; +} + +// Choose operation +$action = $this->choice('Choose operation', ['ping', 'send', 'info']); +``` + +#### 4. Configuration Access + +```php +// Access plugin configuration in commands +protected function getConfig(string $key, $default = null): mixed +{ + // Get plugin instance through PluginManager + $plugin = app(\App\Services\Plugin\PluginManager::class) + ->getEnabledPlugins()['example_plugin'] ?? null; + + return $plugin ? $plugin->getConfig($key, $default) : $default; +} +``` + +### Advanced Usage + +#### 1. Multi-Command Plugins + +```php +// One plugin can have multiple commands +plugins/YourPlugin/Commands/ +├── BackupCommand.php # Backup command +├── CleanupCommand.php # Cleanup command +├── StatsCommand.php # Statistics command +└── TestCommand.php # Test command +``` + +#### 2. Inter-Command Communication + +```php +// Share data between commands through cache or database +Cache::put('plugin:backup:progress', $progress, 3600); +$progress = Cache::get('plugin:backup:progress'); +``` + +#### 3. Scheduled Task Integration + +```php +// Call commands in plugin's schedule method +public function schedule(Schedule $schedule): void +{ + $schedule->command('your-plugin:backup')->daily(); + $schedule->command('your-plugin:cleanup')->weekly(); +} +``` + +### Debugging Tips + +#### 1. Command Testing + +```bash +# View command help +php artisan your-plugin:command --help + +# Verbose output +php artisan your-plugin:command --verbose + +# Debug mode +php artisan your-plugin:command --debug +``` + +#### 2. Logging + +```php +// Log in commands +Log::info('Plugin command executed', [ + 'command' => $this->signature, + 'arguments' => $this->arguments(), + 'options' => $this->options() +]); +``` + +#### 3. Performance Monitoring + +```php +// Record command execution time +$startTime = microtime(true); +// ... execution logic +$endTime = microtime(true); +$this->info("Execution time: " . round(($endTime - $startTime) * 1000, 2) . "ms"); +``` + +### Common Issues + +#### Q: Commands not showing in list? + +A: Check if plugin is enabled and ensure `Commands/` directory exists and contains valid command classes. + +#### Q: Command execution failed? + +A: Check if command class namespace is correct and ensure it extends `Illuminate\Console\Command`. + +#### Q: How to access plugin configuration? + +A: Get plugin instance through `PluginManager`, then call `getConfig()` method. + +#### Q: Can commands call other commands? + +A: Yes, use `Artisan::call()` method to call other commands. + +```php +Artisan::call('other-plugin:command', ['arg' => 'value']); +``` + +### Summary + +The plugin command system provides powerful extension capabilities for XBoard: + +- 🚀 **Development Efficiency**: Quickly create management commands +- 🔧 **Operational Convenience**: Automate daily operations +- 📊 **Monitoring Capability**: Real-time system status viewing +- 🛠️ **Debug Support**: Convenient problem troubleshooting tools + +By properly using plugin commands, you can greatly improve system maintainability and user experience! 🎉 diff --git a/Xboard/docs/en/installation/1panel.md b/Xboard/docs/en/installation/1panel.md new file mode 100644 index 0000000..1237444 --- /dev/null +++ b/Xboard/docs/en/installation/1panel.md @@ -0,0 +1,210 @@ +# Quick Deployment Guide for 1Panel + +This guide explains how to deploy Xboard using 1Panel. + +## 1. Environment Preparation + +Install 1Panel: +```bash +curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && \ +sudo bash quick_start.sh +``` + +## 2. Environment Configuration + +1. Install from App Store: + - OpenResty (any version) + - ⚠️ Check "External Port Access" to open firewall + - MySQL 5.7 (Use MariaDB for ARM architecture) + +2. Create Database: + - Database name: `xboard` + - Username: `xboard` + - Access rights: All hosts (%) + - Save the database password for installation + +## 3. Deployment Steps + +1. Add Website: + - Go to "Website" > "Create Website" > "Reverse Proxy" + - Domain: Enter your domain + - Code: `xboard` + - Proxy address: `127.0.0.1:7001` + +2. Configure Reverse Proxy: +```nginx +location /ws/ { + proxy_pass http://127.0.0.1:8076; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; +} + +location ^~ / { + proxy_pass http://127.0.0.1:7001; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-PORT $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header Server-Protocol $server_protocol; + proxy_set_header Server-Name $server_name; + proxy_set_header Server-Addr $server_addr; + proxy_set_header Server-Port $server_port; + proxy_cache off; +} +``` +> The `/ws/` location enables WebSocket real-time node synchronization via `ws-server`. This service is enabled by default and can be toggled in Admin Panel > System Settings > Server. + +3. Install Xboard: +```bash +# Enter site directory +cd /opt/1panel/apps/openresty/openresty/www/sites/xboard/index + +# Install Git (if not installed) +## Ubuntu/Debian +apt update && apt install -y git +## CentOS/RHEL +yum update && yum install -y git + +# Clone repository +git clone -b compose --depth 1 https://github.com/cedar2025/Xboard ./ + +# Configure Docker Compose +``` + +4. Edit compose.yaml: +```yaml +services: + web: + image: ghcr.io/cedar2025/xboard:new + volumes: + - redis-data:/data + - ./.env:/www/.env + - ./.docker/.data/:/www/.docker/.data + - ./storage/logs:/www/storage/logs + - ./storage/theme:/www/storage/theme + - ./plugins:/www/plugins + environment: + - docker=true + depends_on: + - redis + command: php artisan octane:start --host=0.0.0.0 --port=7001 + restart: on-failure + ports: + - 7001:7001 + networks: + - 1panel-network + + horizon: + image: ghcr.io/cedar2025/xboard:new + volumes: + - redis-data:/data + - ./.env:/www/.env + - ./.docker/.data/:/www/.docker/.data + - ./storage/logs:/www/storage/logs + - ./plugins:/www/plugins + restart: on-failure + command: php artisan horizon + networks: + - 1panel-network + depends_on: + - redis + ws-server: + image: ghcr.io/cedar2025/xboard:new + volumes: + - redis-data:/data + - ./.env:/www/.env + - ./.docker/.data/:/www/.docker/.data + - ./storage/logs:/www/storage/logs + - ./plugins:/www/plugins + restart: on-failure + ports: + - 8076:8076 + networks: + - 1panel-network + command: php artisan ws-server start + depends_on: + - redis + + redis: + image: redis:7-alpine + command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 + restart: unless-stopped + networks: + - 1panel-network + volumes: + - redis-data:/data + +volumes: + redis-data: + +networks: + 1panel-network: + external: true +``` + +5. Initialize Installation: +```bash +# Install dependencies and initialize +docker compose run -it --rm web php artisan xboard:install +``` + +⚠️ Important Configuration Notes: +1. Database Configuration + - Database Host: Choose based on your deployment: + 1. If database and Xboard are in the same network, use `mysql` + 2. If connection fails, go to: Database -> Select Database -> Connection Info -> Container Connection, and use the "Host" value + 3. If using external database, enter your actual database host + - Database Port: `3306` (default port unless configured otherwise) + - Database Name: `xboard` (the database created earlier) + - Database User: `xboard` (the user created earlier) + - Database Password: Enter the password saved earlier + +2. Redis Configuration + - Choose to use built-in Redis + - No additional configuration needed + +3. Administrator Information + - Save the admin credentials displayed after installation + - Note down the admin panel access URL + +After configuration, start the services: +```bash +docker compose up -d +``` + +6. Start Services: +```bash +docker compose up -d +``` + +## 4. Version Update + +> 💡 Important Note: The update command varies depending on your installation version: +> - If you installed recently (new version), use this command: +```bash +docker compose pull && \ +docker compose run -it --rm web php artisan xboard:update && \ +docker compose up -d +``` +> - If you installed earlier (old version), replace `web` with `xboard`: +```bash +docker compose pull && \ +docker compose run -it --rm xboard php artisan xboard:update && \ +docker compose up -d +``` +> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command. + +## Important Notes + +- ⚠️ Ensure firewall is enabled to prevent port 7001 exposure to public +- Service restart is required after code modifications +- SSL certificate configuration is recommended for secure access + +> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side. diff --git a/Xboard/docs/en/installation/aapanel-docker.md b/Xboard/docs/en/installation/aapanel-docker.md new file mode 100644 index 0000000..348ed98 --- /dev/null +++ b/Xboard/docs/en/installation/aapanel-docker.md @@ -0,0 +1,151 @@ +# Xboard Deployment Guide for aaPanel + Docker Environment + +## Table of Contents +1. [Requirements](#requirements) +2. [Quick Deployment](#quick-deployment) +3. [Detailed Configuration](#detailed-configuration) +4. [Maintenance Guide](#maintenance-guide) +5. [Troubleshooting](#troubleshooting) + +## Requirements + +### Hardware Requirements +- CPU: 1 core or above +- Memory: 2GB or above +- Storage: 10GB+ available space + +### Software Requirements +- Operating System: Ubuntu 20.04+ / CentOS 7+ / Debian 10+ +- Latest version of aaPanel +- Docker and Docker Compose +- Nginx (any version) +- MySQL 5.7+ + +## Quick Deployment + +### 1. Install aaPanel +```bash +curl -sSL https://www.aapanel.com/script/install_6.0_en.sh -o install_6.0_en.sh && \ +bash install_6.0_en.sh aapanel +``` + +### 2. Basic Environment Setup + +#### 2.1 Install Docker +```bash +# Install Docker +curl -sSL https://get.docker.com | bash + +# For CentOS systems, also run: +systemctl enable docker +systemctl start docker +``` + +#### 2.2 Install Required Components +In the aaPanel dashboard, install: +- Nginx (any version) +- MySQL 5.7 +- ⚠️ PHP and Redis are not required + +### 3. Site Configuration + +#### 3.1 Create Website +1. Navigate to: aaPanel > Website > Add site +2. Fill in the information: + - Domain: Enter your site domain + - Database: Select MySQL + - PHP Version: Select Pure Static + +#### 3.2 Deploy Xboard +```bash +# Enter site directory +cd /www/wwwroot/your-domain + +# Clean directory +chattr -i .user.ini +rm -rf .htaccess 404.html 502.html index.html .user.ini + +# Clone repository +git clone https://github.com/cedar2025/Xboard.git ./ + +# Prepare configuration file +cp compose.sample.yaml compose.yaml + +# Install dependencies and initialize +docker compose run -it --rm web sh init.sh +``` +> ⚠️ Please save the admin dashboard URL, username, and password shown after installation + +#### 3.3 Start Services +```bash +docker compose up -d +``` + +#### 3.4 Configure Reverse Proxy +Add the following content to your site configuration: +```nginx +location /ws/ { + proxy_pass http://127.0.0.1:8076; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; +} + +location ^~ / { + proxy_pass http://127.0.0.1:7001; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-PORT $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header Server-Protocol $server_protocol; + proxy_set_header Server-Name $server_name; + proxy_set_header Server-Addr $server_addr; + proxy_set_header Server-Port $server_port; + proxy_cache off; +} +``` +> The `/ws/` location enables real-time node synchronization via `ws-server`. This service is enabled by default and can be toggled in Admin Panel > System Settings > Server. + +## Maintenance Guide + +### Version Updates + +> 💡 Important Note: Update commands may vary depending on your installed version: +> - For recent installations (new version), use: +```bash +docker compose pull && \ +docker compose run -it --rm web sh update.sh && \ +docker compose up -d +``` +> - For older installations, replace `web` with `xboard`: +```bash +git config --global --add safe.directory $(pwd) +git fetch --all && git reset --hard origin/master && git pull origin master +docker compose pull && \ +docker compose run -it --rm xboard sh update.sh && \ +docker compose up -d +``` +> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command. + +### Routine Maintenance +- Regular log checking: `docker compose logs` +- Monitor system resource usage +- Regular backup of database and configuration files + +## Troubleshooting + +If you encounter any issues during installation or operation, please check: +1. **Empty Admin Dashboard**: If the admin panel is blank, run `git submodule update --init --recursive --force` to restore the theme files. +2. System requirements are met +3. All required ports are available +3. Docker services are running properly +4. Nginx configuration is correct +5. Check logs for detailed error messages + +> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side. diff --git a/Xboard/docs/en/installation/aapanel.md b/Xboard/docs/en/installation/aapanel.md new file mode 100644 index 0000000..7394bc1 --- /dev/null +++ b/Xboard/docs/en/installation/aapanel.md @@ -0,0 +1,210 @@ +# Xboard Deployment Guide for aaPanel Environment + +## Table of Contents +1. [Requirements](#requirements) +2. [Quick Deployment](#quick-deployment) +3. [Detailed Configuration](#detailed-configuration) +4. [Maintenance Guide](#maintenance-guide) +5. [Troubleshooting](#troubleshooting) + +## Requirements + +### Hardware Requirements +- CPU: 1 core or above +- Memory: 2GB or above +- Storage: 10GB+ available space + +### Software Requirements +- Operating System: Ubuntu 20.04+ / Debian 10+ (⚠️ CentOS 7 is not recommended) +- Latest version of aaPanel +- PHP 8.2 +- MySQL 5.7+ +- Redis +- Nginx (any version) + +## Quick Deployment + +### 1. Install aaPanel +```bash +URL=https://www.aapanel.com/script/install_6.0_en.sh && \ +if [ -f /usr/bin/curl ];then curl -ksSO "$URL" ;else wget --no-check-certificate -O install_6.0_en.sh "$URL";fi && \ +bash install_6.0_en.sh aapanel +``` + +### 2. Basic Environment Setup + +#### 2.1 Install LNMP Environment +In the aaPanel dashboard, install: +- Nginx (any version) +- MySQL 5.7 +- PHP 8.2 + +#### 2.2 Install PHP Extensions +Required PHP extensions: +- redis +- fileinfo +- swoole +- readline +- event +- mbstring + +#### 2.3 Enable Required PHP Functions +Functions that need to be enabled: +- putenv +- proc_open +- pcntl_alarm +- pcntl_signal + +### 3. Site Configuration + +#### 3.1 Create Website +1. Navigate to: aaPanel > Website > Add site +2. Fill in the information: + - Domain: Enter your site domain + - Database: Select MySQL + - PHP Version: Select 8.2 + +#### 3.2 Deploy Xboard +```bash +# Enter site directory +cd /www/wwwroot/your-domain + +# Clean directory +chattr -i .user.ini +rm -rf .htaccess 404.html 502.html index.html .user.ini + +# Clone repository +git clone https://github.com/cedar2025/Xboard.git ./ + +# Install dependencies +sh init.sh +``` + +#### 3.3 Configure Site +1. Set running directory to `/public` +2. Add rewrite rules: +```nginx +location /downloads { +} + +location / { + try_files $uri $uri/ /index.php$is_args$query_string; +} + +location ~ .*\.(js|css)?$ +{ + expires 1h; + error_log off; + access_log /dev/null; +} +``` + +## Detailed Configuration + +### 1. Configure Daemon Process +1. Install Supervisor +2. Add queue daemon process: + - Name: `Xboard` + - Run User: `www` + - Running Directory: Site directory + - Start Command: `php artisan horizon` + - Process Count: 1 + +### 2. Configure Scheduled Tasks +- Type: Shell Script +- Task Name: v2board +- Run User: www +- Frequency: 1 minute +- Script Content: `php /www/wwwroot/site-directory/artisan schedule:run` + +### 3. Octane Configuration (Optional) +#### 3.1 Add Octane Daemon Process +- Name: Octane +- Run User: www +- Running Directory: Site directory +- Start Command: `/www/server/php/82/bin/php artisan octane:start --port 7010` +- Process Count: 1 + +#### 3.2 Octane-specific Rewrite Rules +```nginx +location ~* \.(jpg|jpeg|png|gif|js|css|svg|woff2|woff|ttf|eot|wasm|json|ico)$ { +} + +location ~ .* { + proxy_pass http://127.0.0.1:7010; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-PORT $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header Server-Protocol $server_protocol; + proxy_set_header Server-Name $server_name; + proxy_set_header Server-Addr $server_addr; + proxy_set_header Server-Port $server_port; +} +``` + +## Maintenance Guide + +### Version Updates +```bash +# Enter site directory +cd /www/wwwroot/your-domain + +# Execute update script +git fetch --all && git reset --hard origin/master && git pull origin master +sh update.sh + +# If Octane is enabled, restart the daemon process +# aaPanel > App Store > Tools > Supervisor > Restart Octane +``` + +### Routine Maintenance +- Regular log checking +- Monitor system resource usage +- Regular backup of database and configuration files + +## Troubleshooting + +### Common Issues +1. **Empty Admin Dashboard**: If the admin panel is blank, run `git submodule update --init --recursive --force` to restore the theme files. +2. Changes to admin path require service restart to take effect +3. Any code changes after enabling Octane require restart to take effect +3. When PHP extension installation fails, check if PHP version is correct +4. For database connection failures, check database configuration and permissions + +## Enable WebSocket Real-time Sync (Optional) + +WebSocket enables real-time synchronization of configurations and user changes to nodes. + +### 1. Start WS Server + +Add a WebSocket daemon process in aaPanel Supervisor: +- Name: `Xboard-WS` +- Run User: `www` +- Running Directory: Site directory +- Start Command: `php artisan ws-server start` +- Process Count: 1 + +### 2. Configure Nginx + +Add the WebSocket location **before** the main `location ^~ /` block in your site's Nginx configuration: +```nginx +location /ws/ { + proxy_pass http://127.0.0.1:8076; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; +} +``` + +### 3. Restart Services + +Restart the Octane and WS Server processes in Supervisor. + +> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side. diff --git a/Xboard/docs/en/installation/docker-compose.md b/Xboard/docs/en/installation/docker-compose.md new file mode 100644 index 0000000..c05818b --- /dev/null +++ b/Xboard/docs/en/installation/docker-compose.md @@ -0,0 +1,77 @@ +# Quick Deployment Guide with Docker Compose + +This guide explains how to quickly deploy Xboard using Docker Compose. By default, it uses SQLite database, eliminating the need for a separate MySQL installation. + +### 1. Environment Preparation + +Install Docker: +```bash +curl -sSL https://get.docker.com | bash + +# For CentOS systems, also run: +systemctl enable docker +systemctl start docker +``` + +### 2. Deployment Steps + +1. Get project files: +```bash +git clone -b compose --depth 1 https://github.com/cedar2025/Xboard +cd Xboard +``` + +2. Install database: + +- Quick installation (Recommended for beginners) +```bash +docker compose run -it --rm \ + -e ENABLE_SQLITE=true \ + -e ENABLE_REDIS=true \ + -e ADMIN_ACCOUNT=admin@demo.com \ + web php artisan xboard:install +``` +- Custom configuration installation (Advanced users) +```bash +docker compose run -it --rm web php artisan xboard:install +``` +> Please save the admin dashboard URL, username, and password shown after installation + +3. Start services: +```bash +docker compose up -d +``` + +4. Access the site: +- Default port: 7001 +- Website URL: http://your-server-ip:7001 + +### 3. Version Updates + +> 💡 Important Note: Update commands may vary depending on your installed version: +> - For recent installations (new version), use: +```bash +cd Xboard +docker compose pull && \ +docker compose run -it --rm web php artisan xboard:update && \ +docker compose up -d +``` +> - For older installations, replace `web` with `xboard`: +```bash +cd Xboard +docker compose pull && \ +docker compose run -it --rm xboard php artisan xboard:update && \ +docker compose up -d +``` +> 🤔 Not sure which to use? Try the new version command first, if it fails, use the old version command. + +### 4. Version Rollback + +1. Modify the version number in `docker-compose.yaml` to the version you want to roll back to +2. Execute: `docker compose up -d` + +### Important Notes + +- If you need to use MySQL, please install it separately and redeploy +- Code changes require service restart to take effect +- You can configure Nginx reverse proxy to use port 80 \ No newline at end of file diff --git a/Xboard/docs/en/migration/config.md b/Xboard/docs/en/migration/config.md new file mode 100644 index 0000000..0a91fdd --- /dev/null +++ b/Xboard/docs/en/migration/config.md @@ -0,0 +1,54 @@ +# Configuration Migration Guide + +This guide explains how to migrate configuration files from v2board to Xboard. Xboard stores configurations in the database instead of files. + +### 1. Docker Compose Environment + +1. Prepare configuration file: +```bash +# Create config directory +mkdir config + +# Copy old configuration file +cp old-project-path/config/v2board.php config/ +``` + +2. Modify `docker-compose.yaml`, uncomment the following line: +```yaml +- ./config/v2board.php:/www/config/v2board.php +``` + +3. Execute migration: +```bash +docker compose run -it --rm web php artisan migrateFromV2b config +``` + +### 2. aaPanel Environment + +1. Copy configuration file: +```bash +cp old-project-path/config/v2board.php config/v2board.php +``` + +2. Execute migration: +```bash +php artisan migrateFromV2b config +``` + +### 3. aaPanel + Docker Environment + +1. Copy configuration file: +```bash +cp old-project-path/config/v2board.php config/v2board.php +``` + +2. Execute migration: +```bash +docker compose run -it --rm web php artisan migrateFromV2b config +``` + +### Important Notes + +- After modifying the admin path, service restart is required: + - Docker environment: `docker compose restart` + - aaPanel environment: Restart the Octane daemon process \ No newline at end of file diff --git a/Xboard/docs/en/migration/v2board-1.7.3.md b/Xboard/docs/en/migration/v2board-1.7.3.md new file mode 100644 index 0000000..06ecedf --- /dev/null +++ b/Xboard/docs/en/migration/v2board-1.7.3.md @@ -0,0 +1,63 @@ +# V2board 1.7.3 Migration Guide + +This guide explains how to migrate from V2board version 1.7.3 to Xboard. + +### 1. Database Changes Overview + +- `v2_stat_order` table renamed to `v2_stat`: + - `order_amount` → `order_total` + - `commission_amount` → `commission_total` + - New fields added: + - `paid_count` (integer, nullable) + - `paid_total` (integer, nullable) + - `register_count` (integer, nullable) + - `invite_count` (integer, nullable) + - `transfer_used_total` (string(32), nullable) + +- New tables added: + - `v2_log` + - `v2_server_hysteria` + - `v2_server_vless` + +### 2. Prerequisites + +⚠️ Please complete the basic Xboard installation first (SQLite not supported): +- [Docker Compose Deployment](../installation/docker-compose.md) +- [aaPanel + Docker Deployment](../installation/aapanel-docker.md) +- [aaPanel Deployment](../installation/aapanel.md) + +### 3. Migration Steps + +#### Docker Environment + +```bash +# 1. Stop services +docker compose down + +# 2. Clear database +docker compose run -it --rm web php artisan db:wipe + +# 3. Import old database (Important) +# Please manually import the V2board 1.7.3 database + +# 4. Execute migration +docker compose run -it --rm web php artisan migratefromv2b 1.7.3 +``` + +#### aaPanel Environment + +```bash +# 1. Clear database +php artisan db:wipe + +# 2. Import old database (Important) +# Please manually import the V2board 1.7.3 database + +# 3. Execute migration +php artisan migratefromv2b 1.7.3 +``` + +### 4. Configuration Migration + +After completing the data migration, you need to migrate the configuration file: +- [Configuration Migration Guide](./config.md) \ No newline at end of file diff --git a/Xboard/docs/en/migration/v2board-1.7.4.md b/Xboard/docs/en/migration/v2board-1.7.4.md new file mode 100644 index 0000000..46c1588 --- /dev/null +++ b/Xboard/docs/en/migration/v2board-1.7.4.md @@ -0,0 +1,51 @@ +# V2board 1.7.4 Migration Guide + +This guide explains how to migrate from V2board version 1.7.4 to Xboard. + +### 1. Database Changes Overview + +- New table added: + - `v2_server_vless` + +### 2. Prerequisites + +⚠️ Please complete the basic Xboard installation first (SQLite not supported): +- [Docker Compose Deployment](../installation/docker-compose.md) +- [aaPanel + Docker Deployment](../installation/aapanel-docker.md) +- [aaPanel Deployment](../installation/aapanel.md) + +### 3. Migration Steps + +#### Docker Environment + +```bash +# 1. Stop services +docker compose down + +# 2. Clear database +docker compose run -it --rm web php artisan db:wipe + +# 3. Import old database (Important) +# Please manually import the V2board 1.7.4 database + +# 4. Execute migration +docker compose run -it --rm web php artisan migratefromv2b 1.7.4 +``` + +#### aaPanel Environment + +```bash +# 1. Clear database +php artisan db:wipe + +# 2. Import old database (Important) +# Please manually import the V2board 1.7.4 database + +# 3. Execute migration +php artisan migratefromv2b 1.7.4 +``` + +### 4. Configuration Migration + +After completing the data migration, you need to migrate the configuration file: +- [Configuration Migration Guide](./config.md) \ No newline at end of file diff --git a/Xboard/docs/en/migration/v2board-dev.md b/Xboard/docs/en/migration/v2board-dev.md new file mode 100644 index 0000000..31621ad --- /dev/null +++ b/Xboard/docs/en/migration/v2board-dev.md @@ -0,0 +1,61 @@ +# V2board Dev Migration Guide + +This guide explains how to migrate from V2board Dev version (2023/10/27) to Xboard. + +⚠️ Please upgrade to version 2023/10/27 following the official guide before proceeding with migration. + +### 1. Database Changes Overview + +- `v2_order` table: + - Added `surplus_order_ids` (text, nullable) - Deduction orders + +- `v2_plan` table: + - Removed `daily_unit_price` - Affects period value + - Removed `transfer_unit_price` - Affects traffic value + +- `v2_server_hysteria` table: + - Removed `ignore_client_bandwidth` - Affects bandwidth configuration + - Removed `obfs_type` - Affects obfuscation type configuration + +### 2. Prerequisites + +⚠️ Please complete the basic Xboard installation first (SQLite not supported): +- [Docker Compose Deployment](../installation/docker-compose.md) +- [aaPanel + Docker Deployment](../installation/aapanel-docker.md) +- [aaPanel Deployment](../installation/aapanel.md) + +### 3. Migration Steps + +#### Docker Environment + +```bash +# 1. Stop services +docker compose down + +# 2. Clear database +docker compose run -it --rm web php artisan db:wipe + +# 3. Import old database (Important) +# Please manually import the V2board Dev database + +# 4. Execute migration +docker compose run -it --rm web php artisan migratefromv2b dev231027 +``` + +#### aaPanel Environment + +```bash +# 1. Clear database +php artisan db:wipe + +# 2. Import old database (Important) +# Please manually import the V2board Dev database + +# 3. Execute migration +php artisan migratefromv2b dev231027 +``` + +### 4. Configuration Migration + +After completing the data migration, you need to migrate the configuration file: +- [Configuration Migration Guide](./config.md) \ No newline at end of file diff --git a/Xboard/docs/images/admin.png b/Xboard/docs/images/admin.png new file mode 100644 index 0000000000000000000000000000000000000000..dd553ad4266af7e680f24bf7bfcc14e271b1ddd1 GIT binary patch literal 1423703 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 z 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

W-s+eus?I1Bg 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�b}+fDqJ=%})7hS4mR4Hl2^5!B>muW4o=p)q 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