first commit

This commit is contained in:
CN-JS-HuiBai
2026-04-07 16:54:24 +08:00
commit 2c6a38c80d
399 changed files with 42205 additions and 0 deletions

3
Xboard/.docker/.data/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!redis

2
Xboard/.docker/.data/redis/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -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

26
Xboard/.dockerignore Normal file
View File

@@ -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

15
Xboard/.editorconfig Normal file
View File

@@ -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

41
Xboard/.env.example Normal file
View File

@@ -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

5
Xboard/.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
CHANGELOG.md export-ignore

View File

@@ -0,0 +1,39 @@
---
name: 🐛 问题反馈 | Bug Report
about: 提交使用过程中遇到的问题 | Report an issue
title: "Bug Report"
labels: '🐛 bug'
assignees: ''
---
<!-- 🔴 请注意XrayR等非XBoard问题请前往相应项目提问 -->
<!-- 🔴 Note: For XrayR and other non-XBoard issues, please report to their respective projects -->
> ⚠️ 请务必按照模板填写完整信息没有详细描述的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**
<!-- storage/logs 目录下的日志 -->
```log
// 粘贴日志内容到这里
```

View File

@@ -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**
<!-- 其他补充说明或者参考示例 -->

View File

@@ -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

34
Xboard/.gitignore vendored Normal file
View File

@@ -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

3
Xboard/.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "public/assets/admin"]
path = public/assets/admin
url = https://github.com/cedar2025/xboard-admin-dist.git

47
Xboard/Dockerfile Normal file
View File

@@ -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"]

21
Xboard/LICENSE Normal file
View File

@@ -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.

100
Xboard/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Xboard
<div align="center">
[![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)
</div>
## 📖 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)

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Google\Cloud\Storage\StorageClient;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BackupDatabase extends Command
{
protected $signature = 'backup:database {upload?}';
protected $description = '备份数据库并上传到 Google Cloud Storage';
public function handle()
{
$isUpload = $this->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("2Mysql备份完成");
}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("2Sqlite备份完成");
}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);
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use App\Models\CommissionLog;
use Illuminate\Console\Command;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CheckCommission extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'check:commission';
/**
* The console command description.
*
* @var string
*/
protected $description = '返佣服务';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->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;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Jobs\OrderHandleJob;
use App\Services\OrderService;
use Illuminate\Console\Command;
use App\Models\Order;
use App\Models\User;
use App\Models\Plan;
use Illuminate\Support\Facades\DB;
class CheckOrder extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'check:order';
/**
* The console command description.
*
* @var string
*/
protected $description = '订单检查任务';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING])
->orderBy('created_at', 'ASC')
->lazyById(200)
->each(function ($order) {
OrderHandleJob::dispatch($order->trade_no);
});
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Services\ServerService;
use App\Services\TelegramService;
use App\Utils\CacheKey;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class CheckServer extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'check:server';
/**
* The console command description.
*
* @var string
*/
protected $description = '节点检查任务';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->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));
}
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Models\Ticket;
use Illuminate\Console\Command;
class CheckTicket extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'check:ticket';
/**
* The console command description.
*
* @var string
*/
protected $description = '工单检查任务';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
Ticket::where('status', 0)
->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();
});
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Models\Server;
use App\Models\User;
use App\Services\NodeSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CheckTrafficExceeded extends Command
{
protected $signature = 'check:traffic-exceeded';
protected $description = '检查流量超标用户并通知节点';
public function handle()
{
$count = Redis::scard('traffic:pending_check');
if ($count <= 0) {
return;
}
$pendingUserIds = array_map('intval', Redis::spop('traffic:pending_check', $count));
$exceededUsers = User::toBase()
->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.");
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Console\Command;
class ClearUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'clear:user';
/**
* The console command description.
*
* @var string
*/
protected $description = '清理用户';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$builder = User::where('plan_id', NULL)
->where('transfer_enable', 0)
->where('expired_at', 0)
->where('last_login_at', NULL);
$count = $builder->count();
if ($builder->delete()) {
$this->info("已删除{$count}位没有任何数据的用户");
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class HookList extends Command
{
protected $signature = 'hook:list';
protected $description = '列出系统支持的所有 hooks静态扫描代码';
public function handle()
{
$paths = [base_path('app'), base_path('plugins')];
$hooks = collect();
$pattern = '/HookManager::(call|filter|register|registerFilter)\([\'\"]([a-zA-Z0-9_.-]+)[\'\"]/';
foreach ($paths as $path) {
$files = collect(
is_dir($path) ? (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path))) : []
)->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);
}
}
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class MigrateFromV2b extends Command
{
protected $signature = 'migrateFromV2b {version?}';
protected $description = '供不同版本V2b迁移到本项目的脚本';
public function handle()
{
$version = $this->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('所有配置迁移完成');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\WebSocket\NodeWorker;
use Illuminate\Console\Command;
class NodeWebSocketServer extends Command
{
protected $signature = 'ws-server
{action=start : start | stop | restart | reload | status}
{--d : Start in daemon mode}
{--host=0.0.0.0 : Listen address}
{--port=8076 : Listen port}';
protected $description = 'Start the WebSocket server for node-panel synchronization';
public function handle(): void
{
global $argv;
$action = $this->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();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Models\AdminAuditLog;
use App\Models\StatServer;
use App\Models\StatUser;
use Illuminate\Console\Command;
class ResetLog extends Command
{
protected $builder;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reset:log';
/**
* The console command description.
*
* @var string
*/
protected $description = '清空日志';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
StatUser::where('record_at', '<', strtotime('-2 month', time()))->delete();
StatServer::where('record_at', '<', strtotime('-2 month', time()))->delete();
AdminAuditLog::where('created_at', '<', strtotime('-3 month', time()))->delete();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use App\Models\Plan;
use App\Utils\Helper;
use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ResetPassword extends Command
{
protected $builder;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reset:password {email} {password?}';
/**
* The console command description.
*
* @var string
*/
protected $description = '重置用户密码';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$password = $this->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},请尽快修改密码。");
}
}

View File

@@ -0,0 +1,289 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\TrafficResetLog;
use App\Services\TrafficResetService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ResetTraffic extends Command
{
protected $signature = 'reset:traffic {--fix-null : 修正模式重新计算next_reset_at为null的用户} {--force : 强制模式,重新计算所有用户的重置时间}';
protected $description = '流量重置 - 处理所有需要重置的用户';
public function __construct(
private readonly TrafficResetService $trafficResetService
) {
parent::__construct();
}
public function handle(): int
{
$fixNull = $this->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();
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Console\Commands;
use App\Models\Plan;
use App\Utils\Helper;
use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ResetUser extends Command
{
protected $builder;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reset:user';
/**
* The console command description.
*
* @var string
*/
protected $description = '重置所有用户信息';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if (!$this->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}的安全信息");
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Services\MailService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendRemindMail extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'send:remindMail
{--chunk-size=500 : 每批处理的用户数量}
{--force : 强制执行,跳过确认}';
/**
* The console command description.
*
* @var string
*/
protected $description = '发送提醒邮件';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
if (!admin_setting('remind_mail_enable', false)) {
$this->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]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class Test extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
}
}

View File

@@ -0,0 +1,397 @@
<?php
namespace App\Console\Commands;
use App\Services\Plugin\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
use App\Models\User;
use App\Utils\Helper;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\text;
use function Laravel\Prompts\note;
use function Laravel\Prompts\select;
use App\Models\Plugin;
use Illuminate\Support\Str;
class XboardInstall extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'xboard:install';
/**
* The console command description.
*
* @var string
*/
protected $description = 'xboard 初始化安装';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
try {
$isDocker = file_exists('/.dockerenv');
$enableSqlite = getenv('ENABLE_SQLITE', false);
$enableRedis = getenv('ENABLE_REDIS', false);
$adminAccount = getenv('ADMIN_ACCOUNT', false);
$this->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}]");
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class XboardRollback extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'xboard:rollback';
/**
* The console command description.
*
* @var string
*/
protected $description = 'xboard 回滚';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info('正在回滚数据库请稍等...');
\Artisan::call("migrate:rollback");
$this->info(\Artisan::output());
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Services\StatisticalService;
use Illuminate\Console\Command;
use App\Models\Stat;
use Illuminate\Support\Facades\Log;
class XboardStatistics extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'xboard:statistics';
/**
* The console command description.
*
* @var string
*/
protected $description = '统计任务';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$startAt = microtime(true);
ini_set('memory_limit', -1);
// $this->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]);
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Console\Commands;
use App\Services\ThemeService;
use App\Services\UpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use App\Services\Plugin\PluginManager;
use App\Models\Plugin;
use Illuminate\Support\Str;
use App\Console\Commands\XboardInstall;
class XboardUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'xboard:update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'xboard 更新';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->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('更新完毕,队列服务已重启,你无需进行任何操作。');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Console;
use App\Services\Plugin\PluginManager;
use App\Utils\CacheKey;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Facades\Cache;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule): void
{
Cache::put(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null), time());
// v2board
$schedule->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');
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Contracts;
interface PaymentInterface
{
public function form(): array;
public function pay($order): array;
public function notify($params);
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Exceptions;
use Exception;
class ApiException extends Exception
{
protected $code; // 错误码
protected $message; // 错误消息
protected $errors; // 全部错误信息
public function __construct($message = null, $code = 400, $errors = null)
{
$this->message = $message;
$this->code = $code;
$this->errors = $errors;
}
public function errors(){
return $this->errors;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
class BusinessException extends Exception
{
/**
* 业务异常构造函数
* @param array $codeResponse 状态码
* @param string $info 自定义返回信息不为空时会替换掉codeResponse 里面的message文字信息
*/
public function __construct(array $codeResponse, $info = '')
{
[$code, $message] = $codeResponse;
parent::__construct($info ?: $message, $code);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Exceptions;
use App\Helpers\ApiResponse;
use App\Services\Plugin\InterceptResponseException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Arr;
use Illuminate\View\ViewException;
use Throwable;
class Handler extends ExceptionHandler
{
use ApiResponse;
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<Throwable>>
*/
protected $dontReport = [
ApiException::class,
InterceptResponseException::class
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array<int, string>
*/
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."),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Helpers;
use App\Helpers\ResponseEnum;
use App\Exceptions\BusinessException;
use Illuminate\Http\JsonResponse;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
trait ApiResponse
{
/**
* 成功
* @param mixed $data
* @param array $codeResponse
* @return JsonResponse
*/
public function success($data = null, $codeResponse = ResponseEnum::HTTP_OK): JsonResponse
{
return $this->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);
}
}

View File

@@ -0,0 +1,82 @@
<?php
use App\Support\Setting;
if (!function_exists('admin_setting')) {
/**
* 获取或保存配置参数.
*
* @param string|array $key
* @param mixed $default
* @return App\Support\Setting|mixed
*/
function admin_setting($key = null, $default = null)
{
$setting = app(Setting::class);
if ($key === null) {
return $setting->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;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Helpers;
class ResponseEnum
{
// 001 ~ 099 表示系统状态100 ~ 199 表示授权业务200 ~ 299 表示用户业务
/*-------------------------------------------------------------------------------------------*/
// 100开头的表示 信息提示,这类状态表示临时的响应
// 100 - 继续
// 101 - 切换协议
/*-------------------------------------------------------------------------------------------*/
// 200表示服务器成功地接受了客户端请求
const HTTP_OK = [200001, '操作成功'];
const HTTP_ERROR = [200002, '操作失败'];
const HTTP_ACTION_COUNT_ERROR = [200302, '操作频繁'];
const USER_SERVICE_LOGIN_SUCCESS = [200200, '登录成功'];
const USER_SERVICE_LOGIN_ERROR = [200201, '登录失败'];
const USER_SERVICE_LOGOUT_SUCCESS = [200202, '退出登录成功'];
const USER_SERVICE_LOGOUT_ERROR = [200203, '退出登录失败'];
const USER_SERVICE_REGISTER_SUCCESS = [200104, '注册成功'];
const USER_SERVICE_REGISTER_ERROR = [200105, '注册失败'];
const USER_ACCOUNT_REGISTERED = [23001, '账号已注册'];
/*-------------------------------------------------------------------------------------------*/
// 300开头的表示服务器重定向,指向的别的地方,客户端浏览器必须采取更多操作来实现请求
// 302 - 对象已移动。
// 304 - 未修改。
// 307 - 临时重定向。
/*-------------------------------------------------------------------------------------------*/
// 400开头的表示客户端错误请求错误请求不到数据或者找不到等等
// 400 - 错误的请求
const CLIENT_NOT_FOUND_HTTP_ERROR = [400001, '请求失败'];
const CLIENT_PARAMETER_ERROR = [400200, '参数错误'];
const CLIENT_CREATED_ERROR = [400201, '数据已存在'];
const CLIENT_DELETED_ERROR = [400202, '数据不存在'];
// 401 - 访问被拒绝
const CLIENT_HTTP_UNAUTHORIZED = [401001, '授权失败,请先登录'];
const CLIENT_HTTP_UNAUTHORIZED_EXPIRED = [401200, '账号信息已过期,请重新登录'];
const CLIENT_HTTP_UNAUTHORIZED_BLACKLISTED = [401201, '账号在其他设备登录,请重新登录'];
// 403 - 禁止访问
// 404 - 没有找到文件或目录
const CLIENT_NOT_FOUND_ERROR = [404001, '没有找到该页面'];
// 405 - 用来访问本页面的 HTTP 谓词不被允许(方法不被允许)
const CLIENT_METHOD_HTTP_TYPE_ERROR = [405001, 'HTTP请求类型错误'];
// 406 - 客户端浏览器不接受所请求页面的 MIME 类型
// 407 - 要求进行代理身份验证
// 412 - 前提条件失败
// 413 请求实体太大
// 414 - 请求 URI 太长
// 415 不支持的媒体类型
// 416 所请求的范围无法满足
// 417 执行失败
// 423 锁定的错误
/*-------------------------------------------------------------------------------------------*/
// 500开头的表示服务器错误服务器因为代码或者什么原因终止运行
// 服务端操作错误码500 ~ 599 开头,后拼接 3 位
// 500 - 内部服务器错误
const SYSTEM_ERROR = [500001, '服务器错误'];
const SYSTEM_UNAVAILABLE = [500002, '服务器正在维护,暂不可用'];
const SYSTEM_CACHE_CONFIG_ERROR = [500003, '缓存配置错误'];
const SYSTEM_CACHE_MISSED_ERROR = [500004, '缓存未命中'];
const SYSTEM_CONFIG_ERROR = [500005, '系统配置错误'];
// 业务操作错误码(外部服务或内部服务调用)
const SERVICE_REGISTER_ERROR = [500101, '注册失败'];
const SERVICE_LOGIN_ERROR = [500102, '登录失败'];
const SERVICE_LOGIN_ACCOUNT_ERROR = [500103, '账号或密码错误'];
const SERVICE_USER_INTEGRAL_ERROR = [500200, '积分不足'];
//501 - 页眉值指定了未实现的配置
//502 - Web 服务器用作网关或代理服务器时收到了无效响应
//503 - 服务不可用。这个错误代码为 IIS 6.0 所专用
//504 - 网关超时
//505 - HTTP 版本不受支持
/*-------------------------------------------------------------------------------------------*/
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\ApiResponse;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use DispatchesJobs, ValidatesRequests, ApiResponse;
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Traits\HasPluginConfig;
/**
* 插件控制器基类
*
* 为所有插件控制器提供通用功能
*/
abstract class PluginController extends Controller
{
use HasPluginConfig;
/**
* 执行插件操作前的检查
*/
protected function beforePluginAction(): ?array
{
if (!$this->isPluginEnabled()) {
return [400, '插件未启用'];
}
return null;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\V1\Client;
use App\Http\Controllers\Controller;
use App\Services\ServerService;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml;
class AppController extends Controller
{
public function getConfig(Request $request)
{
$servers = [];
$user = $request->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);
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers\V1\Client;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Protocols\General;
use App\Services\Plugin\HookManager;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\Helper;
use Illuminate\Http\Request;
class ClientController extends Controller
{
/**
* Protocol prefix mapping for server names
*/
private const PROTOCOL_PREFIXES = [
'hysteria' => [
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'] ?? '');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\V1\Guest;
use App\Http\Controllers\Controller;
use App\Services\Plugin\HookManager;
use App\Utils\Dict;
use App\Utils\Helper;
use Illuminate\Support\Facades\Http;
class CommController extends Controller
{
public function config()
{
$data = [
'tos_url' => admin_setting('tos_url'),
'is_email_verify' => (int) admin_setting('email_verify', 0) ? 1 : 0,
'is_invite_force' => (int) admin_setting('invite_force', 0) ? 1 : 0,
'email_whitelist_suffix' => (int) admin_setting('email_whitelist_enable', 0)
? 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);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\V1\Guest;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Services\OrderService;
use App\Services\PaymentService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Services\Plugin\HookManager;
class PaymentController extends Controller
{
public function notify($method, $uuid, Request $request)
{
HookManager::call('payment.notify.before', [$method, $uuid, $request]);
try {
$paymentService = new PaymentService($method, null, $uuid);
$verify = $paymentService->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;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\V1\Guest;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlanResource;
use App\Models\Plan;
use App\Services\PlanService;
use Auth;
use Illuminate\Http\Request;
class PlanController extends Controller
{
protected $planService;
public function __construct(PlanService $planService)
{
$this->planService = $planService;
}
public function fetch(Request $request)
{
$plan = $this->planService->getAvailablePlans();
return $this->success(PlanResource::collection($plan));
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\V1\Guest;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Services\TelegramService;
use App\Services\UserService;
use Illuminate\Http\Request;
class TelegramController extends Controller
{
protected ?object $msg = null;
protected TelegramService $telegramService;
protected UserService $userService;
public function __construct(TelegramService $telegramService, UserService $userService)
{
$this->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);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Http\Controllers\V1\Passport;
use App\Helpers\ResponseEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Passport\AuthForget;
use App\Http\Requests\Passport\AuthLogin;
use App\Http\Requests\Passport\AuthRegister;
use App\Services\Auth\LoginService;
use App\Services\Auth\MailLinkService;
use App\Services\Auth\RegisterService;
use App\Services\AuthService;
use Illuminate\Http\Request;
class AuthController extends Controller
{
protected MailLinkService $mailLinkService;
protected RegisterService $registerService;
protected LoginService $loginService;
public function __construct(
MailLinkService $mailLinkService,
RegisterService $registerService,
LoginService $loginService
) {
$this->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);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\V1\Passport;
use App\Http\Controllers\Controller;
use App\Http\Requests\Passport\CommSendEmailVerify;
use App\Jobs\SendEmailJob;
use App\Models\InviteCode;
use App\Models\User;
use App\Services\CaptchaService;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class CommController extends Controller
{
public function sendEmailVerify(CommSendEmailVerify $request)
{
// 验证人机验证码
$captchaService = app(CaptchaService::class);
[$captchaValid, $captchaError] = $captchaService->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);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\V1\Server;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\ServerShadowsocks;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/*
* Tidal Lab Shadowsocks
* Github: https://github.com/tokumeikoi/tidalab-ss
*/
class ShadowsocksTidalabController extends Controller
{
// 后端获取用户
public function user(Request $request)
{
ini_set('memory_limit', -1);
$server = $request->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'
]);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\V1\Server;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\ServerTrojan;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/*
* Tidal Lab Trojan
* Github: https://github.com/tokumeikoi/tidalab-trojan
*/
class TrojanTidalabController extends Controller
{
const TROJAN_CONFIG = '{"run_type":"server","local_addr":"0.0.0.0","local_port":443,"remote_addr":"www.taobao.com","remote_port":80,"password":[],"ssl":{"cert":"server.crt","key":"server.key","sni":"domain.com"},"api":{"enabled":true,"api_addr":"127.0.0.1","api_port":10000}}';
// 后端获取用户
public function user(Request $request)
{
ini_set('memory_limit', -1);
$server = $request->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;
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\V1\Server;
use App\Http\Controllers\Controller;
use App\Services\DeviceStateService;
use App\Services\NodeSyncService;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
class UniProxyController extends Controller
{
public function __construct(
private readonly DeviceStateService $deviceStateService
) {
}
/**
* 获取当前请求的节点信息
*/
private function getNodeInfo(Request $request)
{
return $request->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"]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\Payment;
use App\Utils\Dict;
use Illuminate\Http\Request;
class CommController extends Controller
{
public function config()
{
$data = [
'is_telegram' => (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']);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Resources\CouponResource;
use App\Services\CouponService;
use Illuminate\Http\Request;
class CouponController extends Controller
{
public function check(Request $request)
{
if (empty($request->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()));
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\GiftCardCheckRequest;
use App\Http\Requests\User\GiftCardRedeemRequest;
use App\Models\GiftCardUsage;
use App\Services\GiftCardService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class GiftCardController extends Controller
{
/**
* 查询兑换码信息
*/
public function check(GiftCardCheckRequest $request)
{
try {
$giftCardService = new GiftCardService($request->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(),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Resources\ComissionLogResource;
use App\Http\Resources\InviteCodeResource;
use App\Models\CommissionLog;
use App\Models\InviteCode;
use App\Models\Order;
use App\Models\User;
use App\Utils\Helper;
use Illuminate\Http\Request;
class InviteController extends Controller
{
public function save(Request $request)
{
if (InviteCode::where('user_id', $request->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);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Resources\KnowledgeResource;
use App\Models\Knowledge;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Services\UserService;
use App\Utils\Helper;
use Illuminate\Http\Request;
class KnowledgeController extends Controller
{
private UserService $userService;
public function __construct(UserService $userService)
{
$this->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' => '/<!--access start-->(.*?)<!--access end-->/s',
'replacement' => '<div class="v2board-no-access">' . __('You must have a valid subscription to view content in this area') . '</div>'
]
];
$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);
}
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Http\Controllers\Controller;
use App\Models\Notice;
use Illuminate\Http\Request;
class NoticeController extends Controller
{
public function fetch(Request $request)
{
$current = $request->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
]);
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\OrderSave;
use App\Http\Resources\OrderResource;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Plan;
use App\Models\User;
use App\Services\CouponService;
use App\Services\OrderService;
use App\Services\PaymentService;
use App\Services\PlanService;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OrderController extends Controller
{
public function fetch(Request $request)
{
$request->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);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlanResource;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanService;
use Illuminate\Http\Request;
class PlanController extends Controller
{
protected PlanService $planService;
public function __construct(PlanService $planService)
{
$this->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));
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Http\Controllers\Controller;
use App\Http\Resources\NodeResource;
use App\Models\User;
use App\Services\ServerService;
use App\Services\UserService;
use Illuminate\Http\Request;
class ServerController extends Controller
{
public function fetch(Request $request)
{
$user = User::find($request->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}\"");
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Http\Controllers\Controller;
use App\Http\Resources\TrafficLogResource;
use App\Models\StatUser;
use App\Services\StatisticalService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StatController extends Controller
{
public function getTrafficLog(Request $request)
{
$startDate = now()->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);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\TelegramService;
use Illuminate\Http\Request;
class TelegramController extends Controller
{
public function getBotInfo()
{
$telegramService = new TelegramService();
$response = $telegramService->getMe();
$data = [
'username' => $response->result->username
];
return $this->success($data);
}
public function unbind(Request $request)
{
$user = User::where('user_id', $request->user()->id)->first();
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\TicketSave;
use App\Http\Requests\User\TicketWithdraw;
use App\Http\Resources\TicketResource;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\TicketService;
use App\Utils\Dict;
use Illuminate\Http\Request;
use App\Services\Plugin\HookManager;
use Illuminate\Support\Facades\Log;
class TicketController extends Controller
{
public function fetch(Request $request)
{
if ($request->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);
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Http\Controllers\V1\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\UserChangePassword;
use App\Http\Requests\User\UserTransfer;
use App\Http\Requests\User\UserUpdate;
use App\Models\Order;
use App\Models\Plan;
use App\Models\Ticket;
use App\Models\User;
use App\Services\Auth\LoginService;
use App\Services\AuthService;
use App\Services\Plugin\HookManager;
use App\Services\UserService;
use App\Utils\CacheKey;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
protected $loginService;
public function __construct(
LoginService $loginService
) {
$this->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);
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ConfigSave;
use App\Models\SubscribeTemplate;
use App\Services\MailService;
use App\Services\TelegramService;
use App\Services\ThemeService;
use App\Utils\Dict;
use Illuminate\Http\Request;
class ConfigController extends Controller
{
public function getEmailTemplate()
{
$path = resource_path('views/mail/');
$files = array_map(function ($item) use ($path) {
return str_replace($path, '', $item);
}, glob($path . '*'));
return $this->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';
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CouponGenerate;
use App\Http\Requests\Admin\CouponSave;
use App\Models\Coupon;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CouponController extends Controller
{
private function applyFiltersAndSorts(Request $request, $builder)
{
if ($request->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);
}
}

View File

@@ -0,0 +1,622 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\GiftCardCode;
use App\Models\GiftCardTemplate;
use App\Models\GiftCardUsage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\DB;
class GiftCardController extends Controller
{
/**
* 获取礼品卡模板列表
*/
public function templates(Request $request)
{
$request->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, '删除失败']);
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\KnowledgeSave;
use App\Http\Requests\Admin\KnowledgeSort;
use App\Models\Knowledge;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class KnowledgeController extends Controller
{
public function fetch(Request $request)
{
if ($request->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);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\NoticeSave;
use App\Models\Notice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class NoticeController extends Controller
{
public function fetch(Request $request)
{
return $this->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, '排序保存失败']);
}
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\OrderAssign;
use App\Http\Requests\Admin\OrderUpdate;
use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
use App\Services\OrderService;
use App\Services\PlanService;
use App\Services\UserService;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class OrderController extends Controller
{
public function detail(Request $request)
{
$order = Order::with(['user', 'plan', 'commission_log', 'invite_user'])->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);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\Payment;
use App\Services\PaymentService;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
public function getPaymentMethods()
{
$methods = [];
$pluginMethods = PaymentService::getAllPaymentMethodNames();
$methods = array_merge($methods, $pluginMethods);
return $this->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);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\PlanSave;
use App\Models\Order;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PlanController extends Controller
{
public function fetch(Request $request)
{
$plans = Plan::orderBy('sort', 'ASC')
->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);
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\Plugin;
use App\Services\Plugin\PluginManager;
use App\Services\Plugin\PluginConfigService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class PluginController extends Controller
{
protected PluginManager $pluginManager;
protected PluginConfigService $configService;
public function __construct(
PluginManager $pluginManager,
PluginConfigService $configService
) {
$this->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);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\V2\Admin\Server;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class GroupController extends Controller
{
public function fetch(Request $request): JsonResponse
{
$serverGroups = ServerGroup::query()
->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());
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace App\Http\Controllers\V2\Admin\Server;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ServerSave;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Services\ServerService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ManageController extends Controller
{
public function getNodes(Request $request)
{
$servers = ServerService::getAllServers()->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);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\V2\Admin\Server;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\ServerRoute;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class RouteController extends Controller
{
public function fetch(Request $request)
{
$routes = ServerRoute::get();
return [
'data' => $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
];
}
}

View File

@@ -0,0 +1,508 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\CommissionLog;
use App\Models\Order;
use App\Models\Server;
use App\Models\Stat;
use App\Models\StatServer;
use App\Models\StatUser;
use App\Models\Ticket;
use App\Models\User;
use App\Services\StatisticalService;
use Illuminate\Http\Request;
class StatController extends Controller
{
private $service;
public function __construct(StatisticalService $service)
{
$this->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
];
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\AdminAuditLog;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Laravel\Horizon\Contracts\JobRepository;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\Contracts\MetricsRepository;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Laravel\Horizon\Contracts\WorkloadRepository;
use Laravel\Horizon\WaitTimeCalculator;
use App\Helpers\ResponseEnum;
class SystemController extends Controller
{
public function getSystemStatus()
{
$data = [
'schedule' => $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,
]);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Services\ThemeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class ThemeController extends Controller
{
private $themeService;
public function __construct(ThemeService $themeService)
{
$this->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);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\Ticket;
use App\Services\TicketService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
class TicketController extends Controller
{
private function applyFiltersAndSorts(Request $request, $builder)
{
if ($request->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
]);
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\TrafficResetLog;
use App\Services\TrafficResetService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
/**
* 流量重置管理控制器
*/
class TrafficResetController extends Controller
{
private TrafficResetService $trafficResetService;
public function __construct(TrafficResetService $trafficResetService)
{
$this->trafficResetService = $trafficResetService;
}
/**
* 获取流量重置日志列表
*/
public function logs(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'nullable|integer',
'user_email' => 'nullable|string',
'reset_type' => 'nullable|string|in:' . implode(',', array_keys(TrafficResetLog::getResetTypeNames())),
'trigger_source' => 'nullable|string|in:' . implode(',', array_keys(TrafficResetLog::getSourceNames())),
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'per_page' => 'nullable|integer|min:1|max:10000',
'page' => 'nullable|integer|min:1',
]);
$query = TrafficResetLog::with(['user:id,email'])
->orderBy('reset_time', 'desc');
// 筛选条件
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('user_email')) {
$query->whereHas('user', function ($query) use ($request) {
$query->where('email', 'like', '%' . $request->user_email . '%');
});
}
if ($request->filled('reset_type')) {
$query->where('reset_type', $request->reset_type);
}
if ($request->filled('trigger_source')) {
$query->where('trigger_source', $request->trigger_source);
}
if ($request->filled('start_date')) {
$query->where('reset_time', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('reset_time', '<=', $request->end_date . ' 23:59:59');
}
$perPage = $request->get('per_page', 20);
$logs = $query->paginate($perPage);
// 格式化数据
$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<int, \App\Models\TrafficResetLog> $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,
]
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Services\UpdateService;
use Illuminate\Http\Request;
class UpdateController extends Controller
{
protected $updateService;
public function __construct(UpdateService $updateService)
{
$this->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']]);
}
}

View File

@@ -0,0 +1,682 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UserGenerate;
use App\Http\Requests\Admin\UserSendMail;
use App\Http\Requests\Admin\UserUpdate;
use App\Jobs\SendEmailJob;
use App\Models\Plan;
use App\Models\User;
use App\Services\AuthService;
use App\Services\NodeSyncService;
use App\Services\UserService;
use App\Traits\QueryOperators;
use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class UserController extends Controller
{
use QueryOperators;
public function resetSecret(Request $request)
{
$user = User::find($request->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, '删除失败']);
}
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\V2\Client;
use App\Http\Controllers\Controller;
use App\Services\ServerService;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml;
class AppController extends Controller
{
public function getConfig(Request $request)
{
$config = [
'app_info' => [
'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);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\V2\Server;
use App\Http\Controllers\Controller;
use App\Services\DeviceStateService;
use App\Services\ServerService;
use App\Services\UserService;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Log;
class ServerController extends Controller
{
/**
* server handshake api
*/
public function handshake(Request $request): JsonResponse
{
$websocket = ['enabled' => 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]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string>
*/
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<string, array<int, class-string|string>>
*/
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<string, class-string>
*/
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<class-string>
*/
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,
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Exceptions\ApiException;
use Illuminate\Support\Facades\Auth;
use Closure;
use App\Models\User;
class Admin
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
/** @var User|null $user */
$user = Auth::guard('sanctum')->user();
if (!$user || !$user->is_admin) {
return response()->json(['message' => 'Unauthorized'], 403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
class ApplyRuntimeSettings
{
public function handle(Request $request, Closure $next)
{
$appUrl = admin_setting('app_url');
if (is_string($appUrl) && $appUrl !== '') {
URL::forceRootUrl($appUrl);
}
if ((bool) admin_setting('force_https', false)) {
URL::forceScheme('https');
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : null;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
class CheckForMaintenanceMode extends PreventRequestsDuringMaintenance
{
/**
* 维护模式白名单URI
* @var array<int, string>
*/
protected $except = [
// 示例:
// '/api/health-check',
// '/status'
];
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Exceptions\ApiException;
use Closure;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
class Client
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$token = $request->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);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* 不需要加密的Cookie名称列表
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class EnsureTransactionState
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
try {
return $next($request);
} finally {
// Rollback any stale transactions to ensure a clean state for the next request.
// This is crucial for long-running processes like Octane.
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
class ForceJson
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $guard
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
$request->headers->set('accept', 'application/json');
return $next($request);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use App\Services\Plugin\PluginManager;
use Closure;
use Illuminate\Http\Request;
/**
* Middleware to initialize all enabled plugins at the beginning of a request.
* It ensures that all plugin hooks, routes, and services are ready.
*/
class InitializePlugins
{
protected PluginManager $pluginManager;
public function __construct(PluginManager $pluginManager)
{
$this->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);
}
}

Some files were not shown because too many files have changed in this diff Show More