first commit
This commit is contained in:
3
Xboard/.docker/.data/.gitignore
vendored
Normal file
3
Xboard/.docker/.data/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!redis
|
||||
2
Xboard/.docker/.data/redis/.gitignore
vendored
Normal file
2
Xboard/.docker/.data/redis/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
81
Xboard/.docker/supervisor/supervisord.conf
Normal file
81
Xboard/.docker/supervisor/supervisord.conf
Normal 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
26
Xboard/.dockerignore
Normal 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
15
Xboard/.editorconfig
Normal 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
41
Xboard/.env.example
Normal 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
5
Xboard/.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
* text=auto
|
||||
*.css linguist-vendored
|
||||
*.scss linguist-vendored
|
||||
*.js linguist-vendored
|
||||
CHANGELOG.md export-ignore
|
||||
39
Xboard/.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
39
Xboard/.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal 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
|
||||
// 粘贴日志内容到这里
|
||||
```
|
||||
28
Xboard/.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
28
Xboard/.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal 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**
|
||||
<!-- 其他补充说明或者参考示例 -->
|
||||
100
Xboard/.github/workflows/docker-publish.yml
vendored
Normal file
100
Xboard/.github/workflows/docker-publish.yml
vendored
Normal 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
34
Xboard/.gitignore
vendored
Normal 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
3
Xboard/.gitmodules
vendored
Normal 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
47
Xboard/Dockerfile
Normal 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
21
Xboard/LICENSE
Normal 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
100
Xboard/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Xboard
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://t.me/XboardOfficial)
|
||||

|
||||

|
||||
[](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
|
||||

|
||||
|
||||

|
||||
|
||||
## ⚠️ 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
|
||||
|
||||
[](https://starchart.cc/cedar2025/Xboard)
|
||||
100
Xboard/app/Console/Commands/BackupDatabase.php
Normal file
100
Xboard/app/Console/Commands/BackupDatabase.php
Normal 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("2️⃣:Mysql备份完成");
|
||||
}elseif(config('database.default') === 'sqlite'){
|
||||
$databaseBackupPath = storage_path('backup/' . now()->format('Y-m-d_H-i-s') . '_sqlite' . '_database_backup.sql');
|
||||
$this->info("1️⃣:开始备份Sqlite");
|
||||
\Spatie\DbDumper\Databases\Sqlite::create()
|
||||
->setDbName(config('database.connections.sqlite.database'))
|
||||
->dumpToFile($databaseBackupPath);
|
||||
$this->info("2️⃣:Sqlite备份完成");
|
||||
}else{
|
||||
$this->error('备份失败,你的数据库不是sqlite或者mysql');
|
||||
return;
|
||||
}
|
||||
$this->info('3️⃣:开始压缩备份文件');
|
||||
// 使用 gzip 压缩备份文件
|
||||
$compressedBackupPath = $databaseBackupPath . '.gz';
|
||||
$gzipCommand = new Process(["gzip", "-c", $databaseBackupPath]);
|
||||
$gzipCommand->run();
|
||||
|
||||
// 检查压缩是否成功
|
||||
if ($gzipCommand->isSuccessful()) {
|
||||
// 压缩成功,你可以删除原始备份文件
|
||||
file_put_contents($compressedBackupPath, $gzipCommand->getOutput());
|
||||
$this->info('4️⃣:文件压缩成功');
|
||||
unlink($databaseBackupPath);
|
||||
} else {
|
||||
// 压缩失败,处理错误
|
||||
echo $gzipCommand->getErrorOutput();
|
||||
$this->error('😔:文件压缩失败');
|
||||
unlink($databaseBackupPath);
|
||||
return;
|
||||
}
|
||||
if (!$isUpload){
|
||||
$this->info("🎉:数据库成功备份到:$compressedBackupPath");
|
||||
}else{
|
||||
// 传到云盘
|
||||
$this->info("5️⃣:开始将备份上传到Google Cloud");
|
||||
// Google Cloud Storage 配置
|
||||
$storage = new StorageClient([
|
||||
'keyFilePath' => config('cloud_storage.google_cloud.key_file'),
|
||||
]);
|
||||
$bucket = $storage->bucket(config('cloud_storage.google_cloud.storage_bucket'));
|
||||
$objectName = 'backup/' . now()->format('Y-m-d_H-i-s') . '_database_backup.sql.gz';
|
||||
// 上传文件
|
||||
$bucket->upload(fopen($compressedBackupPath, 'r'), [
|
||||
'name' => $objectName,
|
||||
]);
|
||||
|
||||
// 输出文件链接
|
||||
Log::channel('backup')->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName");
|
||||
$this->info("🎉:数据库备份已上传到 Google Cloud Storage: $objectName");
|
||||
File::delete($compressedBackupPath);
|
||||
}
|
||||
}catch(\Exception $e){
|
||||
Log::channel('backup')->error("😔:数据库备份失败 \n" . $e);
|
||||
$this->error("😔:数据库备份失败\n" . $e);
|
||||
File::delete($compressedBackupPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
Xboard/app/Console/Commands/CheckCommission.php
Normal file
129
Xboard/app/Console/Commands/CheckCommission.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
53
Xboard/app/Console/Commands/CheckOrder.php
Normal file
53
Xboard/app/Console/Commands/CheckOrder.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
64
Xboard/app/Console/Commands/CheckServer.php
Normal file
64
Xboard/app/Console/Commands/CheckServer.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Xboard/app/Console/Commands/CheckTicket.php
Normal file
51
Xboard/app/Console/Commands/CheckTicket.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
63
Xboard/app/Console/Commands/CheckTrafficExceeded.php
Normal file
63
Xboard/app/Console/Commands/CheckTrafficExceeded.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
51
Xboard/app/Console/Commands/ClearUser.php
Normal file
51
Xboard/app/Console/Commands/ClearUser.php
Normal 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}位没有任何数据的用户");
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Xboard/app/Console/Commands/HookList.php
Normal file
42
Xboard/app/Console/Commands/HookList.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
186
Xboard/app/Console/Commands/MigrateFromV2b.php
Normal file
186
Xboard/app/Console/Commands/MigrateFromV2b.php
Normal 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('所有配置迁移完成');
|
||||
}
|
||||
}
|
||||
34
Xboard/app/Console/Commands/NodeWebSocketServer.php
Normal file
34
Xboard/app/Console/Commands/NodeWebSocketServer.php
Normal 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();
|
||||
}
|
||||
}
|
||||
48
Xboard/app/Console/Commands/ResetLog.php
Normal file
48
Xboard/app/Console/Commands/ResetLog.php
Normal 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();
|
||||
}
|
||||
}
|
||||
55
Xboard/app/Console/Commands/ResetPassword.php
Normal file
55
Xboard/app/Console/Commands/ResetPassword.php
Normal 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},请尽快修改密码。");
|
||||
}
|
||||
}
|
||||
289
Xboard/app/Console/Commands/ResetTraffic.php
Normal file
289
Xboard/app/Console/Commands/ResetTraffic.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
58
Xboard/app/Console/Commands/ResetUser.php
Normal file
58
Xboard/app/Console/Commands/ResetUser.php
Normal 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}的安全信息");
|
||||
}
|
||||
}
|
||||
}
|
||||
103
Xboard/app/Console/Commands/SendRemindMail.php
Normal file
103
Xboard/app/Console/Commands/SendRemindMail.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
41
Xboard/app/Console/Commands/Test.php
Normal file
41
Xboard/app/Console/Commands/Test.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
397
Xboard/app/Console/Commands/XboardInstall.php
Normal file
397
Xboard/app/Console/Commands/XboardInstall.php
Normal 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}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Xboard/app/Console/Commands/XboardRollback.php
Normal file
45
Xboard/app/Console/Commands/XboardRollback.php
Normal 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());
|
||||
}
|
||||
}
|
||||
75
Xboard/app/Console/Commands/XboardStatistics.php
Normal file
75
Xboard/app/Console/Commands/XboardStatistics.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Xboard/app/Console/Commands/XboardUpdate.php
Normal file
65
Xboard/app/Console/Commands/XboardUpdate.php
Normal 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('更新完毕,队列服务已重启,你无需进行任何操作。');
|
||||
}
|
||||
}
|
||||
68
Xboard/app/Console/Kernel.php
Normal file
68
Xboard/app/Console/Kernel.php
Normal 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');
|
||||
}
|
||||
}
|
||||
10
Xboard/app/Contracts/PaymentInterface.php
Normal file
10
Xboard/app/Contracts/PaymentInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
interface PaymentInterface
|
||||
{
|
||||
public function form(): array;
|
||||
public function pay($order): array;
|
||||
public function notify($params);
|
||||
}
|
||||
23
Xboard/app/Exceptions/ApiException.php
Normal file
23
Xboard/app/Exceptions/ApiException.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
19
Xboard/app/Exceptions/BusinessException.php
Normal file
19
Xboard/app/Exceptions/BusinessException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
Xboard/app/Exceptions/Handler.php
Normal file
101
Xboard/app/Exceptions/Handler.php
Normal 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."),
|
||||
];
|
||||
}
|
||||
}
|
||||
79
Xboard/app/Helpers/ApiResponse.php
Normal file
79
Xboard/app/Helpers/ApiResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
82
Xboard/app/Helpers/Functions.php
Normal file
82
Xboard/app/Helpers/Functions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
Xboard/app/Helpers/ResponseEnum.php
Normal file
81
Xboard/app/Helpers/ResponseEnum.php
Normal 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 版本不受支持
|
||||
/*-------------------------------------------------------------------------------------------*/
|
||||
}
|
||||
13
Xboard/app/Http/Controllers/Controller.php
Normal file
13
Xboard/app/Http/Controllers/Controller.php
Normal 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;
|
||||
}
|
||||
26
Xboard/app/Http/Controllers/PluginController.php
Normal file
26
Xboard/app/Http/Controllers/PluginController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
Xboard/app/Http/Controllers/V1/Client/AppController.php
Normal file
90
Xboard/app/Http/Controllers/V1/Client/AppController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
247
Xboard/app/Http/Controllers/V1/Client/ClientController.php
Normal file
247
Xboard/app/Http/Controllers/V1/Client/ClientController.php
Normal 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'] ?? '');
|
||||
}
|
||||
}
|
||||
39
Xboard/app/Http/Controllers/V1/Guest/CommController.php
Normal file
39
Xboard/app/Http/Controllers/V1/Guest/CommController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
Xboard/app/Http/Controllers/V1/Guest/PaymentController.php
Normal file
52
Xboard/app/Http/Controllers/V1/Guest/PaymentController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
Xboard/app/Http/Controllers/V1/Guest/PlanController.php
Normal file
25
Xboard/app/Http/Controllers/V1/Guest/PlanController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
126
Xboard/app/Http/Controllers/V1/Guest/TelegramController.php
Normal file
126
Xboard/app/Http/Controllers/V1/Guest/TelegramController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
175
Xboard/app/Http/Controllers/V1/Passport/AuthController.php
Normal file
175
Xboard/app/Http/Controllers/V1/Passport/AuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
Xboard/app/Http/Controllers/V1/Passport/CommController.php
Normal file
76
Xboard/app/Http/Controllers/V1/Passport/CommController.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
178
Xboard/app/Http/Controllers/V1/Server/UniProxyController.php
Normal file
178
Xboard/app/Http/Controllers/V1/Server/UniProxyController.php
Normal 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"]);
|
||||
}
|
||||
}
|
||||
39
Xboard/app/Http/Controllers/V1/User/CommController.php
Normal file
39
Xboard/app/Http/Controllers/V1/User/CommController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
25
Xboard/app/Http/Controllers/V1/User/CouponController.php
Normal file
25
Xboard/app/Http/Controllers/V1/User/CouponController.php
Normal 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()));
|
||||
}
|
||||
}
|
||||
193
Xboard/app/Http/Controllers/V1/User/GiftCardController.php
Normal file
193
Xboard/app/Http/Controllers/V1/User/GiftCardController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
Xboard/app/Http/Controllers/V1/User/InviteController.php
Normal file
79
Xboard/app/Http/Controllers/V1/User/InviteController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
150
Xboard/app/Http/Controllers/V1/User/KnowledgeController.php
Normal file
150
Xboard/app/Http/Controllers/V1/User/KnowledgeController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Xboard/app/Http/Controllers/V1/User/NoticeController.php
Normal file
26
Xboard/app/Http/Controllers/V1/User/NoticeController.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
212
Xboard/app/Http/Controllers/V1/User/OrderController.php
Normal file
212
Xboard/app/Http/Controllers/V1/User/OrderController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
Xboard/app/Http/Controllers/V1/User/PlanController.php
Normal file
38
Xboard/app/Http/Controllers/V1/User/PlanController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
31
Xboard/app/Http/Controllers/V1/User/ServerController.php
Normal file
31
Xboard/app/Http/Controllers/V1/User/ServerController.php
Normal 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}\"");
|
||||
}
|
||||
}
|
||||
26
Xboard/app/Http/Controllers/V1/User/StatController.php
Normal file
26
Xboard/app/Http/Controllers/V1/User/StatController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
Xboard/app/Http/Controllers/V1/User/TelegramController.php
Normal file
26
Xboard/app/Http/Controllers/V1/User/TelegramController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
154
Xboard/app/Http/Controllers/V1/User/TicketController.php
Normal file
154
Xboard/app/Http/Controllers/V1/User/TicketController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
223
Xboard/app/Http/Controllers/V1/User/UserController.php
Normal file
223
Xboard/app/Http/Controllers/V1/User/UserController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
300
Xboard/app/Http/Controllers/V2/Admin/ConfigController.php
Normal file
300
Xboard/app/Http/Controllers/V2/Admin/ConfigController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
186
Xboard/app/Http/Controllers/V2/Admin/CouponController.php
Normal file
186
Xboard/app/Http/Controllers/V2/Admin/CouponController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
622
Xboard/app/Http/Controllers/V2/Admin/GiftCardController.php
Normal file
622
Xboard/app/Http/Controllers/V2/Admin/GiftCardController.php
Normal 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, '删除失败']);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
Xboard/app/Http/Controllers/V2/Admin/KnowledgeController.php
Normal file
113
Xboard/app/Http/Controllers/V2/Admin/KnowledgeController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
Xboard/app/Http/Controllers/V2/Admin/NoticeController.php
Normal file
101
Xboard/app/Http/Controllers/V2/Admin/NoticeController.php
Normal 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, '排序保存失败']);
|
||||
}
|
||||
}
|
||||
}
|
||||
252
Xboard/app/Http/Controllers/V2/Admin/OrderController.php
Normal file
252
Xboard/app/Http/Controllers/V2/Admin/OrderController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
133
Xboard/app/Http/Controllers/V2/Admin/PaymentController.php
Normal file
133
Xboard/app/Http/Controllers/V2/Admin/PaymentController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
132
Xboard/app/Http/Controllers/V2/Admin/PlanController.php
Normal file
132
Xboard/app/Http/Controllers/V2/Admin/PlanController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
333
Xboard/app/Http/Controllers/V2/Admin/PluginController.php
Normal file
333
Xboard/app/Http/Controllers/V2/Admin/PluginController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
219
Xboard/app/Http/Controllers/V2/Admin/Server/ManageController.php
Normal file
219
Xboard/app/Http/Controllers/V2/Admin/Server/ManageController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
508
Xboard/app/Http/Controllers/V2/Admin/StatController.php
Normal file
508
Xboard/app/Http/Controllers/V2/Admin/StatController.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
144
Xboard/app/Http/Controllers/V2/Admin/SystemController.php
Normal file
144
Xboard/app/Http/Controllers/V2/Admin/SystemController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
150
Xboard/app/Http/Controllers/V2/Admin/ThemeController.php
Normal file
150
Xboard/app/Http/Controllers/V2/Admin/ThemeController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
156
Xboard/app/Http/Controllers/V2/Admin/TicketController.php
Normal file
156
Xboard/app/Http/Controllers/V2/Admin/TicketController.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
235
Xboard/app/Http/Controllers/V2/Admin/TrafficResetController.php
Normal file
235
Xboard/app/Http/Controllers/V2/Admin/TrafficResetController.php
Normal 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
28
Xboard/app/Http/Controllers/V2/Admin/UpdateController.php
Normal file
28
Xboard/app/Http/Controllers/V2/Admin/UpdateController.php
Normal 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']]);
|
||||
}
|
||||
}
|
||||
682
Xboard/app/Http/Controllers/V2/Admin/UserController.php
Normal file
682
Xboard/app/Http/Controllers/V2/Admin/UserController.php
Normal 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, '删除失败']);
|
||||
}
|
||||
}
|
||||
}
|
||||
153
Xboard/app/Http/Controllers/V2/Client/AppController.php
Normal file
153
Xboard/app/Http/Controllers/V2/Client/AppController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
138
Xboard/app/Http/Controllers/V2/Server/ServerController.php
Normal file
138
Xboard/app/Http/Controllers/V2/Server/ServerController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
99
Xboard/app/Http/Kernel.php
Normal file
99
Xboard/app/Http/Kernel.php
Normal 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,
|
||||
];
|
||||
}
|
||||
30
Xboard/app/Http/Middleware/Admin.php
Normal file
30
Xboard/app/Http/Middleware/Admin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
Xboard/app/Http/Middleware/ApplyRuntimeSettings.php
Normal file
25
Xboard/app/Http/Middleware/ApplyRuntimeSettings.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
17
Xboard/app/Http/Middleware/Authenticate.php
Normal file
17
Xboard/app/Http/Middleware/Authenticate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
18
Xboard/app/Http/Middleware/CheckForMaintenanceMode.php
Normal file
18
Xboard/app/Http/Middleware/CheckForMaintenanceMode.php
Normal 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'
|
||||
];
|
||||
}
|
||||
33
Xboard/app/Http/Middleware/Client.php
Normal file
33
Xboard/app/Http/Middleware/Client.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
Xboard/app/Http/Middleware/EncryptCookies.php
Normal file
16
Xboard/app/Http/Middleware/EncryptCookies.php
Normal 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 = [
|
||||
//
|
||||
];
|
||||
}
|
||||
29
Xboard/app/Http/Middleware/EnsureTransactionState.php
Normal file
29
Xboard/app/Http/Middleware/EnsureTransactionState.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Xboard/app/Http/Middleware/ForceJson.php
Normal file
22
Xboard/app/Http/Middleware/ForceJson.php
Normal 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);
|
||||
}
|
||||
}
|
||||
37
Xboard/app/Http/Middleware/InitializePlugins.php
Normal file
37
Xboard/app/Http/Middleware/InitializePlugins.php
Normal 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
Reference in New Issue
Block a user