mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 23:56:36 +08:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2262b55d | |||
| 7ca01aba5d | |||
| 0f57ec58b3 | |||
| ac30f0172f | |||
| 531ba0bfed | |||
| d787e4b07a | |||
| 68e5d0ac0b | |||
| 38b70821c5 | |||
| 936158dd32 | |||
| e0d0553fd3 | |||
| 78aa86b23c | |||
| 1f850593dd | |||
| c4519c243a | |||
| 31ccc48436 | |||
| 1c4914ec77 | |||
| 71c40127d5 | |||
| aac1d63ac0 | |||
| 8222b31917 | |||
| 81c9c117a2 | |||
| de8e487dff | |||
| f4c59f64b0 | |||
| 6adf90d45c | |||
| 71eab8e07e | |||
| dd58ac67de | |||
| e6226d8069 | |||
| 6e25e1838d | |||
| c0c88a8466 | |||
| 4edbb66224 | |||
| 84e01c7851 | |||
| 761db1844d | |||
| ca76cc29de | |||
| c20a78a196 | |||
| 2053cbec32 | |||
| 687e9c78ad | |||
| 07105309fb | |||
| 31290137fe | |||
| 4a23f3b45a | |||
| 0b2974726c | |||
| 43aebdf8f4 | |||
| 37ea338a35 | |||
| 4063d39d6a | |||
| f6dd3ad657 | |||
| 252c16de29 | |||
| 4fef8eeb32 | |||
| 9146eb0375 | |||
| ad974875e4 | |||
| acd00ae413 | |||
| 65ca18d649 | |||
| a27b4d781e | |||
| ba50076468 |
+169
-127
@@ -1,55 +1,74 @@
|
||||
name: Build and Push Images Based on Version
|
||||
name: Build and Push Images
|
||||
|
||||
# 在这里定义统一版本号
|
||||
env:
|
||||
VERSION: "1.4.3"
|
||||
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
tags:
|
||||
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check Version and Decide Build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ env.VERSION }}
|
||||
should_build: ${{ steps.check-tag.outputs.should_build }}
|
||||
should_build_gost: ${{ steps.check-tag.outputs.should_build_gost }}
|
||||
tag_exists: ${{ steps.check-tag.outputs.tag_exists }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
should_build: ${{ steps.version.outputs.should_build }}
|
||||
should_build_gost: ${{ steps.version.outputs.should_build_gost }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
image_owner: ${{ steps.version.outputs.image_owner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Display version
|
||||
- name: Determine version and build strategy
|
||||
id: version
|
||||
run: |
|
||||
echo "Current version: ${{ env.VERSION }}"
|
||||
# 镜像 owner 需要小写
|
||||
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if tag exists and detect changes
|
||||
id: check-tag
|
||||
run: |
|
||||
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then
|
||||
echo "Tag ${{ env.VERSION }} already exists"
|
||||
echo "tag_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查从tag到现在,go-gost目录下的文件是否有变化
|
||||
TAG_COMMIT=$(git rev-list -n 1 "${{ env.VERSION }}")
|
||||
if git diff --quiet $TAG_COMMIT HEAD -- go-gost/; then
|
||||
echo "✅ GOST files unchanged since tag"
|
||||
echo "should_build_gost=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "🔄 GOST files changed since tag, will rebuild GOST binary"
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
# Tag 触发:直接使用 tag 名作为版本,全量构建
|
||||
VERSION="${{ github.ref_name }}"
|
||||
echo "🏷️ Tag trigger detected: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
echo "tag_exists=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# 分支触发:使用 env.VERSION,检查是否需要构建
|
||||
VERSION="${{ env.VERSION }}"
|
||||
echo "🌿 Branch trigger detected, using version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查 tag 是否已存在
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists"
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查 go-gost 目录是否有变化
|
||||
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
|
||||
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
|
||||
echo "✅ GOST files unchanged since tag"
|
||||
echo "should_build_gost=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "🔄 Detected changes in go-gost directory"
|
||||
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "Tag $VERSION does not exist, will build all components"
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
build-gost:
|
||||
@@ -58,48 +77,42 @@ jobs:
|
||||
if: needs.check-version.outputs.should_build_gost == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install UPX
|
||||
run: |
|
||||
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
|
||||
wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
|
||||
tar -xf upx-4.2.1-amd64_linux.tar.xz
|
||||
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
|
||||
rm -rf upx-4.2.1-amd64_linux*
|
||||
|
||||
- name: Build GOST binary (AMD64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
|
||||
|
||||
- name: Build GOST binary (ARM64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
|
||||
|
||||
- name: Compress with UPX (AMD64)
|
||||
- name: Compress with UPX
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost-amd64
|
||||
|
||||
- name: Compress with UPX (ARM64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost-arm64
|
||||
|
||||
- name: Upload GOST AMD64 artifact
|
||||
@@ -119,31 +132,40 @@ jobs:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare build args
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
{
|
||||
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
|
||||
echo "VITE_APP_VERSION=$VERSION"
|
||||
} > ./vite-frontend/.env.production
|
||||
|
||||
- name: Build and push Vite Docker images
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
-t bqlpfy/vite-frontend:latest \
|
||||
-t bqlpfy/vite-frontend:${VERSION} \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
|
||||
./vite-frontend
|
||||
|
||||
build-java:
|
||||
@@ -151,17 +173,20 @@ jobs:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK and Maven
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 21
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||
@@ -172,33 +197,36 @@ jobs:
|
||||
run: mvn clean package -DskipTests
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Java Docker images
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
-t bqlpfy/springboot-backend:latest \
|
||||
-t bqlpfy/springboot-backend:${VERSION} \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION} \
|
||||
./springboot-backend
|
||||
|
||||
create-release:
|
||||
name: Create Release and Tag
|
||||
name: Create Release (Tag Only)
|
||||
needs: [check-version, build-gost, build-vite, build-java]
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
if: needs.check-version.outputs.is_tag == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download GOST AMD64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -212,60 +240,106 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Rename binaries
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 移动二进制文件
|
||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
|
||||
|
||||
# 复制并修改 docker-compose 文件
|
||||
cp docker-compose-v4.yml ./artifacts/docker-compose-v4.yml
|
||||
cp docker-compose-v6.yml ./artifacts/docker-compose-v6.yml
|
||||
|
||||
# 替换镜像地址为 GHCR
|
||||
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
|
||||
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
|
||||
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
|
||||
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
|
||||
|
||||
# 复制并修改安装脚本
|
||||
cp install.sh ./artifacts/install.sh
|
||||
cp panel_install.sh ./artifacts/panel_install.sh
|
||||
|
||||
# 替换仓库地址和版本号
|
||||
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/install.sh
|
||||
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/panel_install.sh
|
||||
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
|
||||
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
# 创建并推送 tag
|
||||
git tag "${VERSION}" ${{ github.sha }}
|
||||
git push origin "${VERSION}"
|
||||
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
# 获取 commit 信息
|
||||
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
|
||||
COMMIT_DATE=$(git log -1 --pretty=format:"%ai")
|
||||
|
||||
|
||||
# 创建 release
|
||||
gh release create "${VERSION}" \
|
||||
--title "Release ${VERSION}" \
|
||||
--notes "## 📝 Commit Information
|
||||
- **Message**: ${COMMIT_MSG}
|
||||
--notes "## 📝 Release Information
|
||||
|
||||
- **Version**: ${VERSION}
|
||||
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
|
||||
- **Author**: ${COMMIT_AUTHOR}
|
||||
- **Date**: ${COMMIT_DATE}
|
||||
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})" \
|
||||
- **Message**: ${COMMIT_MSG}
|
||||
|
||||
## 📦 Docker Images
|
||||
|
||||
\`\`\`bash
|
||||
# Backend
|
||||
docker pull ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}
|
||||
|
||||
# Frontend
|
||||
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
|
||||
\`\`\`
|
||||
|
||||
## 🚀 Quick Install
|
||||
|
||||
**Panel:**
|
||||
\`\`\`bash
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
\`\`\`
|
||||
|
||||
**Node:**
|
||||
\`\`\`bash
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
\`\`\`" \
|
||||
--repo ${{ github.repository }}
|
||||
|
||||
|
||||
# 上传所有文件到 release
|
||||
echo "📤 上传 GOST 二进制文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
|
||||
echo "📤 上传安装脚本..."
|
||||
gh release upload "${VERSION}" ./install.sh --clobber
|
||||
gh release upload "${VERSION}" ./panel_install.sh --clobber
|
||||
|
||||
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
||||
|
||||
echo "📤 上传 Docker Compose 配置文件..."
|
||||
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
|
||||
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
|
||||
|
||||
echo "✅ 所有文件已上传到 Release ${VERSION}"
|
||||
gh release upload "${VERSION}" ./artifacts/docker-compose-v4.yml --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/docker-compose-v6.yml --clobber
|
||||
|
||||
echo "✅ Release ${VERSION} 创建完成"
|
||||
|
||||
update-release-gost:
|
||||
name: Update GOST Binaries in Release
|
||||
needs: [check-version, build-gost]
|
||||
if: needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
|
||||
if: needs.check-version.outputs.is_tag == 'false' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download GOST AMD64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -289,42 +363,10 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
|
||||
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
|
||||
|
||||
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成!"
|
||||
echo "ℹ️ Docker 镜像未重新构建"
|
||||
|
||||
update-release-files:
|
||||
name: Update Release Files Only
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.tag_exists == 'true' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Update Release Files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
echo "📤 更新 Release ${VERSION} 中的脚本和配置文件..."
|
||||
|
||||
# 上传安装脚本(覆盖)
|
||||
echo "📤 更新安装脚本..."
|
||||
gh release upload "${VERSION}" ./install.sh --clobber
|
||||
gh release upload "${VERSION}" ./panel_install.sh --clobber
|
||||
|
||||
# 上传 Docker Compose 配置文件(覆盖)
|
||||
echo "📤 更新 Docker Compose 配置文件..."
|
||||
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
|
||||
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
|
||||
|
||||
echo "✅ 文件更新完成!Release ${VERSION} 中的脚本和配置已更新"
|
||||
echo "ℹ️ Docker 镜像和 GOST 二进制未重新构建"
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
+2
-1
@@ -256,4 +256,5 @@ gitee/
|
||||
*.cert
|
||||
doraemon.jks
|
||||
device.id
|
||||
commit.sh
|
||||
commit.sh
|
||||
sql/
|
||||
@@ -0,0 +1,71 @@
|
||||
# Plan: 搭建开发环境
|
||||
|
||||
## 目标
|
||||
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
|
||||
|
||||
## 当前状态
|
||||
|
||||
### ✅ 已安装
|
||||
| 工具 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Node.js | v20.19.2 | vite-frontend |
|
||||
| npm | 9.2.0 | vite-frontend |
|
||||
| Go | 1.24.4 | go-gost |
|
||||
| Docker | 29.1.4 | 容器化部署 |
|
||||
|
||||
### ❌ 缺失
|
||||
| 工具 | 需求版本 | 用途 |
|
||||
|------|----------|------|
|
||||
| Java | 21 | springboot-backend |
|
||||
| Maven | 3.x | 构建后端 |
|
||||
| Docker Compose | v2 | 容器编排 |
|
||||
|
||||
---
|
||||
|
||||
## 执行任务
|
||||
|
||||
### Task 1: 安装 Java 21
|
||||
```bash
|
||||
apt-get update && apt-get install -y openjdk-21-jdk
|
||||
```
|
||||
**验证**: `java -version` 应显示 openjdk 21
|
||||
|
||||
### Task 2: 安装 Maven
|
||||
```bash
|
||||
apt-get install -y maven
|
||||
```
|
||||
**验证**: `mvn -v` 应显示 Maven 3.x
|
||||
|
||||
### Task 3: 安装 Docker Compose Plugin
|
||||
```bash
|
||||
apt-get install -y docker-compose-plugin
|
||||
```
|
||||
**验证**: `docker compose version` 应显示版本号
|
||||
|
||||
### Task 4: 安装前端依赖
|
||||
```bash
|
||||
cd /root/flux-panel/vite-frontend && npm install
|
||||
```
|
||||
**验证**: `node_modules/` 目录存在
|
||||
|
||||
### Task 5: 验证后端可构建
|
||||
```bash
|
||||
cd /root/flux-panel/springboot-backend && mvn clean compile -q
|
||||
```
|
||||
**验证**: 编译成功无错误
|
||||
|
||||
### Task 6: 验证 Go 模块
|
||||
```bash
|
||||
cd /root/flux-panel/go-gost && go mod download
|
||||
```
|
||||
**验证**: 依赖下载成功
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
- [ ] `java -version` → openjdk 21
|
||||
- [ ] `mvn -v` → Maven 3.x
|
||||
- [ ] `docker compose version` → v2.x
|
||||
- [ ] 前端: `npm run dev` 可启动
|
||||
- [ ] 后端: `mvn compile` 成功
|
||||
- [ ] Go: `go build .` 成功
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 100
|
||||
completion_promise: "DONE"
|
||||
started_at: "2026-01-24T05:20:50.887Z"
|
||||
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
|
||||
---
|
||||
Complete the task as instructed
|
||||
@@ -0,0 +1,66 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Commit:** 7ca01ab
|
||||
**Branch:** beta
|
||||
|
||||
## OVERVIEW
|
||||
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
./
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
|
||||
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
|
||||
├── android-app/ # Android WebView wrapper (optional)
|
||||
├── ios-app/ # iOS WebView wrapper (optional)
|
||||
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
|
||||
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
|
||||
├── panel_install.sh # Panel installer/upgrader (downloads compose)
|
||||
├── install.sh # Node installer/upgrader (downloads gost binary)
|
||||
└── .github/workflows/ # CI: build/push images + release artifacts
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
|
||||
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
|
||||
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
|
||||
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
|
||||
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
|
||||
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
|
||||
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
|
||||
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
|
||||
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
|
||||
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
|
||||
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||
|
||||
## CONVENTIONS
|
||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
|
||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
# Panel (Docker)
|
||||
docker compose -f docker-compose-v4.yml up -d
|
||||
docker compose -f docker-compose-v6.yml up -d
|
||||
|
||||
# Release-based install scripts
|
||||
./panel_install.sh
|
||||
./install.sh
|
||||
|
||||
# Local dev (per subproject)
|
||||
(cd springboot-backend && mvn clean package)
|
||||
(cd vite-frontend && npm run dev)
|
||||
(cd go-gost && go run .)
|
||||
```
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
@@ -1,23 +1,5 @@
|
||||
# flux-panel转发面板 哆啦A梦转发面板
|
||||
|
||||
# 赞助商
|
||||
<p align="center">
|
||||
<a href="https://vps.town" style="margin: 0 20px; text-align:center;">
|
||||
<img src="./doc/vpstown.png" width="300">
|
||||
</a>
|
||||
|
||||
<a href="https://whmcs.as211392.com" style="margin: 0 20px; text-align:center;">
|
||||
<img src="./doc/as211392.png" width="300">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
使用1.x版本的请使用上面给出的脚本,不要使用最新的,最新的默认2.x
|
||||
嫌弃麻烦或者想稳定的我不推荐你升级到2.x版本 目前还在beta阶段,很多问题。
|
||||
|
||||
|
||||
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
||||
---
|
||||
## 特性
|
||||
@@ -36,21 +18,21 @@
|
||||
#### 快速部署
|
||||
面板端(稳定版):
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端(稳定版):
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
|
||||
```
|
||||
|
||||
面板端(开发版):
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端(开发版):
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
|
||||
```
|
||||
|
||||
@@ -84,16 +66,4 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/inst
|
||||
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
|
||||
|
||||
|
||||
请务必在合法、合规、安全的前提下使用本项目。
|
||||
|
||||
---
|
||||
## ⭐ 喝杯咖啡!(USDT)
|
||||
|
||||
| 网络 | 地址 |
|
||||
|------------|----------------------------------------------------------------------|
|
||||
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
|
||||
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
|
||||
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
|
||||
|
||||
[](https://www.star-history.com/#bqlpfy/flux-panel&Date)
|
||||
|
||||
请务必在合法、合规、安全的前提下使用本项目。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
+9
-38
@@ -1,39 +1,10 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
container_name: gost-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
command: >
|
||||
--default-authentication-plugin=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=256M
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:1.4.3
|
||||
image: bqlpfy/springboot-backend:2.0.7-beta
|
||||
container_name: springboot-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_PATH: /app/data/gost.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
@@ -41,20 +12,20 @@ services:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:1.4.3
|
||||
image: bqlpfy/vite-frontend:2.0.7-beta
|
||||
container_name: vite-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -67,8 +38,8 @@ services:
|
||||
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
name: mysql_data
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
|
||||
+9
-38
@@ -1,39 +1,10 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
container_name: gost-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
command: >
|
||||
--default-authentication-plugin=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=256M
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:1.4.3
|
||||
image: bqlpfy/springboot-backend:2.0.7-beta
|
||||
container_name: springboot-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_PATH: /app/data/gost.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
@@ -41,20 +12,20 @@ services:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:1.4.3
|
||||
image: bqlpfy/vite-frontend:2.0.7-beta
|
||||
container_name: vite-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -67,8 +38,8 @@ services:
|
||||
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
name: mysql_data
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/
|
||||
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
|
||||
├── config.go # Panel config.json loader (addr/secret + ports)
|
||||
├── program.go # GOST runtime: parse config, run/reload services
|
||||
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
|
||||
└── go.mod # replace github.com/go-gost/x => ./x
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
|
||||
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
|
||||
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
|
||||
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
|
||||
|
||||
## CONVENTIONS
|
||||
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
|
||||
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go run .
|
||||
go test ./...
|
||||
go build .
|
||||
```
|
||||
+1
-1
@@ -119,7 +119,7 @@ func main() {
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "1.2.4")
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "2.0.2")
|
||||
defer wsReporter.Stop()
|
||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/
|
||||
├── api/ # Gin management API + embedded swagger docs
|
||||
├── config/ # Config model + parsing/load/reload
|
||||
├── connector/ # Outbound connect implementations
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
|
||||
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
|
||||
├── limiter/ # Traffic/rate/conn limiters
|
||||
├── registry/ # Registries for services/handlers/listeners/etc
|
||||
├── service/ # Service wrappers + reporting hooks
|
||||
├── socket/ # WebSocket reporter / panel integration
|
||||
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
|
||||
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
|
||||
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
|
||||
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
|
||||
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
|
||||
|
||||
## CONVENTIONS
|
||||
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
|
||||
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# GO-GOST/X API KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Gin-based management API for reading/writing config and controlling services at runtime.
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
|
||||
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
|
||||
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
|
||||
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
|
||||
|
||||
## CONVENTIONS
|
||||
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
|
||||
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
|
||||
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
|
||||
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
|
||||
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
|
||||
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
|
||||
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
|
||||
|
||||
## CONVENTIONS
|
||||
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
|
||||
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/dialer/
|
||||
├── direct/ # Baseline dialer
|
||||
├── tcp/
|
||||
├── udp/
|
||||
├── tls/
|
||||
├── ws/
|
||||
├── quic/
|
||||
├── http2/
|
||||
├── http3/
|
||||
├── ssh/
|
||||
├── wg/ # WireGuard dialer
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
|
||||
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
|
||||
|
||||
## CONVENTIONS
|
||||
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/handler/
|
||||
├── http/ # handler.go + metadata.go (+ udp.go)
|
||||
├── socks/ # SOCKS variants
|
||||
├── tunnel/ # Tunnel forwarding
|
||||
├── relay/ # Relay forwarding
|
||||
├── redirect/ # TCP/UDP redirect handlers
|
||||
├── router/ # Routing/association entrypoints
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
|
||||
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
|
||||
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
|
||||
|
||||
## CONVENTIONS
|
||||
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/listener/
|
||||
├── tcp/ # listener.go + metadata.go
|
||||
├── udp/
|
||||
├── tls/
|
||||
├── ws/
|
||||
├── quic/
|
||||
├── redirect/ # tcp/ + udp/
|
||||
├── tun/ # TUN device listener
|
||||
├── tap/ # TAP device listener
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
|
||||
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
|
||||
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
|
||||
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
|
||||
|
||||
## CONVENTIONS
|
||||
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,206 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GlobalTrafficManager 全局流量管理器(所有服务共享)
|
||||
type GlobalTrafficManager struct {
|
||||
mu sync.RWMutex
|
||||
serviceTraffic map[string]*ServiceTraffic // key: 服务名, value: 流量数据
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
reportTicker *time.Ticker
|
||||
}
|
||||
|
||||
// ServiceTraffic 单个服务的流量累积
|
||||
type ServiceTraffic struct {
|
||||
mu sync.Mutex
|
||||
ServiceName string
|
||||
UpBytes int64 // 上行流量(累积)
|
||||
DownBytes int64 // 下行流量(累积)
|
||||
}
|
||||
|
||||
var (
|
||||
globalManager *GlobalTrafficManager
|
||||
globalManagerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalTrafficManager 获取全局流量管理器单例
|
||||
func GetGlobalTrafficManager() *GlobalTrafficManager {
|
||||
globalManagerOnce.Do(func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
globalManager = &GlobalTrafficManager{
|
||||
serviceTraffic: make(map[string]*ServiceTraffic),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
reportTicker: time.NewTicker(5 * time.Second),
|
||||
}
|
||||
// 启动定时上报协程
|
||||
go globalManager.startReporting()
|
||||
})
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// AddTraffic 添加流量到指定服务(由各服务调用)
|
||||
func (m *GlobalTrafficManager) AddTraffic(serviceName string, upBytes, downBytes int64) {
|
||||
if upBytes == 0 && downBytes == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 获取或创建服务流量记录
|
||||
traffic, exists := m.serviceTraffic[serviceName]
|
||||
if !exists {
|
||||
traffic = &ServiceTraffic{
|
||||
ServiceName: serviceName,
|
||||
}
|
||||
m.serviceTraffic[serviceName] = traffic
|
||||
}
|
||||
|
||||
// 累加流量
|
||||
traffic.mu.Lock()
|
||||
traffic.UpBytes += upBytes
|
||||
traffic.DownBytes += downBytes
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
|
||||
// startReporting 启动定时上报协程(每5秒执行一次)
|
||||
func (m *GlobalTrafficManager) startReporting() {
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.reportTicker.C:
|
||||
m.collectAndReport()
|
||||
|
||||
case <-m.ctx.Done():
|
||||
fmt.Printf("⏹️ 全局流量上报器已停止\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectAndReport 收集所有服务流量并合并上报
|
||||
func (m *GlobalTrafficManager) collectAndReport() {
|
||||
m.mu.Lock()
|
||||
|
||||
// 如果没有流量,直接返回
|
||||
if len(m.serviceTraffic) == 0 {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// 复制当前所有流量数据(避免长时间持锁)
|
||||
trafficSnapshot := make(map[string]*ServiceTraffic)
|
||||
reportData := make(map[string]struct {
|
||||
up int64
|
||||
down int64
|
||||
})
|
||||
|
||||
for name, traffic := range m.serviceTraffic {
|
||||
traffic.mu.Lock()
|
||||
if traffic.UpBytes > 0 || traffic.DownBytes > 0 {
|
||||
trafficSnapshot[name] = traffic
|
||||
reportData[name] = struct {
|
||||
up int64
|
||||
down int64
|
||||
}{
|
||||
up: traffic.UpBytes,
|
||||
down: traffic.DownBytes,
|
||||
}
|
||||
}
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// 如果没有需要上报的流量,返回
|
||||
if len(reportData) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建上报数据数组(保持每个服务独立)
|
||||
reportItems := make([]TrafficReportItem, 0, len(reportData))
|
||||
var totalUp, totalDown int64
|
||||
|
||||
for serviceName, data := range reportData {
|
||||
reportItems = append(reportItems, TrafficReportItem{
|
||||
N: serviceName, // 保持服务名不变
|
||||
U: data.up,
|
||||
D: data.down,
|
||||
})
|
||||
totalUp += data.up
|
||||
totalDown += data.down
|
||||
}
|
||||
|
||||
// 批量发送上报请求(一次HTTP请求包含所有服务)
|
||||
success, err := sendBatchTrafficReport(m.ctx, reportItems)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 全局流量上报失败: %v (总流量: ↑%d ↓%d, %d个服务)\n", err, totalUp, totalDown, len(reportItems))
|
||||
return
|
||||
}
|
||||
|
||||
if !success {
|
||||
fmt.Printf("⚠️ 全局流量上报未成功 (总流量: ↑%d ↓%d, %d个服务)\n", totalUp, totalDown, len(reportItems))
|
||||
return
|
||||
}
|
||||
|
||||
// 上报成功,清空已上报的流量
|
||||
m.clearReportedTraffic(reportData)
|
||||
}
|
||||
|
||||
// clearReportedTraffic 清空已成功上报的流量
|
||||
func (m *GlobalTrafficManager) clearReportedTraffic(reportedData map[string]struct {
|
||||
up int64
|
||||
down int64
|
||||
}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for serviceName, reported := range reportedData {
|
||||
if traffic, exists := m.serviceTraffic[serviceName]; exists {
|
||||
traffic.mu.Lock()
|
||||
// 减去已上报的流量
|
||||
traffic.UpBytes -= reported.up
|
||||
traffic.DownBytes -= reported.down
|
||||
|
||||
// 如果流量归零,从map中删除该服务记录(避免内存泄漏)
|
||||
if traffic.UpBytes <= 0 && traffic.DownBytes <= 0 {
|
||||
traffic.mu.Unlock()
|
||||
delete(m.serviceTraffic, serviceName)
|
||||
} else {
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止全局流量管理器
|
||||
func (m *GlobalTrafficManager) Stop() {
|
||||
if m.reportTicker != nil {
|
||||
m.reportTicker.Stop()
|
||||
}
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
fmt.Printf("🛑 全局流量管理器已停止\n")
|
||||
}
|
||||
|
||||
// GetServiceTraffic 获取指定服务的当前流量(用于调试)
|
||||
func (m *GlobalTrafficManager) GetServiceTraffic(serviceName string) (upBytes, downBytes int64) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if traffic, exists := m.serviceTraffic[serviceName]; exists {
|
||||
traffic.mu.Lock()
|
||||
upBytes = traffic.UpBytes
|
||||
downBytes = traffic.DownBytes
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -403,19 +403,15 @@ func (s *defaultService) observeStats(ctx context.Context) {
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
},
|
||||
}
|
||||
|
||||
// 将流量累积到全局管理器,而不是立即上报
|
||||
if outputBytes > 0 || inputBytes > 0 {
|
||||
reportItems := TrafficReportItem{
|
||||
N: s.name,
|
||||
U: int64(outputBytes),
|
||||
D: int64(inputBytes),
|
||||
}
|
||||
success, err := sendTrafficReport(ctx, reportItems)
|
||||
if err != nil {
|
||||
fmt.Printf("发送流量报告失败: %v", err)
|
||||
} else if success {
|
||||
if xstats, ok := st.(*xstats.Stats); ok {
|
||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
||||
}
|
||||
globalManager := GetGlobalTrafficManager()
|
||||
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
|
||||
|
||||
// 立即重置流量计数(因为已经记录到全局管理器中)
|
||||
if xstats, ok := st.(*xstats.Stats); ok {
|
||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ func SetHTTPReportURL(addr string, secret string) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendTrafficReport 发送流量报告到HTTP接口
|
||||
func sendTrafficReport(ctx context.Context, reportItems TrafficReportItem) (bool, error) {
|
||||
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
|
||||
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
|
||||
jsonData, err := json.Marshal(reportItems)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("序列化报告数据失败: %v", err)
|
||||
@@ -112,6 +112,7 @@ func sendTrafficReport(ctx context.Context, reportItems TrafficReportItem) (bool
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// sendConfigReport 发送配置报告到HTTP接口
|
||||
func sendConfigReport(ctx context.Context) (bool, error) {
|
||||
if configReportURL == "" {
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
-- phpMyAdmin SQL Dump
|
||||
-- version 5.2.0
|
||||
-- https://www.phpmyadmin.net/
|
||||
--
|
||||
-- 主机: localhost
|
||||
-- 生成日期: 2025-08-14 21:52:52
|
||||
-- 服务器版本: 5.7.40-log
|
||||
-- PHP 版本: 7.4.33
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
|
||||
--
|
||||
-- 数据库: `gost`
|
||||
--
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `forward`
|
||||
--
|
||||
|
||||
CREATE TABLE `forward` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`user_name` varchar(100) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`tunnel_id` int(10) NOT NULL,
|
||||
`in_port` int(10) NOT NULL,
|
||||
`out_port` int(10) DEFAULT NULL,
|
||||
`remote_addr` longtext NOT NULL,
|
||||
`strategy` varchar(100) NOT NULL DEFAULT 'fifo',
|
||||
`interface_name` varchar(200) DEFAULT NULL,
|
||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) NOT NULL,
|
||||
`status` int(10) NOT NULL,
|
||||
`inx` int(10) NOT NULL DEFAULT '0'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `node`
|
||||
--
|
||||
|
||||
CREATE TABLE `node` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`secret` varchar(100) NOT NULL,
|
||||
`ip` longtext,
|
||||
`server_ip` varchar(100) NOT NULL,
|
||||
`port_sta` int(10) NOT NULL,
|
||||
`port_end` int(10) NOT NULL,
|
||||
`version` varchar(100) DEFAULT NULL,
|
||||
`http` int(10) NOT NULL DEFAULT '0',
|
||||
`tls` int(10) NOT NULL DEFAULT '0',
|
||||
`socks` int(10) NOT NULL DEFAULT '0',
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) DEFAULT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `speed_limit`
|
||||
--
|
||||
|
||||
CREATE TABLE `speed_limit` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`speed` int(10) NOT NULL,
|
||||
`tunnel_id` int(10) NOT NULL,
|
||||
`tunnel_name` varchar(100) NOT NULL,
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) DEFAULT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `statistics_flow`
|
||||
--
|
||||
|
||||
CREATE TABLE `statistics_flow` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`flow` bigint(20) NOT NULL,
|
||||
`total_flow` bigint(20) NOT NULL,
|
||||
`time` varchar(100) NOT NULL,
|
||||
`created_time` bigint(20) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `tunnel`
|
||||
--
|
||||
|
||||
CREATE TABLE `tunnel` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0',
|
||||
`in_node_id` int(10) NOT NULL,
|
||||
`in_ip` varchar(100) NOT NULL,
|
||||
`out_node_id` int(10) NOT NULL,
|
||||
`out_ip` varchar(100) NOT NULL,
|
||||
`type` int(10) NOT NULL,
|
||||
`protocol` varchar(10) NOT NULL DEFAULT 'tls',
|
||||
`flow` int(10) NOT NULL,
|
||||
`tcp_listen_addr` varchar(100) NOT NULL DEFAULT '[::]',
|
||||
`udp_listen_addr` varchar(100) NOT NULL DEFAULT '[::]',
|
||||
`interface_name` varchar(200) DEFAULT NULL,
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) NOT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `user`
|
||||
--
|
||||
|
||||
CREATE TABLE `user` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user` varchar(100) NOT NULL,
|
||||
`pwd` varchar(100) NOT NULL,
|
||||
`role_id` int(10) NOT NULL,
|
||||
`exp_time` bigint(20) NOT NULL,
|
||||
`flow` bigint(20) NOT NULL,
|
||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`flow_reset_time` bigint(20) NOT NULL,
|
||||
`num` int(10) NOT NULL,
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) DEFAULT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
--
|
||||
-- 转存表中的数据 `user`
|
||||
--
|
||||
|
||||
INSERT INTO `user` (`id`, `user`, `pwd`, `role_id`, `exp_time`, `flow`, `in_flow`, `out_flow`, `flow_reset_time`, `num`, `created_time`, `updated_time`, `status`) VALUES
|
||||
(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `user_tunnel`
|
||||
--
|
||||
|
||||
CREATE TABLE `user_tunnel` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`tunnel_id` int(10) NOT NULL,
|
||||
`speed_id` int(10) DEFAULT NULL,
|
||||
`num` int(10) NOT NULL,
|
||||
`flow` bigint(20) NOT NULL,
|
||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`flow_reset_time` bigint(20) NOT NULL,
|
||||
`exp_time` bigint(20) NOT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `vite_config`
|
||||
--
|
||||
|
||||
CREATE TABLE `vite_config` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(200) NOT NULL,
|
||||
`value` varchar(200) NOT NULL,
|
||||
`time` bigint(20) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
--
|
||||
-- 转存表中的数据 `vite_config`
|
||||
--
|
||||
|
||||
INSERT INTO `vite_config` (`id`, `name`, `value`, `time`) VALUES
|
||||
(1, 'app_name', 'flux', 1755147963000);
|
||||
|
||||
--
|
||||
-- 转储表的索引
|
||||
--
|
||||
|
||||
--
|
||||
-- 表的索引 `forward`
|
||||
--
|
||||
ALTER TABLE `forward`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `node`
|
||||
--
|
||||
ALTER TABLE `node`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `speed_limit`
|
||||
--
|
||||
ALTER TABLE `speed_limit`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `statistics_flow`
|
||||
--
|
||||
ALTER TABLE `statistics_flow`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `tunnel`
|
||||
--
|
||||
ALTER TABLE `tunnel`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `user`
|
||||
--
|
||||
ALTER TABLE `user`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `user_tunnel`
|
||||
--
|
||||
ALTER TABLE `user_tunnel`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `vite_config`
|
||||
--
|
||||
ALTER TABLE `vite_config`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `name` (`name`);
|
||||
|
||||
--
|
||||
-- 在导出的表使用AUTO_INCREMENT
|
||||
--
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `forward`
|
||||
--
|
||||
ALTER TABLE `forward`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `node`
|
||||
--
|
||||
ALTER TABLE `node`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `speed_limit`
|
||||
--
|
||||
ALTER TABLE `speed_limit`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `statistics_flow`
|
||||
--
|
||||
ALTER TABLE `statistics_flow`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `tunnel`
|
||||
--
|
||||
ALTER TABLE `tunnel`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `user`
|
||||
--
|
||||
ALTER TABLE `user`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `user_tunnel`
|
||||
--
|
||||
ALTER TABLE `user_tunnel`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `vite_config`
|
||||
--
|
||||
ALTER TABLE `vite_config`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
COMMIT;
|
||||
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
+153
-62
@@ -1,12 +1,108 @@
|
||||
#!/bin/bash
|
||||
# 下载地址
|
||||
DOWNLOAD_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.3/gost"
|
||||
INSTALL_DIR="/etc/gost"
|
||||
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
# 获取系统架构
|
||||
get_architecture() {
|
||||
ARCH=$(uname -m)
|
||||
case $ARCH in
|
||||
x86_64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo "amd64" # 默认使用 amd64
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 识别国家(用于镜像加速)
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://ghfast.top/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
local effective_url tag api_tag latest_url api_url
|
||||
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# CN 环境下可尝试通过镜像访问(不影响非 CN)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 方式2:GitHub API(无需 jq)
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
echo "$VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 构建下载地址
|
||||
build_download_url() {
|
||||
local ARCH=$(get_architecture)
|
||||
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||
}
|
||||
|
||||
# 解析版本并构建下载地址
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
|
||||
|
||||
|
||||
@@ -131,8 +227,8 @@ while getopts "a:s:" opt; do
|
||||
done
|
||||
|
||||
# 安装功能
|
||||
install_gost() {
|
||||
echo "🚀 开始安装 GOST..."
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
get_config_params
|
||||
|
||||
# 检查并安装 tcpkill
|
||||
@@ -142,27 +238,27 @@ install_gost() {
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# 停止并禁用已有服务
|
||||
if systemctl list-units --full -all | grep -Fq "gost.service"; then
|
||||
echo "🔍 检测到已存在的gost服务"
|
||||
systemctl stop gost 2>/dev/null && echo "🛑 停止服务"
|
||||
systemctl disable gost 2>/dev/null && echo "🚫 禁用自启"
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🔍 检测到已存在的flux_agent服务"
|
||||
systemctl stop flux_agent 2>/dev/null && echo "🛑 停止服务"
|
||||
systemctl disable flux_agent 2>/dev/null && echo "🚫 禁用自启"
|
||||
fi
|
||||
|
||||
# 删除旧文件
|
||||
[[ -f "$INSTALL_DIR/gost" ]] && echo "🧹 删除旧文件 gost" && rm -f "$INSTALL_DIR/gost"
|
||||
[[ -f "$INSTALL_DIR/flux_agent" ]] && echo "🧹 删除旧文件 flux_agent" && rm -f "$INSTALL_DIR/flux_agent"
|
||||
|
||||
# 下载 gost
|
||||
echo "⬇️ 下载 gost 中..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/gost"
|
||||
if [[ ! -f "$INSTALL_DIR/gost" || ! -s "$INSTALL_DIR/gost" ]]; then
|
||||
# 下载 flux_agent
|
||||
echo "⬇️ 下载 flux_agent 中..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent"
|
||||
if [[ ! -f "$INSTALL_DIR/flux_agent" || ! -s "$INSTALL_DIR/flux_agent" ]]; then
|
||||
echo "❌ 下载失败,请检查网络或下载链接。"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/gost"
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
echo "✅ 下载完成"
|
||||
|
||||
# 打印版本
|
||||
echo "🔎 gost 版本:$($INSTALL_DIR/gost -V)"
|
||||
echo "🔎 flux_agent 版本:$($INSTALL_DIR/flux_agent -V)"
|
||||
|
||||
# 写入 config.json (安装时总是创建新的)
|
||||
CONFIG_FILE="$INSTALL_DIR/config.json"
|
||||
@@ -189,15 +285,15 @@ EOF
|
||||
chmod 600 "$INSTALL_DIR"/*.json
|
||||
|
||||
# 创建 systemd 服务
|
||||
SERVICE_FILE="/etc/systemd/system/gost.service"
|
||||
SERVICE_FILE="/etc/systemd/system/flux_agent.service"
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Gost Proxy Service
|
||||
Description=Flux_agent Proxy Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$INSTALL_DIR/gost
|
||||
ExecStart=$INSTALL_DIR/flux_agent
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
@@ -206,27 +302,27 @@ EOF
|
||||
|
||||
# 启动服务
|
||||
systemctl daemon-reload
|
||||
systemctl enable gost
|
||||
systemctl start gost
|
||||
systemctl enable flux_agent
|
||||
systemctl start flux_agent
|
||||
|
||||
# 检查状态
|
||||
echo "🔄 检查服务状态..."
|
||||
if systemctl is-active --quiet gost; then
|
||||
echo "✅ 安装完成,gost服务已启动并设置为开机启动。"
|
||||
if systemctl is-active --quiet flux_agent; then
|
||||
echo "✅ 安装完成,flux_agent服务已启动并设置为开机启动。"
|
||||
echo "📁 配置目录: $INSTALL_DIR"
|
||||
echo "🔧 服务状态: $(systemctl is-active gost)"
|
||||
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
|
||||
else
|
||||
echo "❌ gost服务启动失败,请执行以下命令查看日志:"
|
||||
echo "journalctl -u gost -f"
|
||||
echo "❌ flux_agent服务启动失败,请执行以下命令查看日志:"
|
||||
echo "journalctl -u flux_agent -f"
|
||||
fi
|
||||
}
|
||||
|
||||
# 更新功能
|
||||
update_gost() {
|
||||
echo "🔄 开始更新 GOST..."
|
||||
update_flux_agent() {
|
||||
echo "🔄 开始更新 flux_agent..."
|
||||
|
||||
if [[ ! -d "$INSTALL_DIR" ]]; then
|
||||
echo "❌ GOST 未安装,请先选择安装。"
|
||||
echo "❌ flux_agent 未安装,请先选择安装。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -237,52 +333,52 @@ update_gost() {
|
||||
|
||||
# 先下载新版本
|
||||
echo "⬇️ 下载最新版本..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/gost.new"
|
||||
if [[ ! -f "$INSTALL_DIR/gost.new" || ! -s "$INSTALL_DIR/gost.new" ]]; then
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent.new"
|
||||
if [[ ! -f "$INSTALL_DIR/flux_agent.new" || ! -s "$INSTALL_DIR/flux_agent.new" ]]; then
|
||||
echo "❌ 下载失败。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 停止服务
|
||||
if systemctl list-units --full -all | grep -Fq "gost.service"; then
|
||||
echo "🛑 停止 gost 服务..."
|
||||
systemctl stop gost
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🛑 停止 flux_agent 服务..."
|
||||
systemctl stop flux_agent
|
||||
fi
|
||||
|
||||
# 替换文件
|
||||
mv "$INSTALL_DIR/gost.new" "$INSTALL_DIR/gost"
|
||||
chmod +x "$INSTALL_DIR/gost"
|
||||
mv "$INSTALL_DIR/flux_agent.new" "$INSTALL_DIR/flux_agent"
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
|
||||
# 打印版本
|
||||
echo "🔎 新版本:$($INSTALL_DIR/gost -V)"
|
||||
echo "🔎 新版本:$($INSTALL_DIR/flux_agent -V)"
|
||||
|
||||
# 重启服务
|
||||
echo "🔄 重启服务..."
|
||||
systemctl start gost
|
||||
systemctl start flux_agent
|
||||
|
||||
echo "✅ 更新完成,服务已重新启动。"
|
||||
}
|
||||
|
||||
# 卸载功能
|
||||
uninstall_gost() {
|
||||
echo "🗑️ 开始卸载 GOST..."
|
||||
uninstall_flux_agent() {
|
||||
echo "🗑️ 开始卸载 flux_agent..."
|
||||
|
||||
read -p "确认卸载 GOST 吗?此操作将删除所有相关文件 (y/N): " confirm
|
||||
read -p "确认卸载 flux_agent 吗?此操作将删除所有相关文件 (y/N): " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "❌ 取消卸载"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 停止并禁用服务
|
||||
if systemctl list-units --full -all | grep -Fq "gost.service"; then
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🛑 停止并禁用服务..."
|
||||
systemctl stop gost 2>/dev/null
|
||||
systemctl disable gost 2>/dev/null
|
||||
systemctl stop flux_agent 2>/dev/null
|
||||
systemctl disable flux_agent 2>/dev/null
|
||||
fi
|
||||
|
||||
# 删除服务文件
|
||||
if [[ -f "/etc/systemd/system/gost.service" ]]; then
|
||||
rm -f "/etc/systemd/system/gost.service"
|
||||
if [[ -f "/etc/systemd/system/flux_agent.service" ]]; then
|
||||
rm -f "/etc/systemd/system/flux_agent.service"
|
||||
echo "🧹 删除服务文件"
|
||||
fi
|
||||
|
||||
@@ -302,7 +398,7 @@ uninstall_gost() {
|
||||
main() {
|
||||
# 如果提供了命令行参数,直接执行安装
|
||||
if [[ -n "$SERVER_ADDR" && -n "$SECRET" ]]; then
|
||||
install_gost
|
||||
install_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
fi
|
||||
@@ -310,36 +406,31 @@ main() {
|
||||
# 显示交互式菜单
|
||||
while true; do
|
||||
show_menu
|
||||
read -p "请输入选项 (1-5): " choice
|
||||
read -p "请输入选项 (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
install_gost
|
||||
install_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
update_gost
|
||||
update_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
3)
|
||||
uninstall_gost
|
||||
uninstall_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
block_protocol
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
5)
|
||||
echo "👋 退出脚本"
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ 无效选项,请输入 1-5"
|
||||
echo "❌ 无效选项,请输入 1-4"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
@@ -347,4 +438,4 @@ main() {
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main
|
||||
main
|
||||
|
||||
+86
-781
@@ -7,18 +7,82 @@ export LC_ALL=C
|
||||
|
||||
|
||||
|
||||
# 全局下载地址配置
|
||||
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.3/docker-compose-v4.yml"
|
||||
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.3/docker-compose-v6.yml"
|
||||
GOST_SQL_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.3/gost.sql"
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
||||
GOST_SQL_URL="https://ghfast.top/${GOST_SQL_URL}"
|
||||
fi
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://ghfast.top/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
local effective_url tag api_tag latest_url api_url
|
||||
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
echo "$VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./panel_install.sh" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
|
||||
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
|
||||
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
|
||||
|
||||
|
||||
|
||||
@@ -149,8 +213,7 @@ show_menu() {
|
||||
echo "1. 安装面板"
|
||||
echo "2. 更新面板"
|
||||
echo "3. 卸载面板"
|
||||
echo "4. 导出备份"
|
||||
echo "5. 退出"
|
||||
echo "4. 退出"
|
||||
echo "==============================================="
|
||||
}
|
||||
|
||||
@@ -173,17 +236,13 @@ delete_self() {
|
||||
get_config_params() {
|
||||
echo "🔧 请输入配置参数:"
|
||||
|
||||
|
||||
|
||||
read -p "前端端口(默认 6366): " FRONTEND_PORT
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-6366}
|
||||
|
||||
read -p "后端端口(默认 6365): " BACKEND_PORT
|
||||
BACKEND_PORT=${BACKEND_PORT:-6365}
|
||||
|
||||
DB_NAME=$(generate_random)
|
||||
DB_USER=$(generate_random)
|
||||
DB_PASSWORD=$(generate_random)
|
||||
# 生成JWT密钥
|
||||
JWT_SECRET=$(generate_random)
|
||||
}
|
||||
|
||||
@@ -197,14 +256,6 @@ install_panel() {
|
||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
||||
|
||||
# 检查 gost.sql 是否已存在
|
||||
if [[ -f "gost.sql" ]]; then
|
||||
echo "⏭️ 跳过下载: gost.sql (使用当前位置的文件)"
|
||||
else
|
||||
echo "📡 下载数据库初始化文件..."
|
||||
curl -L -o gost.sql "$GOST_SQL_URL"
|
||||
fi
|
||||
echo "✅ 文件准备完成"
|
||||
|
||||
# 自动检测并配置 IPv6 支持
|
||||
@@ -214,9 +265,6 @@ install_panel() {
|
||||
fi
|
||||
|
||||
cat > .env <<EOF
|
||||
DB_NAME=$DB_NAME
|
||||
DB_USER=$DB_USER
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
FRONTEND_PORT=$FRONTEND_PORT
|
||||
BACKEND_PORT=$BACKEND_PORT
|
||||
@@ -252,7 +300,15 @@ update_panel() {
|
||||
configure_docker_ipv6
|
||||
fi
|
||||
|
||||
echo "🛑 停止当前服务..."
|
||||
# 先发送 SIGTERM 信号,让应用优雅关闭
|
||||
docker stop -t 30 springboot-backend 2>/dev/null || true
|
||||
docker stop -t 10 vite-frontend 2>/dev/null || true
|
||||
|
||||
# 等待 WAL 文件同步
|
||||
echo "⏳ 等待数据同步..."
|
||||
sleep 5
|
||||
|
||||
# 然后再完全停止
|
||||
$DOCKER_CMD down
|
||||
|
||||
echo "⬇️ 拉取最新镜像..."
|
||||
@@ -295,755 +351,9 @@ update_panel() {
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 检查数据库容器健康状态
|
||||
echo "🔍 检查数据库服务状态..."
|
||||
for i in {1..60}; do
|
||||
if docker ps --format "{{.Names}}" | grep -q "^gost-mysql$"; then
|
||||
DB_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' gost-mysql 2>/dev/null || echo "unknown")
|
||||
if [[ "$DB_HEALTH" == "healthy" ]]; then
|
||||
echo "✅ 数据库服务健康检查通过"
|
||||
break
|
||||
elif [[ "$DB_HEALTH" == "starting" ]]; then
|
||||
# 继续等待
|
||||
:
|
||||
elif [[ "$DB_HEALTH" == "unhealthy" ]]; then
|
||||
echo "⚠️ 数据库健康状态:$DB_HEALTH"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 数据库容器未找到或未运行"
|
||||
DB_HEALTH="not_running"
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "❌ 数据库服务启动超时(60秒)"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' gost-mysql 2>/dev/null || echo '容器不存在')"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
# 每10秒显示一次进度
|
||||
if [ $((i % 10)) -eq 1 ]; then
|
||||
echo "⏳ 等待数据库服务启动... ($i/60) 状态:${DB_HEALTH:-unknown}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 从容器环境变量获取数据库信息
|
||||
echo "🔍 获取数据库配置信息..."
|
||||
|
||||
# 等待一下让服务完全就绪
|
||||
echo "⏳ 等待服务完全就绪..."
|
||||
sleep 5
|
||||
|
||||
# 先检查后端容器是否在运行
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^springboot-backend$"; then
|
||||
echo "❌ 后端容器未运行,无法获取数据库配置"
|
||||
echo "🔍 当前运行的容器:"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
|
||||
DB_INFO=$(docker exec springboot-backend env | grep "^DB_" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$DB_INFO" ]]; then
|
||||
DB_NAME=$(echo "$DB_INFO" | grep "^DB_NAME=" | cut -d'=' -f2)
|
||||
DB_PASSWORD=$(echo "$DB_INFO" | grep "^DB_PASSWORD=" | cut -d'=' -f2)
|
||||
DB_USER=$(echo "$DB_INFO" | grep "^DB_USER=" | cut -d'=' -f2)
|
||||
DB_HOST=$(echo "$DB_INFO" | grep "^DB_HOST=" | cut -d'=' -f2)
|
||||
|
||||
echo "📋 数据库配置:"
|
||||
echo " 数据库名: $DB_NAME"
|
||||
echo " 用户名: $DB_USER"
|
||||
echo " 主机: $DB_HOST"
|
||||
else
|
||||
echo "❌ 无法获取数据库配置信息"
|
||||
echo "🔍 尝试诊断问题:"
|
||||
echo " 容器状态: $(docker inspect -f '{{.State.Status}}' springboot-backend 2>/dev/null || echo '容器不存在')"
|
||||
echo " 健康状态: $(docker inspect -f '{{.State.Health.Status}}' springboot-backend 2>/dev/null || echo '无健康检查')"
|
||||
|
||||
# 尝试从 .env 文件读取配置
|
||||
if [[ -f ".env" ]]; then
|
||||
echo "🔄 尝试从 .env 文件读取配置..."
|
||||
DB_NAME=$(grep "^DB_NAME=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
DB_PASSWORD=$(grep "^DB_PASSWORD=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
DB_USER=$(grep "^DB_USER=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
|
||||
if [[ -n "$DB_NAME" && -n "$DB_PASSWORD" && -n "$DB_USER" ]]; then
|
||||
echo "✅ 从 .env 文件成功读取数据库配置"
|
||||
echo "📋 数据库配置:"
|
||||
echo " 数据库名: $DB_NAME"
|
||||
echo " 用户名: $DB_USER"
|
||||
else
|
||||
echo "❌ .env 文件中的数据库配置不完整"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 未找到 .env 文件"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查必要的数据库配置
|
||||
if [[ -z "$DB_PASSWORD" || -z "$DB_USER" || -z "$DB_NAME" ]]; then
|
||||
echo "❌ 数据库配置不完整(缺少必要参数)"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 执行数据库字段变更
|
||||
echo "🔄 执行数据库结构更新..."
|
||||
|
||||
# 创建临时迁移文件(现在有了数据库信息)
|
||||
cat > temp_migration.sql <<EOF
|
||||
-- 数据库结构更新
|
||||
USE \`$DB_NAME\`;
|
||||
|
||||
-- user 表:删除 name 字段(如果存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'user'
|
||||
AND column_name = 'name'
|
||||
),
|
||||
'ALTER TABLE \`user\` DROP COLUMN \`name\`;',
|
||||
'SELECT "Column \`name\` not exists in \`user\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- node 表:删除 port 字段、添加 server_ip 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'port'
|
||||
),
|
||||
'ALTER TABLE \`node\` DROP COLUMN \`port\`;',
|
||||
'SELECT "Column \`port\` not exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'server_ip'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`server_ip\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;',
|
||||
'SELECT "Column \`server_ip\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 将 ip 赋值给 server_ip(如果字段都存在)
|
||||
UPDATE \`node\`
|
||||
SET \`server_ip\` = \`ip\`
|
||||
WHERE \`server_ip\` IS NULL;
|
||||
|
||||
-- node 表:修改 ip 字段类型为 longtext
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'ip'
|
||||
AND data_type = 'varchar'
|
||||
),
|
||||
'ALTER TABLE \`node\` MODIFY COLUMN \`ip\` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;',
|
||||
'SELECT "Column \`ip\` not exists or already modified in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- node 表:添加 version 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'version'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`version\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
|
||||
'SELECT "Column \`version\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- node 表:添加 port_sta 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'port_sta'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`port_sta\` INT(10) DEFAULT 1000 COMMENT "端口起始范围";',
|
||||
'SELECT "Column \`port_sta\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- node 表:添加 port_end 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'port_end'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`port_end\` INT(10) DEFAULT 65535 COMMENT "端口结束范围";',
|
||||
'SELECT "Column \`port_end\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为现有节点设置默认端口范围
|
||||
UPDATE \`node\`
|
||||
SET \`port_sta\` = 1000, \`port_end\` = 65535
|
||||
WHERE \`port_sta\` IS NULL OR \`port_end\` IS NULL;
|
||||
|
||||
-- node 表:添加 http、tls、socks 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'http'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`http\` INT(10) DEFAULT 0 COMMENT "HTTP 服务端口";',
|
||||
'SELECT "Column \`http\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'tls'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`tls\` INT(10) DEFAULT 0 COMMENT "TLS 服务端口";',
|
||||
'SELECT "Column \`tls\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'socks'
|
||||
),
|
||||
'ALTER TABLE \`node\` ADD COLUMN \`socks\` INT(10) DEFAULT 0 COMMENT "SOCKS 服务端口";',
|
||||
'SELECT "Column \`socks\` already exists in \`node\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为现有节点设置 http、tls、socks 默认值
|
||||
UPDATE \`node\`
|
||||
SET \`http\` = IFNULL(\`http\`, 0),
|
||||
\`tls\` = IFNULL(\`tls\`, 0),
|
||||
\`socks\` = IFNULL(\`socks\`, 0);
|
||||
|
||||
-- tunnel 表:删除废弃字段(如果存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'in_port_sta'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_sta\`;',
|
||||
'SELECT "Column \`in_port_sta\` not exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'in_port_end'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_end\`;',
|
||||
'SELECT "Column \`in_port_end\` not exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'out_ip_sta'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_sta\`;',
|
||||
'SELECT "Column \`out_ip_sta\` not exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'out_ip_end'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_end\`;',
|
||||
'SELECT "Column \`out_ip_end\` not exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- tunnel 表:添加 tcp_listen_addr、udp_listen_addr、protocol(如果不存在)
|
||||
|
||||
-- tcp_listen_addr
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'tcp_listen_addr'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` ADD COLUMN \`tcp_listen_addr\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "0.0.0.0";',
|
||||
'SELECT "Column \`tcp_listen_addr\` already exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- udp_listen_addr
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'udp_listen_addr'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` ADD COLUMN \`udp_listen_addr\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "0.0.0.0";',
|
||||
'SELECT "Column \`udp_listen_addr\` already exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- protocol
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'protocol'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` ADD COLUMN \`protocol\` VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "tls";',
|
||||
'SELECT "Column \`protocol\` already exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- traffic_ratio (流量倍率)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'traffic_ratio'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` ADD COLUMN \`traffic_ratio\` DECIMAL(5,1) DEFAULT 1.0 COMMENT "流量倍率";',
|
||||
'SELECT "Column \`traffic_ratio\` already exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为现有数据设置默认流量倍率
|
||||
UPDATE \`tunnel\`
|
||||
SET \`traffic_ratio\` = 1.0
|
||||
WHERE \`traffic_ratio\` IS NULL;
|
||||
|
||||
-- forward 表:删除 proxy_protocol 字段(如果存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'forward'
|
||||
AND column_name = 'proxy_protocol'
|
||||
),
|
||||
'ALTER TABLE \`forward\` DROP COLUMN \`proxy_protocol\`;',
|
||||
'SELECT "Column \`proxy_protocol\` not exists in \`forward\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- forward 表:修改 remote_addr 字段类型为 longtext
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'forward'
|
||||
AND column_name = 'remote_addr'
|
||||
AND data_type = 'varchar'
|
||||
),
|
||||
'ALTER TABLE \`forward\` MODIFY COLUMN \`remote_addr\` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL;',
|
||||
'SELECT "Column \`remote_addr\` not exists or already modified in \`forward\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- forward 表:添加 strategy 字段(负载均衡策略)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'forward'
|
||||
AND column_name = 'strategy'
|
||||
),
|
||||
'ALTER TABLE \`forward\` ADD COLUMN \`strategy\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "fifo" COMMENT "负载均衡策略";',
|
||||
'SELECT "Column \`strategy\` already exists in \`forward\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为现有数据设置默认负载均衡策略
|
||||
UPDATE \`forward\`
|
||||
SET \`strategy\` = 'fifo'
|
||||
WHERE \`strategy\` IS NULL;
|
||||
|
||||
-- forward 表:添加 inx 字段(排序索引)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'forward'
|
||||
AND column_name = 'inx'
|
||||
),
|
||||
'ALTER TABLE \`forward\` ADD COLUMN \`inx\` INT(10) DEFAULT 0 COMMENT "排序索引";',
|
||||
'SELECT "Column \`inx\` already exists in \`forward\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为现有数据设置默认排序索引
|
||||
UPDATE \`forward\`
|
||||
SET \`inx\` = 0
|
||||
WHERE \`inx\` IS NULL;
|
||||
|
||||
-- tunnel 表:添加 interface_name 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tunnel'
|
||||
AND column_name = 'interface_name'
|
||||
),
|
||||
'ALTER TABLE \`tunnel\` ADD COLUMN \`interface_name\` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
|
||||
'SELECT "Column \`interface_name\` already exists in \`tunnel\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- forward 表:添加 interface_name 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'forward'
|
||||
AND column_name = 'interface_name'
|
||||
),
|
||||
'ALTER TABLE \`forward\` ADD COLUMN \`interface_name\` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
|
||||
'SELECT "Column \`interface_name\` already exists in \`forward\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 创建 vite_config 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS \`vite_config\` (
|
||||
\`id\` int(10) NOT NULL AUTO_INCREMENT,
|
||||
\`name\` varchar(200) NOT NULL,
|
||||
\`value\` varchar(200) NOT NULL,
|
||||
\`time\` bigint(20) NOT NULL,
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`unique_name\` (\`name\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 创建 statistics_flow 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS \`statistics_flow\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
\`user_id\` int(10) NOT NULL,
|
||||
\`flow\` bigint(20) NOT NULL,
|
||||
\`total_flow\` bigint(20) NOT NULL,
|
||||
\`time\` varchar(100) NOT NULL,
|
||||
\`created_time\` bigint(20) NOT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- statistics_flow 表:添加 created_time 字段(如果不存在)
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'statistics_flow'
|
||||
AND column_name = 'created_time'
|
||||
),
|
||||
'ALTER TABLE \`statistics_flow\` ADD COLUMN \`created_time\` BIGINT(20) NOT NULL DEFAULT 0 COMMENT "创建时间毫秒时间戳";',
|
||||
'SELECT "Column \`created_time\` already exists in \`statistics_flow\`";'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为现有记录设置当前毫秒时间戳(仅当 created_time 为 0 或 NULL 时)
|
||||
UPDATE \`statistics_flow\`
|
||||
SET \`created_time\` = UNIX_TIMESTAMP() * 1000
|
||||
WHERE \`created_time\` = 0 OR \`created_time\` IS NULL;
|
||||
|
||||
EOF
|
||||
|
||||
# 检查数据库容器
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^gost-mysql$"; then
|
||||
echo "❌ 数据库容器 gost-mysql 未运行"
|
||||
echo "🔍 当前运行的容器:"
|
||||
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||
echo "❌ 数据库结构更新失败,请手动执行 temp_migration.sql"
|
||||
echo "📁 迁移文件已保存为 temp_migration.sql"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 执行数据库迁移
|
||||
if docker exec -i gost-mysql mysql -u "$DB_USER" -p"$DB_PASSWORD" < temp_migration.sql 2>/dev/null; then
|
||||
echo "✅ 数据库结构更新完成"
|
||||
else
|
||||
echo "⚠️ 使用用户密码失败,尝试root密码..."
|
||||
if docker exec -i gost-mysql mysql -u root -p"$DB_PASSWORD" < temp_migration.sql 2>/dev/null; then
|
||||
echo "✅ 数据库结构更新完成"
|
||||
else
|
||||
echo "❌ 数据库结构更新失败,请手动执行 temp_migration.sql"
|
||||
echo "📁 迁移文件已保存为 temp_migration.sql"
|
||||
echo "🔍 数据库容器状态: $(docker inspect -f '{{.State.Status}}' gost-mysql 2>/dev/null || echo '容器不存在')"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
rm -f temp_migration.sql
|
||||
|
||||
echo "✅ 更新完成"
|
||||
}
|
||||
|
||||
# 导出数据库备份
|
||||
export_migration_sql() {
|
||||
echo "📄 开始导出数据库备份..."
|
||||
|
||||
# 获取数据库配置信息
|
||||
echo "🔍 获取数据库配置信息..."
|
||||
|
||||
# 先检查后端容器是否在运行
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^springboot-backend$"; then
|
||||
echo "❌ 后端容器未运行,尝试从 .env 文件读取配置..."
|
||||
|
||||
# 从 .env 文件读取配置
|
||||
if [[ -f ".env" ]]; then
|
||||
DB_NAME=$(grep "^DB_NAME=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
DB_PASSWORD=$(grep "^DB_PASSWORD=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
DB_USER=$(grep "^DB_USER=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
|
||||
if [[ -n "$DB_NAME" && -n "$DB_PASSWORD" && -n "$DB_USER" ]]; then
|
||||
echo "✅ 从 .env 文件读取数据库配置成功"
|
||||
else
|
||||
echo "❌ .env 文件中的数据库配置不完整"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 未找到 .env 文件"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# 从容器环境变量获取数据库信息
|
||||
DB_INFO=$(docker exec springboot-backend env | grep "^DB_" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$DB_INFO" ]]; then
|
||||
DB_NAME=$(echo "$DB_INFO" | grep "^DB_NAME=" | cut -d'=' -f2)
|
||||
DB_PASSWORD=$(echo "$DB_INFO" | grep "^DB_PASSWORD=" | cut -d'=' -f2)
|
||||
DB_USER=$(echo "$DB_INFO" | grep "^DB_USER=" | cut -d'=' -f2)
|
||||
|
||||
echo "✅ 从容器环境变量读取数据库配置成功"
|
||||
else
|
||||
echo "❌ 无法从容器获取数据库配置,尝试从 .env 文件读取..."
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
DB_NAME=$(grep "^DB_NAME=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
DB_PASSWORD=$(grep "^DB_PASSWORD=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
DB_USER=$(grep "^DB_USER=" .env | cut -d'=' -f2 2>/dev/null)
|
||||
|
||||
if [[ -n "$DB_NAME" && -n "$DB_PASSWORD" && -n "$DB_USER" ]]; then
|
||||
echo "✅ 从 .env 文件读取数据库配置成功"
|
||||
else
|
||||
echo "❌ .env 文件中的数据库配置不完整"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 未找到 .env 文件"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查必要的数据库配置
|
||||
if [[ -z "$DB_PASSWORD" || -z "$DB_USER" || -z "$DB_NAME" ]]; then
|
||||
echo "❌ 数据库配置不完整(缺少必要参数)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "📋 数据库配置:"
|
||||
echo " 数据库名: $DB_NAME"
|
||||
echo " 用户名: $DB_USER"
|
||||
|
||||
# 检查数据库容器是否运行
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^gost-mysql$"; then
|
||||
echo "❌ 数据库容器未运行,无法导出数据"
|
||||
echo "🔍 当前运行的容器:"
|
||||
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 生成数据库备份文件
|
||||
SQL_FILE="database_backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
echo "📝 导出数据库备份: $SQL_FILE"
|
||||
|
||||
# 使用 mysqldump 导出数据库
|
||||
echo "⏳ 正在导出数据库..."
|
||||
if docker exec gost-mysql mysqldump -u "$DB_USER" -p"$DB_PASSWORD" --single-transaction --routines --triggers "$DB_NAME" > "$SQL_FILE" 2>/dev/null; then
|
||||
echo "✅ 数据库导出成功"
|
||||
else
|
||||
echo "⚠️ 使用用户密码失败,尝试root密码..."
|
||||
if docker exec gost-mysql mysqldump -u root -p"$DB_PASSWORD" --single-transaction --routines --triggers "$DB_NAME" > "$SQL_FILE" 2>/dev/null; then
|
||||
echo "✅ 数据库导出成功"
|
||||
else
|
||||
echo "❌ 数据库导出失败"
|
||||
rm -f "$SQL_FILE"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查文件大小
|
||||
if [[ -f "$SQL_FILE" ]] && [[ -s "$SQL_FILE" ]]; then
|
||||
FILE_SIZE=$(du -h "$SQL_FILE" | cut -f1)
|
||||
echo "📁 文件位置: $(pwd)/$SQL_FILE"
|
||||
echo "📊 文件大小: $FILE_SIZE"
|
||||
else
|
||||
echo "❌ 导出的文件为空或不存在"
|
||||
rm -f "$SQL_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# 卸载功能
|
||||
@@ -1068,7 +378,7 @@ uninstall_panel() {
|
||||
echo "🛑 停止并删除容器、镜像、卷..."
|
||||
$DOCKER_CMD down --rmi all --volumes --remove-orphans
|
||||
echo "🧹 删除配置文件..."
|
||||
rm -f docker-compose.yml gost.sql .env
|
||||
rm -f docker-compose.yml .env
|
||||
echo "✅ 卸载完成"
|
||||
}
|
||||
|
||||
@@ -1097,11 +407,6 @@ main() {
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
export_migration_sql
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
5)
|
||||
echo "👋 退出脚本"
|
||||
delete_self
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<factorypath>
|
||||
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
|
||||
</factorypath>
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPRINGBOOT BACKEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
|
||||
## OVERVIEW
|
||||
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
|
||||
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
springboot-backend/
|
||||
├── src/main/java/com/admin/
|
||||
│ ├── controller/ # /api/v1/* endpoints
|
||||
│ ├── entity/ # DB models
|
||||
│ ├── mapper/ # MyBatis Plus mappers
|
||||
│ ├── service/ # Business logic
|
||||
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
|
||||
│ └── common/ # DTOs, auth, exception handling, utilities
|
||||
└── src/main/resources/
|
||||
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
|
||||
├── mapper/ # XML mappers
|
||||
├── schema.sql # Schema
|
||||
└── data.sql # Seed data
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
|
||||
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
|
||||
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
|
||||
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
|
||||
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd springboot-backend
|
||||
mvn clean package
|
||||
mvn test
|
||||
java -jar target/admin-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
@@ -16,11 +16,12 @@ ENV LC_ALL=en_US.UTF-8
|
||||
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu \
|
||||
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu sqlite3 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
|
||||
EXPOSE 6365
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
|
||||
@@ -69,10 +69,9 @@
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>8.2.0</version>
|
||||
<scope>runtime</scope>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<version>3.45.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
springboot-backend/src/main/java/com/admin/
|
||||
├── controller/ # REST controllers (e.g., /api/v1/user)
|
||||
├── service/ # Business logic interfaces + impl/
|
||||
├── mapper/ # MyBatis Plus mappers
|
||||
├── entity/ # DB entities
|
||||
├── config/ # WebMvc/JWT/CORS/WebSocket config
|
||||
└── common/ # DTOs, auth, exception handling, utilities
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
|
||||
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
|
||||
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
|
||||
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
|
||||
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
|
||||
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
|
||||
|
||||
## CONVENTIONS
|
||||
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
|
||||
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
|
||||
@@ -1,128 +0,0 @@
|
||||
package com.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringPool;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
|
||||
import com.baomidou.mybatisplus.generator.AutoGenerator;
|
||||
import com.baomidou.mybatisplus.generator.InjectionConfig;
|
||||
import com.baomidou.mybatisplus.generator.config.*;
|
||||
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
|
||||
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
|
||||
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Scanner;
|
||||
|
||||
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
|
||||
public class CodeGenerator {
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 读取控制台内容
|
||||
* </p>
|
||||
*/
|
||||
public static String scanner(String tip) {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
StringBuilder help = new StringBuilder();
|
||||
help.append("请输入" + tip + ":");
|
||||
System.out.println(help.toString());
|
||||
if (scanner.hasNext()) {
|
||||
String ipt = scanner.next();
|
||||
if (StringUtils.isNotBlank(ipt)) {
|
||||
return ipt;
|
||||
}
|
||||
}
|
||||
throw new MybatisPlusException("请输入正确的" + tip + "!");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 代码生成器
|
||||
AutoGenerator mpg = new AutoGenerator();
|
||||
|
||||
// 全局配置
|
||||
GlobalConfig gc = new GlobalConfig();
|
||||
String projectPath = System.getProperty("user.dir");
|
||||
gc.setOutputDir(projectPath + "/src/main/java");
|
||||
gc.setAuthor("QAQ");
|
||||
gc.setOpen(false);
|
||||
// gc.setSwagger2(true); 实体属性 Swagger2 注解
|
||||
gc.setServiceName("%sService");
|
||||
mpg.setGlobalConfig(gc);
|
||||
|
||||
// 数据源配置 - 使用环境变量
|
||||
DataSourceConfig dsc = new DataSourceConfig();
|
||||
String dbHost = System.getenv("DB_HOST");
|
||||
String dbName = System.getenv("DB_NAME");
|
||||
String dbUser = System.getenv("DB_USER");
|
||||
String dbPassword = System.getenv("DB_PASSWORD");
|
||||
|
||||
if (dbHost == null || dbName == null || dbUser == null || dbPassword == null) {
|
||||
throw new MybatisPlusException("请设置数据库环境变量: DB_HOST, DB_NAME, DB_USER, DB_PASSWORD");
|
||||
}
|
||||
|
||||
dsc.setUrl("jdbc:mysql://" + dbHost + "/" + dbName + "?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
|
||||
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
|
||||
dsc.setUsername(dbUser);
|
||||
dsc.setPassword(dbPassword);
|
||||
mpg.setDataSource(dsc);
|
||||
|
||||
// 包配置
|
||||
PackageConfig pc = new PackageConfig();
|
||||
// pc.setModuleName(scanner("模块名"));
|
||||
pc.setParent("com.admin");
|
||||
mpg.setPackageInfo(pc);
|
||||
|
||||
// 自定义配置
|
||||
InjectionConfig cfg = new InjectionConfig() {
|
||||
@Override
|
||||
public void initMap() {
|
||||
// to do nothing
|
||||
}
|
||||
};
|
||||
|
||||
// 如果模板引擎是 freemarker
|
||||
String templatePath = "/templates/mapper.xml.ftl";
|
||||
// 如果模板引擎是 velocity
|
||||
// String templatePath = "/templates/mapper.xml.vm";
|
||||
|
||||
// 自定义输出配置
|
||||
List<FileOutConfig> focList = new ArrayList<>();
|
||||
// 自定义配置会被优先输出
|
||||
focList.add(new FileOutConfig(templatePath) {
|
||||
@Override
|
||||
public String outputFile(TableInfo tableInfo) {
|
||||
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
|
||||
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
|
||||
}
|
||||
});
|
||||
|
||||
cfg.setFileOutConfigList(focList);
|
||||
mpg.setCfg(cfg);
|
||||
|
||||
// 配置模板
|
||||
TemplateConfig templateConfig = new TemplateConfig();
|
||||
|
||||
|
||||
templateConfig.setXml(null);
|
||||
mpg.setTemplate(templateConfig);
|
||||
|
||||
// 策略配置
|
||||
StrategyConfig strategy = new StrategyConfig();
|
||||
strategy.setNaming(NamingStrategy.underline_to_camel);
|
||||
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
|
||||
strategy.setSuperEntityClass("com.admin.entity.BaseEntity");
|
||||
strategy.setEntityLombokModel(true);
|
||||
strategy.setRestControllerStyle(true);
|
||||
// 公共父类
|
||||
strategy.setSuperControllerClass("com.admin.controller.BaseController");
|
||||
strategy.setSuperEntityColumns("id", "created_time", "updated_time", "status");
|
||||
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
|
||||
strategy.setControllerMappingHyphenStyle(true);
|
||||
// strategy.setTablePrefix("sys_");//动态调整
|
||||
mpg.setStrategy(strategy);
|
||||
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
|
||||
mpg.execute();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ChainNodesItems {
|
||||
|
||||
private String mode;
|
||||
|
||||
private List<Integer> nodeIds;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class DiagnosisResult {
|
||||
private Long nodeId;
|
||||
private String nodeName;
|
||||
private String targetIp;
|
||||
private Integer targetPort;
|
||||
private String description;
|
||||
private boolean success;
|
||||
private String message;
|
||||
private double averageTime;
|
||||
private double packetLoss;
|
||||
private long timestamp;
|
||||
private Integer fromChainType;
|
||||
private Integer fromInx;
|
||||
private Integer toChainType;
|
||||
private Integer toInx;
|
||||
}
|
||||
@@ -21,14 +21,6 @@ public class ForwardDto {
|
||||
private String remoteAddr;
|
||||
|
||||
private String strategy;
|
||||
|
||||
/**
|
||||
* 入口端口(可选,为空时自动分配)
|
||||
*/
|
||||
@Min(value = 1, message = "端口号不能小于1")
|
||||
@Max(value = 65535, message = "端口号不能大于65535")
|
||||
|
||||
private Integer inPort;
|
||||
|
||||
private String interfaceName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ForwardPortDto {
|
||||
private Integer node_id;
|
||||
private Integer port;
|
||||
}
|
||||
@@ -19,22 +19,14 @@ public class ForwardUpdateDto {
|
||||
|
||||
@NotBlank(message = "转发名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "隧道ID不能为空")
|
||||
|
||||
private Integer tunnelId;
|
||||
|
||||
|
||||
@NotBlank(message = "远程地址不能为空")
|
||||
private String remoteAddr;
|
||||
|
||||
private String strategy;
|
||||
|
||||
/**
|
||||
* 入口端口(可选,为空时自动分配)
|
||||
*/
|
||||
@Min(value = 1, message = "端口号不能小于1")
|
||||
@Max(value = 65535, message = "端口号不能大于65535")
|
||||
|
||||
private Integer inPort;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.IGNORED)
|
||||
private String interfaceName;
|
||||
}
|
||||
@@ -25,6 +25,11 @@ public class ForwardWithTunnelDto {
|
||||
private String name;
|
||||
|
||||
|
||||
/**
|
||||
* 入口IP
|
||||
*/
|
||||
private String inIp;
|
||||
|
||||
/**
|
||||
* 入口端口
|
||||
*/
|
||||
@@ -56,15 +61,10 @@ public class ForwardWithTunnelDto {
|
||||
* 隧道名称
|
||||
*/
|
||||
private String tunnelName;
|
||||
|
||||
/**
|
||||
* 入口IP
|
||||
*/
|
||||
private String inIp;
|
||||
|
||||
|
||||
private String userName;
|
||||
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@@ -87,6 +87,4 @@ public class ForwardWithTunnelDto {
|
||||
private String strategy;
|
||||
|
||||
private Integer inx;
|
||||
|
||||
private String interfaceName;
|
||||
}
|
||||
@@ -13,19 +13,20 @@ public class NodeDto {
|
||||
@NotBlank(message = "节点名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "入口IP不能为空")
|
||||
private String ip;
|
||||
|
||||
@NotBlank(message = "服务器ip不能为空")
|
||||
private String serverIp;
|
||||
|
||||
@NotNull(message = "起始端口不能为空")
|
||||
@Min(value = 1, message = "起始端口必须大于0")
|
||||
@Max(value = 65535, message = "起始端口不能超过65535")
|
||||
private Integer portSta;
|
||||
private String serverIpV4;
|
||||
|
||||
@NotNull(message = "结束端口不能为空")
|
||||
@Min(value = 1, message = "结束端口必须大于0")
|
||||
@Max(value = 65535, message = "结束端口不能超过65535")
|
||||
private Integer portEnd;
|
||||
}
|
||||
private String serverIpV6;
|
||||
|
||||
@NotBlank(message = "可用端口不能为空")
|
||||
private String port;
|
||||
|
||||
private String interfaceName;
|
||||
|
||||
private String tcpListenAddr = "0.0.0.0";
|
||||
|
||||
private String udpListenAddr = "0.0.0.0";
|
||||
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
|
||||
@Data
|
||||
public class NodeUpdateDto {
|
||||
@@ -16,23 +14,22 @@ public class NodeUpdateDto {
|
||||
@NotBlank(message = "节点名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "入口IP不能为空")
|
||||
private String ip;
|
||||
|
||||
@NotBlank(message = "服务器ip不能为空")
|
||||
private String serverIp;
|
||||
|
||||
@NotNull(message = "起始端口不能为空")
|
||||
@Min(value = 1, message = "起始端口必须大于0")
|
||||
@Max(value = 65535, message = "起始端口不能超过65535")
|
||||
private Integer portSta;
|
||||
private String serverIpV4;
|
||||
|
||||
@NotNull(message = "结束端口不能为空")
|
||||
@Min(value = 1, message = "结束端口必须大于0")
|
||||
@Max(value = 65535, message = "结束端口不能超过65535")
|
||||
private Integer portEnd;
|
||||
private String serverIpV6;
|
||||
|
||||
@NotBlank(message = "可用port不能为空")
|
||||
private String port;
|
||||
|
||||
private String interfaceName;
|
||||
private Integer http;
|
||||
private Integer tls;
|
||||
private Integer socks;
|
||||
}
|
||||
|
||||
private String tcpListenAddr = "0.0.0.0";
|
||||
|
||||
private String udpListenAddr = "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -17,10 +17,4 @@ public class SpeedLimitUpdateDto {
|
||||
@NotNull(message = "速度限制不能为空")
|
||||
@Min(value = 1, message = "速度限制必须大于0")
|
||||
private Integer speed;
|
||||
|
||||
@NotNull(message = "隧道ID不能为空")
|
||||
private Long tunnelId;
|
||||
|
||||
@NotBlank(message = "隧道名称不能为空")
|
||||
private String tunnelName;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.admin.entity.ChainTunnel;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 隧道详情DTO - 包含节点配置信息
|
||||
*/
|
||||
@Data
|
||||
public class TunnelDetailDto {
|
||||
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
|
||||
private String name;
|
||||
|
||||
private Integer type; // 1: 端口转发, 2: 隧道转发
|
||||
|
||||
private Integer flow; // 1: 单向, 2: 双向
|
||||
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private Long createdTime;
|
||||
|
||||
private Long updatedTime;
|
||||
|
||||
private String inIp;
|
||||
|
||||
// 入口节点列表
|
||||
private List<ChainTunnel> inNodeId = new ArrayList<>();
|
||||
|
||||
// 转发链节点列表(二维数组结构)
|
||||
private List<List<ChainTunnel>> chainNodes = new ArrayList<>();
|
||||
|
||||
// 出口节点列表
|
||||
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
||||
}
|
||||
@@ -1,45 +1,36 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.admin.entity.ChainTunnel;
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.DecimalMax;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class TunnelDto {
|
||||
|
||||
@NotBlank(message = "隧道名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "入口节点不能为空")
|
||||
private Long inNodeId;
|
||||
|
||||
// 出口节点ID,当type=1时可以为空,会自动设置为入口节点ID
|
||||
private Long outNodeId;
|
||||
|
||||
@NotNull(message = "入口节点不能为空")
|
||||
private List<ChainTunnel> inNodeId;
|
||||
|
||||
private List<List<ChainTunnel>> chainNodes;
|
||||
|
||||
private List<ChainTunnel> outNodeId;
|
||||
|
||||
private String inIp;
|
||||
|
||||
@NotNull(message = "隧道类型不能为空")
|
||||
private Integer type;
|
||||
|
||||
@NotNull(message = "流量计算类型不能为空")
|
||||
private Integer flow;
|
||||
|
||||
// 流量倍率,默认为1.0
|
||||
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
|
||||
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
private String interfaceName;
|
||||
|
||||
// 协议类型,默认为tls
|
||||
private String protocol;
|
||||
|
||||
// TCP监听地址,默认为0.0.0.0
|
||||
private String tcpListenAddr = "0.0.0.0";
|
||||
|
||||
// UDP监听地址,默认为0.0.0.0
|
||||
private String udpListenAddr = "0.0.0.0";
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class TunnelListDto {
|
||||
|
||||
private Integer id;
|
||||
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 入口IP
|
||||
*/
|
||||
private String ip;
|
||||
|
||||
/**
|
||||
* 入口节点端口起始范围
|
||||
*/
|
||||
private Integer inNodePortSta;
|
||||
|
||||
/**
|
||||
* 入口节点端口结束范围
|
||||
*/
|
||||
private Integer inNodePortEnd;
|
||||
|
||||
/**
|
||||
* 隧道类型(1-端口转发,2-隧道转发)
|
||||
*/
|
||||
private Integer type;
|
||||
|
||||
/**
|
||||
* 协议类型
|
||||
*/
|
||||
private String protocol;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.admin.entity.ChainTunnel;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import lombok.Data;
|
||||
@@ -10,35 +11,32 @@ import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.DecimalMax;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class TunnelUpdateDto {
|
||||
|
||||
|
||||
@NotNull(message = "隧道ID不能为空")
|
||||
private Long id;
|
||||
|
||||
|
||||
@NotBlank(message = "隧道名称不能为空")
|
||||
private String name;
|
||||
|
||||
|
||||
@NotNull(message = "流量计算类型不能为空")
|
||||
private Integer flow;
|
||||
|
||||
// 流量倍率
|
||||
|
||||
private String inIp;
|
||||
|
||||
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
|
||||
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
@NotBlank
|
||||
private String protocol;
|
||||
// 入口节点配置(可选,为空时不更新节点配置)
|
||||
private List<ChainTunnel> inNodeId;
|
||||
|
||||
// TCP监听地址
|
||||
@NotBlank
|
||||
private String tcpListenAddr;
|
||||
|
||||
// UDP监听地址
|
||||
@NotBlank
|
||||
private String udpListenAddr;
|
||||
// 转发链节点配置(二维数组,每一跳可有多个节点)
|
||||
private List<List<ChainTunnel>> chainNodes;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.IGNORED)
|
||||
private String interfaceName;
|
||||
}
|
||||
// 出口节点配置
|
||||
private List<ChainTunnel> outNodeId;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserInfo {
|
||||
private final Integer userId;
|
||||
private final Integer roleId;
|
||||
private final String userName;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.service.impl.ForwardServiceImpl;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserPermissionResult {
|
||||
public boolean hasError;
|
||||
private String errorMessage;
|
||||
private Integer limiter;
|
||||
private UserTunnel userTunnel;
|
||||
|
||||
public static UserPermissionResult success(Integer limiter, UserTunnel userTunnel) {
|
||||
UserPermissionResult result = new UserPermissionResult();
|
||||
result.setLimiter(limiter);
|
||||
result.setUserTunnel(userTunnel);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static UserPermissionResult error(String errorMessage) {
|
||||
UserPermissionResult result = new UserPermissionResult();
|
||||
result.setErrorMessage(errorMessage);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.admin.common.migration;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Lightweight SQLite schema migration.
|
||||
*
|
||||
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
|
||||
* won't automatically receive new columns. This runner adds missing columns in-place.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class SqliteSchemaMigration implements ApplicationRunner {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
private void ensureColumn(String table, String column, String columnDefinition) {
|
||||
Set<String> columns = new HashSet<>(
|
||||
jdbcTemplate.query(
|
||||
"PRAGMA table_info(" + table + ")",
|
||||
(rs, rowNum) -> rs.getString("name")
|
||||
)
|
||||
);
|
||||
|
||||
if (columns.contains(column)) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Adding missing column {}.{}", table, column);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import com.admin.common.lang.R;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.service.*;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
@@ -14,6 +13,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -33,8 +33,7 @@ public class CheckGostConfigAsync {
|
||||
private SpeedLimitService speedLimitService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private TunnelService tunnelService;
|
||||
TunnelService tunnelService;
|
||||
|
||||
|
||||
|
||||
@@ -43,56 +42,51 @@ public class CheckGostConfigAsync {
|
||||
*/
|
||||
@Async
|
||||
public void cleanNodeConfigs(String node_id, GostConfigDto gostConfig) {
|
||||
System.out.println(JSONObject.toJSONString(gostConfig));
|
||||
Node node = nodeService.getById(node_id);
|
||||
if (node != null) {
|
||||
cleanOrphanedServices(gostConfig, node);
|
||||
cleanOrphanedChains(gostConfig, node);
|
||||
cleanOrphanedLimiters(gostConfig, node);
|
||||
cleanOrphanedServices(gostConfig.getServices(), node);
|
||||
cleanOrphanedChains(gostConfig.getChains(), node);
|
||||
cleanOrphanedLimiters(gostConfig.getLimiters(), node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理孤立的服务
|
||||
*/
|
||||
private void cleanOrphanedServices(GostConfigDto gostConfig, Node node) {
|
||||
if (gostConfig.getServices() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (ConfigItem service : gostConfig.getServices()) {
|
||||
private void cleanOrphanedServices(List<ConfigItem> configItems, Node node) {
|
||||
if (configItems == null) return;
|
||||
for (ConfigItem service : configItems) {
|
||||
safeExecute(() -> {
|
||||
|
||||
if (!Objects.equals(service.getName(), "web_api")){
|
||||
String[] serviceIds = parseServiceName(service.getName());
|
||||
if (serviceIds.length == 4) {
|
||||
String forwardId = serviceIds[0];
|
||||
String userId = serviceIds[1];
|
||||
String userTunnelId = serviceIds[2];
|
||||
String type = serviceIds[3];
|
||||
List<String> serviceIds = parseServiceName(service.getName());
|
||||
|
||||
if (Objects.equals(type, "tcp")) { // 只处理TCP,避免重复处理
|
||||
Forward forward = forwardService.getById(forwardId);
|
||||
if (forward == null) {
|
||||
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
|
||||
GostDto gostDto = GostUtil.DeleteService(node.getId(), forwardId + "_" + userId + "_" + userTunnelId);
|
||||
System.out.println(gostDto);
|
||||
}
|
||||
}
|
||||
JSONArray services = new JSONArray();
|
||||
if (Objects.equals(serviceIds.getLast(), "tls")){
|
||||
String forward_id = serviceIds.getFirst();
|
||||
services.add(forward_id + "_tls");
|
||||
|
||||
|
||||
if (Objects.equals(type, "tls")) {
|
||||
Forward forward = forwardService.getById(forwardId);
|
||||
if (forward == null) {
|
||||
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
|
||||
GostUtil.DeleteRemoteService(node.getId(), forwardId+"_"+userId+"_"+userTunnelId);
|
||||
}
|
||||
Tunnel tunnel = tunnelService.getById(forward_id);
|
||||
if (tunnel == null) {
|
||||
GostUtil.DeleteService(node.getId(), services);
|
||||
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (Objects.equals(serviceIds.getLast(), "tcp")){
|
||||
String forward_id = serviceIds.getFirst();
|
||||
services.add(forward_id + "_" + serviceIds.get(1) + "_" + serviceIds.get(2) + "_tcp");
|
||||
services.add(forward_id + "_" + serviceIds.get(1) + "_" + serviceIds.get(2) + "_udp");
|
||||
|
||||
Forward forward = forwardService.getById(forward_id);
|
||||
if (forward == null) {
|
||||
GostUtil.DeleteService(node.getId(), services);
|
||||
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}, "清理服务 " + service.getName());
|
||||
}
|
||||
|
||||
@@ -101,28 +95,15 @@ public class CheckGostConfigAsync {
|
||||
/**
|
||||
* 清理孤立的链
|
||||
*/
|
||||
private void cleanOrphanedChains(GostConfigDto gostConfig, Node node) {
|
||||
if (gostConfig.getChains() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
for (ConfigItem chain : gostConfig.getChains()) {
|
||||
private void cleanOrphanedChains(List<ConfigItem> configItems, Node node) {
|
||||
if (configItems == null) return;
|
||||
for (ConfigItem chain : configItems) {
|
||||
safeExecute(() -> {
|
||||
String[] serviceIds = parseServiceName(chain.getName());
|
||||
if (serviceIds.length == 4) {
|
||||
String forwardId = serviceIds[0];
|
||||
String userId = serviceIds[1];
|
||||
String userTunnelId = serviceIds[2];
|
||||
String type = serviceIds[3];
|
||||
|
||||
if (Objects.equals(type, "chains")) {
|
||||
Forward forward = forwardService.getById(forwardId);
|
||||
if (forward == null) {
|
||||
log.info("删除孤立的链: {} (节点: {})", chain.getName(), node.getId());
|
||||
GostUtil.DeleteChains(node.getId(), forwardId+"_"+userId+"_"+userTunnelId);
|
||||
}
|
||||
}
|
||||
List<String> serviceIds = parseServiceName(chain.getName());
|
||||
Tunnel tunnel = tunnelService.getById(serviceIds.getLast());
|
||||
if (tunnel == null) {
|
||||
GostUtil.DeleteChains(node.getId(), chain.getName());
|
||||
log.info("删除孤立的链: {} (节点: {})", chain.getName(), node.getId());
|
||||
}
|
||||
}, "清理链 " + chain.getName());
|
||||
}
|
||||
@@ -131,69 +112,21 @@ public class CheckGostConfigAsync {
|
||||
/**
|
||||
* 清理孤立的限流器
|
||||
*/
|
||||
private void cleanOrphanedLimiters(GostConfigDto gostConfig, Node node) {
|
||||
if (gostConfig.getLimiters() == null) {
|
||||
return;
|
||||
}
|
||||
private void cleanOrphanedLimiters(List<ConfigItem> configItems, Node node) {
|
||||
if (configItems == null) return;
|
||||
|
||||
|
||||
for (ConfigItem limiter : gostConfig.getLimiters()) {
|
||||
for (ConfigItem limiter : configItems) {
|
||||
safeExecute(() -> {
|
||||
SpeedLimit speedLimit = speedLimitService.getById(limiter.getName());
|
||||
if (speedLimit == null) {
|
||||
log.info("删除孤立的限流器: {} (节点: {})", limiter.getName(), node.getId());
|
||||
GostUtil.DeleteLimiters(node.getId(), Long.parseLong(limiter.getName()));
|
||||
log.info("删除孤立的限流器: {} (节点: {})", limiter.getName(), node.getId());
|
||||
}
|
||||
}, "清理限流器 " + limiter.getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步限流器
|
||||
*/
|
||||
private void syncLimiters(GostConfigDto gostConfig, Node node) {
|
||||
List<Tunnel> tunnelList = tunnelService.list(new QueryWrapper<Tunnel>().eq("in_node_id", node.getId()));
|
||||
if (tunnelList == null || tunnelList.isEmpty()) return;
|
||||
safeExecute(() -> {
|
||||
StringBuilder tunnelIds = new StringBuilder();
|
||||
for (Tunnel tunnel : tunnelList) {
|
||||
tunnelIds.append(tunnel.getId()).append(",");
|
||||
}
|
||||
String ids = tunnelIds.deleteCharAt(tunnelIds.length() - 1).toString();
|
||||
List<SpeedLimit> speedLimits = speedLimitService.list(new QueryWrapper<SpeedLimit>().in("tunnel_id", ids));
|
||||
if (speedLimits != null && !speedLimits.isEmpty()) {
|
||||
List<ConfigItem> limiters = gostConfig.getLimiters();
|
||||
List<Long> limiters_ids = new ArrayList<>();
|
||||
List<Long> speedLimits_ids = new ArrayList<>();
|
||||
if (limiters != null){
|
||||
for (ConfigItem limiter : limiters) {
|
||||
limiters_ids.add(Long.valueOf(limiter.getName()));
|
||||
}
|
||||
}
|
||||
for (SpeedLimit speedLimit : speedLimits) {
|
||||
speedLimits_ids.add(speedLimit.getId());
|
||||
}
|
||||
List<Long> diff = new ArrayList<>(speedLimits_ids);
|
||||
diff.removeAll(limiters_ids);
|
||||
System.out.println(diff);
|
||||
if (!diff.isEmpty()) {
|
||||
|
||||
for (Long speed_id : diff) {
|
||||
SpeedLimit speedLimit = speedLimitService.getById(speed_id);
|
||||
if (speedLimit != null) {
|
||||
SpeedLimitUpdateDto speedLimitUpdateDto = new SpeedLimitUpdateDto();
|
||||
speedLimitUpdateDto.setId(speed_id);
|
||||
speedLimitUpdateDto.setName(speedLimit.getName());
|
||||
speedLimitUpdateDto.setSpeed(speedLimit.getSpeed());
|
||||
speedLimitUpdateDto.setTunnelId(speedLimit.getTunnelId());
|
||||
speedLimitUpdateDto.setTunnelName(speedLimit.getTunnelName());
|
||||
speedLimitService.updateSpeedLimit(speedLimitUpdateDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "同步限流器 ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行操作,捕获异常
|
||||
@@ -210,7 +143,8 @@ public class CheckGostConfigAsync {
|
||||
/**
|
||||
* 解析服务名称
|
||||
*/
|
||||
private String[] parseServiceName(String serviceName) {
|
||||
return serviceName.split("_");
|
||||
private List<String> parseServiceName(String serviceName) {
|
||||
String[] split = serviceName.split("_");
|
||||
return new ArrayList<>(Arrays.asList(split));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package com.admin.common.task;
|
||||
|
||||
import com.admin.common.dto.GostDto;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.Forward;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.User;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.service.ForwardService;
|
||||
import com.admin.service.TunnelService;
|
||||
import com.admin.service.UserService;
|
||||
import com.admin.service.UserTunnelService;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.service.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +33,9 @@ public class ResetFlowAsync {
|
||||
@Resource
|
||||
TunnelService tunnelService;
|
||||
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
/**
|
||||
* 每天0点执行流量重置任务
|
||||
* 查询出用户和隧道的重置流量日期是今天的数据,将上下流量重置为0
|
||||
@@ -197,12 +195,18 @@ public class ResetFlowAsync {
|
||||
// 查询对应转发
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", user.getId()).eq("status", 1));
|
||||
for (Forward forward : forwardList) {
|
||||
UserTunnel userTunnel = userTunnelService.getOne(new QueryWrapper<UserTunnel>().eq("user_id", forward.getUserId()).eq("tunnel_id", forward.getTunnelId()));
|
||||
if (userTunnel != null) {
|
||||
pauseForwardService(forward, userTunnel.getId());
|
||||
forward.setStatus(0);
|
||||
forwardService.updateById(forward);
|
||||
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
UserTunnel userTunnel = userTunnelService.getOne(new QueryWrapper<UserTunnel>().eq("user_id", forward.getUserId()).eq("tunnel_id", forward.getTunnelId()));
|
||||
if (userTunnel != null) {
|
||||
String name = buildServiceName(forward.getId(), forward.getUserId(), userTunnel.getId());
|
||||
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
|
||||
}
|
||||
}
|
||||
|
||||
forward.setStatus(0);
|
||||
forwardService.updateById(forward);
|
||||
}
|
||||
user.setStatus(0);
|
||||
userService.updateById(user);
|
||||
@@ -217,25 +221,23 @@ public class ResetFlowAsync {
|
||||
for (UserTunnel userTunnel : user_tunnel_list) {
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("tunnel_id", userTunnel.getTunnelId()).eq("user_id", userTunnel.getUserId()).eq("status", 1));
|
||||
for (Forward forward : forwardList) {
|
||||
pauseForwardService(forward, userTunnel.getId());
|
||||
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
String name = buildServiceName(forward.getId(), forward.getUserId(), userTunnel.getId());
|
||||
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
|
||||
}
|
||||
forward.setStatus(0);
|
||||
forwardService.updateById(forward);
|
||||
}
|
||||
|
||||
|
||||
userTunnel.setStatus(0);
|
||||
userTunnelService.updateById(userTunnel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void pauseForwardService(Forward forward, Integer userTunnelId) {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel == null) return;
|
||||
|
||||
GostUtil.PauseService(tunnel.getInNodeId(), buildServiceName(forward.getId(), forward.getUserId(), userTunnelId));
|
||||
if (tunnel.getType() == 2){
|
||||
GostUtil.PauseRemoteService(tunnel.getOutNodeId(), buildServiceName(forward.getId(), forward.getUserId(), userTunnelId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String buildServiceName(Long forwardId, Integer userId, Integer userTunnelId) {
|
||||
|
||||
@@ -42,7 +42,6 @@ public class AESCrypto {
|
||||
this.secretKey = new SecretKeySpec(keyBytes, ALGORITHM);
|
||||
this.secureRandom = new SecureRandom();
|
||||
|
||||
log.info("AES加密器初始化成功");
|
||||
} catch (Exception e) {
|
||||
log.info("AES加密器初始化失败", e);
|
||||
throw new RuntimeException("AES加密器初始化失败: " + e.getMessage(), e);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package com.admin.common.utils;
|
||||
|
||||
import com.admin.common.dto.GostConfigDto;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.admin.common.dto.GostDto;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.*;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.aspectj.apache.bcel.generic.RET;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class GostUtil {
|
||||
@@ -15,7 +17,11 @@ public class GostUtil {
|
||||
|
||||
public static GostDto AddLimiters(Long node_id, Long name, String speed) {
|
||||
JSONObject data = createLimiterData(name, speed);
|
||||
return WebSocketServer.send_msg(node_id, data, "AddLimiters");
|
||||
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddLimiters");
|
||||
if (gostDto.getMsg().contains("exists")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto UpdateLimiters(Long node_id, Long name, String speed) {
|
||||
@@ -29,266 +35,182 @@ public class GostUtil {
|
||||
public static GostDto DeleteLimiters(Long node_id, Long name) {
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("limiter", name + "");
|
||||
return WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
|
||||
}
|
||||
|
||||
public static GostDto AddService(Long node_id, String name, Integer in_port, Integer limiter, String remoteAddr, Integer fow_type, Tunnel tunnel, String strategy, String interfaceName) {
|
||||
JSONArray services = new JSONArray();
|
||||
String[] protocols = {"tcp", "udp"};
|
||||
for (String protocol : protocols) {
|
||||
JSONObject service = createServiceConfig(name, in_port, limiter, remoteAddr, protocol, fow_type, tunnel, strategy, interfaceName);
|
||||
services.add(service);
|
||||
GostDto gostDto = WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
|
||||
if (gostDto.getMsg().contains("not found")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
return WebSocketServer.send_msg(node_id, services, "AddService");
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto UpdateService(Long node_id, String name, Integer in_port, Integer limiter, String remoteAddr, Integer fow_type, Tunnel tunnel, String strategy, String interfaceName) {
|
||||
JSONArray services = new JSONArray();
|
||||
String[] protocols = {"tcp", "udp"};
|
||||
for (String protocol : protocols) {
|
||||
JSONObject service = createServiceConfig(name, in_port, limiter, remoteAddr, protocol, fow_type, tunnel, strategy, interfaceName);
|
||||
services.add(service);
|
||||
}
|
||||
return WebSocketServer.send_msg(node_id, services, "UpdateService");
|
||||
}
|
||||
|
||||
public static GostDto DeleteService(Long node_id, String name) {
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tcp");
|
||||
services.add(name + "_udp");
|
||||
data.put("services", services);
|
||||
return WebSocketServer.send_msg(node_id, data, "DeleteService");
|
||||
}
|
||||
|
||||
public static GostDto AddRemoteService(Long node_id, String name, Integer out_port, String remoteAddr, String protocol, String strategy, String interfaceName) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("name", name + "_tls");
|
||||
data.put("addr", ":" + out_port);
|
||||
|
||||
if (StringUtils.isNotBlank(interfaceName)) {
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("interface", interfaceName);
|
||||
data.put("metadata", metadata);
|
||||
}
|
||||
|
||||
|
||||
JSONObject handler = new JSONObject();
|
||||
handler.put("type", "relay");
|
||||
data.put("handler", handler);
|
||||
JSONObject listener = new JSONObject();
|
||||
listener.put("type", protocol);
|
||||
data.put("listener", listener);
|
||||
JSONObject forwarder = new JSONObject();
|
||||
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
|
||||
JSONArray nodes = new JSONArray();
|
||||
Node fromNode = node_s.get(node_id);
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
JSONObject dialer = new JSONObject();
|
||||
dialer.put("type", chainTunnel.getProtocol());
|
||||
|
||||
String[] split = remoteAddr.split(",");
|
||||
int num = 1;
|
||||
for (String addr : split) {
|
||||
JSONObject connector = new JSONObject();
|
||||
connector.put("type", "relay");
|
||||
|
||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("name", "node_" + num );
|
||||
node.put("addr", addr);
|
||||
node.put("name", "node_" + chainTunnel.getInx());
|
||||
|
||||
String dialHost = (fromNode != null && node_info != null)
|
||||
? selectDialHost(fromNode, node_info)
|
||||
: (node_info != null ? node_info.getServerIp() : null);
|
||||
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
|
||||
node.put("connector", connector);
|
||||
node.put("dialer", dialer);
|
||||
|
||||
|
||||
|
||||
nodes.add(node);
|
||||
num ++;
|
||||
}
|
||||
if (strategy == null || strategy.equals("")){
|
||||
strategy = "fifo";
|
||||
}
|
||||
forwarder.put("nodes", nodes);
|
||||
JSONObject selector = new JSONObject();
|
||||
selector.put("strategy", strategy);
|
||||
selector.put("maxFails", 1);
|
||||
selector.put("failTimeout", "600s");
|
||||
forwarder.put("selector", selector);
|
||||
|
||||
data.put("forwarder", forwarder);
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(data);
|
||||
return WebSocketServer.send_msg(node_id, services, "AddService");
|
||||
}
|
||||
|
||||
public static GostDto UpdateRemoteService(Long node_id, String name, Integer out_port, String remoteAddr,String protocol, String strategy, String interfaceName) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("name", name + "_tls");
|
||||
data.put("addr", ":" + out_port);
|
||||
|
||||
if (StringUtils.isNotBlank(interfaceName)) {
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("interface", interfaceName);
|
||||
data.put("metadata", metadata);
|
||||
}
|
||||
|
||||
|
||||
JSONObject handler = new JSONObject();
|
||||
handler.put("type", "relay");
|
||||
data.put("handler", handler);
|
||||
JSONObject listener = new JSONObject();
|
||||
listener.put("type", protocol);
|
||||
data.put("listener", listener);
|
||||
JSONObject forwarder = new JSONObject();
|
||||
JSONArray nodes = new JSONArray();
|
||||
|
||||
String[] split = remoteAddr.split(",");
|
||||
int num = 1;
|
||||
for (String addr : split) {
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("name", "node_" + num );
|
||||
node.put("addr", addr);
|
||||
nodes.add(node);
|
||||
num ++;
|
||||
}
|
||||
if (strategy == null || strategy.equals("")){
|
||||
strategy = "fifo";
|
||||
}
|
||||
forwarder.put("nodes", nodes);
|
||||
JSONObject selector = new JSONObject();
|
||||
selector.put("strategy", strategy);
|
||||
selector.put("maxFails", 1);
|
||||
selector.put("failTimeout", "600s");
|
||||
forwarder.put("selector", selector);
|
||||
|
||||
data.put("forwarder", forwarder);
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(data);
|
||||
return WebSocketServer.send_msg(node_id, services, "UpdateService");
|
||||
}
|
||||
|
||||
public static GostDto DeleteRemoteService(Long node_id, String name) {
|
||||
JSONArray data = new JSONArray();
|
||||
data.add(name + "_tls");
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("services", data);
|
||||
return WebSocketServer.send_msg(node_id, req, "DeleteService");
|
||||
}
|
||||
|
||||
public static GostDto PauseService(Long node_id, String name) {
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tcp");
|
||||
services.add(name + "_udp");
|
||||
data.put("services", services);
|
||||
return WebSocketServer.send_msg(node_id, data, "PauseService");
|
||||
}
|
||||
|
||||
public static GostDto ResumeService(Long node_id, String name) {
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tcp");
|
||||
services.add(name + "_udp");
|
||||
data.put("services", services);
|
||||
return WebSocketServer.send_msg(node_id, data, "ResumeService");
|
||||
}
|
||||
|
||||
public static GostDto PauseRemoteService(Long node_id, String name) {
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tls");
|
||||
data.put("services", services);
|
||||
return WebSocketServer.send_msg(node_id, data, "PauseService");
|
||||
}
|
||||
|
||||
public static GostDto ResumeRemoteService(Long node_id, String name) {
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tls");
|
||||
data.put("services", services);
|
||||
return WebSocketServer.send_msg(node_id, data, "ResumeService");
|
||||
}
|
||||
|
||||
public static GostDto AddChains(Long node_id, String name, String remoteAddr, String protocol, String interfaceName) {
|
||||
JSONObject dialer = new JSONObject();
|
||||
dialer.put("type", protocol);
|
||||
if (Objects.equals(protocol, "quic")){
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("keepAlive", true);
|
||||
metadata.put("ttl", "10s");
|
||||
dialer.put("metadata", metadata);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
JSONObject connector = new JSONObject();
|
||||
connector.put("type", "relay");
|
||||
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("name", "node-" + name);
|
||||
node.put("addr", remoteAddr);
|
||||
node.put("connector", connector);
|
||||
node.put("dialer", dialer);
|
||||
|
||||
if (StringUtils.isNotBlank(interfaceName)) {
|
||||
node.put("interface", interfaceName);
|
||||
}
|
||||
|
||||
|
||||
JSONArray nodes = new JSONArray();
|
||||
nodes.add(node);
|
||||
|
||||
JSONObject hop = new JSONObject();
|
||||
hop.put("name", "hop-" + name);
|
||||
hop.put("name", "hop_" + chainTunnels.getFirst().getTunnelId());
|
||||
|
||||
// interface设置在转发链
|
||||
if (StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
|
||||
hop.put("interface", node_s.get(node_id).getInterfaceName());
|
||||
}
|
||||
|
||||
|
||||
JSONObject selector = new JSONObject();
|
||||
selector.put("strategy", chainTunnels.getFirst().getStrategy());
|
||||
selector.put("maxFails", 1);
|
||||
selector.put("failTimeout", 600000000000L); // 600 秒(纳秒单位)
|
||||
|
||||
|
||||
hop.put("selector", selector);
|
||||
hop.put("nodes", nodes);
|
||||
|
||||
JSONArray hops = new JSONArray();
|
||||
hops.add(hop);
|
||||
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("name", name + "_chains");
|
||||
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
|
||||
data.put("hops", hops);
|
||||
|
||||
return WebSocketServer.send_msg(node_id, data, "AddChains");
|
||||
}
|
||||
|
||||
public static GostDto UpdateChains(Long node_id, String name, String remoteAddr, String protocol, String interfaceName) {
|
||||
JSONObject dialer = new JSONObject();
|
||||
dialer.put("type", protocol);
|
||||
|
||||
if (Objects.equals(protocol, "quic")){
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("keepAlive", true);
|
||||
metadata.put("ttl", "10s");
|
||||
dialer.put("metadata", metadata);
|
||||
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddChains");
|
||||
if (gostDto.getMsg().contains("exists")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
|
||||
|
||||
JSONObject connector = new JSONObject();
|
||||
connector.put("type", "relay");
|
||||
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("name", "node-" + name);
|
||||
node.put("addr", remoteAddr);
|
||||
node.put("connector", connector);
|
||||
node.put("dialer", dialer);
|
||||
|
||||
if (StringUtils.isNotBlank(interfaceName)) {
|
||||
node.put("interface", interfaceName);
|
||||
}
|
||||
|
||||
JSONArray nodes = new JSONArray();
|
||||
nodes.add(node);
|
||||
|
||||
JSONObject hop = new JSONObject();
|
||||
hop.put("name", "hop-" + name);
|
||||
hop.put("nodes", nodes);
|
||||
|
||||
JSONArray hops = new JSONArray();
|
||||
hops.add(hop);
|
||||
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("name", name + "_chains");
|
||||
data.put("hops", hops);
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("chain", name + "_chains");
|
||||
req.put("data", data);
|
||||
return WebSocketServer.send_msg(node_id, req, "UpdateChains");
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto DeleteChains(Long node_id, String name) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("chain", name + "_chains");
|
||||
return WebSocketServer.send_msg(node_id, data, "DeleteChains");
|
||||
data.put("chain", name);
|
||||
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteChains");
|
||||
if (gostDto.getMsg().contains("not found")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto AddChainService(Long node_id, ChainTunnel chainTunnel, Map<Long, Node> node_s) {
|
||||
JSONArray services = new JSONArray();
|
||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||
JSONObject service_item = new JSONObject();
|
||||
service_item.put("name", chainTunnel.getTunnelId() + "_tls");
|
||||
service_item.put("addr", node_info.getTcpListenAddr() + ":" + chainTunnel.getPort());
|
||||
|
||||
// 只为出口节点(chainType=3)设置 interface
|
||||
if (chainTunnel.getChainType() == 3 && StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("interface", node_s.get(node_id).getInterfaceName());
|
||||
service_item.put("metadata", metadata);
|
||||
}
|
||||
|
||||
JSONObject handler = new JSONObject();
|
||||
handler.put("type", "relay");
|
||||
if (chainTunnel.getChainType() == 2){
|
||||
handler.put("chain","chains_" + chainTunnel.getTunnelId());
|
||||
}
|
||||
service_item.put("handler", handler);
|
||||
|
||||
JSONObject listener = new JSONObject();
|
||||
listener.put("type", chainTunnel.getProtocol());
|
||||
service_item.put("listener", listener);
|
||||
|
||||
services.add(service_item);
|
||||
|
||||
GostDto gostDto = WebSocketServer.send_msg(node_id, services, "AddService");
|
||||
if (gostDto.getMsg().contains("exists")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
|
||||
JSONArray services = new JSONArray();
|
||||
String[] protocols = {"tcp", "udp"};
|
||||
for (String protocol : protocols) {
|
||||
JSONObject service = new JSONObject();
|
||||
service.put("name", name + "_" + protocol);
|
||||
if (Objects.equals(protocol, "tcp")){
|
||||
service.put("addr", node.getTcpListenAddr() + ":" + forwardPort.getPort());
|
||||
}else {
|
||||
service.put("addr", node.getUdpListenAddr() + ":" + forwardPort.getPort());
|
||||
}
|
||||
|
||||
// 只在端口转发时设置 interface(隧道转发时 interface 在转发链的节点上设置)
|
||||
if (tunnel.getType() == 1 && StringUtils.isNotBlank(node.getInterfaceName())) {
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("interface", node.getInterfaceName());
|
||||
service.put("metadata", metadata);
|
||||
}
|
||||
|
||||
// 添加限流器配置
|
||||
if (limiter != null) {
|
||||
service.put("limiter", limiter.toString());
|
||||
}
|
||||
|
||||
// 配置处理器
|
||||
JSONObject handler = new JSONObject();
|
||||
handler.put("type", protocol);
|
||||
if (tunnel.getType() == 2){
|
||||
handler.put("chain", "chains_" + forward.getTunnelId());
|
||||
}
|
||||
service.put("handler", handler);
|
||||
|
||||
// 配置监听器
|
||||
JSONObject listener = createListener(protocol);
|
||||
service.put("listener", listener);
|
||||
|
||||
JSONObject forwarder = createForwarder(forward.getRemoteAddr(), forward.getStrategy());
|
||||
service.put("forwarder", forwarder);
|
||||
|
||||
services.add(service);
|
||||
}
|
||||
GostDto gostDto = WebSocketServer.send_msg(node.getId(), services, meth);
|
||||
if (gostDto.getMsg().contains("exists")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto DeleteService(Long node_id, JSONArray services) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("services", services);
|
||||
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteService");
|
||||
if (gostDto.getMsg().contains("not found")){
|
||||
gostDto.setMsg("OK");
|
||||
}
|
||||
return gostDto;
|
||||
}
|
||||
|
||||
public static GostDto PauseAndResumeService(Long node_id, String name, String meth) {
|
||||
JSONObject data = new JSONObject();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tcp");
|
||||
services.add(name + "_udp");
|
||||
data.put("services", services);
|
||||
return WebSocketServer.send_msg(node_id, data, meth);
|
||||
}
|
||||
|
||||
|
||||
private static JSONObject createLimiterData(Long name, String speed) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("name", name.toString());
|
||||
@@ -298,59 +220,10 @@ public class GostUtil {
|
||||
return data;
|
||||
}
|
||||
|
||||
private static JSONObject createServiceConfig(String name, Integer in_port, Integer limiter, String remoteAddr, String protocol, Integer fow_type, Tunnel tunnel, String strategy, String interfaceName) {
|
||||
JSONObject service = new JSONObject();
|
||||
service.put("name", name + "_" + protocol);
|
||||
if (Objects.equals(protocol, "tcp")){
|
||||
service.put("addr", tunnel.getTcpListenAddr() + ":" + in_port);
|
||||
}else {
|
||||
service.put("addr", tunnel.getUdpListenAddr() + ":" + in_port);
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(interfaceName)) {
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("interface", interfaceName);
|
||||
service.put("metadata", metadata);
|
||||
}
|
||||
|
||||
|
||||
// 添加限流器配置
|
||||
if (limiter != null) {
|
||||
service.put("limiter", limiter.toString());
|
||||
}
|
||||
|
||||
// 配置处理器
|
||||
JSONObject handler = createHandler(protocol, name, fow_type);
|
||||
service.put("handler", handler);
|
||||
|
||||
// 配置监听器
|
||||
JSONObject listener = createListener(protocol);
|
||||
service.put("listener", listener);
|
||||
|
||||
// 端口转发需要配置转发器
|
||||
if (isPortForwarding(fow_type)) {
|
||||
JSONObject forwarder = createForwarder(remoteAddr, strategy);
|
||||
service.put("forwarder", forwarder);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
private static JSONObject createHandler(String protocol, String name, Integer fow_type) {
|
||||
JSONObject handler = new JSONObject();
|
||||
handler.put("type", protocol);
|
||||
|
||||
// 隧道转发需要添加链配置
|
||||
if (isTunnelForwarding(fow_type)) {
|
||||
handler.put("chain", name + "_chains");
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
private static JSONObject createListener(String protocol) {
|
||||
JSONObject listener = new JSONObject();
|
||||
listener.put("type", protocol);
|
||||
if (Objects.equals(protocol, "udp")){
|
||||
if (Objects.equals(protocol, "udp")) {
|
||||
JSONObject metadata = new JSONObject();
|
||||
metadata.put("keepAlive", true);
|
||||
listener.put("metadata", metadata);
|
||||
@@ -366,13 +239,13 @@ public class GostUtil {
|
||||
int num = 1;
|
||||
for (String addr : split) {
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("name", "node_" + num );
|
||||
node.put("name", "node_" + num);
|
||||
node.put("addr", addr);
|
||||
nodes.add(node);
|
||||
num ++;
|
||||
num++;
|
||||
}
|
||||
|
||||
if (strategy == null || strategy.equals("")){
|
||||
if (strategy == null || strategy.isEmpty()) {
|
||||
strategy = "fifo";
|
||||
}
|
||||
|
||||
@@ -386,12 +259,153 @@ public class GostUtil {
|
||||
return forwarder;
|
||||
}
|
||||
|
||||
private static boolean isPortForwarding(Integer fow_type) {
|
||||
return fow_type != null && fow_type == 1;
|
||||
public static String processServerAddress(String serverAddr) {
|
||||
if (StrUtil.isBlank(serverAddr)) {
|
||||
return serverAddr;
|
||||
}
|
||||
|
||||
// 如果已经被方括号包裹,直接返回
|
||||
if (serverAddr.startsWith("[")) {
|
||||
return serverAddr;
|
||||
}
|
||||
|
||||
// 查找最后一个冒号,分离主机和端口
|
||||
int lastColonIndex = serverAddr.lastIndexOf(':');
|
||||
if (lastColonIndex == -1) {
|
||||
// 没有端口号,直接检查是否需要包裹
|
||||
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
|
||||
}
|
||||
|
||||
String host = serverAddr.substring(0, lastColonIndex);
|
||||
String port = serverAddr.substring(lastColonIndex);
|
||||
|
||||
// 检查主机部分是否为IPv6地址
|
||||
if (isIPv6Address(host)) {
|
||||
return "[" + host + "]" + port;
|
||||
}
|
||||
|
||||
return serverAddr;
|
||||
}
|
||||
|
||||
private static boolean isTunnelForwarding(Integer fow_type) {
|
||||
return fow_type != null && fow_type != 1;
|
||||
private static boolean isIPv6Address(String address) {
|
||||
// IPv6地址包含多个冒号,至少2个
|
||||
if (!address.contains(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算冒号数量,IPv6地址至少有2个冒号
|
||||
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
||||
return colonCount >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
|
||||
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
|
||||
*/
|
||||
public static String selectDialHost(Node fromNode, Node toNode) {
|
||||
if (fromNode == null || toNode == null) {
|
||||
throw new IllegalArgumentException("node is null");
|
||||
}
|
||||
|
||||
boolean fromV4 = supportsV4(fromNode);
|
||||
boolean fromV6 = supportsV6(fromNode);
|
||||
boolean toV4 = supportsV4(toNode);
|
||||
boolean toV6 = supportsV6(toNode);
|
||||
|
||||
if (fromV4 && toV4) {
|
||||
return pickToAddressV4(toNode);
|
||||
}
|
||||
if (fromV6 && toV6) {
|
||||
return pickToAddressV6(toNode);
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
|
||||
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
|
||||
);
|
||||
}
|
||||
|
||||
private static String safeName(Node node) {
|
||||
if (node.getName() == null || node.getName().isBlank()) {
|
||||
return "node_" + node.getId();
|
||||
}
|
||||
return node.getName();
|
||||
}
|
||||
|
||||
private static boolean supportsV4(Node node) {
|
||||
// New dual-stack fields take precedence over legacy serverIp.
|
||||
// If user explicitly provided only v6, treat as v6-only.
|
||||
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String legacy = node.getServerIp();
|
||||
if (StrUtil.isBlank(legacy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
legacy = legacy.trim();
|
||||
if (looksLikeIpv4(legacy)) {
|
||||
return true;
|
||||
}
|
||||
if (isIPv6Address(legacy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 域名/其它:无法判断,按双栈处理以保持兼容
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean supportsV6(Node node) {
|
||||
// New dual-stack fields take precedence over legacy serverIp.
|
||||
// If user explicitly provided only v4, treat as v4-only.
|
||||
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String legacy = node.getServerIp();
|
||||
if (StrUtil.isBlank(legacy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
legacy = legacy.trim();
|
||||
if (isIPv6Address(legacy)) {
|
||||
return true;
|
||||
}
|
||||
if (looksLikeIpv4(legacy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 域名/其它:无法判断,按双栈处理以保持兼容
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String pickToAddressV4(Node toNode) {
|
||||
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
|
||||
return toNode.getServerIpV4().trim();
|
||||
}
|
||||
String legacy = toNode.getServerIp();
|
||||
return legacy != null ? legacy.trim() : null;
|
||||
}
|
||||
|
||||
private static String pickToAddressV6(Node toNode) {
|
||||
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
|
||||
return toNode.getServerIpV6().trim();
|
||||
}
|
||||
String legacy = toNode.getServerIp();
|
||||
return legacy != null ? legacy.trim() : null;
|
||||
}
|
||||
|
||||
private static boolean looksLikeIpv4(String value) {
|
||||
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||
return ipv4.matcher(value).matches();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,7 @@ public class WebSocketServer extends TextWebSocketHandler {
|
||||
String socks = (String) session.getAttributes().get("socks");
|
||||
|
||||
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
|
||||
log.info("屏蔽协议数据:http->{}, tls->{}, socks->{}", http, tls, socks);
|
||||
|
||||
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
|
||||
WebSocketSession existingSession = nodeSessions.get(nodeId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.admin.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
|
||||
@@ -15,7 +16,9 @@ public class MybatisPlusConfig {
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件
|
||||
// 配置分页插件,指定数据库类型为 SQLite
|
||||
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.SQLITE);
|
||||
interceptor.addInnerInterceptor(paginationInterceptor); // 分页插件
|
||||
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 防止全表更新插件
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.admin.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PreDestroy;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* SQLite 数据库配置
|
||||
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
|
||||
* 添加定期 checkpoint 和优雅关闭处理
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@EnableScheduling
|
||||
public class SQLiteConfig implements ApplicationRunner {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public SQLiteConfig(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
|
||||
statement.execute("PRAGMA journal_mode=WAL;");
|
||||
statement.execute("PRAGMA synchronous=NORMAL;");
|
||||
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
|
||||
statement.execute("PRAGMA temp_store=MEMORY;");
|
||||
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
|
||||
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
|
||||
|
||||
ensureNodeDualStackColumns(connection);
|
||||
|
||||
log.info("SQLite WAL mode configured successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to configure SQLite database", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
|
||||
Set<String> cols = getTableColumns(connection, "node");
|
||||
if (cols.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
|
||||
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
|
||||
|
||||
backfillNodeDualStackColumns(connection);
|
||||
}
|
||||
|
||||
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
|
||||
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
|
||||
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
|
||||
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
|
||||
) {
|
||||
while (rs.next()) {
|
||||
long id = rs.getLong("id");
|
||||
String serverIp = rs.getString("server_ip");
|
||||
String v4 = rs.getString("server_ip_v4");
|
||||
String v6 = rs.getString("server_ip_v6");
|
||||
|
||||
if (serverIp == null || serverIp.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String trimmed = serverIp.trim();
|
||||
|
||||
if (ipv4.matcher(trimmed).matches()) {
|
||||
updV4.setString(1, trimmed);
|
||||
updV4.setLong(2, id);
|
||||
updV4.executeUpdate();
|
||||
} else {
|
||||
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
|
||||
if (colonCount >= 2) {
|
||||
updV6.setString(1, trimmed);
|
||||
updV6.setLong(2, id);
|
||||
updV6.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
|
||||
Set<String> cols = new HashSet<>();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
cols.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
private void ensureColumnIfMissing(
|
||||
Connection connection,
|
||||
Set<String> existingColumns,
|
||||
String table,
|
||||
String column,
|
||||
String type
|
||||
) throws Exception {
|
||||
if (existingColumns.contains(column)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
|
||||
}
|
||||
|
||||
log.info("SQLite schema updated: added {}.{}", table, column);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
|
||||
* 每5分钟执行一次
|
||||
*/
|
||||
@Scheduled(fixedDelay = 300000, initialDelay = 300000)
|
||||
public void performCheckpoint() {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
|
||||
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
log.debug("SQLite WAL checkpoint completed");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to perform SQLite checkpoint", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用关闭前执行最终的 checkpoint,确保所有数据都写入主数据库文件
|
||||
*/
|
||||
@PreDestroy
|
||||
public void onShutdown() {
|
||||
log.info("Performing final SQLite checkpoint before shutdown...");
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
|
||||
// 强制执行 checkpoint,将所有 WAL 内容写入主数据库
|
||||
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
log.info("Final SQLite checkpoint completed successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to perform final SQLite checkpoint", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,17 @@ package com.admin.controller;
|
||||
import com.admin.common.aop.LogAnnotation;
|
||||
import com.admin.common.dto.FlowDto;
|
||||
import com.admin.common.dto.GostConfigDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.common.task.CheckGostConfigAsync;
|
||||
import com.admin.common.utils.AESCrypto;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.service.ChainTunnelService;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -21,7 +23,6 @@ import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 流量上报控制器
|
||||
@@ -65,6 +66,10 @@ public class FlowController extends BaseController {
|
||||
@Resource
|
||||
CheckGostConfigAsync checkGostConfigAsync;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
/**
|
||||
* 加密消息包装器
|
||||
*/
|
||||
@@ -147,15 +152,17 @@ public class FlowController extends BaseController {
|
||||
String decryptedData = decryptIfNeeded(rawData, secret);
|
||||
|
||||
// 3. 解析为FlowDto列表
|
||||
FlowDto flowDataList = JSONObject.parseObject(decryptedData, FlowDto.class);
|
||||
if (Objects.equals(flowDataList.getN(), "web_api")) {
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
JSONArray flowDataList = JSONObject.parseArray(decryptedData);
|
||||
log.info("节点上报流量数据{}", flowDataList);
|
||||
// 4. 处理流量数据
|
||||
return processFlowData(flowDataList);
|
||||
for (int i = 0; i < flowDataList.size(); i++) {
|
||||
String jsonObject = flowDataList.getJSONObject(i).toJSONString();
|
||||
FlowDto flowDto = JSONObject.parseObject(jsonObject, FlowDto.class);
|
||||
if (!Objects.equals(flowDto.getN(), "web_api")) {
|
||||
processFlowData(flowDto);
|
||||
}
|
||||
}
|
||||
return SUCCESS_RESPONSE;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,24 +219,30 @@ public class FlowController extends BaseController {
|
||||
/**
|
||||
* 处理流量数据的核心逻辑
|
||||
*/
|
||||
private String processFlowData(FlowDto flowDataList) {
|
||||
private void processFlowData(FlowDto flowDataList) {
|
||||
String[] serviceIds = parseServiceName(flowDataList.getN());
|
||||
String forwardId = serviceIds[0];
|
||||
String userId = serviceIds[1];
|
||||
String userTunnelId = serviceIds[2];
|
||||
|
||||
Forward forward = forwardService.getById(forwardId);
|
||||
if (forward != null){
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
|
||||
// 获取流量计费类型
|
||||
int flowType = getFlowType(forward);
|
||||
|
||||
// 处理流量倍率及单双向计算
|
||||
FlowDto flowStats = filterFlowData(flowDataList, forward, flowType);
|
||||
// 处理流量倍率及单双向计算
|
||||
BigDecimal trafficRatio = tunnel.getTrafficRatio();
|
||||
BigDecimal originalD = BigDecimal.valueOf(flowDataList.getD());
|
||||
BigDecimal originalU = BigDecimal.valueOf(flowDataList.getU());
|
||||
BigDecimal newD = originalD.multiply(trafficRatio);
|
||||
BigDecimal newU = originalU.multiply(trafficRatio);
|
||||
flowDataList.setD(newD.longValue() * tunnel.getFlow());
|
||||
flowDataList.setU(newU.longValue() * tunnel.getFlow());
|
||||
}
|
||||
|
||||
// 先更新所有流量统计 - 确保流量数据的一致性
|
||||
updateForwardFlow(forwardId, flowStats);
|
||||
updateUserFlow(userId, flowStats);
|
||||
updateUserTunnelFlow(userTunnelId, flowStats);
|
||||
updateForwardFlow(forwardId, flowDataList);
|
||||
updateUserFlow(userId, flowDataList);
|
||||
updateUserTunnelFlow(userTunnelId, flowDataList);
|
||||
|
||||
// 7. 检查和服务暂停操作
|
||||
String name = buildServiceName(forwardId, userId, userTunnelId);
|
||||
@@ -238,7 +251,6 @@ public class FlowController extends BaseController {
|
||||
checkUserTunnelRelatedLimits(userTunnelId, name, userId);
|
||||
}
|
||||
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
|
||||
private void checkUserRelatedLimits(String userId, String name) {
|
||||
@@ -277,7 +289,7 @@ public class FlowController extends BaseController {
|
||||
UserTunnel userTunnel = userTunnelService.getById(userTunnelId);
|
||||
if (userTunnel == null) return;
|
||||
long flow = userTunnel.getInFlow() + userTunnel.getOutFlow();
|
||||
if (flow >= userTunnel.getFlow() * BYTES_TO_GB) {
|
||||
if (flow >= userTunnel.getFlow() * BYTES_TO_GB) {
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name, userId);
|
||||
return;
|
||||
}
|
||||
@@ -301,45 +313,15 @@ public class FlowController extends BaseController {
|
||||
|
||||
public void pauseService(List<Forward> forwardList, String name) {
|
||||
for (Forward forward : forwardList) {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel != null){
|
||||
GostUtil.PauseService(tunnel.getInNodeId(), name);
|
||||
if (tunnel.getType() == 2){
|
||||
GostUtil.PauseRemoteService(tunnel.getOutNodeId(), name);
|
||||
}
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
|
||||
}
|
||||
forward.setStatus(0);
|
||||
forwardService.updateById(forward);
|
||||
}
|
||||
}
|
||||
|
||||
private FlowDto filterFlowData(FlowDto flowDto, Forward forward, int flowType) {
|
||||
if (forward != null) {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel != null) {
|
||||
BigDecimal trafficRatio = tunnel.getTrafficRatio();
|
||||
|
||||
BigDecimal originalD = BigDecimal.valueOf(flowDto.getD());
|
||||
BigDecimal originalU = BigDecimal.valueOf(flowDto.getU());
|
||||
|
||||
BigDecimal newD = originalD.multiply(trafficRatio);
|
||||
BigDecimal newU = originalU.multiply(trafficRatio);
|
||||
|
||||
flowDto.setD(newD.longValue() * flowType);
|
||||
flowDto.setU(newU.longValue() * flowType);
|
||||
}
|
||||
}
|
||||
return flowDto;
|
||||
}
|
||||
|
||||
private int getFlowType(Forward forward) {
|
||||
int defaultFlowType = 2;
|
||||
if (forward == null) return defaultFlowType;
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel == null) return defaultFlowType;
|
||||
return tunnel.getFlow();
|
||||
}
|
||||
|
||||
private void updateForwardFlow(String forwardId, FlowDto flowStats) {
|
||||
// 对相同转发的流量更新进行同步,避免并发覆盖
|
||||
synchronized (getForwardLock(forwardId)) {
|
||||
|
||||
@@ -54,12 +54,24 @@ public class NodeController extends BaseController {
|
||||
return nodeService.deleteNode(id);
|
||||
}
|
||||
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/install")
|
||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
return nodeService.getInstallCommand(id);
|
||||
}
|
||||
|
||||
}
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/install")
|
||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
return nodeService.getInstallCommand(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点排序
|
||||
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||
* @return 更新结果
|
||||
*/
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/update-order")
|
||||
public R updateNodeOrder(@RequestBody Map<String, Object> params) {
|
||||
return nodeService.updateNodeOrder(params);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -137,4 +137,16 @@ public class TunnelController extends BaseController {
|
||||
return tunnelService.diagnoseTunnel(tunnelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新隧道排序
|
||||
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||
* @return 更新结果
|
||||
*/
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/update-order")
|
||||
public R updateTunnelOrder(@RequestBody Map<String, Object> params) {
|
||||
return tunnelService.updateTunnelOrder(params);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.admin.entity;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class ChainTunnel implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
// 关联的隧道ID
|
||||
private Long tunnelId;
|
||||
|
||||
// 记录所选节点的类型: 1是入口节点 2是转发链 3是出口节点
|
||||
private Integer chainType;
|
||||
|
||||
// 记录所选节点的ID
|
||||
private Long nodeId;
|
||||
|
||||
// 记录节点使用的端口 入口没有次配置为null
|
||||
private Integer port;
|
||||
|
||||
// 负载模式 入口没有次配置为null
|
||||
private String strategy;
|
||||
|
||||
// 只有转发链有次参数 表示是第几条
|
||||
private Integer inx;
|
||||
|
||||
// 使用的协议 入口没有次配置为null
|
||||
private String protocol;
|
||||
|
||||
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.admin.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
import com.admin.common.dto.ForwardPortDto;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@@ -14,6 +20,7 @@ import lombok.EqualsAndHashCode;
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName(autoResultMap = true)
|
||||
public class Forward extends BaseEntity{
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@@ -26,14 +33,8 @@ public class Forward extends BaseEntity{
|
||||
|
||||
private Integer tunnelId;
|
||||
|
||||
private Integer inPort;
|
||||
|
||||
private Integer outPort;
|
||||
|
||||
private String remoteAddr;
|
||||
|
||||
private String interfaceName;
|
||||
|
||||
private String strategy;
|
||||
|
||||
private Long inFlow;
|
||||
@@ -41,5 +42,4 @@ public class Forward extends BaseEntity{
|
||||
private Long outFlow;
|
||||
|
||||
private Integer inx;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.admin.entity;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName(autoResultMap = true)
|
||||
public class ForwardPort{
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long forwardId;
|
||||
|
||||
private Long nodeId;
|
||||
|
||||
private Integer port;
|
||||
}
|
||||
@@ -14,23 +14,23 @@ import lombok.EqualsAndHashCode;
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Node extends BaseEntity {
|
||||
public class Node extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String name;
|
||||
|
||||
private String secret;
|
||||
|
||||
private String ip;
|
||||
|
||||
private String serverIp;
|
||||
private String secret;
|
||||
|
||||
private String serverIp;
|
||||
|
||||
private String serverIpV4;
|
||||
|
||||
private String serverIpV6;
|
||||
|
||||
private String version;
|
||||
|
||||
private Integer portSta;
|
||||
|
||||
private Integer portEnd;
|
||||
private String port;
|
||||
|
||||
private Integer http;
|
||||
|
||||
@@ -38,4 +38,15 @@ public class Node extends BaseEntity {
|
||||
|
||||
private Integer socks;
|
||||
|
||||
}
|
||||
private String interfaceName;
|
||||
|
||||
private String tcpListenAddr;
|
||||
|
||||
private String udpListenAddr;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ package com.admin.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import com.admin.common.dto.ChainNodesItems;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@@ -15,59 +21,24 @@ import lombok.EqualsAndHashCode;
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Tunnel extends BaseEntity {
|
||||
@TableName(autoResultMap = true)
|
||||
public class Tunnel extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 隧道名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 入口节点ID
|
||||
*/
|
||||
private Long inNodeId;
|
||||
|
||||
/**
|
||||
* 入口IP (兼容字段)
|
||||
*/
|
||||
private String inIp;
|
||||
|
||||
/**
|
||||
* 出口节点ID
|
||||
*/
|
||||
private Long outNodeId;
|
||||
|
||||
/**
|
||||
* 出口IP (兼容字段)
|
||||
*/
|
||||
private String outIp;
|
||||
|
||||
/**
|
||||
* 隧道类型(1-端口转发,2-隧道转发)
|
||||
*/
|
||||
private Integer type;
|
||||
|
||||
/**
|
||||
* 流量计算类型(1 单向计算上传。2 双向)
|
||||
*/
|
||||
private int flow;
|
||||
|
||||
/**
|
||||
* 协议类型
|
||||
*/
|
||||
private String protocol;
|
||||
|
||||
/**
|
||||
* 流量倍率
|
||||
*/
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
|
||||
private String tcpListenAddr;
|
||||
|
||||
private String udpListenAddr;
|
||||
|
||||
private String interfaceName;
|
||||
}
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
private String inIp;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.admin.mapper;
|
||||
|
||||
import com.admin.entity.ChainTunnel;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
public interface ChainTunnelMapper extends BaseMapper<ChainTunnel> {
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.admin.entity.Forward;
|
||||
import com.admin.common.dto.ForwardWithTunnelDto;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -30,4 +31,5 @@ public interface ForwardMapper extends BaseMapper<Forward> {
|
||||
*/
|
||||
List<ForwardWithTunnelDto> selectForwardsWithTunnelByUserId(@Param("userId") Integer userId);
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.admin.mapper;
|
||||
|
||||
import com.admin.entity.Forward;
|
||||
import com.admin.entity.ForwardPort;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
public interface ForwardPortMapper extends BaseMapper<ForwardPort> {
|
||||
}
|
||||
@@ -30,10 +30,5 @@ public interface UserMapper extends BaseMapper<User> {
|
||||
* @return 转发列表
|
||||
*/
|
||||
List<UserPackageDto.UserForwardDetailDto> getUserForwardDetails(@Param("userId") Integer userId);
|
||||
|
||||
/**
|
||||
* 管理员查询所有隧道(流量和转发设置为99999)
|
||||
* @return 隧道列表
|
||||
*/
|
||||
List<UserPackageDto.UserTunnelDetailDto> getAllTunnelsForAdmin();
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.admin.service;
|
||||
|
||||
import com.admin.entity.ChainTunnel;
|
||||
import com.admin.entity.Forward;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
public interface ChainTunnelService extends IService<ChainTunnel> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.admin.service;
|
||||
|
||||
import com.admin.entity.ForwardPort;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
public interface ForwardPortService extends IService<ForwardPort> {
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.admin.common.lang.R;
|
||||
import com.admin.entity.Forward;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -79,7 +80,4 @@ public interface ForwardService extends IService<Forward> {
|
||||
* @return 更新结果
|
||||
*/
|
||||
R updateForwardOrder(Map<String, Object> params);
|
||||
|
||||
|
||||
void updateForwardA(Forward forward);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.admin.service;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -14,7 +16,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
public interface NodeService extends IService<Node> {
|
||||
public interface NodeService extends IService<Node> {
|
||||
|
||||
R createNode(NodeDto nodeDto);
|
||||
|
||||
@@ -24,8 +26,12 @@ public interface NodeService extends IService<Node> {
|
||||
|
||||
R deleteNode(Long id);
|
||||
|
||||
Node getNodeById(Long id);
|
||||
|
||||
R getInstallCommand(Long id);
|
||||
|
||||
}
|
||||
R getInstallCommand(Long id);
|
||||
|
||||
/**
|
||||
* 更新节点排序(管理员)
|
||||
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||
*/
|
||||
R updateNodeOrder(Map<String, Object> params);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import com.admin.common.dto.TunnelDto;
|
||||
import com.admin.common.dto.TunnelUpdateDto;
|
||||
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -15,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
public interface TunnelService extends IService<Tunnel> {
|
||||
public interface TunnelService extends IService<Tunnel> {
|
||||
|
||||
/**
|
||||
* 创建隧道
|
||||
@@ -55,5 +57,11 @@ public interface TunnelService extends IService<Tunnel> {
|
||||
* @param tunnelId 隧道ID
|
||||
* @return 诊断结果
|
||||
*/
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
}
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
|
||||
/**
|
||||
* 更新隧道排序(管理员)
|
||||
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||
*/
|
||||
R updateTunnelOrder(Map<String, Object> params);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.admin.service.impl;
|
||||
|
||||
import com.admin.common.dto.ForwardDto;
|
||||
import com.admin.common.dto.ForwardUpdateDto;
|
||||
import com.admin.common.dto.ForwardWithTunnelDto;
|
||||
import com.admin.common.dto.GostDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.common.utils.JwtUtil;
|
||||
import com.admin.common.utils.WebSocketServer;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.ChainTunnelMapper;
|
||||
import com.admin.mapper.ForwardMapper;
|
||||
import com.admin.service.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 端口转发服务实现类
|
||||
* </p>
|
||||
*
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ChainTunnelServiceImpl extends ServiceImpl<ChainTunnelMapper, ChainTunnel> implements ChainTunnelService {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.admin.service.impl;
|
||||
|
||||
import com.admin.entity.ChainTunnel;
|
||||
import com.admin.entity.ForwardPort;
|
||||
import com.admin.mapper.ChainTunnelMapper;
|
||||
import com.admin.mapper.ForwardPortMapper;
|
||||
import com.admin.service.ChainTunnelService;
|
||||
import com.admin.service.ForwardPortService;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ForwardPortServiceImpl extends ServiceImpl<ForwardPortMapper, ForwardPort> implements ForwardPortService {
|
||||
|
||||
}
|
||||
+723
-1028
File diff suppressed because it is too large
Load Diff
@@ -6,71 +6,38 @@ import com.admin.common.dto.GostDto;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.common.utils.WebSocketServer;
|
||||
import com.admin.entity.Node;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.ViteConfig;
|
||||
import com.admin.mapper.NodeMapper;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.service.TunnelService;
|
||||
import com.admin.service.ViteConfigService;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.NodeMapper;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
import com.admin.service.*;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@Service
|
||||
@Slf4j
|
||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 节点服务实现类
|
||||
* 提供节点的增删改查功能,包括节点创建、更新、删除和查询操作
|
||||
* </p>
|
||||
*
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
@Service
|
||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/** 节点默认状态:启用 */
|
||||
private static final int NODE_STATUS_ACTIVE = 0;
|
||||
|
||||
/** 成功响应消息 */
|
||||
private static final String SUCCESS_CREATE_MSG = "节点创建成功";
|
||||
private static final String SUCCESS_UPDATE_MSG = "节点更新成功";
|
||||
private static final String SUCCESS_DELETE_MSG = "节点删除成功";
|
||||
|
||||
/** 错误响应消息 */
|
||||
private static final String ERROR_CREATE_MSG = "节点创建失败";
|
||||
private static final String ERROR_UPDATE_MSG = "节点更新失败";
|
||||
private static final String ERROR_DELETE_MSG = "节点删除失败";
|
||||
private static final String ERROR_NODE_NOT_FOUND = "节点不存在";
|
||||
|
||||
/** 隧道使用检查相关消息 */
|
||||
private static final String ERROR_IN_NODE_IN_USE = "该节点还有 %d 个隧道作为入口节点在使用,请先删除相关隧道";
|
||||
private static final String ERROR_OUT_NODE_IN_USE = "该节点还有 %d 个隧道作为出口节点在使用,请先删除相关隧道";
|
||||
|
||||
/** 端口范围验证相关消息 */
|
||||
private static final String ERROR_PORT_STA_REQUIRED = "起始端口不能为空";
|
||||
private static final String ERROR_PORT_END_REQUIRED = "结束端口不能为空";
|
||||
private static final String ERROR_PORT_RANGE_INVALID = "端口必须在1-65535范围内";
|
||||
private static final String ERROR_PORT_ORDER_INVALID = "结束端口不能小于起始端口";
|
||||
|
||||
// ========== 依赖注入 ==========
|
||||
|
||||
@Resource
|
||||
private TunnelMapper tunnelMapper;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
@@ -79,52 +46,79 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
@Resource
|
||||
ViteConfigService viteConfigService;
|
||||
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
@Resource
|
||||
ForwardPortService forwardPortService;
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
/**
|
||||
* 创建新节点
|
||||
*
|
||||
* @param nodeDto 节点创建数据传输对象
|
||||
* @return 创建结果响应
|
||||
*/
|
||||
@Override
|
||||
public R createNode(NodeDto nodeDto) {
|
||||
Node node = buildNewNode(nodeDto);
|
||||
boolean result = this.save(node);
|
||||
return result ? R.ok(SUCCESS_CREATE_MSG) : R.err(ERROR_CREATE_MSG);
|
||||
}
|
||||
public R createNode(NodeDto nodeDto) {
|
||||
validatePortRange(nodeDto.getPort());
|
||||
|
||||
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
|
||||
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
|
||||
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
|
||||
|
||||
Node node = new Node();
|
||||
node.setSecret(IdUtil.simpleUUID());
|
||||
node.setStatus(0);
|
||||
node.setPort(nodeDto.getPort());
|
||||
node.setName(nodeDto.getName());
|
||||
node.setServerIp(primaryServerIp);
|
||||
node.setServerIpV4(normalizedV4);
|
||||
node.setServerIpV6(normalizedV6);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
node.setCreatedTime(currentTime);
|
||||
node.setUpdatedTime(currentTime);
|
||||
node.setInterfaceName(nodeDto.getInterfaceName());
|
||||
this.save(node);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public R getAllNodes() {
|
||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
|
||||
nodeList.forEach(node -> node.setSecret(null));
|
||||
return R.ok(nodeList);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R updateNodeOrder(Map<String, Object> params) {
|
||||
if (!params.containsKey("nodes")) {
|
||||
return R.err("缺少nodes参数");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> nodesList = (List<Map<String, Object>>) params.get("nodes");
|
||||
if (nodesList == null || nodesList.isEmpty()) {
|
||||
return R.err("nodes参数不能为空");
|
||||
}
|
||||
|
||||
List<Node> nodesToUpdate = new ArrayList<>();
|
||||
for (Map<String, Object> nodeData : nodesList) {
|
||||
Long id = Long.valueOf(nodeData.get("id").toString());
|
||||
Integer inx = Integer.valueOf(nodeData.get("inx").toString());
|
||||
|
||||
Node node = new Node();
|
||||
node.setId(id);
|
||||
node.setInx(inx);
|
||||
nodesToUpdate.add(node);
|
||||
}
|
||||
|
||||
this.updateBatchById(nodesToUpdate);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有节点列表
|
||||
* 注意:返回结果中会隐藏节点密钥信息
|
||||
*
|
||||
* @return 包含所有节点的响应对象
|
||||
*/
|
||||
@Override
|
||||
public R getAllNodes() {
|
||||
List<Node> nodeList = this.list();
|
||||
hideNodeSecrets(nodeList);
|
||||
return R.ok(nodeList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点信息
|
||||
*
|
||||
* @param nodeUpdateDto 节点更新数据传输对象
|
||||
* @return 更新结果响应
|
||||
*/
|
||||
@Override
|
||||
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
// 1. 验证节点是否存在
|
||||
Node node = this.getById(nodeUpdateDto.getId());
|
||||
if (node == null) {
|
||||
return R.err(ERROR_NODE_NOT_FOUND);
|
||||
return R.err("节点不存在");
|
||||
}
|
||||
|
||||
//1.1 如果节点在线 且传入更新的 http/tls/socks 任意一项与数据库不一致,则通过 WS 通知节点更新设置
|
||||
boolean online = node.getStatus() != null && node.getStatus() == 1;
|
||||
Integer newHttp = nodeUpdateDto.getHttp();
|
||||
Integer newTls = nodeUpdateDto.getTls();
|
||||
@@ -147,314 +141,355 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
}
|
||||
|
||||
|
||||
// 2. 构建更新对象并执行更新
|
||||
|
||||
Node updateNode = buildUpdateNode(nodeUpdateDto);
|
||||
boolean result = this.updateById(updateNode);
|
||||
|
||||
// 更新隧道入口ip
|
||||
List<Tunnel> inNodeId = tunnelService.list(new QueryWrapper<Tunnel>().eq("in_node_id", updateNode.getId()));
|
||||
if (!inNodeId.isEmpty()) {
|
||||
for (Tunnel tunnel : inNodeId) {
|
||||
tunnel.setInIp(updateNode.getIp());
|
||||
}
|
||||
tunnelService.updateBatchById(inNodeId);
|
||||
}
|
||||
|
||||
// 更新服务器出口ip
|
||||
List<Tunnel> outNodeId = tunnelService.list(new QueryWrapper<Tunnel>().eq("out_node_id", updateNode.getId()));
|
||||
if (!outNodeId.isEmpty()) {
|
||||
for (Tunnel tunnel : outNodeId) {
|
||||
tunnel.setOutIp(updateNode.getServerIp());
|
||||
}
|
||||
tunnelService.updateBatchById(outNodeId);
|
||||
}
|
||||
|
||||
return result ? R.ok(SUCCESS_UPDATE_MSG) : R.err(ERROR_UPDATE_MSG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
* 删除前会检查是否有隧道正在使用该节点
|
||||
*
|
||||
* @param id 节点ID
|
||||
* @return 删除结果响应
|
||||
*/
|
||||
@Override
|
||||
public R deleteNode(Long id) {
|
||||
// 1. 验证节点是否存在
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
return R.err(ERROR_NODE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 2. 检查节点使用情况
|
||||
R usageCheckResult = checkNodeUsage(id);
|
||||
if (usageCheckResult.getCode() != 0) {
|
||||
return usageCheckResult;
|
||||
}
|
||||
|
||||
// 3. 执行删除操作
|
||||
boolean result = this.removeById(id);
|
||||
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_MSG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取节点信息
|
||||
*
|
||||
* @param id 节点ID
|
||||
* @return 节点对象
|
||||
* @throws RuntimeException 当节点不存在时抛出异常
|
||||
*/
|
||||
@Override
|
||||
public Node getNodeById(Long id) {
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
throw new RuntimeException(ERROR_NODE_NOT_FOUND);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 构建新节点对象
|
||||
*
|
||||
* @param nodeDto 节点创建DTO
|
||||
* @return 构建完成的节点对象
|
||||
*/
|
||||
private Node buildNewNode(NodeDto nodeDto) {
|
||||
Node node = new Node();
|
||||
BeanUtils.copyProperties(nodeDto, node);
|
||||
|
||||
// 验证端口范围
|
||||
validatePortRange(node.getPortSta(), node.getPortEnd());
|
||||
|
||||
// 设置默认属性
|
||||
node.setSecret(IdUtil.simpleUUID());
|
||||
node.setStatus(NODE_STATUS_ACTIVE);
|
||||
|
||||
// 设置时间戳
|
||||
long currentTime = System.currentTimeMillis();
|
||||
node.setCreatedTime(currentTime);
|
||||
node.setUpdatedTime(currentTime);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建节点更新对象
|
||||
*
|
||||
* @param nodeUpdateDto 节点更新DTO
|
||||
* @return 构建完成的更新对象
|
||||
*/
|
||||
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
Node node = new Node();
|
||||
node.setId(nodeUpdateDto.getId());
|
||||
node.setName(nodeUpdateDto.getName());
|
||||
node.setIp(nodeUpdateDto.getIp());
|
||||
node.setServerIp(nodeUpdateDto.getServerIp());
|
||||
node.setPortSta(nodeUpdateDto.getPortSta());
|
||||
node.setPortEnd(nodeUpdateDto.getPortEnd());
|
||||
node.setHttp(nodeUpdateDto.getHttp());
|
||||
node.setTls(nodeUpdateDto.getTls());
|
||||
node.setSocks(nodeUpdateDto.getSocks());
|
||||
// 验证端口范围
|
||||
validatePortRange(node.getPortSta(), node.getPortEnd());
|
||||
|
||||
node.setUpdatedTime(System.currentTimeMillis());
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏节点列表中的密钥信息
|
||||
*
|
||||
* @param nodeList 节点列表
|
||||
*/
|
||||
private void hideNodeSecrets(List<Node> nodeList) {
|
||||
nodeList.forEach(node -> node.setSecret(null));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查节点使用情况
|
||||
* 验证是否有隧道正在使用该节点作为入口或出口节点
|
||||
*
|
||||
* @param nodeId 节点ID
|
||||
* @return 检查结果响应
|
||||
*/
|
||||
private R checkNodeUsage(Long nodeId) {
|
||||
// 检查入口节点使用情况
|
||||
R inNodeCheckResult = checkInNodeUsage(nodeId);
|
||||
if (inNodeCheckResult.getCode() != 0) {
|
||||
return inNodeCheckResult;
|
||||
}
|
||||
|
||||
// 检查出口节点使用情况
|
||||
return checkOutNodeUsage(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点作为入口节点的使用情况
|
||||
*
|
||||
* @param nodeId 节点ID
|
||||
* @return 检查结果响应
|
||||
*/
|
||||
private R checkInNodeUsage(Long nodeId) {
|
||||
QueryWrapper<Tunnel> query = new QueryWrapper<>();
|
||||
query.eq("in_node_id", nodeId);
|
||||
|
||||
long tunnelCount = tunnelMapper.selectCount(query);
|
||||
if (tunnelCount > 0) {
|
||||
String errorMsg = String.format(ERROR_IN_NODE_IN_USE, tunnelCount);
|
||||
return R.err(errorMsg);
|
||||
}
|
||||
|
||||
this.updateById(updateNode);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点作为出口节点的使用情况
|
||||
*
|
||||
* @param nodeId 节点ID
|
||||
* @return 检查结果响应
|
||||
*/
|
||||
private R checkOutNodeUsage(Long nodeId) {
|
||||
QueryWrapper<Tunnel> query = new QueryWrapper<>();
|
||||
query.eq("out_node_id", nodeId);
|
||||
|
||||
long tunnelCount = tunnelMapper.selectCount(query);
|
||||
if (tunnelCount > 0) {
|
||||
String errorMsg = String.format(ERROR_OUT_NODE_IN_USE, tunnelCount);
|
||||
return R.err(errorMsg);
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
@Override
|
||||
public R deleteNode(Long id) {
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
return R.err("节点不存在");
|
||||
}
|
||||
|
||||
List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
|
||||
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
|
||||
.filter(ct -> ct.getTunnelId() != null)
|
||||
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
|
||||
|
||||
for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
|
||||
Long tunnelId = entry.getKey();
|
||||
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||
|
||||
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||
|
||||
// Remove the node from the tunnel definition (do NOT delete the tunnel).
|
||||
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
|
||||
|
||||
if (tunnel == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||
Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType();
|
||||
|
||||
// Keep tunnel.inIp consistent when it was auto-derived from entry nodes.
|
||||
String oldDerivedInIp = buildDerivedInIp(before);
|
||||
String newDerivedInIp = buildDerivedInIp(after);
|
||||
if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) {
|
||||
updateTunnelInIp(tunnelId, newDerivedInIp);
|
||||
}
|
||||
|
||||
boolean valid = isTunnelConfigValid(tunnel, after);
|
||||
if (!valid) {
|
||||
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete");
|
||||
continue;
|
||||
}
|
||||
|
||||
// For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config.
|
||||
// Removing an entry node (chainType=1) does not affect remaining nodes' chain targets.
|
||||
if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) {
|
||||
try {
|
||||
cleanupGostConfig(after, tunnelId);
|
||||
rebuildGostConfig(after, tunnel);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e);
|
||||
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove per-forward port allocations on this node (avoid orphan ForwardPort rows).
|
||||
try {
|
||||
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("node_id", id));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e);
|
||||
}
|
||||
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private boolean isTunnelConfigValid(Tunnel tunnel, List<ChainTunnel> chainTunnels) {
|
||||
if (tunnel == null || chainTunnels == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long inCount = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||
.count();
|
||||
if (inCount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||
long outCount = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||
.count();
|
||||
return outCount > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) {
|
||||
if (StrUtil.isBlank(currentInIp)) {
|
||||
return true;
|
||||
}
|
||||
if (oldDerivedInIp == null) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(currentInIp, oldDerivedInIp);
|
||||
}
|
||||
|
||||
private void updateTunnelInIp(Long tunnelId, String derivedInIp) {
|
||||
Tunnel update = new Tunnel();
|
||||
update.setId(tunnelId);
|
||||
update.setInIp(derivedInIp == null ? "" : derivedInIp);
|
||||
update.setUpdatedTime(System.currentTimeMillis());
|
||||
tunnelService.updateById(update);
|
||||
}
|
||||
|
||||
private String buildDerivedInIp(List<ChainTunnel> chainTunnels) {
|
||||
if (chainTunnels == null) {
|
||||
return "";
|
||||
}
|
||||
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||
.collect(Collectors.toList());
|
||||
if (inNodes.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder inIp = new StringBuilder();
|
||||
for (ChainTunnel inNode : inNodes) {
|
||||
Node n = this.getById(inNode.getNodeId());
|
||||
if (n == null || StrUtil.isBlank(n.getServerIp())) {
|
||||
return null;
|
||||
}
|
||||
inIp.append(n.getServerIp()).append(",");
|
||||
}
|
||||
inIp.deleteCharAt(inIp.length() - 1);
|
||||
return inIp.toString();
|
||||
}
|
||||
|
||||
private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List<ChainTunnel> remaining, String reason) {
|
||||
try {
|
||||
Tunnel update = new Tunnel();
|
||||
update.setId(tunnel.getId());
|
||||
update.setStatus(0);
|
||||
update.setUpdatedTime(System.currentTimeMillis());
|
||||
tunnelService.updateById(update);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||
}
|
||||
|
||||
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||
try {
|
||||
cleanupGostConfig(remaining, tunnel.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
|
||||
if (chainTunnels == null) {
|
||||
return;
|
||||
}
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
if (chainTunnel.getChainType() == null) {
|
||||
continue;
|
||||
}
|
||||
if (chainTunnel.getChainType() == 1) {
|
||||
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
|
||||
} else if (chainTunnel.getChainType() == 2) {
|
||||
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(tunnelId + "_tls");
|
||||
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
|
||||
} else if (chainTunnel.getChainType() == 3) {
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(tunnelId + "_tls");
|
||||
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
|
||||
if (tunnel == null || chainTunnels == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Long, Node> nodes = new HashMap<>();
|
||||
for (ChainTunnel ct : chainTunnels) {
|
||||
Node n = this.getById(ct.getNodeId());
|
||||
if (n != null) {
|
||||
nodes.put(n.getId(), n);
|
||||
}
|
||||
}
|
||||
|
||||
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
|
||||
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
|
||||
|
||||
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.map(Map.Entry::getValue)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<ChainTunnel> outNodes = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||
for (ChainTunnel inNode : inNodes) {
|
||||
if (chainNodesList.isEmpty()) {
|
||||
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
|
||||
} else {
|
||||
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < chainNodesList.size(); i++) {
|
||||
for (ChainTunnel chainNode : chainNodesList.get(i)) {
|
||||
if (i + 1 >= chainNodesList.size()) {
|
||||
GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes);
|
||||
} else {
|
||||
GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes);
|
||||
}
|
||||
GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes);
|
||||
}
|
||||
}
|
||||
|
||||
for (ChainTunnel outNode : outNodes) {
|
||||
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取节点安装命令
|
||||
* 根据节点信息生成对应的安装命令
|
||||
*
|
||||
* @param id 节点ID
|
||||
* @return 包含安装命令的响应对象
|
||||
*/
|
||||
@Override
|
||||
public R getInstallCommand(Long id) {
|
||||
// 1. 验证节点是否存在
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
return R.err(ERROR_NODE_NOT_FOUND);
|
||||
return R.err("节点不存在");
|
||||
}
|
||||
|
||||
// 2. 构建安装命令
|
||||
return buildInstallCommand(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建节点安装命令
|
||||
*
|
||||
* @param node 节点对象
|
||||
* @return 格式化的安装命令
|
||||
*/
|
||||
private R buildInstallCommand(Node node) {
|
||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
||||
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
||||
|
||||
StringBuilder command = new StringBuilder();
|
||||
|
||||
// 第一部分:下载安装脚本
|
||||
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.3/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
|
||||
// 处理服务器地址,如果是IPv6需要添加方括号
|
||||
String processedServerAddr = processServerAddress(viteConfig.getValue());
|
||||
|
||||
// 第二部分:执行安装脚本(去掉-u参数)
|
||||
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||
command.append("./install.sh")
|
||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||
.append(" -s ").append(node.getSecret()); // 节点密钥
|
||||
|
||||
return R.ok(command.toString());
|
||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||
.append(" -s ").append(node.getSecret()); // 节点密钥
|
||||
return R.ok(command);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理服务器地址,确保IPv6地址被方括号包裹
|
||||
*
|
||||
* @param serverAddr 原始服务器地址,格式可能为 host:port
|
||||
* @return 处理后的服务器地址
|
||||
*/
|
||||
private String processServerAddress(String serverAddr) {
|
||||
if (StrUtil.isBlank(serverAddr)) {
|
||||
return serverAddr;
|
||||
|
||||
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
validatePortRange(nodeUpdateDto.getPort());
|
||||
|
||||
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
|
||||
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
|
||||
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
|
||||
|
||||
Node node = new Node();
|
||||
node.setId(nodeUpdateDto.getId());
|
||||
node.setName(nodeUpdateDto.getName());
|
||||
node.setServerIp(primaryServerIp);
|
||||
node.setServerIpV4(normalizedV4);
|
||||
node.setServerIpV6(normalizedV6);
|
||||
node.setPort(nodeUpdateDto.getPort());
|
||||
node.setHttp(nodeUpdateDto.getHttp());
|
||||
node.setTls(nodeUpdateDto.getTls());
|
||||
node.setSocks(nodeUpdateDto.getSocks());
|
||||
node.setUpdatedTime(System.currentTimeMillis());
|
||||
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
|
||||
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
|
||||
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
|
||||
return node;
|
||||
}
|
||||
|
||||
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
|
||||
if (StrUtil.isNotBlank(serverIpV4)) {
|
||||
return serverIpV4.trim();
|
||||
}
|
||||
if (StrUtil.isNotBlank(serverIpV6)) {
|
||||
return serverIpV6.trim();
|
||||
}
|
||||
return fallback != null ? fallback.trim() : null;
|
||||
}
|
||||
|
||||
private String normalizeV4(String serverIpV4, String legacyServerIp) {
|
||||
if (StrUtil.isNotBlank(serverIpV4)) {
|
||||
return serverIpV4.trim();
|
||||
}
|
||||
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
|
||||
return legacyServerIp.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeV6(String serverIpV6, String legacyServerIp) {
|
||||
if (StrUtil.isNotBlank(serverIpV6)) {
|
||||
return serverIpV6.trim();
|
||||
}
|
||||
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
|
||||
return legacyServerIp.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean looksLikeIpv4(String value) {
|
||||
// 仅用于判定地址族(不解析域名)
|
||||
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||
return ipv4.matcher(value).matches();
|
||||
}
|
||||
|
||||
private boolean looksLikeIpv6(String value) {
|
||||
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
|
||||
long colonCount = value.chars().filter(ch -> ch == ':').count();
|
||||
return colonCount >= 2;
|
||||
}
|
||||
|
||||
|
||||
private void validatePortRange(String port) {
|
||||
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
||||
if (port == null || port.isEmpty()) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
|
||||
// 如果已经被方括号包裹,直接返回
|
||||
if (serverAddr.startsWith("[")) {
|
||||
return serverAddr;
|
||||
String[] parts = port.split(",");
|
||||
for (String part : parts) {
|
||||
part = part.trim();
|
||||
if (!PORT_PATTERN.matcher(part).matches()) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
if (part.contains("-")) {
|
||||
String[] range = part.split("-");
|
||||
int start = Integer.parseInt(range[0]);
|
||||
int end = Integer.parseInt(range[1]);
|
||||
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
} else {
|
||||
int ports = Integer.parseInt(part);
|
||||
if (ports < 0 || ports > 65535) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找最后一个冒号,分离主机和端口
|
||||
int lastColonIndex = serverAddr.lastIndexOf(':');
|
||||
if (lastColonIndex == -1) {
|
||||
// 没有端口号,直接检查是否需要包裹
|
||||
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
|
||||
}
|
||||
|
||||
String host = serverAddr.substring(0, lastColonIndex);
|
||||
String port = serverAddr.substring(lastColonIndex);
|
||||
|
||||
// 检查主机部分是否为IPv6地址
|
||||
if (isIPv6Address(host)) {
|
||||
return "[" + host + "]" + port;
|
||||
}
|
||||
|
||||
return serverAddr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为IPv6地址
|
||||
*
|
||||
* @param address 地址字符串(不包含端口号)
|
||||
* @return 是否为IPv6地址
|
||||
*/
|
||||
private boolean isIPv6Address(String address) {
|
||||
// IPv6地址包含多个冒号,至少2个
|
||||
if (!address.contains(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算冒号数量,IPv6地址至少有2个冒号
|
||||
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
||||
return colonCount >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证端口范围的有效性
|
||||
*
|
||||
* @param portSta 起始端口
|
||||
* @param portEnd 结束端口
|
||||
* @throws RuntimeException 当端口范围无效时抛出异常
|
||||
*/
|
||||
private void validatePortRange(Integer portSta, Integer portEnd) {
|
||||
// 检查起始端口是否为空
|
||||
if (portSta == null) {
|
||||
throw new RuntimeException(ERROR_PORT_STA_REQUIRED);
|
||||
}
|
||||
|
||||
// 检查结束端口是否为空
|
||||
if (portEnd == null) {
|
||||
throw new RuntimeException(ERROR_PORT_END_REQUIRED);
|
||||
}
|
||||
|
||||
// 检查端口范围是否在有效区间内
|
||||
if (portSta < 1 || portSta > 65535 || portEnd < 1 || portEnd > 65535) {
|
||||
throw new RuntimeException(ERROR_PORT_RANGE_INVALID);
|
||||
}
|
||||
|
||||
// 检查端口顺序是否正确
|
||||
if (portEnd < portSta) {
|
||||
throw new RuntimeException(ERROR_PORT_ORDER_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+73
-320
@@ -5,15 +5,10 @@ import com.admin.common.dto.SpeedLimitDto;
|
||||
import com.admin.common.dto.SpeedLimitUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.Node;
|
||||
import com.admin.entity.SpeedLimit;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.SpeedLimitMapper;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.service.SpeedLimitService;
|
||||
import com.admin.service.TunnelService;
|
||||
import com.admin.service.UserTunnelService;
|
||||
import com.admin.service.*;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.Data;
|
||||
@@ -22,8 +17,10 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@@ -41,354 +38,110 @@ import java.util.UUID;
|
||||
@Service
|
||||
public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLimit> implements SpeedLimitService {
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/** Gost操作成功响应消息 */
|
||||
private static final String GOST_SUCCESS_MSG = "OK";
|
||||
|
||||
/** Gost未找到资源响应消息 */
|
||||
private static final String GOST_NOT_FOUND_MSG = "not found";
|
||||
|
||||
/** 限速规则状态 */
|
||||
private static final int SPEED_LIMIT_ACTIVE_STATUS = 1;
|
||||
private static final int SPEED_LIMIT_INACTIVE_STATUS = 0;
|
||||
|
||||
/** 速度转换比率:比特到字节 */
|
||||
private static final double BITS_TO_BYTES_RATIO = 8.0;
|
||||
|
||||
/** 成功响应消息 */
|
||||
private static final String SUCCESS_UPDATE_MSG = "限速规则更新成功";
|
||||
private static final String SUCCESS_DELETE_MSG = "限速规则删除成功";
|
||||
|
||||
/** 错误响应消息 */
|
||||
private static final String ERROR_CREATE_MSG = "限速规则创建失败";
|
||||
private static final String ERROR_UPDATE_MSG = "限速规则更新失败";
|
||||
private static final String ERROR_DELETE_MSG = "限速规则删除失败";
|
||||
private static final String ERROR_SPEED_LIMIT_NOT_FOUND = "限速规则不存在";
|
||||
private static final String ERROR_TUNNEL_NOT_FOUND = "指定的隧道不存在";
|
||||
private static final String ERROR_TUNNEL_NOT_EXISTS = "隧道不存在";
|
||||
private static final String ERROR_TUNNEL_NAME_MISMATCH = "隧道名称与隧道ID不匹配";
|
||||
private static final String ERROR_SPEED_LIMIT_IN_USE = "该限速规则还有用户在使用 请先取消分配";
|
||||
|
||||
// ========== 依赖注入 ==========
|
||||
|
||||
@Autowired
|
||||
@Resource
|
||||
@Lazy
|
||||
private TunnelService tunnelService;
|
||||
TunnelService tunnelService;
|
||||
|
||||
@Autowired
|
||||
private NodeService nodeService;
|
||||
@Resource
|
||||
NodeService nodeService;
|
||||
|
||||
@Autowired
|
||||
private UserTunnelService userTunnelService;
|
||||
@Resource
|
||||
UserTunnelService userTunnelService;
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private SpeedLimitService speedLimitService;
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
/**
|
||||
* 创建限速规则
|
||||
*
|
||||
* @param speedLimitDto 限速规则创建数据传输对象
|
||||
* @return 创建结果响应
|
||||
*/
|
||||
@Override
|
||||
public R createSpeedLimit(SpeedLimitDto speedLimitDto) {
|
||||
// 1. 验证隧道
|
||||
TunnelValidationResult tunnelValidation = validateTunnelWithResult(speedLimitDto.getTunnelId(), speedLimitDto.getTunnelName());
|
||||
if (tunnelValidation.isHasError()) {
|
||||
return R.err(tunnelValidation.getErrorMessage());
|
||||
}
|
||||
Tunnel tunnel = tunnelService.getById(speedLimitDto.getTunnelId());
|
||||
if (tunnel == null) return R.err("隧道不存在");
|
||||
|
||||
// 2. 创建限速规则实体
|
||||
SpeedLimit speedLimit = createSpeedLimitEntity(speedLimitDto);
|
||||
if (!this.save(speedLimit)) {
|
||||
return R.err(ERROR_CREATE_MSG);
|
||||
}
|
||||
SpeedLimit speedLimit = new SpeedLimit();
|
||||
BeanUtils.copyProperties(speedLimitDto, speedLimit);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
speedLimit.setCreatedTime(currentTime);
|
||||
speedLimit.setUpdatedTime(currentTime);
|
||||
speedLimit.setStatus(1);
|
||||
this.save(speedLimit);
|
||||
|
||||
// 3. 调用Gost API添加限速器
|
||||
R gostResult = addGostLimiter(speedLimit, tunnelValidation.getTunnel());
|
||||
if (gostResult.getCode() != 0) {
|
||||
handleGostOperationFailure(speedLimit);
|
||||
this.removeById(speedLimit.getId());
|
||||
return gostResult;
|
||||
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
||||
|
||||
List<Long> limit_success = new ArrayList<>();
|
||||
|
||||
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
|
||||
for (ChainTunnel chainTunnel : tunnelList) {
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
GostDto gostDto = GostUtil.AddLimiters(node.getId(), speedLimit.getId(), speedInMBps);
|
||||
if (Objects.equals(gostDto.getMsg(), "OK")){
|
||||
limit_success.add(node.getId());
|
||||
}else {
|
||||
this.removeById(speedLimit.getId());
|
||||
for (Long node_id : limit_success) {
|
||||
GostDto deleteLimiters = GostUtil.DeleteLimiters(node_id, speedLimit.getId());
|
||||
System.out.println(deleteLimiters);
|
||||
}
|
||||
return R.err(gostDto.getMsg());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有限速规则
|
||||
*
|
||||
* @return 包含所有限速规则的响应对象
|
||||
*/
|
||||
@Override
|
||||
public R getAllSpeedLimits() {
|
||||
List<SpeedLimit> speedLimits = this.list();
|
||||
return R.ok(speedLimits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新限速规则
|
||||
*
|
||||
* @param speedLimitUpdateDto 限速规则更新数据传输对象
|
||||
* @return 更新结果响应
|
||||
*/
|
||||
@Override
|
||||
public R updateSpeedLimit(SpeedLimitUpdateDto speedLimitUpdateDto) {
|
||||
// 1. 验证限速规则是否存在
|
||||
SpeedLimit speedLimit = this.getById(speedLimitUpdateDto.getId());
|
||||
if (speedLimit == null) {
|
||||
return R.err(ERROR_SPEED_LIMIT_NOT_FOUND);
|
||||
if (speedLimit == null) return R.err("限速不存在");
|
||||
|
||||
BeanUtils.copyProperties(speedLimitUpdateDto, speedLimit);
|
||||
speedLimit.setUpdatedTime(System.currentTimeMillis());
|
||||
|
||||
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
||||
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
|
||||
for (ChainTunnel chainTunnel : tunnelList) {
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
GostDto gostDto = GostUtil.UpdateLimiters(node.getId(), speedLimit.getId(), speedInMBps);
|
||||
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 验证隧道
|
||||
TunnelValidationResult tunnelValidation = validateTunnelWithResult(speedLimitUpdateDto.getTunnelId(), speedLimitUpdateDto.getTunnelName());
|
||||
if (tunnelValidation.isHasError()) {
|
||||
return R.err(tunnelValidation.getErrorMessage());
|
||||
}
|
||||
|
||||
// 3. 更新限速规则数据
|
||||
updateSpeedLimitEntity(speedLimitUpdateDto, speedLimit);
|
||||
|
||||
// 4. 调用Gost API更新限速器
|
||||
R gostResult = updateGostLimiter(speedLimit, tunnelValidation.getTunnel());
|
||||
if (gostResult.getCode() != 0) {
|
||||
return gostResult;
|
||||
}
|
||||
|
||||
// 5. 保存更新
|
||||
boolean result = this.updateById(speedLimit);
|
||||
return result ? R.ok(SUCCESS_UPDATE_MSG) : R.err(ERROR_UPDATE_MSG);
|
||||
this.updateById(speedLimit);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除限速规则
|
||||
* 删除前会检查是否有用户正在使用该限速规则
|
||||
*
|
||||
* @param id 限速规则ID
|
||||
* @return 删除结果响应
|
||||
*/
|
||||
@Override
|
||||
public R deleteSpeedLimit(Long id) {
|
||||
// 1. 验证限速规则是否存在
|
||||
SpeedLimit speedLimit = this.getById(id);
|
||||
if (speedLimit == null) {
|
||||
return R.err(ERROR_SPEED_LIMIT_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 2. 检查使用情况
|
||||
R usageCheckResult = checkSpeedLimitUsage(id);
|
||||
if (usageCheckResult.getCode() != 0) {
|
||||
return usageCheckResult;
|
||||
}
|
||||
|
||||
// 3. 获取隧道信息
|
||||
Tunnel tunnel = tunnelService.getById(speedLimit.getTunnelId());
|
||||
if (tunnel == null) {
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// 4. 调用Gost API删除限速器
|
||||
deleteGostLimiter(id, tunnel);
|
||||
|
||||
// 5. 删除限速规则
|
||||
boolean result = this.removeById(id);
|
||||
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_MSG);
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 验证隧道是否存在且名称匹配(返回详细结果)
|
||||
*
|
||||
* @param tunnelId 隧道ID
|
||||
* @param tunnelName 隧道名称
|
||||
* @return 隧道验证结果
|
||||
*/
|
||||
private TunnelValidationResult validateTunnelWithResult(Long tunnelId, String tunnelName) {
|
||||
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||
if (tunnel == null) {
|
||||
return TunnelValidationResult.error(ERROR_TUNNEL_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!tunnel.getName().equals(tunnelName)) {
|
||||
return TunnelValidationResult.error(ERROR_TUNNEL_NAME_MISMATCH);
|
||||
}
|
||||
|
||||
return TunnelValidationResult.success(tunnel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证隧道是否存在且名称匹配(兼容原有方法)
|
||||
*
|
||||
* @param tunnelId 隧道ID
|
||||
* @param tunnelName 隧道名称
|
||||
* @return 验证结果响应
|
||||
*/
|
||||
private R validateTunnel(Long tunnelId, String tunnelName) {
|
||||
TunnelValidationResult result = validateTunnelWithResult(tunnelId, tunnelName);
|
||||
return result.isHasError() ? R.err(result.getErrorMessage()) : R.ok(result.getTunnel());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建限速规则实体对象
|
||||
*
|
||||
* @param speedLimitDto 限速规则创建DTO
|
||||
* @return 构建完成的限速规则对象
|
||||
*/
|
||||
private SpeedLimit createSpeedLimitEntity(SpeedLimitDto speedLimitDto) {
|
||||
SpeedLimit speedLimit = new SpeedLimit();
|
||||
BeanUtils.copyProperties(speedLimitDto, speedLimit);
|
||||
|
||||
// 设置默认属性
|
||||
long currentTime = System.currentTimeMillis();
|
||||
speedLimit.setCreatedTime(currentTime);
|
||||
speedLimit.setUpdatedTime(currentTime);
|
||||
speedLimit.setStatus(SPEED_LIMIT_ACTIVE_STATUS);
|
||||
|
||||
return speedLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新限速规则实体对象
|
||||
*
|
||||
* @param speedLimitUpdateDto 限速规则更新DTO
|
||||
* @param speedLimit 待更新的限速规则对象
|
||||
*/
|
||||
private void updateSpeedLimitEntity(SpeedLimitUpdateDto speedLimitUpdateDto, SpeedLimit speedLimit) {
|
||||
BeanUtils.copyProperties(speedLimitUpdateDto, speedLimit);
|
||||
speedLimit.setUpdatedTime(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查限速规则使用情况
|
||||
*
|
||||
* @param speedLimitId 限速规则ID
|
||||
* @return 检查结果响应
|
||||
*/
|
||||
private R checkSpeedLimitUsage(Long speedLimitId) {
|
||||
int userCount = userTunnelService.count(new QueryWrapper<UserTunnel>().eq("speed_id", speedLimitId));
|
||||
if (userCount != 0) {
|
||||
return R.err(ERROR_SPEED_LIMIT_IN_USE);
|
||||
if (speedLimit == null) return R.err("限速规则不存在");
|
||||
|
||||
|
||||
int userCount = userTunnelService.count(new QueryWrapper<UserTunnel>().eq("speed_id", speedLimit.getId()));
|
||||
if (userCount != 0) return R.err("该限速规则还有用户在使用 请先取消分配");
|
||||
|
||||
|
||||
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
|
||||
for (ChainTunnel chainTunnel : tunnelList) {
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
GostDto gostDto = GostUtil.DeleteLimiters(node.getId(), speedLimit.getId());
|
||||
if (!Objects.equals(gostDto.getMsg(), "OK"))return R.err(gostDto.getMsg());
|
||||
}
|
||||
}
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Gost限速器
|
||||
*
|
||||
* @param speedLimit 限速规则对象
|
||||
* @param tunnel 隧道对象
|
||||
* @return 操作结果响应
|
||||
*/
|
||||
private R addGostLimiter(SpeedLimit speedLimit, Tunnel tunnel) {
|
||||
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
||||
Node node = nodeService.getNodeById(tunnel.getInNodeId());
|
||||
|
||||
GostDto gostResult = GostUtil.AddLimiters(
|
||||
node.getId(),
|
||||
speedLimit.getId(),
|
||||
speedInMBps
|
||||
);
|
||||
|
||||
return isGostOperationSuccess(gostResult) ? R.ok() : R.err(gostResult.getMsg());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Gost限速器
|
||||
*
|
||||
* @param speedLimit 限速规则对象
|
||||
* @param tunnel 隧道对象
|
||||
* @return 操作结果响应
|
||||
*/
|
||||
private R updateGostLimiter(SpeedLimit speedLimit, Tunnel tunnel) {
|
||||
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
||||
Node node = nodeService.getNodeById(tunnel.getInNodeId());
|
||||
|
||||
// 尝试更新限速器
|
||||
GostDto gostResult = GostUtil.UpdateLimiters(node.getId(), speedLimit.getId(), speedInMBps);
|
||||
|
||||
// 如果限速器不存在,则创建新的
|
||||
if (gostResult.getMsg().contains(GOST_NOT_FOUND_MSG)) {
|
||||
gostResult = GostUtil.AddLimiters(node.getId(), speedLimit.getId(), speedInMBps);
|
||||
}
|
||||
|
||||
return isGostOperationSuccess(gostResult) ? R.ok() : R.err(gostResult.getMsg());
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Gost限速器
|
||||
*
|
||||
* @param speedLimitId 限速规则ID
|
||||
* @param tunnel 隧道对象
|
||||
* @return 操作结果响应
|
||||
*/
|
||||
private R deleteGostLimiter(Long speedLimitId, Tunnel tunnel) {
|
||||
Node node = nodeService.getNodeById(tunnel.getInNodeId());
|
||||
GostDto gostResult = GostUtil.DeleteLimiters(node.getId(), speedLimitId);
|
||||
|
||||
return isGostOperationSuccess(gostResult) ? R.ok() : R.err(gostResult.getMsg());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Gost操作失败的情况
|
||||
*
|
||||
* @param speedLimit 限速规则对象
|
||||
*/
|
||||
private void handleGostOperationFailure(SpeedLimit speedLimit) {
|
||||
speedLimit.setStatus(SPEED_LIMIT_INACTIVE_STATUS);
|
||||
speedLimitService.updateById(speedLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将比特率转换为兆字节每秒
|
||||
*
|
||||
* @param speedInBits 比特率速度
|
||||
* @return 兆字节每秒字符串
|
||||
*/
|
||||
private String convertBitsToMBps(Integer speedInBits) {
|
||||
double mbs = speedInBits / BITS_TO_BYTES_RATIO;
|
||||
double mbs = speedInBits / 8.0;
|
||||
BigDecimal bd = new BigDecimal(mbs).setScale(1, RoundingMode.HALF_UP);
|
||||
return bd.doubleValue() + "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Gost操作是否成功
|
||||
*
|
||||
* @param gostResult Gost操作结果
|
||||
* @return 是否成功
|
||||
*/
|
||||
private boolean isGostOperationSuccess(GostDto gostResult) {
|
||||
return Objects.equals(gostResult.getMsg(), GOST_SUCCESS_MSG);
|
||||
}
|
||||
|
||||
// ========== 内部数据类 ==========
|
||||
|
||||
/**
|
||||
* 隧道验证结果封装类
|
||||
*/
|
||||
@Data
|
||||
private static class TunnelValidationResult {
|
||||
private final boolean hasError;
|
||||
private final String errorMessage;
|
||||
private final Tunnel tunnel;
|
||||
|
||||
private TunnelValidationResult(boolean hasError, String errorMessage, Tunnel tunnel) {
|
||||
this.hasError = hasError;
|
||||
this.errorMessage = errorMessage;
|
||||
this.tunnel = tunnel;
|
||||
}
|
||||
|
||||
public static TunnelValidationResult success(Tunnel tunnel) {
|
||||
return new TunnelValidationResult(false, null, tunnel);
|
||||
}
|
||||
|
||||
public static TunnelValidationResult error(String errorMessage) {
|
||||
return new TunnelValidationResult(true, errorMessage, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,7 @@ import com.admin.common.utils.GostUtil;
|
||||
import com.admin.common.utils.JwtUtil;
|
||||
import com.admin.common.utils.Md5Util;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.ForwardMapper;
|
||||
import com.admin.mapper.UserMapper;
|
||||
import com.admin.mapper.UserTunnelMapper;
|
||||
import com.admin.service.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
@@ -20,6 +18,7 @@ import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -28,83 +27,25 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 用户服务实现类
|
||||
* 提供用户的增删改查功能,包括用户登录、创建、更新、删除和套餐信息查询
|
||||
* 支持用户关联数据的级联删除,包括转发和Gost服务的清理
|
||||
* </p>
|
||||
*
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/** 用户角色常量 */
|
||||
private static final int ADMIN_ROLE_ID = 0; // 管理员角色ID
|
||||
private static final int USER_ROLE_ID = 1; // 普通用户角色ID
|
||||
|
||||
/** 用户状态常量 */
|
||||
private static final int USER_STATUS_ACTIVE = 1; // 用户启用状态
|
||||
private static final int USER_STATUS_DISABLED = 0; // 用户停用状态
|
||||
|
||||
/** 隧道类型常量 */
|
||||
private static final int TUNNEL_TYPE_TUNNEL_FORWARD = 2; // 隧道转发类型
|
||||
|
||||
/** 成功响应消息 */
|
||||
private static final String SUCCESS_CREATE_MSG = "用户创建成功";
|
||||
private static final String SUCCESS_UPDATE_MSG = "用户更新成功";
|
||||
private static final String SUCCESS_DELETE_MSG = "用户及关联数据删除成功";
|
||||
|
||||
/** 错误响应消息 */
|
||||
private static final String ERROR_LOGIN_CREDENTIALS = "账号或密码错误";
|
||||
private static final String ERROR_ACCOUNT_DISABLED = "账户停用";
|
||||
private static final String ERROR_CREATE_FAILED = "用户创建失败";
|
||||
private static final String ERROR_UPDATE_FAILED = "用户更新失败";
|
||||
private static final String ERROR_DELETE_FAILED = "用户删除失败";
|
||||
private static final String ERROR_USER_NOT_FOUND = "用户不存在";
|
||||
private static final String ERROR_USERNAME_EXISTS = "用户名已存在";
|
||||
private static final String ERROR_USERNAME_TAKEN = "用户名已被其他用户使用";
|
||||
private static final String ERROR_CANNOT_DELETE_ADMIN = "不能删除管理员用户";
|
||||
private static final String ERROR_CANNOT_UPDATE_ADMIN = "不能修改管理员用户信息";
|
||||
private static final String ERROR_USER_NOT_LOGGED_IN = "用户未登录或token无效";
|
||||
private static final String ERROR_GET_PACKAGE_INFO_FAILED = "获取套餐信息失败";
|
||||
private static final String ERROR_CURRENT_PASSWORD_WRONG = "当前密码错误";
|
||||
private static final String ERROR_PASSWORD_NOT_MATCH = "新密码和确认密码不匹配";
|
||||
@Resource
|
||||
@Lazy
|
||||
ForwardService forwardService;
|
||||
|
||||
/** 默认账号密码 */
|
||||
private static final String DEFAULT_USERNAME = "admin_user";
|
||||
private static final String DEFAULT_PASSWORD = "admin_user";
|
||||
|
||||
/** 登录响应字段名 */
|
||||
private static final String LOGIN_TOKEN_FIELD = "token";
|
||||
private static final String LOGIN_NAME_FIELD = "name";
|
||||
private static final String LOGIN_ROLE_ID_FIELD = "role_id";
|
||||
private static final String LOGIN_REQUIRE_PASSWORD_CHANGE_FIELD = "requirePasswordChange";
|
||||
@Resource
|
||||
UserMapper userMapper;
|
||||
|
||||
// ========== 依赖注入 ==========
|
||||
|
||||
@Resource
|
||||
private UserMapper userMapper;
|
||||
@Lazy
|
||||
TunnelService tunnelService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private ForwardMapper forwardMapper;
|
||||
|
||||
@Resource
|
||||
private UserTunnelMapper userTunnelMapper;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private TunnelService tunnelService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private NodeService nodeService;
|
||||
NodeService nodeService;
|
||||
|
||||
@Resource
|
||||
UserTunnelService userTunnelService;
|
||||
@@ -116,21 +57,15 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
StatisticsFlowService statisticsFlowService;
|
||||
|
||||
@Resource
|
||||
private ImageCaptchaApplication application;
|
||||
@Lazy
|
||||
ForwardPortService forwardPortService;
|
||||
|
||||
@Resource
|
||||
ImageCaptchaApplication application;
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* 验证验证码、用户名密码,检查账户状态,生成JWT令牌
|
||||
*
|
||||
* @param loginDto 登录数据传输对象
|
||||
* @return 登录结果响应,包含令牌和用户信息
|
||||
*/
|
||||
@Override
|
||||
public R login(LoginDto loginDto) {
|
||||
|
||||
// 1. 验证验证码
|
||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_enabled"));
|
||||
if (viteConfig != null && Objects.equals(viteConfig.getValue(), "true")) {
|
||||
if (StringUtils.isBlank(loginDto.getCaptchaId())) return R.err("验证码校验失败");
|
||||
@@ -138,216 +73,129 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (!valid) return R.err("验证码校验失败");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 2. 验证用户凭据
|
||||
LoginValidationResult validationResult = validateUserCredentials(loginDto);
|
||||
if (validationResult.isHasError()) {
|
||||
return R.err(validationResult.getErrorMessage());
|
||||
}
|
||||
|
||||
// 3. 生成令牌并返回用户信息
|
||||
User user = validationResult.getUser();
|
||||
User user = this.getOne(new QueryWrapper<User>().eq("user", loginDto.getUsername()));
|
||||
if (user == null) return R.err("账号或密码错误");
|
||||
if (!user.getPwd().equals(Md5Util.md5(loginDto.getPassword()))) return R.err("账号或密码错误");
|
||||
if (user.getStatus() == 0) return R.err("账号被停用");
|
||||
String token = JwtUtil.generateToken(user);
|
||||
|
||||
// 4. 检查是否使用默认账号密码
|
||||
boolean requirePasswordChange = isDefaultCredentials(loginDto.getUsername(), loginDto.getPassword());
|
||||
|
||||
boolean requirePasswordChange = Objects.equals(loginDto.getUsername(), "admin_user") || Objects.equals(loginDto.getPassword(), "admin_user");
|
||||
return R.ok(MapUtil.builder()
|
||||
.put(LOGIN_TOKEN_FIELD, token)
|
||||
.put(LOGIN_NAME_FIELD, user.getUser())
|
||||
.put(LOGIN_ROLE_ID_FIELD, user.getRoleId())
|
||||
.put(LOGIN_REQUIRE_PASSWORD_CHANGE_FIELD, requirePasswordChange)
|
||||
.put("token", token)
|
||||
.put("name", user.getUser())
|
||||
.put("role_id", user.getRoleId())
|
||||
.put("requirePasswordChange", requirePasswordChange)
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* 检查用户名唯一性,设置默认属性,加密密码
|
||||
*
|
||||
* @param userDto 用户创建数据传输对象
|
||||
* @return 创建结果响应
|
||||
*/
|
||||
@Override
|
||||
public R createUser(UserDto userDto) {
|
||||
// 1. 验证用户名唯一性
|
||||
R usernameValidationResult = validateUsernameUniqueness(userDto.getUser(), null);
|
||||
if (usernameValidationResult.getCode() != 0) {
|
||||
return usernameValidationResult;
|
||||
}
|
||||
|
||||
// 2. 构建用户实体并保存
|
||||
User user = buildNewUserEntity(userDto);
|
||||
boolean result = this.save(user);
|
||||
|
||||
if (result) {
|
||||
// 3. 添加到期时间延时任务
|
||||
return R.ok(SUCCESS_CREATE_MSG);
|
||||
} else {
|
||||
return R.err(ERROR_CREATE_FAILED);
|
||||
}
|
||||
int count = this.count(new QueryWrapper<User>().eq("user", userDto.getUser()));
|
||||
if (count > 0) return R.err("用户名已存在");
|
||||
User user = new User();
|
||||
BeanUtils.copyProperties(userDto, user);
|
||||
user.setPwd(Md5Util.md5(userDto.getPwd()));
|
||||
user.setStatus(1);
|
||||
user.setRoleId(1);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
user.setCreatedTime(currentTime);
|
||||
user.setUpdatedTime(currentTime);
|
||||
this.save(user);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户(分页)
|
||||
* 支持关键字搜索,排除管理员用户,清除密码信息
|
||||
*
|
||||
* @return 分页用户列表响应
|
||||
*/
|
||||
@Override
|
||||
public R getAllUsers() {
|
||||
return R.ok(this.list(new QueryWrapper<User>().ne("role_id", ADMIN_ROLE_ID)));
|
||||
List<User> list = this.list(new QueryWrapper<User>().ne("role_id", 0));
|
||||
return R.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* 验证用户存在性和用户名唯一性,处理密码加密
|
||||
*
|
||||
* @param userUpdateDto 用户更新数据传输对象
|
||||
* @return 更新结果响应
|
||||
*/
|
||||
@Override
|
||||
public R updateUser(UserUpdateDto userUpdateDto) {
|
||||
// 1. 验证用户是否存在
|
||||
if (!isUserExists(userUpdateDto.getId())) {
|
||||
return R.err(ERROR_USER_NOT_FOUND);
|
||||
}
|
||||
User user = this.getById(userUpdateDto.getId());
|
||||
if (user == null) return R.err("用户不存在");
|
||||
if (user.getRoleId() == 0) return R.err("请不要作死");
|
||||
|
||||
// 2. 验证用户名唯一性
|
||||
R usernameValidationResult = validateUsernameUniqueness(userUpdateDto.getUser(), userUpdateDto.getId());
|
||||
if (usernameValidationResult.getCode() != 0) {
|
||||
return usernameValidationResult;
|
||||
}
|
||||
int count = this.count(new QueryWrapper<User>().eq("user", userUpdateDto.getUser()).ne("id", userUpdateDto.getId()));
|
||||
if (count > 0) return R.err("用户名已存在");
|
||||
|
||||
// 3. 验证更新条件
|
||||
R updateValidationResult = validateUserUpdate(userUpdateDto.getId());
|
||||
if (updateValidationResult.getCode() != 0) {
|
||||
return updateValidationResult;
|
||||
}
|
||||
|
||||
// 4. 构建更新实体并保存
|
||||
User updateUser = buildUpdateUserEntity(userUpdateDto);
|
||||
boolean result = this.updateById(updateUser);
|
||||
|
||||
if (result) {
|
||||
// 5. 处理到期时间延时任务
|
||||
return R.ok(SUCCESS_UPDATE_MSG);
|
||||
User updateUser = new User();
|
||||
BeanUtils.copyProperties(userUpdateDto, updateUser);
|
||||
if (StrUtil.isNotBlank(userUpdateDto.getPwd())) {
|
||||
updateUser.setPwd(Md5Util.md5(userUpdateDto.getPwd()));
|
||||
} else {
|
||||
return R.err(ERROR_UPDATE_FAILED);
|
||||
updateUser.setPwd(null); // 不更新密码字段
|
||||
}
|
||||
updateUser.setUpdatedTime(System.currentTimeMillis());
|
||||
this.updateById(updateUser);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* 级联删除用户相关的所有数据,包括转发、Gost服务和隧道权限
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @return 删除结果响应
|
||||
*/
|
||||
@Override
|
||||
public R deleteUser(Long id) {
|
||||
// 1. 验证删除条件
|
||||
R deleteValidationResult = validateUserDeletion(id);
|
||||
if (deleteValidationResult.getCode() != 0) {
|
||||
return deleteValidationResult;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 级联删除用户相关数据
|
||||
deleteUserRelatedData(id);
|
||||
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
|
||||
// 3. 删除用户
|
||||
boolean result = this.removeById(id);
|
||||
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_FAILED);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return R.err("删除用户时发生错误:" + e.getMessage());
|
||||
User user = this.getById(id);
|
||||
if (user == null) return R.err("用户不存在");
|
||||
if (user.getRoleId() == 0) return R.err("请不要作死");
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", id));
|
||||
for (Forward forward : forwardList) {
|
||||
forwardService.deleteForward(forward.getId());
|
||||
}
|
||||
forwardService.remove(new QueryWrapper<Forward>().eq("user_id", id));
|
||||
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("user_id", id));
|
||||
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户套餐信息
|
||||
* 包括用户基本信息、隧道权限详情和转发详情
|
||||
*
|
||||
* @return 用户套餐信息响应
|
||||
*/
|
||||
@Override
|
||||
public R getUserPackageInfo() {
|
||||
try {
|
||||
// 1. 获取当前用户信息
|
||||
CurrentUserInfo currentUser = getCurrentUserInfo();
|
||||
if (currentUser.isHasError()) {
|
||||
return R.err(currentUser.getErrorMessage());
|
||||
}
|
||||
|
||||
// 2. 构建套餐信息
|
||||
UserPackageDto packageDto = buildUserPackageDto(currentUser);
|
||||
|
||||
return R.ok(packageDto);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return R.err(ERROR_GET_PACKAGE_INFO_FAILED);
|
||||
}
|
||||
Integer userId = JwtUtil.getUserIdFromToken();
|
||||
User user = this.getById(userId);
|
||||
if (user == null) return R.err("用户不存在");
|
||||
UserPackageDto.UserInfoDto userInfo = buildUserInfoDto(user);
|
||||
List<UserPackageDto.UserTunnelDetailDto> tunnelPermissions = userMapper.getUserTunnelDetails(userId);
|
||||
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
|
||||
fillForwardInIpAndPort(forwards);
|
||||
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
|
||||
UserPackageDto packageDto = new UserPackageDto();
|
||||
packageDto.setUserInfo(userInfo);
|
||||
packageDto.setTunnelPermissions(tunnelPermissions);
|
||||
packageDto.setForwards(forwards);
|
||||
packageDto.setStatisticsFlows(statisticsFlows);
|
||||
return R.ok(packageDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改账号密码
|
||||
* 验证当前密码、新密码确认、用户名唯一性、更新用户账号密码
|
||||
*
|
||||
* @param changePasswordDto 修改账号密码数据传输对象
|
||||
* @return 修改结果响应
|
||||
*/
|
||||
@Override
|
||||
public R updatePassword(ChangePasswordDto changePasswordDto) {
|
||||
try {
|
||||
// 1. 获取当前用户信息
|
||||
CurrentUserInfo currentUser = getCurrentUserInfo();
|
||||
if (currentUser.isHasError()) {
|
||||
return R.err(currentUser.getErrorMessage());
|
||||
}
|
||||
|
||||
// 2. 验证新密码和确认密码是否匹配
|
||||
if (!changePasswordDto.getNewPassword().equals(changePasswordDto.getConfirmPassword())) {
|
||||
return R.err(ERROR_PASSWORD_NOT_MATCH);
|
||||
}
|
||||
|
||||
// 3. 验证当前密码是否正确
|
||||
User user = currentUser.getUser();
|
||||
String currentPasswordMd5 = Md5Util.md5(changePasswordDto.getCurrentPassword());
|
||||
if (!user.getPwd().equals(currentPasswordMd5)) {
|
||||
return R.err(ERROR_CURRENT_PASSWORD_WRONG);
|
||||
}
|
||||
|
||||
// 4. 验证新用户名唯一性(如果与当前用户名不同)
|
||||
if (!user.getUser().equals(changePasswordDto.getNewUsername())) {
|
||||
R usernameValidationResult = validateUsernameUniqueness(changePasswordDto.getNewUsername(), user.getId());
|
||||
if (usernameValidationResult.getCode() != 0) {
|
||||
return usernameValidationResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 更新用户名和密码
|
||||
User updateUser = new User();
|
||||
updateUser.setId(user.getId());
|
||||
updateUser.setUser(changePasswordDto.getNewUsername());
|
||||
updateUser.setPwd(Md5Util.md5(changePasswordDto.getNewPassword()));
|
||||
updateUser.setUpdatedTime(System.currentTimeMillis());
|
||||
|
||||
boolean result = this.updateById(updateUser);
|
||||
return result ? R.ok("账号密码修改成功") : R.err(ERROR_UPDATE_FAILED);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return R.err("修改账号密码时发生错误:" + e.getMessage());
|
||||
Integer userId = JwtUtil.getUserIdFromToken();
|
||||
User user = this.getById(userId);
|
||||
if (user == null) return R.err("用户不存在");
|
||||
if (!changePasswordDto.getNewPassword().equals(changePasswordDto.getConfirmPassword())) {
|
||||
return R.err("新密码和确认密码不匹配");
|
||||
}
|
||||
String currentPasswordMd5 = Md5Util.md5(changePasswordDto.getCurrentPassword());
|
||||
if (!user.getPwd().equals(currentPasswordMd5)) {
|
||||
return R.err("当前密码错误");
|
||||
}
|
||||
if (!user.getUser().equals(changePasswordDto.getNewUsername())) {
|
||||
user.setPwd(Md5Util.md5(changePasswordDto.getNewPassword()));
|
||||
int count = this.count(new QueryWrapper<User>().eq("user", changePasswordDto.getNewUsername()).ne("id", user.getId()));
|
||||
if (count > 0) return R.err("用户名已存在");
|
||||
}
|
||||
User updateUser = new User();
|
||||
updateUser.setId(user.getId());
|
||||
updateUser.setUser(changePasswordDto.getNewUsername());
|
||||
updateUser.setPwd(Md5Util.md5(changePasswordDto.getNewPassword()));
|
||||
updateUser.setUpdatedTime(System.currentTimeMillis());
|
||||
this.updateById(updateUser);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public R reset(ResetFlowDto resetFlowDto) {
|
||||
if (resetFlowDto.getType() == 1){ // 清零账号流量
|
||||
User user = this.getById(resetFlowDto.getId());
|
||||
if (user == null) return R.err(ERROR_USER_NOT_FOUND);
|
||||
if (user == null) return R.err("用户不存在");
|
||||
user.setInFlow(0L);
|
||||
user.setOutFlow(0L);
|
||||
this.updateById(user);
|
||||
@@ -361,339 +209,6 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
|
||||
/**
|
||||
* 验证用户登录凭据
|
||||
*
|
||||
* @param loginDto 登录数据传输对象
|
||||
* @return 登录验证结果
|
||||
*/
|
||||
private LoginValidationResult validateUserCredentials(LoginDto loginDto) {
|
||||
User user = this.getOne(new QueryWrapper<User>().eq("user", loginDto.getUsername()));
|
||||
if (user == null) {
|
||||
return LoginValidationResult.error(ERROR_LOGIN_CREDENTIALS);
|
||||
}
|
||||
|
||||
if (!user.getPwd().equals(Md5Util.md5(loginDto.getPassword()))) {
|
||||
return LoginValidationResult.error(ERROR_LOGIN_CREDENTIALS);
|
||||
}
|
||||
|
||||
if (user.getStatus() == USER_STATUS_DISABLED) {
|
||||
return LoginValidationResult.error(ERROR_ACCOUNT_DISABLED);
|
||||
}
|
||||
|
||||
return LoginValidationResult.success(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否使用默认账号密码
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @return 是否是默认凭据
|
||||
*/
|
||||
private boolean isDefaultCredentials(String username, String password) {
|
||||
return DEFAULT_USERNAME.equals(username) || DEFAULT_PASSWORD.equals(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户名唯一性
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param excludeUserId 排除的用户ID(用于更新时排除自己)
|
||||
* @return 验证结果响应
|
||||
*/
|
||||
private R validateUsernameUniqueness(String username, Long excludeUserId) {
|
||||
QueryWrapper<User> queryWrapper = new QueryWrapper<User>().eq("user", username);
|
||||
if (excludeUserId != null) {
|
||||
queryWrapper.ne("id", excludeUserId);
|
||||
}
|
||||
|
||||
User existUser = this.getOne(queryWrapper);
|
||||
if (existUser != null) {
|
||||
String errorMsg = excludeUserId != null ? ERROR_USERNAME_TAKEN : ERROR_USERNAME_EXISTS;
|
||||
return R.err(errorMsg);
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建新用户实体对象
|
||||
*
|
||||
* @param userDto 用户创建DTO
|
||||
* @return 构建完成的用户对象
|
||||
*/
|
||||
private User buildNewUserEntity(UserDto userDto) {
|
||||
User user = new User();
|
||||
BeanUtils.copyProperties(userDto, user);
|
||||
|
||||
// 设置加密密码
|
||||
user.setPwd(Md5Util.md5(userDto.getPwd()));
|
||||
|
||||
// 设置默认属性
|
||||
user.setStatus(userDto.getStatus() != null ? userDto.getStatus() : USER_STATUS_ACTIVE);
|
||||
user.setRoleId(USER_ROLE_ID);
|
||||
|
||||
// 设置时间戳
|
||||
long currentTime = System.currentTimeMillis();
|
||||
user.setCreatedTime(currentTime);
|
||||
user.setUpdatedTime(currentTime);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 检查用户是否存在
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户是否存在
|
||||
*/
|
||||
private boolean isUserExists(Long userId) {
|
||||
return this.getById(userId) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户更新实体对象
|
||||
*
|
||||
* @param userUpdateDto 用户更新DTO
|
||||
* @return 构建完成的更新对象
|
||||
*/
|
||||
private User buildUpdateUserEntity(UserUpdateDto userUpdateDto) {
|
||||
User user = new User();
|
||||
BeanUtils.copyProperties(userUpdateDto, user);
|
||||
|
||||
// 处理密码更新
|
||||
if (StrUtil.isNotBlank(userUpdateDto.getPwd())) {
|
||||
user.setPwd(Md5Util.md5(userUpdateDto.getPwd()));
|
||||
} else {
|
||||
user.setPwd(null); // 不更新密码字段
|
||||
}
|
||||
|
||||
// 设置更新时间
|
||||
user.setUpdatedTime(System.currentTimeMillis());
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户删除条件
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 验证结果响应
|
||||
*/
|
||||
private R validateUserDeletion(Long userId) {
|
||||
User user = this.getById(userId);
|
||||
if (user == null) {
|
||||
return R.err(ERROR_USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (user.getRoleId() == ADMIN_ROLE_ID) {
|
||||
return R.err(ERROR_CANNOT_DELETE_ADMIN);
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户更新条件
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 验证结果响应
|
||||
*/
|
||||
private R validateUserUpdate(Long userId) {
|
||||
User user = this.getById(userId);
|
||||
if (user == null) {
|
||||
return R.err(ERROR_USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (user.getRoleId() == ADMIN_ROLE_ID) {
|
||||
return R.err(ERROR_CANNOT_UPDATE_ADMIN);
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户相关的所有数据
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
private void deleteUserRelatedData(Long userId) {
|
||||
// 1. 删除用户的所有转发和对应的Gost服务
|
||||
deleteUserForwardsAndGostServices(userId);
|
||||
|
||||
// 2. 删除用户隧道权限
|
||||
deleteUserTunnelPermissions(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户转发和对应的Gost服务
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
private void deleteUserForwardsAndGostServices(Long userId) {
|
||||
QueryWrapper<Forward> forwardQuery = new QueryWrapper<>();
|
||||
forwardQuery.eq("user_id", userId);
|
||||
List<Forward> userForwards = forwardMapper.selectList(forwardQuery);
|
||||
|
||||
for (Forward forward : userForwards) {
|
||||
try {
|
||||
// 删除Gost服务
|
||||
deleteGostServicesForForward(forward, userId);
|
||||
} catch (Exception e) {
|
||||
// 记录错误但继续删除,避免因为Gost服务删除失败而阻断用户删除
|
||||
System.err.println("删除用户转发对应的Gost服务失败,转发ID: " + forward.getId() + ", 错误: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 删除数据库中的转发记录
|
||||
forwardMapper.deleteById(forward.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除转发对应的Gost服务
|
||||
*
|
||||
* @param forward 转发对象
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
private void deleteGostServicesForForward(Forward forward, Long userId) {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel == null) return;
|
||||
|
||||
Node inNode = nodeService.getNodeById(tunnel.getInNodeId());
|
||||
if (inNode == null) return;
|
||||
|
||||
// 获取用户隧道关系
|
||||
UserTunnel userTunnel = getUserTunnelRelation(userId, tunnel.getId());
|
||||
if (userTunnel == null) return;
|
||||
|
||||
String serviceName = buildServiceName(forward.getId(), userId, userTunnel.getId());
|
||||
|
||||
// 删除主服务
|
||||
GostUtil.DeleteService(inNode.getId(), serviceName);
|
||||
|
||||
// 如果是隧道转发,还需要删除链和远程服务
|
||||
if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
|
||||
deleteGostTunnelForwardServices(tunnel, serviceName, inNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除隧道转发相关的Gost服务
|
||||
*
|
||||
* @param tunnel 隧道对象
|
||||
* @param serviceName 服务名称
|
||||
* @param inNode 入口节点
|
||||
*/
|
||||
private void deleteGostTunnelForwardServices(Tunnel tunnel, String serviceName, Node inNode) {
|
||||
Node outNode = nodeService.getNodeById(tunnel.getOutNodeId());
|
||||
if (outNode != null) {
|
||||
GostUtil.DeleteChains(inNode.getId(), serviceName);
|
||||
GostUtil.DeleteRemoteService(outNode.getId(), serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户隧道关系
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tunnelId 隧道ID
|
||||
* @return 用户隧道关系对象
|
||||
*/
|
||||
private UserTunnel getUserTunnelRelation(Long userId, Long tunnelId) {
|
||||
return userTunnelService.getOne(new QueryWrapper<UserTunnel>()
|
||||
.eq("user_id", userId)
|
||||
.eq("tunnel_id", tunnelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建服务名称
|
||||
*
|
||||
* @param forwardId 转发ID
|
||||
* @param userId 用户ID
|
||||
* @param userTunnelId 用户隧道ID
|
||||
* @return 服务名称
|
||||
*/
|
||||
private String buildServiceName(Long forwardId, Long userId, Integer userTunnelId) {
|
||||
return forwardId + "_" + userId + "_" + userTunnelId;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除用户隧道权限
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
private void deleteUserTunnelPermissions(Long userId) {
|
||||
QueryWrapper<UserTunnel> userTunnelQuery = new QueryWrapper<>();
|
||||
userTunnelQuery.eq("user_id", userId);
|
||||
userTunnelMapper.delete(userTunnelQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*
|
||||
* @return 当前用户信息结果
|
||||
*/
|
||||
private CurrentUserInfo getCurrentUserInfo() {
|
||||
Integer userId = JwtUtil.getUserIdFromToken();
|
||||
Integer roleId = JwtUtil.getRoleIdFromToken();
|
||||
|
||||
if (userId == null) {
|
||||
return CurrentUserInfo.error(ERROR_USER_NOT_LOGGED_IN);
|
||||
}
|
||||
|
||||
User user = this.getById(userId);
|
||||
if (user == null) {
|
||||
return CurrentUserInfo.error(ERROR_USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return CurrentUserInfo.success(user, roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户套餐信息DTO
|
||||
*
|
||||
* @param currentUser 当前用户信息
|
||||
* @return 用户套餐信息DTO
|
||||
*/
|
||||
private UserPackageDto buildUserPackageDto(CurrentUserInfo currentUser) {
|
||||
User user = currentUser.getUser();
|
||||
Integer roleId = currentUser.getRoleId();
|
||||
|
||||
// 1. 构造用户基本信息
|
||||
UserPackageDto.UserInfoDto userInfo = buildUserInfoDto(user);
|
||||
|
||||
// 2. 获取隧道权限详情
|
||||
List<UserPackageDto.UserTunnelDetailDto> tunnelPermissions = getTunnelPermissions(user.getId());
|
||||
|
||||
// 3. 获取转发详情
|
||||
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
|
||||
|
||||
// 4. 查询最近24小时流量信息,没有的补0
|
||||
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
|
||||
|
||||
// 5. 构造返回结果
|
||||
UserPackageDto packageDto = new UserPackageDto();
|
||||
packageDto.setUserInfo(userInfo);
|
||||
packageDto.setTunnelPermissions(tunnelPermissions);
|
||||
packageDto.setForwards(forwards);
|
||||
packageDto.setStatisticsFlows(statisticsFlows);
|
||||
|
||||
return packageDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户基本信息DTO
|
||||
*
|
||||
* @param user 用户对象
|
||||
* @return 用户基本信息DTO
|
||||
*/
|
||||
private UserPackageDto.UserInfoDto buildUserInfoDto(User user) {
|
||||
UserPackageDto.UserInfoDto userInfo = new UserPackageDto.UserInfoDto();
|
||||
userInfo.setId(user.getId());
|
||||
@@ -710,134 +225,102 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隧道权限详情
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 隧道权限详情列表
|
||||
*/
|
||||
private List<UserPackageDto.UserTunnelDetailDto> getTunnelPermissions(Long userId) {
|
||||
return userMapper.getUserTunnelDetails(userId.intValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最近24小时的流量统计数据,没有数据的时间点补0
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 最近24小时流量统计列表
|
||||
*/
|
||||
private List<StatisticsFlow> getLast24HoursFlowStatistics(Long userId) {
|
||||
// 按ID倒序查最近24条记录(ID越大越新,时间就是23:00, 22:00, 21:00...这样倒序)
|
||||
List<StatisticsFlow> recentFlows = statisticsFlowService.list(
|
||||
new QueryWrapper<StatisticsFlow>()
|
||||
.eq("user_id", userId)
|
||||
.orderByDesc("id")
|
||||
.last("LIMIT 24")
|
||||
);
|
||||
|
||||
List<StatisticsFlow> result = new ArrayList<>(recentFlows);
|
||||
|
||||
// 如果查出来的记录不足24条,需要补0和对应的时间
|
||||
if (result.size() < 24) {
|
||||
// 获取最早记录的时间,继续往前推
|
||||
int startHour = getCurrentHour();
|
||||
int startHour = java.time.LocalDateTime.now().getHour();
|
||||
if (!result.isEmpty()) {
|
||||
// 从最后一条记录的时间继续往前推
|
||||
String lastTime = result.get(result.size() - 1).getTime();
|
||||
String lastTime = result.getLast().getTime();
|
||||
startHour = parseHour(lastTime) - 1;
|
||||
}
|
||||
|
||||
// 补0到24条
|
||||
while (result.size() < 24) {
|
||||
if (startHour < 0) startHour = 23; // 跨天处理
|
||||
|
||||
if (startHour < 0) startHour = 23;
|
||||
StatisticsFlow emptyFlow = new StatisticsFlow();
|
||||
emptyFlow.setUserId(userId);
|
||||
emptyFlow.setFlow(0L);
|
||||
emptyFlow.setTotalFlow(0L);
|
||||
emptyFlow.setTime(String.format("%02d:00", startHour));
|
||||
result.add(emptyFlow);
|
||||
|
||||
startHour--;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("用户 {} 获取到 {} 条实际记录,补齐为 {} 条24小时记录", userId, recentFlows.size(), result.size());
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前小时(0-23)
|
||||
*/
|
||||
private int getCurrentHour() {
|
||||
private int parseHour(String timeStr) {
|
||||
if (timeStr != null && timeStr.contains(":")) {
|
||||
return Integer.parseInt(timeStr.split(":")[0]);
|
||||
}
|
||||
return java.time.LocalDateTime.now().getHour();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间字符串获取小时数
|
||||
*/
|
||||
private int parseHour(String timeStr) {
|
||||
try {
|
||||
if (timeStr != null && timeStr.contains(":")) {
|
||||
return Integer.parseInt(timeStr.split(":")[0]);
|
||||
private void fillForwardInIpAndPort(List<UserPackageDto.UserForwardDetailDto> forwards) {
|
||||
for (UserPackageDto.UserForwardDetailDto forward : forwards) {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel == null) continue;
|
||||
|
||||
List<ForwardPort> forwardPorts = forwardPortService.list(
|
||||
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
|
||||
);
|
||||
if (forwardPorts.isEmpty()) continue;
|
||||
|
||||
boolean useTunnelInIp = tunnel.getInIp() != null && !tunnel.getInIp().trim().isEmpty();
|
||||
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
|
||||
|
||||
if (useTunnelInIp) {
|
||||
// 使用隧道的inIp(求笛卡尔积)
|
||||
List<String> ipList = new ArrayList<>();
|
||||
List<Integer> portList = new ArrayList<>();
|
||||
|
||||
String[] tunnelInIps = tunnel.getInIp().split(",");
|
||||
for (String ip : tunnelInIps) {
|
||||
if (ip != null && !ip.trim().isEmpty()) {
|
||||
ipList.add(ip.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (ForwardPort forwardPort : forwardPorts) {
|
||||
if (forwardPort.getPort() != null) {
|
||||
portList.add(forwardPort.getPort());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> uniqueIps = ipList.stream().distinct().toList();
|
||||
List<Integer> uniquePorts = portList.stream().distinct().toList();
|
||||
|
||||
for (String ip : uniqueIps) {
|
||||
for (Integer port : uniquePorts) {
|
||||
ipPortSet.add(ip + ":" + port);
|
||||
}
|
||||
}
|
||||
|
||||
if (!uniquePorts.isEmpty()) {
|
||||
forward.setInPort(uniquePorts.getFirst());
|
||||
}
|
||||
} else {
|
||||
// 使用节点的serverIp(一对一,不求笛卡尔积)
|
||||
for (ForwardPort forwardPort : forwardPorts) {
|
||||
Node node = nodeService.getById(forwardPort.getNodeId());
|
||||
if (node != null && node.getServerIp() != null && forwardPort.getPort() != null) {
|
||||
ipPortSet.add(node.getServerIp() + ":" + forwardPort.getPort());
|
||||
}
|
||||
}
|
||||
|
||||
if (!forwardPorts.isEmpty() && forwardPorts.getFirst().getPort() != null) {
|
||||
forward.setInPort(forwardPorts.getFirst().getPort());
|
||||
}
|
||||
}
|
||||
|
||||
if (!ipPortSet.isEmpty()) {
|
||||
forward.setInIp(String.join(",", ipPortSet));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 解析失败,返回当前小时
|
||||
}
|
||||
return getCurrentHour();
|
||||
}
|
||||
|
||||
|
||||
// ========== 内部数据类 ==========
|
||||
|
||||
/**
|
||||
* 登录验证结果封装类
|
||||
*/
|
||||
@Data
|
||||
private static class LoginValidationResult {
|
||||
private final boolean hasError;
|
||||
private final String errorMessage;
|
||||
private final User user;
|
||||
|
||||
private LoginValidationResult(boolean hasError, String errorMessage, User user) {
|
||||
this.hasError = hasError;
|
||||
this.errorMessage = errorMessage;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public static LoginValidationResult success(User user) {
|
||||
return new LoginValidationResult(false, null, user);
|
||||
}
|
||||
|
||||
public static LoginValidationResult error(String errorMessage) {
|
||||
return new LoginValidationResult(true, errorMessage, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户信息封装类
|
||||
*/
|
||||
@Data
|
||||
private static class CurrentUserInfo {
|
||||
private final boolean hasError;
|
||||
private final String errorMessage;
|
||||
private final User user;
|
||||
private final Integer roleId;
|
||||
|
||||
private CurrentUserInfo(boolean hasError, String errorMessage, User user, Integer roleId) {
|
||||
this.hasError = hasError;
|
||||
this.errorMessage = errorMessage;
|
||||
this.user = user;
|
||||
this.roleId = roleId;
|
||||
}
|
||||
|
||||
public static CurrentUserInfo success(User user, Integer roleId) {
|
||||
return new CurrentUserInfo(false, null, user, roleId);
|
||||
}
|
||||
|
||||
public static CurrentUserInfo error(String errorMessage) {
|
||||
return new CurrentUserInfo(true, errorMessage, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-376
@@ -1,9 +1,6 @@
|
||||
package com.admin.service.impl;
|
||||
|
||||
import com.admin.common.dto.UserTunnelDto;
|
||||
import com.admin.common.dto.UserTunnelQueryDto;
|
||||
import com.admin.common.dto.UserTunnelUpdateDto;
|
||||
import com.admin.common.dto.UserTunnelWithDetailDto;
|
||||
import com.admin.common.dto.*;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
@@ -42,347 +39,74 @@ import java.util.Map;
|
||||
@Service
|
||||
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/** 成功响应消息 */
|
||||
private static final String SUCCESS_ASSIGN_MSG = "用户隧道权限分配成功";
|
||||
private static final String SUCCESS_REMOVE_MSG = "用户隧道权限删除成功";
|
||||
private static final String SUCCESS_UPDATE_FLOW_MSG = "用户隧道流量限制更新成功";
|
||||
private static final String SUCCESS_UPDATE_MSG = "用户隧道权限更新成功";
|
||||
|
||||
/** 错误响应消息 */
|
||||
private static final String ERROR_ASSIGN_FAILED = "用户隧道权限分配失败";
|
||||
private static final String ERROR_PERMISSION_EXISTS = "该用户已拥有此隧道权限";
|
||||
private static final String ERROR_PERMISSION_NOT_FOUND = "未找到对应的用户隧道权限记录";
|
||||
private static final String ERROR_USER_TUNNEL_NOT_EXISTS = "用户隧道权限不存在";
|
||||
private static final String ERROR_NOT_EXISTS = "不存在";
|
||||
private static final String ERROR_UPDATE_FAILED = "用户隧道权限更新失败";
|
||||
|
||||
// ========== 依赖注入 ==========
|
||||
|
||||
@Autowired
|
||||
@Resource
|
||||
@Lazy
|
||||
private ForwardService forwardService;
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private TunnelService tunnelService;
|
||||
|
||||
@Autowired
|
||||
private NodeService nodeService;
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
/**
|
||||
* 分配用户隧道权限
|
||||
* 检查权限是否已存在,避免重复分配
|
||||
*
|
||||
* @param userTunnelDto 用户隧道权限分配数据传输对象
|
||||
* @return 分配结果响应
|
||||
*/
|
||||
@Override
|
||||
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
|
||||
// 1. 检查权限是否已存在
|
||||
if (isUserTunnelPermissionExists(userTunnelDto.getUserId(), userTunnelDto.getTunnelId())) {
|
||||
return R.err(ERROR_PERMISSION_EXISTS);
|
||||
}
|
||||
|
||||
// 2. 创建用户隧道权限实体并保存
|
||||
UserTunnel userTunnel = buildUserTunnelEntity(userTunnelDto);
|
||||
// 设置默认状态为启用
|
||||
userTunnel.setStatus(1);
|
||||
boolean success = this.save(userTunnel);
|
||||
|
||||
if (success) {
|
||||
return R.ok(SUCCESS_ASSIGN_MSG);
|
||||
}
|
||||
|
||||
return R.err(ERROR_ASSIGN_FAILED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户隧道权限列表
|
||||
* 通过连表查询获取用户隧道权限及隧道详细信息
|
||||
*
|
||||
* @param queryDto 用户隧道权限查询数据传输对象
|
||||
* @return 用户隧道权限详情列表响应
|
||||
*/
|
||||
@Override
|
||||
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
|
||||
List<UserTunnelWithDetailDto> userTunnelDetails = getUserTunnelDetailsFromDatabase(queryDto.getUserId());
|
||||
return R.ok(userTunnelDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户隧道权限
|
||||
*
|
||||
* @param id 用户隧道权限ID
|
||||
* @return 删除结果响应
|
||||
*/
|
||||
@Override
|
||||
public R removeUserTunnel(Integer id) {
|
||||
// 1. 获取用户隧道权限信息
|
||||
UserTunnel userTunnel = this.getById(id);
|
||||
if (userTunnel == null) {
|
||||
return R.err(ERROR_PERMISSION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 2. 删除该用户在该隧道下的所有转发
|
||||
try {
|
||||
removeUserTunnelForwards(userTunnel.getUserId(), userTunnel.getTunnelId());
|
||||
} catch (Exception e) {
|
||||
// 转发删除失败,记录日志但不阻止权限删除
|
||||
}
|
||||
|
||||
|
||||
// 4. 删除用户隧道权限记录
|
||||
boolean success = this.removeById(id);
|
||||
return success ? R.ok(SUCCESS_REMOVE_MSG) : R.err(ERROR_PERMISSION_NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新用户隧道权限
|
||||
* 支持更新流量限制、数量限制、流量重置时间、过期时间和限速规则
|
||||
*
|
||||
* @param updateDto 用户隧道权限更新数据传输对象
|
||||
* @return 更新结果响应
|
||||
*/
|
||||
@Override
|
||||
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
|
||||
// 1. 验证用户隧道权限是否存在
|
||||
UserTunnel existingUserTunnel = this.getById(updateDto.getId());
|
||||
if (existingUserTunnel == null) {
|
||||
return R.err(ERROR_USER_TUNNEL_NOT_EXISTS);
|
||||
}
|
||||
|
||||
// 2. 检查是否更新了限速规则
|
||||
boolean speedChanged = hasSpeedChanged(existingUserTunnel.getSpeedId(), updateDto.getSpeedId());
|
||||
|
||||
// 3. 更新用户隧道权限属性
|
||||
updateUserTunnelProperties(existingUserTunnel, updateDto);
|
||||
|
||||
// 4. 保存更新
|
||||
boolean success = this.updateById(existingUserTunnel);
|
||||
|
||||
if (success) {
|
||||
// 6. 如果限速规则发生变化,更新该用户隧道下的所有转发
|
||||
if (speedChanged) {
|
||||
updateUserTunnelForwardsSpeed(existingUserTunnel.getUserId(), existingUserTunnel.getTunnelId(), updateDto.getSpeedId());
|
||||
}
|
||||
|
||||
return R.ok(SUCCESS_UPDATE_MSG);
|
||||
}
|
||||
|
||||
return R.err(ERROR_UPDATE_FAILED);
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 检查用户隧道权限是否已存在
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tunnelId 隧道ID
|
||||
* @return 权限是否已存在
|
||||
*/
|
||||
private boolean isUserTunnelPermissionExists(Integer userId, Integer tunnelId) {
|
||||
QueryWrapper<UserTunnel> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
|
||||
UserTunnel existingUserTunnel = this.getOne(queryWrapper);
|
||||
return existingUserTunnel != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户隧道权限实体对象
|
||||
*
|
||||
* @param userTunnelDto 用户隧道权限DTO
|
||||
* @return 构建完成的用户隧道权限对象
|
||||
*/
|
||||
private UserTunnel buildUserTunnelEntity(UserTunnelDto userTunnelDto) {
|
||||
int count = this.count(new QueryWrapper<UserTunnel>().eq("user_id", userTunnelDto.getUserId()).eq("tunnel_id", userTunnelDto.getTunnelId()));
|
||||
if (count > 0) return R.err("该用户已拥有此隧道权限");
|
||||
UserTunnel userTunnel = new UserTunnel();
|
||||
BeanUtils.copyProperties(userTunnelDto, userTunnel);
|
||||
return userTunnel;
|
||||
userTunnel.setStatus(1);
|
||||
this.save(userTunnel);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库获取用户隧道权限详情
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户隧道权限详情列表
|
||||
*/
|
||||
private List<UserTunnelWithDetailDto> getUserTunnelDetailsFromDatabase(Integer userId) {
|
||||
return this.baseMapper.getUserTunnelWithDetails(userId);
|
||||
@Override
|
||||
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
|
||||
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
|
||||
return R.ok(userTunnelWithDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户隧道权限属性
|
||||
*
|
||||
* @param existingUserTunnel 现有的用户隧道权限对象
|
||||
* @param updateDto 更新数据传输对象
|
||||
*/
|
||||
private void updateUserTunnelProperties(UserTunnel existingUserTunnel, UserTunnelUpdateDto updateDto) {
|
||||
// 更新基本属性
|
||||
existingUserTunnel.setFlow(updateDto.getFlow());
|
||||
existingUserTunnel.setNum(updateDto.getNum());
|
||||
|
||||
// 更新可选属性(仅在非空时更新)
|
||||
updateOptionalProperty(existingUserTunnel::setFlowResetTime, updateDto.getFlowResetTime());
|
||||
updateOptionalProperty(existingUserTunnel::setExpTime, updateDto.getExpTime());
|
||||
updateOptionalProperty(existingUserTunnel::setStatus, updateDto.getStatus());
|
||||
|
||||
// 更新限速规则ID(允许设置为null,表示不限速)
|
||||
existingUserTunnel.setSpeedId(updateDto.getSpeedId());
|
||||
@Override
|
||||
public R removeUserTunnel(Integer id) {
|
||||
UserTunnel userTunnel = this.getById(id);
|
||||
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
|
||||
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId()));
|
||||
for (Forward forward : forwardList) {
|
||||
forwardService.deleteForward(forward.getId());
|
||||
}
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
|
||||
UserTunnel userTunnel = this.getById(updateDto.getId());
|
||||
if (userTunnel == null) return R.err("隧道不存在");
|
||||
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
|
||||
userTunnel.setFlow(updateDto.getFlow());
|
||||
userTunnel.setNum(updateDto.getNum());
|
||||
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
|
||||
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
|
||||
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
|
||||
userTunnel.setSpeedId(updateDto.getSpeedId());
|
||||
this.updateById(userTunnel);
|
||||
if (speedChanged) {
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId()));
|
||||
for (Forward forward : forwardList) {
|
||||
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
|
||||
forwardUpdateDto.setId(forward.getId());
|
||||
forwardUpdateDto.setUserId(forward.getUserId());
|
||||
forwardUpdateDto.setName(forward.getName());
|
||||
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
|
||||
forwardUpdateDto.setStrategy(forward.getStrategy());
|
||||
forwardService.updateForward(forwardUpdateDto);
|
||||
}
|
||||
}
|
||||
return R.err("用户隧道权限更新失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新可选属性(仅在值非空时更新)
|
||||
*
|
||||
* @param setter 属性设置方法
|
||||
* @param value 属性值
|
||||
* @param <T> 属性类型
|
||||
*/
|
||||
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
|
||||
if (value != null) {
|
||||
setter.accept(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 删除用户在指定隧道下的所有转发
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tunnelId 隧道ID
|
||||
*/
|
||||
private void removeUserTunnelForwards(Integer userId, Integer tunnelId) {
|
||||
// 查询该用户在该隧道下的所有转发
|
||||
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
|
||||
|
||||
List<Forward> userTunnelForwards = forwardService.list(queryWrapper);
|
||||
|
||||
if (!userTunnelForwards.isEmpty()) {
|
||||
// 获取用户隧道权限信息,用于构建服务名称
|
||||
UserTunnel userTunnel = getUserTunnelByUserAndTunnel(userId, tunnelId);
|
||||
|
||||
for (Forward forward : userTunnelForwards) {
|
||||
try {
|
||||
// 先调用GostUtil删除/停止服务
|
||||
stopForwardService(forward, userId, userTunnel != null ? userTunnel.getId() : 0);
|
||||
|
||||
// 然后删除数据库记录
|
||||
forwardService.removeById(forward.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
// 单个转发删除失败,记录错误但继续处理其他转发
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除转发服务(按创建的反向顺序删除:主服务 -> 远端服务 -> 转发链)
|
||||
*
|
||||
* @param forward 转发对象
|
||||
* @param userId 用户ID
|
||||
* @param userTunnelId 用户隧道ID
|
||||
*/
|
||||
private void stopForwardService(Forward forward, Integer userId, Integer userTunnelId) {
|
||||
try {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Node inNode = nodeService.getById(tunnel.getInNodeId());
|
||||
Node outNode = nodeService.getById(tunnel.getOutNodeId());
|
||||
|
||||
String serviceName = buildServiceName(forward.getId(), Long.valueOf(userId), userTunnelId);
|
||||
|
||||
// 1. 先删除主服务
|
||||
if (inNode != null) {
|
||||
try {
|
||||
GostUtil.DeleteService(inNode.getId(), serviceName);
|
||||
} catch (Exception e) {
|
||||
// 主服务删除失败,记录但继续
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果是隧道转发,删除远端服务
|
||||
if (tunnel.getType() == 1 && outNode != null && !outNode.getId().equals(inNode != null ? inNode.getId() : null)) {
|
||||
try {
|
||||
GostUtil.DeleteRemoteService(outNode.getId(), serviceName);
|
||||
} catch (Exception e) {
|
||||
// 远端服务删除失败,记录但继续
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果是隧道转发,最后删除转发链
|
||||
if (tunnel.getType() == 1 && inNode != null) {
|
||||
try {
|
||||
GostUtil.DeleteChains(inNode.getId(), serviceName);
|
||||
} catch (Exception e) {
|
||||
// 转发链删除失败,记录但继续
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// 服务删除失败,记录错误
|
||||
throw new RuntimeException("删除转发服务失败,转发ID:" + forward.getId() + ",错误:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID和隧道ID获取用户隧道权限
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tunnelId 隧道ID
|
||||
* @return 用户隧道权限对象
|
||||
*/
|
||||
private UserTunnel getUserTunnelByUserAndTunnel(Integer userId, Integer tunnelId) {
|
||||
try {
|
||||
QueryWrapper<UserTunnel> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
|
||||
return this.getOne(queryWrapper);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建服务名称
|
||||
*
|
||||
* @param forwardId 转发ID
|
||||
* @param userId 用户ID
|
||||
* @param userTunnelId 用户隧道ID
|
||||
* @return 服务名称
|
||||
*/
|
||||
private String buildServiceName(Long forwardId, Long userId, Integer userTunnelId) {
|
||||
return forwardId + "_" + userId + "_" + userTunnelId;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查用户隧道是否启用且有到期时间
|
||||
*
|
||||
* @param userTunnel 用户隧道对象
|
||||
* @return 是否启用且有到期时间
|
||||
*/
|
||||
private boolean isEnabledAndHasExpTime(UserTunnel userTunnel) {
|
||||
return userTunnel.getStatus() != null && userTunnel.getStatus() == 1
|
||||
&& userTunnel.getExpTime() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查限速规则是否发生变化
|
||||
*
|
||||
* @param oldSpeedId 原始限速规则ID
|
||||
* @param newSpeedId 新的限速规则ID
|
||||
* @return 限速规则是否发生变化
|
||||
*/
|
||||
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
|
||||
if (oldSpeedId == null && newSpeedId == null) {
|
||||
return false;
|
||||
@@ -392,56 +116,5 @@ public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTun
|
||||
}
|
||||
return !oldSpeedId.equals(newSpeedId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户隧道下所有转发的限速规则
|
||||
* 管理员操作,不需要权限检查,直接查出该用户在该隧道下的所有转发并应用新的限速
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tunnelId 隧道ID
|
||||
* @param speedId 新的限速规则ID
|
||||
*/
|
||||
private void updateUserTunnelForwardsSpeed(Integer userId, Integer tunnelId, Integer speedId) {
|
||||
// 1. 查询该用户在该隧道下的所有转发
|
||||
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
|
||||
List<Forward> userTunnelForwards = forwardService.list(queryWrapper);
|
||||
|
||||
if (userTunnelForwards.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取隧道信息
|
||||
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||
if (tunnel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 获取用户隧道权限信息
|
||||
UserTunnel userTunnel = getUserTunnelByUserAndTunnel(userId, tunnelId);
|
||||
if (userTunnel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 获取入口节点信息
|
||||
Node inNode = nodeService.getById(tunnel.getInNodeId());
|
||||
|
||||
if (inNode == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 批量更新该用户在该隧道下所有转发的限速配置(只更新入口节点)
|
||||
for (Forward forward : userTunnelForwards) {
|
||||
String serviceName = buildServiceName(forward.getId(), Long.valueOf(userId), userTunnel.getId());
|
||||
|
||||
String interfaceName = null;
|
||||
// 创建主服务
|
||||
if (tunnel.getType() != 2) { // 不是隧道转发服务才会存在网络接口
|
||||
interfaceName = forward.getInterfaceName();
|
||||
}
|
||||
|
||||
// 6. 更新入口节点的主服务限速配置(使用批量UpdateService接口)
|
||||
GostUtil.UpdateService(inNode.getId(), serviceName, forward.getInPort(), speedId, forward.getRemoteAddr(), tunnel.getType(), tunnel, forward.getStrategy(), interfaceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-77
@@ -24,24 +24,7 @@ import java.util.Map;
|
||||
@Service
|
||||
public class ViteConfigServiceImpl extends ServiceImpl<ViteConfigMapper, ViteConfig> implements ViteConfigService {
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/** 成功响应消息 */
|
||||
private static final String SUCCESS_UPDATE_MSG = "配置更新成功";
|
||||
|
||||
/** 错误响应消息 */
|
||||
private static final String ERROR_UPDATE_MSG = "配置更新失败";
|
||||
private static final String ERROR_CONFIG_NOT_FOUND = "配置不存在";
|
||||
private static final String ERROR_CONFIG_NAME_REQUIRED = "配置名称不能为空";
|
||||
private static final String ERROR_CONFIG_VALUE_REQUIRED = "配置值不能为空";
|
||||
|
||||
// ========== 公共接口实现 ==========
|
||||
|
||||
/**
|
||||
* 获取所有网站配置
|
||||
*
|
||||
* @return 包含所有配置的Map
|
||||
*/
|
||||
@Override
|
||||
public R getConfigs() {
|
||||
List<ViteConfig> configList = this.list();
|
||||
@@ -54,89 +37,48 @@ public class ViteConfigServiceImpl extends ServiceImpl<ViteConfigMapper, ViteCon
|
||||
return R.ok(configMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置名称获取配置值
|
||||
*
|
||||
* @param name 配置名称
|
||||
* @return 配置响应对象
|
||||
*/
|
||||
|
||||
@Override
|
||||
public R getConfigByName(String name) {
|
||||
if (!StringUtils.hasText(name)) {
|
||||
return R.err(ERROR_CONFIG_NAME_REQUIRED);
|
||||
}
|
||||
if (!StringUtils.hasText(name)) return R.err("配置名称不能为空");
|
||||
|
||||
QueryWrapper<ViteConfig> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("name", name);
|
||||
ViteConfig config = this.getOne(queryWrapper);
|
||||
|
||||
if (config == null) {
|
||||
return R.err(ERROR_CONFIG_NOT_FOUND);
|
||||
}
|
||||
if (config == null) return R.err("配置不存在");
|
||||
|
||||
return R.ok(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新网站配置
|
||||
*
|
||||
* @param configMap 配置Map
|
||||
* @return 更新结果响应
|
||||
*/
|
||||
|
||||
@Override
|
||||
public R updateConfigs(Map<String, String> configMap) {
|
||||
if (configMap == null || configMap.isEmpty()) {
|
||||
return R.err("配置数据不能为空");
|
||||
}
|
||||
if (configMap == null || configMap.isEmpty()) return R.err("配置数据不能为空");
|
||||
|
||||
try {
|
||||
for (Map.Entry<String, String> entry : configMap.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
String value = entry.getValue();
|
||||
|
||||
if (!StringUtils.hasText(name)) {
|
||||
continue; // 跳过无效的配置名
|
||||
}
|
||||
|
||||
updateOrCreateConfig(name, value);
|
||||
for (Map.Entry<String, String> entry : configMap.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
String value = entry.getValue();
|
||||
|
||||
if (!StringUtils.hasText(name)) {
|
||||
continue;
|
||||
}
|
||||
return R.ok(SUCCESS_UPDATE_MSG);
|
||||
} catch (Exception e) {
|
||||
return R.err(ERROR_UPDATE_MSG + ": " + e.getMessage());
|
||||
|
||||
updateOrCreateConfig(name, value);
|
||||
}
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个配置项
|
||||
*
|
||||
* @param name 配置名
|
||||
* @param value 配置值
|
||||
* @return 更新结果响应
|
||||
*/
|
||||
|
||||
@Override
|
||||
public R updateConfig(String name, String value) {
|
||||
// 1. 验证必填字段
|
||||
if (!StringUtils.hasText(name)) {
|
||||
return R.err(ERROR_CONFIG_NAME_REQUIRED);
|
||||
}
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return R.err(ERROR_CONFIG_VALUE_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
updateOrCreateConfig(name, value);
|
||||
return R.ok(SUCCESS_UPDATE_MSG);
|
||||
} catch (Exception e) {
|
||||
return R.err(ERROR_UPDATE_MSG + ": " + e.getMessage());
|
||||
}
|
||||
if (!StringUtils.hasText(name)) return R.err("配置名称不能为空");
|
||||
if (!StringUtils.hasText(value)) return R.err("配置值不能为空");
|
||||
updateOrCreateConfig(name, value);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 更新或创建配置项
|
||||
* 如果配置存在则更新,不存在则创建
|
||||
*/
|
||||
private void updateOrCreateConfig(String name, String value) {
|
||||
QueryWrapper<ViteConfig> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("name", name);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://${DB_HOST}:3306/${DB_NAME}?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
|
||||
username: ${DB_USER}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
url: jdbc:sqlite:${DB_PATH:/app/data/gost.db}
|
||||
hikari:
|
||||
max-lifetime: 500000
|
||||
connection-timeout: 30000
|
||||
@@ -12,7 +10,11 @@ spring:
|
||||
minimum-idle: 5
|
||||
pool-name: HikariCP
|
||||
auto-commit: true
|
||||
connection-test-query: SELECT 1
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
platform: sqlite
|
||||
continue-on-error: false
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
INSERT OR IGNORE INTO user (id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
|
||||
|
||||
|
||||
INSERT OR IGNORE INTO vite_config (id, name, value, time)
|
||||
VALUES (1, 'app_name', 'flux', 1755147963000);
|
||||
@@ -20,7 +20,7 @@
|
||||
<Prudent>true</Prudent>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<FileNamePattern>${FILE_PATH}</FileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
<maxHistory>7</maxHistory>
|
||||
</rollingPolicy>
|
||||
<layout class="ch.qos.logback.classic.PatternLayout">
|
||||
<Pattern>
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
f.user_id AS userId,
|
||||
f.name,
|
||||
f.tunnel_id AS tunnelId,
|
||||
f.in_port AS inPort,
|
||||
f.out_port AS outPort,
|
||||
f.remote_addr AS remoteAddr,
|
||||
f.status,
|
||||
f.created_time AS createdTime,
|
||||
@@ -20,13 +18,9 @@
|
||||
f.strategy AS strategy,
|
||||
f.out_flow AS outFlow,
|
||||
f.inx AS inx,
|
||||
f.interface_name AS interfaceName,
|
||||
t.name AS tunnelName,
|
||||
t.in_ip AS inIp,
|
||||
t.out_ip AS outIp,
|
||||
t.type,
|
||||
t.protocol
|
||||
FROM
|
||||
t.type
|
||||
FROM
|
||||
forward f
|
||||
LEFT JOIN
|
||||
tunnel t ON f.tunnel_id = t.id
|
||||
@@ -41,8 +35,6 @@
|
||||
f.user_id AS userId,
|
||||
f.name,
|
||||
f.tunnel_id AS tunnelId,
|
||||
f.in_port AS inPort,
|
||||
f.out_port AS outPort,
|
||||
f.remote_addr AS remoteAddr,
|
||||
f.status,
|
||||
f.created_time AS createdTime,
|
||||
@@ -52,13 +44,9 @@
|
||||
f.out_flow AS outFlow,
|
||||
f.strategy AS strategy,
|
||||
f.inx AS inx,
|
||||
f.interface_name AS interfaceName,
|
||||
t.name AS tunnelName,
|
||||
t.in_ip AS inIp,
|
||||
t.out_ip AS outIp,
|
||||
t.type,
|
||||
t.protocol
|
||||
FROM
|
||||
t.type
|
||||
FROM
|
||||
forward f
|
||||
LEFT JOIN
|
||||
tunnel t ON f.tunnel_id = t.id
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
f.tunnel_id as tunnelId,
|
||||
t.name as tunnelName,
|
||||
t.in_ip as inIp,
|
||||
f.in_port as inPort,
|
||||
f.remote_addr as remoteAddr,
|
||||
f.in_flow as inFlow,
|
||||
f.out_flow as outFlow,
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
t.name as tunnelName,
|
||||
t.flow as tunnelFlow,
|
||||
t.in_ip as inIp,
|
||||
t.out_ip as outIp,
|
||||
t.type,
|
||||
t.protocol,
|
||||
sl.name as speedLimitName,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
-- SQLite Auto-generated schema
|
||||
-- This will be executed automatically on startup if tables don't exist
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_name VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
remote_addr TEXT NOT NULL,
|
||||
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward_port (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
forward_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
server_ip_v4 VARCHAR(100),
|
||||
server_ip_v6 VARCHAR(100),
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
tunnel_name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statistics_flow (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
total_flow INTEGER NOT NULL,
|
||||
time VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_id INTEGER NOT NULL ,
|
||||
chain_type VARCHAR(10) NOT NULL,
|
||||
node_id INTEGER NOT NULL ,
|
||||
port INTEGER,
|
||||
strategy VARCHAR(10),
|
||||
inx INTEGER,
|
||||
protocol VARCHAR(10)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user VARCHAR(100) NOT NULL,
|
||||
pwd VARCHAR(100) NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
exp_time INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
flow_reset_time INTEGER NOT NULL,
|
||||
num INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
speed_id INTEGER,
|
||||
num INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
flow_reset_time INTEGER NOT NULL,
|
||||
exp_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vite_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.admin;
|
||||
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Node;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.service.TunnelService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
// use a local sqlite file for tests (resolved via ${DB_PATH} placeholder)
|
||||
"DB_PATH=./target/test-gost-ordering.db",
|
||||
})
|
||||
class OrderingUpdateTests {
|
||||
|
||||
@Resource
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Resource
|
||||
private NodeService nodeService;
|
||||
|
||||
@Resource
|
||||
private TunnelService tunnelService;
|
||||
|
||||
@BeforeEach
|
||||
void cleanup() {
|
||||
// keep it simple; other tables may have foreign references in real runs
|
||||
jdbcTemplate.execute("DELETE FROM node");
|
||||
jdbcTemplate.execute("DELETE FROM tunnel");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNodeOrder_updatesInx() {
|
||||
Node n1 = new Node();
|
||||
n1.setName("n1");
|
||||
n1.setSecret("s1");
|
||||
n1.setServerIp("127.0.0.1");
|
||||
n1.setPort("1000-2000");
|
||||
n1.setInterfaceName("");
|
||||
n1.setHttp(0);
|
||||
n1.setTls(0);
|
||||
n1.setSocks(0);
|
||||
n1.setTcpListenAddr("[::]");
|
||||
n1.setUdpListenAddr("[::]");
|
||||
n1.setStatus(0);
|
||||
n1.setInx(0);
|
||||
long now = System.currentTimeMillis();
|
||||
n1.setCreatedTime(now);
|
||||
n1.setUpdatedTime(now);
|
||||
assertTrue(nodeService.save(n1));
|
||||
|
||||
Node n2 = new Node();
|
||||
n2.setName("n2");
|
||||
n2.setSecret("s2");
|
||||
n2.setServerIp("127.0.0.2");
|
||||
n2.setPort("1000-2000");
|
||||
n2.setInterfaceName("");
|
||||
n2.setHttp(0);
|
||||
n2.setTls(0);
|
||||
n2.setSocks(0);
|
||||
n2.setTcpListenAddr("[::]");
|
||||
n2.setUdpListenAddr("[::]");
|
||||
n2.setStatus(0);
|
||||
n2.setInx(0);
|
||||
n2.setCreatedTime(now);
|
||||
n2.setUpdatedTime(now);
|
||||
assertTrue(nodeService.save(n2));
|
||||
|
||||
Node n3 = new Node();
|
||||
n3.setName("n3");
|
||||
n3.setSecret("s3");
|
||||
n3.setServerIp("127.0.0.3");
|
||||
n3.setPort("1000-2000");
|
||||
n3.setInterfaceName("");
|
||||
n3.setHttp(0);
|
||||
n3.setTls(0);
|
||||
n3.setSocks(0);
|
||||
n3.setTcpListenAddr("[::]");
|
||||
n3.setUdpListenAddr("[::]");
|
||||
n3.setStatus(0);
|
||||
n3.setInx(0);
|
||||
n3.setCreatedTime(now);
|
||||
n3.setUpdatedTime(now);
|
||||
assertTrue(nodeService.save(n3));
|
||||
|
||||
List<Map<String, Object>> nodes = new ArrayList<>();
|
||||
nodes.add(mapIdInx(n2.getId(), 0));
|
||||
nodes.add(mapIdInx(n1.getId(), 1));
|
||||
nodes.add(mapIdInx(n3.getId(), 2));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("nodes", nodes);
|
||||
|
||||
R res = nodeService.updateNodeOrder(params);
|
||||
assertEquals(0, res.getCode());
|
||||
|
||||
assertEquals(1, nodeService.getById(n1.getId()).getInx());
|
||||
assertEquals(0, nodeService.getById(n2.getId()).getInx());
|
||||
assertEquals(2, nodeService.getById(n3.getId()).getInx());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTunnelOrder_updatesInx() {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
Tunnel t1 = new Tunnel();
|
||||
t1.setName("t1");
|
||||
t1.setType(1);
|
||||
t1.setFlow(1);
|
||||
t1.setTrafficRatio(new BigDecimal("1.0"));
|
||||
t1.setInIp("");
|
||||
t1.setStatus(1);
|
||||
t1.setInx(0);
|
||||
t1.setCreatedTime(now);
|
||||
t1.setUpdatedTime(now);
|
||||
assertTrue(tunnelService.save(t1));
|
||||
|
||||
Tunnel t2 = new Tunnel();
|
||||
t2.setName("t2");
|
||||
t2.setType(2);
|
||||
t2.setFlow(2);
|
||||
t2.setTrafficRatio(new BigDecimal("1.0"));
|
||||
t2.setInIp("");
|
||||
t2.setStatus(1);
|
||||
t2.setInx(0);
|
||||
t2.setCreatedTime(now);
|
||||
t2.setUpdatedTime(now);
|
||||
assertTrue(tunnelService.save(t2));
|
||||
|
||||
List<Map<String, Object>> tunnels = new ArrayList<>();
|
||||
tunnels.add(mapIdInx(t2.getId(), 0));
|
||||
tunnels.add(mapIdInx(t1.getId(), 1));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("tunnels", tunnels);
|
||||
|
||||
R res = tunnelService.updateTunnelOrder(params);
|
||||
assertEquals(0, res.getCode());
|
||||
|
||||
assertEquals(1, tunnelService.getById(t1.getId()).getInx());
|
||||
assertEquals(0, tunnelService.getById(t2.getId()).getInx());
|
||||
}
|
||||
|
||||
private static Map<String, Object> mapIdInx(Long id, int inx) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", id);
|
||||
m.put("inx", inx);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# VITE FRONTEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
|
||||
## OVERVIEW
|
||||
Web management console for Flux Panel.
|
||||
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
vite-frontend/
|
||||
├── src/
|
||||
│ ├── pages/ # Route views (some very large single-file pages)
|
||||
│ ├── components/ # Reusable UI parts
|
||||
│ ├── layouts/ # Admin vs H5 layouts
|
||||
│ ├── api/ # API functions + axios wrapper
|
||||
│ ├── config/ # Site config (title, repo, version)
|
||||
│ └── utils/ # Auth/JWT + WebView helpers
|
||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||
├── eslint.config.mjs # ESLint 9 flat config
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
|
||||
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
|
||||
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
|
||||
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd vite-frontend
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
@@ -4,7 +4,7 @@ FROM node:20.19.0 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# VITE FRONTEND (src) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
React app entry + routing + providers. This is where UI architecture decisions live.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
vite-frontend/src/
|
||||
├── main.tsx # ReactDOM + BrowserRouter + Provider
|
||||
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
|
||||
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||
├── api/ # Axios wrapper + typed endpoint helpers
|
||||
├── pages/ # Route views (large)
|
||||
├── layouts/ # Admin/H5 page chrome
|
||||
├── components/ # Shared UI components
|
||||
├── utils/ # JWT parsing + auth helpers + WebView utilities
|
||||
└── styles/ # globals.css
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
|
||||
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
|
||||
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
|
||||
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
|
||||
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
|
||||
|
||||
## CONVENTIONS
|
||||
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
|
||||
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd vite-frontend
|
||||
npm run dev
|
||||
npm run lint
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
# VITE FRONTEND (src/api) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
|
||||
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
|
||||
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
|
||||
|
||||
## CONVENTIONS
|
||||
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
|
||||
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
|
||||
- 401 responses clear localStorage and redirect to `/`.
|
||||
@@ -29,6 +29,7 @@ export const getNodeList = () => Network.post("/node/list");
|
||||
export const updateNode = (data: any) => Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
|
||||
export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }> }) => Network.post("/node/update-order", data);
|
||||
export const checkNodeStatus = (nodeId?: number) => {
|
||||
const params = nodeId ? { nodeId } : {};
|
||||
return Network.post("/node/check-status", params);
|
||||
@@ -41,6 +42,7 @@ export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id })
|
||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
||||
export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: number }> }) => Network.post("/tunnel/update-order", data);
|
||||
|
||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
||||
@@ -88,4 +90,4 @@ export const updateConfig = (name: string, value: string) => Network.post("/conf
|
||||
// 验证码相关接口
|
||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
||||
|
||||
@@ -4,8 +4,9 @@ export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "1.4.3";
|
||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
const getInitialConfig = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -13,6 +14,7 @@ const getInitialConfig = () => {
|
||||
name: "flux",
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +24,7 @@ const getInitialConfig = () => {
|
||||
name: cachedAppName,
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -311,7 +311,7 @@ export default function AdminLayout({
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{' '}
|
||||
<a
|
||||
href="https://github.com/bqlpfy/flux-panel"
|
||||
href={siteConfig.github_repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# VITE FRONTEND (pages) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Route views rendered by `vite-frontend/src/App.tsx`. Several pages are large, single-file screens.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
vite-frontend/src/pages/
|
||||
├── index.tsx # Login + captcha flow
|
||||
├── dashboard.tsx
|
||||
├── forward.tsx # Large
|
||||
├── tunnel.tsx # Large
|
||||
├── node.tsx # Large
|
||||
├── user.tsx # Large
|
||||
├── config.tsx
|
||||
├── limit.tsx
|
||||
├── profile.tsx
|
||||
├── settings.tsx
|
||||
└── change-password.tsx
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Login flow | `vite-frontend/src/pages/index.tsx` | Calls `login()` and stores `localStorage.token` |
|
||||
| API calls | `vite-frontend/src/api/index.ts` | Thin wrappers around `Network.post` |
|
||||
| Token expiration behavior | `vite-frontend/src/api/network.ts` | Clears localStorage + redirects on 401 |
|
||||
|
||||
## CONVENTIONS
|
||||
- Pages call API wrappers from `vite-frontend/src/api/index.ts` (most endpoints are POST).
|
||||
@@ -425,14 +425,28 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString || !port) return '';
|
||||
if (!ipString) return '';
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
const items = ipString.split(',').map(item => item.trim()).filter(item => item);
|
||||
if (items.length === 0) return '';
|
||||
|
||||
if (ips.length === 0) return '';
|
||||
// 检查第一项是否已经包含端口(格式:IP:端口)
|
||||
const firstItem = items[0];
|
||||
const hasPort = /:\d+$/.test(firstItem);
|
||||
|
||||
if (ips.length === 1) {
|
||||
const ip = ips[0];
|
||||
if (hasPort) {
|
||||
// inIp 已经包含完整的 IP:Port 组合
|
||||
if (items.length === 1) {
|
||||
return items[0];
|
||||
}
|
||||
return `${items[0]} (+${items.length - 1}个)`;
|
||||
}
|
||||
|
||||
// inIp 只包含IP,需要添加端口(兼容旧数据)
|
||||
if (!port) return '';
|
||||
|
||||
if (items.length === 1) {
|
||||
const ip = items[0];
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
@@ -440,16 +454,15 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const firstIp = ips[0];
|
||||
const firstIp = items[0];
|
||||
let formattedFirstIp;
|
||||
|
||||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
} else {
|
||||
formattedFirstIp = firstIp;
|
||||
}
|
||||
|
||||
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
|
||||
return `${formattedFirstIp}:${port} (+${items.length - 1}个)`;
|
||||
};
|
||||
|
||||
const formatRemoteAddress = (remoteAddr: string): string => {
|
||||
@@ -479,32 +492,47 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
const showAddressModal = (ipString: string, port: number, title: string) => {
|
||||
if (!ipString || !port) return;
|
||||
if (!ipString) return;
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
const items = ipString.split(',').map(item => item.trim()).filter(item => item);
|
||||
|
||||
if (ips.length <= 1) {
|
||||
copyToClipboard(formatInAddress(ipString, port));
|
||||
if (items.length <= 1) {
|
||||
copyToClipboard(formatInAddress(ipString, port));
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedList = ips.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
} else {
|
||||
formattedAddress = `${ip}:${port}`;
|
||||
}
|
||||
return {
|
||||
// 检查是否已经包含端口
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
|
||||
let formattedList;
|
||||
if (hasPort) {
|
||||
// 已经包含完整的 IP:Port 组合,直接使用
|
||||
formattedList = items.map((item, index) => ({
|
||||
id: index,
|
||||
ip: ip,
|
||||
address: formattedAddress,
|
||||
ip: item,
|
||||
address: item,
|
||||
copying: false
|
||||
};
|
||||
});
|
||||
}));
|
||||
} else {
|
||||
// 只包含IP,需要添加端口
|
||||
formattedList = items.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
} else {
|
||||
formattedAddress = `${ip}:${port}`;
|
||||
}
|
||||
return {
|
||||
id: index,
|
||||
ip: ip,
|
||||
address: formattedAddress,
|
||||
copying: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setAddressList(formattedList);
|
||||
setAddressModalTitle(`${title} (${ips.length}个)`);
|
||||
setAddressModalTitle(`${title} (${items.length}个)`);
|
||||
setAddressModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -713,31 +741,45 @@ export default function DashboardPage() {
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
||||
tickFormatter={(value) => {
|
||||
if (value === 0) return '0';
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`;
|
||||
return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<p className="font-medium text-foreground">{`时间: ${label}`}</p>
|
||||
<p className="text-primary">
|
||||
{`流量: ${formatFlow(payload[0]?.value as number || 0)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
||||
tickFormatter={(value: number | string) => {
|
||||
const v = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(v)) return String(value);
|
||||
if (v === 0) return '0';
|
||||
if (v < 1024) return `${v}B`;
|
||||
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
|
||||
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)}M`;
|
||||
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value?: number | string }>;
|
||||
label?: string | number;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const firstValue = payload[0]?.value;
|
||||
const numericValue = typeof firstValue === 'number' ? firstValue : Number(firstValue);
|
||||
const flowValue = Number.isFinite(numericValue) ? numericValue : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<p className="font-medium text-foreground">{`时间: ${label ?? ''}`}</p>
|
||||
<p className="text-primary">
|
||||
{`流量: ${formatFlow(flowValue)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
@@ -955,4 +997,4 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+408
-156
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
@@ -103,6 +104,10 @@ interface DiagnosisResult {
|
||||
message?: string;
|
||||
averageTime?: number;
|
||||
packetLoss?: number;
|
||||
fromChainType?: number; // 1: 入口, 2: 链, 3: 出口
|
||||
fromInx?: number;
|
||||
toChainType?: number;
|
||||
toInx?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -196,7 +201,6 @@ export default function ForwardPage() {
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||
const [selectedTunnel, setSelectedTunnel] = useState<Tunnel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -350,8 +354,6 @@ export default function ForwardPage() {
|
||||
const userMap = new Map<string, UserGroup>();
|
||||
|
||||
// 获取排序后的转发列表
|
||||
const sortedForwards = getSortedForwards();
|
||||
|
||||
sortedForwards.forEach(forward => {
|
||||
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
|
||||
const userName = forward.userName || '未知用户';
|
||||
@@ -403,6 +405,14 @@ export default function ForwardPage() {
|
||||
newErrors.tunnelId = '请选择关联隧道';
|
||||
}
|
||||
|
||||
// 验证入口端口(可选,如果填写则验证)
|
||||
if (form.inPort !== null && form.inPort !== undefined) {
|
||||
const port = Number(form.inPort);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
newErrors.inPort = '端口必须在 1-65535 之间';
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.remoteAddr.trim()) {
|
||||
newErrors.remoteAddr = '请输入远程地址';
|
||||
} else {
|
||||
@@ -421,16 +431,6 @@ export default function ForwardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (form.inPort !== null && (form.inPort < 1 || form.inPort > 65535)) {
|
||||
newErrors.inPort = '端口号必须在1-65535之间';
|
||||
}
|
||||
|
||||
if (selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd && form.inPort) {
|
||||
if (form.inPort < selectedTunnel.inNodePortSta || form.inPort > selectedTunnel.inNodePortEnd) {
|
||||
newErrors.inPort = `端口号必须在${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}范围内`;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@@ -446,7 +446,6 @@ export default function ForwardPage() {
|
||||
interfaceName: '',
|
||||
strategy: 'fifo'
|
||||
});
|
||||
setSelectedTunnel(null);
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -464,8 +463,6 @@ export default function ForwardPage() {
|
||||
interfaceName: forward.interfaceName || '',
|
||||
strategy: forward.strategy || 'fifo'
|
||||
});
|
||||
const tunnel = tunnels.find(t => t.id === forward.tunnelId);
|
||||
setSelectedTunnel(tunnel || null);
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -511,8 +508,6 @@ export default function ForwardPage() {
|
||||
|
||||
// 处理隧道选择变化
|
||||
const handleTunnelChange = (tunnelId: string) => {
|
||||
const tunnel = tunnels.find(t => t.id === parseInt(tunnelId));
|
||||
setSelectedTunnel(tunnel || null);
|
||||
setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) }));
|
||||
};
|
||||
|
||||
@@ -540,7 +535,6 @@ export default function ForwardPage() {
|
||||
tunnelId: form.tunnelId,
|
||||
inPort: form.inPort,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
strategy: addressCount > 1 ? form.strategy : 'fifo'
|
||||
};
|
||||
res = await updateForward(updateData);
|
||||
@@ -551,7 +545,6 @@ export default function ForwardPage() {
|
||||
tunnelId: form.tunnelId,
|
||||
inPort: form.inPort,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
strategy: addressCount > 1 ? form.strategy : 'fifo'
|
||||
};
|
||||
res = await createForward(createData);
|
||||
@@ -694,13 +687,28 @@ export default function ForwardPage() {
|
||||
|
||||
// 格式化入口地址
|
||||
const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString || !port) return '';
|
||||
if (!ipString) return '';
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length === 0) return '';
|
||||
const items = ipString.split(',').map(item => item.trim()).filter(item => item);
|
||||
if (items.length === 0) return '';
|
||||
|
||||
if (ips.length === 1) {
|
||||
const ip = ips[0];
|
||||
// 检查第一项是否已经包含端口(格式:IP:端口)
|
||||
const firstItem = items[0];
|
||||
const hasPort = /:\d+$/.test(firstItem);
|
||||
|
||||
if (hasPort) {
|
||||
// inIp 已经包含完整的 IP:Port 组合
|
||||
if (items.length === 1) {
|
||||
return items[0];
|
||||
}
|
||||
return `${items[0]} (+${items.length - 1}个)`;
|
||||
}
|
||||
|
||||
// inIp 只包含IP,需要添加端口(兼容旧数据)
|
||||
if (!port) return '';
|
||||
|
||||
if (items.length === 1) {
|
||||
const ip = items[0];
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
@@ -708,7 +716,7 @@ export default function ForwardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const firstIp = ips[0];
|
||||
const firstIp = items[0];
|
||||
let formattedFirstIp;
|
||||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
@@ -716,7 +724,7 @@ export default function ForwardPage() {
|
||||
formattedFirstIp = firstIp;
|
||||
}
|
||||
|
||||
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
|
||||
return `${formattedFirstIp}:${port} (+${items.length - 1}个)`;
|
||||
};
|
||||
|
||||
// 格式化远程地址
|
||||
@@ -744,18 +752,27 @@ export default function ForwardPage() {
|
||||
let addresses: string[];
|
||||
if (port !== null) {
|
||||
// 入口地址处理
|
||||
const ips = addressString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length <= 1) {
|
||||
const items = addressString.split(',').map(item => item.trim()).filter(item => item);
|
||||
if (items.length <= 1) {
|
||||
copyToClipboard(formatInAddress(addressString, port), title);
|
||||
return;
|
||||
}
|
||||
addresses = ips.map(ip => {
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否已经包含端口
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
if (hasPort) {
|
||||
// 已经包含完整的 IP:Port 组合,直接使用
|
||||
addresses = items;
|
||||
} else {
|
||||
// 只包含IP,需要添加端口
|
||||
addresses = items.map(ip => {
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 远程地址处理
|
||||
addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
@@ -837,7 +854,7 @@ export default function ForwardPage() {
|
||||
);
|
||||
} else {
|
||||
// 直接显示模式下,过滤指定隧道的转发
|
||||
forwardsToExport = getSortedForwards().filter(forward => forward.tunnelId === selectedTunnelForExport);
|
||||
forwardsToExport = sortedForwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
|
||||
}
|
||||
|
||||
if (forwardsToExport.length === 0) {
|
||||
@@ -1084,64 +1101,80 @@ export default function ForwardPage() {
|
||||
|
||||
// 传感器配置 - 使用默认配置避免错误
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const tokenUserId = JwtUtil.getUserIdFromToken();
|
||||
|
||||
// 根据排序顺序获取转发列表
|
||||
const getSortedForwards = (): Forward[] => {
|
||||
const sortedForwards = useMemo((): Forward[] => {
|
||||
// 确保 forwards 数组存在且有效
|
||||
if (!forwards || forwards.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
// 在平铺模式下,只显示当前用户的转发
|
||||
let filteredForwards = forwards;
|
||||
if (viewMode === 'direct') {
|
||||
const currentUserId = JwtUtil.getUserIdFromToken();
|
||||
if (currentUserId !== null) {
|
||||
filteredForwards = forwards.filter(forward => forward.userId === currentUserId);
|
||||
if (tokenUserId !== null) {
|
||||
filteredForwards = forwards.filter(forward => forward.userId === tokenUserId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 确保过滤后的转发列表有效
|
||||
if (!filteredForwards || filteredForwards.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序
|
||||
const sortedForwards = [...filteredForwards].sort((a, b) => {
|
||||
const sortedByDb = [...filteredForwards].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
return aInx - bInx;
|
||||
});
|
||||
|
||||
|
||||
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||
if (forwardOrder && forwardOrder.length > 0 && sortedForwards.every(f => f.inx === undefined || f.inx === 0)) {
|
||||
if (forwardOrder && forwardOrder.length > 0 && sortedByDb.every(f => f.inx === undefined || f.inx === 0)) {
|
||||
const forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
|
||||
const localSortedForwards: Forward[] = [];
|
||||
|
||||
|
||||
forwardOrder.forEach(id => {
|
||||
const forward = forwardMap.get(id);
|
||||
if (forward) {
|
||||
localSortedForwards.push(forward);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 添加不在排序列表中的转发(新添加的)
|
||||
filteredForwards.forEach(forward => {
|
||||
if (!forwardOrder.includes(forward.id)) {
|
||||
localSortedForwards.push(forward);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return localSortedForwards;
|
||||
}
|
||||
|
||||
return sortedForwards;
|
||||
};
|
||||
|
||||
return sortedByDb;
|
||||
}, [forwards, forwardOrder, viewMode, tokenUserId]);
|
||||
|
||||
const sortableForwardIds = useMemo(
|
||||
() => sortedForwards.map(f => f.id).filter(id => id > 0),
|
||||
[sortedForwards]
|
||||
);
|
||||
|
||||
// 可拖拽的转发卡片组件
|
||||
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
|
||||
@@ -1161,8 +1194,9 @@ export default function ForwardPage() {
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
transition: transition || undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: 'transform',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1511,11 +1545,11 @@ export default function ForwardPage() {
|
||||
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
||||
>
|
||||
<SortableContext
|
||||
items={getSortedForwards().map(f => f.id || 0).filter(id => id > 0)}
|
||||
items={sortableForwardIds}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{getSortedForwards().map((forward) => (
|
||||
{sortedForwards.map((forward) => (
|
||||
forward && forward.id ? (
|
||||
<SortableForwardCard key={forward.id} forward={forward} />
|
||||
) : null
|
||||
@@ -1588,6 +1622,7 @@ export default function ForwardPage() {
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
description={isEdit ? "更改隧道将释放原端口并在新隧道分配端口" : undefined}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id} >
|
||||
@@ -1595,24 +1630,23 @@ export default function ForwardPage() {
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
|
||||
<Input
|
||||
label="入口端口"
|
||||
placeholder="留空自动分配"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort?.toString() || ''}
|
||||
onChange={(e) => setForm(prev => ({
|
||||
...prev,
|
||||
inPort: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
value={form.inPort !== null ? form.inPort.toString() : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null
|
||||
}));
|
||||
}}
|
||||
isInvalid={!!errors.inPort}
|
||||
errorMessage={errors.inPort}
|
||||
variant="bordered"
|
||||
description={
|
||||
selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd
|
||||
? `允许范围: ${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}`
|
||||
: '留空将自动分配可用端口'
|
||||
}
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
@@ -1628,17 +1662,6 @@ export default function ForwardPage() {
|
||||
maxRows={6}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="出口网卡名或IP"
|
||||
placeholder="请输入出口网卡名或IP"
|
||||
value={form.interfaceName}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, interfaceName: e.target.value }))}
|
||||
isInvalid={!!errors.interfaceName}
|
||||
errorMessage={errors.interfaceName}
|
||||
variant="bordered"
|
||||
description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行"
|
||||
/>
|
||||
|
||||
{getAddressCount(form.remoteAddr) > 1 && (
|
||||
<Select
|
||||
label="负载策略"
|
||||
@@ -2017,16 +2040,21 @@ export default function ForwardPage() {
|
||||
<Modal
|
||||
isOpen={diagnosisModalOpen}
|
||||
onOpenChange={setDiagnosisModalOpen}
|
||||
|
||||
size="2xl"
|
||||
scrollBehavior="outside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
backdrop="blur"
|
||||
placement="center"
|
||||
classNames={{
|
||||
base: "rounded-2xl",
|
||||
header: "rounded-t-2xl",
|
||||
body: "rounded-none",
|
||||
footer: "rounded-b-2xl"
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
|
||||
<h2 className="text-xl font-bold">转发诊断结果</h2>
|
||||
{currentDiagnosisForward && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -2042,89 +2070,313 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalBody className="bg-content1">
|
||||
{diagnosisLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在诊断转发连接...</span>
|
||||
<span className="text-default-600">正在诊断...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : diagnosisResult ? (
|
||||
<div className="space-y-4">
|
||||
{diagnosisResult.results.map((result, index) => {
|
||||
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
||||
|
||||
return (
|
||||
<Card key={index} className={`shadow-sm border ${result.success ? 'border-success' : 'border-danger'}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{result.description}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-small text-default-500">节点: {result.nodeName}</span>
|
||||
{/* 统计摘要 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
||||
<div className="text-2xl font-bold text-foreground">{diagnosisResult.results.length}</div>
|
||||
<div className="text-xs text-default-500 mt-1">总测试数</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
|
||||
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||
{diagnosisResult.results.filter(r => r.success).length}
|
||||
</div>
|
||||
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">成功</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
|
||||
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
||||
{diagnosisResult.results.filter(r => !r.success).length}
|
||||
</div>
|
||||
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面端表格展示 */}
|
||||
<div className="hidden md:block space-y-3">
|
||||
{(() => {
|
||||
// 使用后端返回的 chainType 和 inx 字段进行分组
|
||||
const groupedResults = {
|
||||
entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
|
||||
chains: {} as Record<number, typeof diagnosisResult.results>,
|
||||
exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
|
||||
};
|
||||
|
||||
// 按 inx 分组链路测试
|
||||
diagnosisResult.results.forEach(r => {
|
||||
if (r.fromChainType === 2 && r.fromInx != null) {
|
||||
if (!groupedResults.chains[r.fromInx]) {
|
||||
groupedResults.chains[r.fromInx] = [];
|
||||
}
|
||||
groupedResults.chains[r.fromInx].push(r);
|
||||
}
|
||||
});
|
||||
|
||||
const renderTableSection = (title: string, results: typeof diagnosisResult.results) => {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={title} className="border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800">
|
||||
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
|
||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-default-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold text-xs">路径</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-20">状态</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-24">延迟(ms)</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-24">丢包率</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-xs w-20">质量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
||||
|
||||
return (
|
||||
<tr key={index} className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success ? 'bg-white dark:bg-gray-800' : 'bg-danger-50 dark:bg-danger-900/30'
|
||||
}`}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? 'bg-success text-white'
|
||||
: 'bg-danger text-white'
|
||||
}`}>
|
||||
{result.success ? '✓' : '✗'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-foreground truncate">{result.description}</div>
|
||||
<div className="text-xs text-default-500 truncate">
|
||||
{result.targetIp}:{result.targetPort}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Chip
|
||||
color={result.success ? 'success' : 'danger'}
|
||||
variant="flat"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="min-w-[50px]"
|
||||
>
|
||||
{result.success ? '连接成功' : '连接失败'}
|
||||
{result.success ? '成功' : '失败'}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span className="font-semibold text-primary">
|
||||
{result.averageTime?.toFixed(0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0 ? 'text-warning' : 'text-success'
|
||||
}`}>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success && quality ? (
|
||||
<Chip
|
||||
color={quality.color as any}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{quality.text}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 入口测试 */}
|
||||
{renderTableSection('🚪 入口测试', groupedResults.entry)}
|
||||
|
||||
{/* 链路测试(按跳数排序) */}
|
||||
{Object.keys(groupedResults.chains)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(hop => renderTableSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
|
||||
|
||||
{/* 出口测试 */}
|
||||
{renderTableSection('🚀 出口测试', groupedResults.exit)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 移动端卡片展示 */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{(() => {
|
||||
// 使用后端返回的 chainType 和 inx 字段进行分组
|
||||
const groupedResults = {
|
||||
entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
|
||||
chains: {} as Record<number, typeof diagnosisResult.results>,
|
||||
exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
|
||||
};
|
||||
|
||||
// 按 inx 分组链路测试
|
||||
diagnosisResult.results.forEach(r => {
|
||||
if (r.fromChainType === 2 && r.fromInx != null) {
|
||||
if (!groupedResults.chains[r.fromInx]) {
|
||||
groupedResults.chains[r.fromInx] = [];
|
||||
}
|
||||
groupedResults.chains[r.fromInx].push(r);
|
||||
}
|
||||
});
|
||||
|
||||
const renderCardSection = (title: string, results: typeof diagnosisResult.results) => {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
<CardBody className="pt-0">
|
||||
{result.success ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">{result.averageTime?.toFixed(0)}</div>
|
||||
<div className="text-small text-default-500">平均延迟(ms)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-warning">{result.packetLoss?.toFixed(1)}</div>
|
||||
<div className="text-small text-default-500">丢包率(%)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{quality && (
|
||||
<>
|
||||
<Chip color={quality.color as any} variant="flat" size="lg">
|
||||
{quality.text}
|
||||
</Chip>
|
||||
<div className="text-small text-default-500 mt-1">连接质量</div>
|
||||
</>
|
||||
return (
|
||||
<div key={title} className="space-y-2">
|
||||
<div className="px-2 py-1.5 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/30">
|
||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
||||
</div>
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
||||
|
||||
return (
|
||||
<div key={index} className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
? 'border-divider bg-white dark:bg-gray-800'
|
||||
: 'border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30'
|
||||
}`}>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success ? 'bg-success text-white' : 'bg-danger text-white'
|
||||
}`}>
|
||||
{result.success ? '✓' : '✗'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{result.description}
|
||||
</div>
|
||||
<div className="text-xs text-default-500 mt-0.5 break-all">
|
||||
{result.targetIp}:{result.targetPort}
|
||||
</div>
|
||||
</div>
|
||||
<Chip
|
||||
color={result.success ? 'success' : 'danger'}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{result.success ? '成功' : '失败'}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{result.success ? (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-primary">
|
||||
{result.averageTime?.toFixed(0)}
|
||||
</div>
|
||||
<div className="text-xs text-default-500">延迟(ms)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
(result.packetLoss || 0) > 0 ? 'text-warning' : 'text-success'
|
||||
}`}>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-default-500">丢包率</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{quality && (
|
||||
<>
|
||||
<Chip
|
||||
color={quality.color as any}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{quality.text}
|
||||
</Chip>
|
||||
<div className="text-xs text-default-500 mt-0.5">质量</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 pt-2 border-t border-divider">
|
||||
<div className="text-xs text-danger">
|
||||
{result.message || '连接失败'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-small text-default-500 flex items-center gap-1">
|
||||
<span className="flex-shrink-0">目标地址:</span>
|
||||
<code className="font-mono truncate min-w-0" title={`${result.targetIp}${result.targetPort ? ':' + result.targetPort : ''}`}>
|
||||
{result.targetIp}{result.targetPort ? ':' + result.targetPort : ''}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-small text-default-500 flex items-center gap-1">
|
||||
<span className="flex-shrink-0">目标地址:</span>
|
||||
<code className="font-mono truncate min-w-0" title={`${result.targetIp}${result.targetPort ? ':' + result.targetPort : ''}`}>
|
||||
{result.targetIp}{result.targetPort ? ':' + result.targetPort : ''}
|
||||
</code>
|
||||
</div>
|
||||
<Alert
|
||||
color="danger"
|
||||
variant="flat"
|
||||
title="错误详情"
|
||||
description={result.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 入口测试 */}
|
||||
{renderCardSection('🚪 入口测试', groupedResults.entry)}
|
||||
|
||||
{/* 链路测试(按跳数排序) */}
|
||||
{Object.keys(groupedResults.chains)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(hop => renderCardSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
|
||||
|
||||
{/* 出口测试 */}
|
||||
{renderCardSection('🚀 出口测试', groupedResults.exit)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
||||
{diagnosisResult.results.some(r => !r.success) && (
|
||||
<div className="space-y-2 hidden md:block">
|
||||
<h4 className="text-sm font-semibold text-danger">失败详情</h4>
|
||||
<div className="space-y-2">
|
||||
{diagnosisResult.results.filter(r => !r.success).map((result, index) => (
|
||||
<Alert
|
||||
key={index}
|
||||
color="danger"
|
||||
variant="flat"
|
||||
title={result.description}
|
||||
description={result.message || '连接失败'}
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
@@ -2137,7 +2389,7 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ModalFooter className="bg-content1 border-t border-divider">
|
||||
<Button variant="light" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
@@ -2158,4 +2410,4 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,14 +308,14 @@ export default function IndexPage() {
|
||||
<div className="fixed inset-x-0 bottom-4 text-center py-4">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{' '}
|
||||
<a
|
||||
href="https://github.com/bqlpfy/flux-panel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
flux-panel
|
||||
</a>
|
||||
<a
|
||||
href={siteConfig.github_repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
flux-panel
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{ isWebView ? siteConfig.app_version : siteConfig.version}
|
||||
|
||||
@@ -404,6 +404,8 @@ export default function LimitPage() {
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
isDisabled={isEdit}
|
||||
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id}>
|
||||
|
||||
+610
-259
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user